Compare commits
2 Commits
102e05c8ba
...
5885e33531
| Author | SHA1 | Date | |
|---|---|---|---|
| 5885e33531 | |||
| 626ca51041 |
@@ -1,4 +1,4 @@
|
||||
import { FlaskConical, Lock, Check, Play } from 'lucide-react';
|
||||
import { FlaskConical, Lock, Check, Play, ListOrdered, X } from 'lucide-react';
|
||||
import { TutorialHint } from '@/components/game/TutorialHint';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatDuration, formatPercent, formatNumber } from '@ai-tycoon/shared';
|
||||
@@ -24,13 +24,17 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
export function ResearchPage() {
|
||||
const completedResearch = useGameStore((s) => s.research.completedResearch);
|
||||
const activeResearch = useGameStore((s) => s.research.activeResearch);
|
||||
const researchQueue = useGameStore((s) => s.research.researchQueue);
|
||||
const researchPoints = useGameStore((s) => s.research.researchPoints);
|
||||
const startResearch = useGameStore((s) => s.startResearch);
|
||||
const queueResearch = useGameStore((s) => s.queueResearch);
|
||||
const removeFromResearchQueue = useGameStore((s) => s.removeFromResearchQueue);
|
||||
const era = useGameStore((s) => s.meta.currentEra);
|
||||
|
||||
const state = useGameStore.getState();
|
||||
const available = getAvailableResearch(state);
|
||||
const availableIds = new Set(available.map(n => n.id));
|
||||
const queuedIds = new Set(researchQueue);
|
||||
|
||||
const handleStart = (node: ResearchNode) => {
|
||||
if (activeResearch) return;
|
||||
@@ -43,6 +47,10 @@ export function ResearchPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleQueue = (node: ResearchNode) => {
|
||||
queueResearch(node.id);
|
||||
};
|
||||
|
||||
const categories = [...new Set(TECH_TREE.map(n => n.category))];
|
||||
|
||||
return (
|
||||
@@ -60,7 +68,7 @@ export function ResearchPage() {
|
||||
</div>
|
||||
|
||||
<TutorialHint id="research-intro">
|
||||
Only one research project can run at a time. Complete prerequisites to unlock advanced technologies that improve your models and infrastructure.
|
||||
Queue up multiple research projects to run in sequence. Complete prerequisites to unlock advanced technologies that improve your models and infrastructure.
|
||||
</TutorialHint>
|
||||
|
||||
{activeResearch && (
|
||||
@@ -88,6 +96,36 @@ export function ResearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{researchQueue.length > 0 && (
|
||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<ListOrdered size={16} className="text-surface-400" />
|
||||
<span className="text-sm font-medium text-surface-300">Queue ({researchQueue.length})</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{researchQueue.map((id, idx) => {
|
||||
const node = TECH_TREE.find(n => n.id === id);
|
||||
if (!node) return null;
|
||||
return (
|
||||
<div key={id} className="flex items-center justify-between bg-surface-800 rounded-lg px-3 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-surface-500 font-mono w-5">{idx + 1}.</span>
|
||||
<span className="text-sm">{node.name}</span>
|
||||
<span className="text-xs text-surface-500">{formatDuration(node.cost.ticks)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFromResearchQueue(id)}
|
||||
className="text-surface-500 hover:text-red-400 transition-colors p-1"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.map(category => {
|
||||
const nodes = TECH_TREE.filter(n => n.category === category);
|
||||
return (
|
||||
@@ -99,24 +137,29 @@ export function ResearchPage() {
|
||||
{nodes.map(node => {
|
||||
const isCompleted = completedResearch.includes(node.id);
|
||||
const isActive = activeResearch?.researchId === node.id;
|
||||
const isQueued = queuedIds.has(node.id);
|
||||
const isAvailable = availableIds.has(node.id);
|
||||
const isLocked = !isCompleted && !isActive && !isAvailable;
|
||||
const isLocked = !isCompleted && !isActive && !isAvailable && !isQueued;
|
||||
const canStart = isAvailable && !activeResearch;
|
||||
const canQueue = isAvailable && !!activeResearch;
|
||||
const queuePosition = isQueued ? researchQueue.indexOf(node.id) + 1 : -1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
onClick={() => isAvailable && !activeResearch && handleStart(node)}
|
||||
onClick={() => canStart ? handleStart(node) : canQueue ? handleQueue(node) : undefined}
|
||||
className={`rounded-xl border p-4 transition-all ${
|
||||
isCompleted ? 'border-success/50 bg-success/5 opacity-70' :
|
||||
isActive ? 'border-accent/50 bg-accent/5' :
|
||||
isAvailable && !activeResearch ? `${CATEGORY_COLORS[category]} hover:brightness-110 cursor-pointer ring-1 ring-transparent hover:ring-accent/30` :
|
||||
isAvailable ? `${CATEGORY_COLORS[category]}` :
|
||||
isQueued ? 'border-amber-500/50 bg-amber-500/5' :
|
||||
(canStart || canQueue) ? `${CATEGORY_COLORS[category]} hover:brightness-110 cursor-pointer ring-1 ring-transparent hover:ring-accent/30` :
|
||||
'border-surface-700 bg-surface-900 opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-medium text-sm">{node.name}</h4>
|
||||
{isCompleted && <Check size={16} className="text-success flex-shrink-0" />}
|
||||
{isQueued && <span className="text-xs text-amber-400 font-mono flex-shrink-0">#{queuePosition}</span>}
|
||||
{isLocked && <Lock size={14} className="text-surface-500 flex-shrink-0" />}
|
||||
</div>
|
||||
<p className="text-xs text-surface-400 mb-3">{node.description}</p>
|
||||
@@ -125,17 +168,32 @@ export function ResearchPage() {
|
||||
{formatDuration(node.cost.ticks)} · {formatNumber(node.cost.compute)} compute
|
||||
{node.cost.researchPoints > 0 && ` · ${node.cost.researchPoints} RP`}
|
||||
</div>
|
||||
{isAvailable && !activeResearch && (
|
||||
{canStart && (
|
||||
<button
|
||||
onClick={() => handleStart(node)}
|
||||
onClick={(e) => { e.stopPropagation(); handleStart(node); }}
|
||||
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-2 py-1 text-xs"
|
||||
>
|
||||
<Play size={12} />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
{isAvailable && activeResearch && (
|
||||
<span className="text-xs text-surface-500 italic">Queue after current</span>
|
||||
{canQueue && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleQueue(node); }}
|
||||
className="flex items-center gap-1 bg-amber-600 hover:bg-amber-700 text-white rounded px-2 py-1 text-xs"
|
||||
>
|
||||
<ListOrdered size={12} />
|
||||
Queue
|
||||
</button>
|
||||
)}
|
||||
{isQueued && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); removeFromResearchQueue(node.id); }}
|
||||
className="flex items-center gap-1 bg-surface-700 hover:bg-red-900 text-surface-300 hover:text-red-300 rounded px-2 py-1 text-xs transition-colors"
|
||||
>
|
||||
<X size={12} />
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{node.prerequisites.length > 0 && isLocked && (
|
||||
|
||||
@@ -138,6 +138,8 @@ interface Actions {
|
||||
setProductPricing: (productLineId: string, field: string, value: number) => void;
|
||||
toggleProductLine: (productLineId: string) => void;
|
||||
startResearch: (research: ActiveResearch) => void;
|
||||
queueResearch: (researchId: string) => void;
|
||||
removeFromResearchQueue: (researchId: string) => void;
|
||||
hireDepartment: (departmentId: string, count: number) => void;
|
||||
purchaseDataset: (dataset: OwnedDataset, cost: number) => void;
|
||||
raiseFunding: (roundType: FundingRoundType) => void;
|
||||
@@ -1170,6 +1172,35 @@ export const useGameStore = create<Store>()(
|
||||
};
|
||||
}),
|
||||
|
||||
queueResearch: (researchId) => set((s) => {
|
||||
if (s.research.researchQueue.includes(researchId)) return s;
|
||||
const node = TECH_TREE.find(n => n.id === researchId);
|
||||
if (!node) return s;
|
||||
const rpCost = node.cost.researchPoints ?? 0;
|
||||
if (rpCost > s.research.researchPoints) return s;
|
||||
return {
|
||||
research: {
|
||||
...s.research,
|
||||
researchQueue: [...s.research.researchQueue, researchId],
|
||||
researchPoints: s.research.researchPoints - rpCost,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
removeFromResearchQueue: (researchId) => set((s) => {
|
||||
const idx = s.research.researchQueue.indexOf(researchId);
|
||||
if (idx === -1) return s;
|
||||
const node = TECH_TREE.find(n => n.id === researchId);
|
||||
const rpRefund = node?.cost.researchPoints ?? 0;
|
||||
return {
|
||||
research: {
|
||||
...s.research,
|
||||
researchQueue: s.research.researchQueue.filter(id => id !== researchId),
|
||||
researchPoints: s.research.researchPoints + rpRefund,
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
hireDepartment: (departmentId, count) => set((s) => {
|
||||
const costPerHire = 2000;
|
||||
const totalCost = costPerHire * count;
|
||||
@@ -1450,7 +1481,12 @@ export const useGameStore = create<Store>()(
|
||||
infraNav: { level: 'clusters' },
|
||||
} as unknown as Store;
|
||||
}
|
||||
return _persisted as Store;
|
||||
const s = _persisted as Record<string, unknown>;
|
||||
const research = s.research as Record<string, unknown>;
|
||||
if (!research.researchQueue) {
|
||||
s.research = { ...research, researchQueue: [] };
|
||||
}
|
||||
return s as unknown as Store;
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
@@ -22,6 +22,8 @@ export function processDeveloperEcosystem(
|
||||
): DeveloperEcosystem {
|
||||
const updated = { ...eco };
|
||||
|
||||
const eraCap = TAM_BASE_SIZES[era].developer;
|
||||
|
||||
const growthRate =
|
||||
BASE_DEV_GROWTH +
|
||||
apiFreeTierDevs * FREE_TIER_DEV_MULTIPLIER +
|
||||
@@ -29,8 +31,9 @@ export function processDeveloperEcosystem(
|
||||
updated.devRelSpending * DEV_REL_EFFECTIVENESS +
|
||||
updated.sdkCoverage * SDK_GROWTH_BONUS;
|
||||
|
||||
updated.communityGrowthRate = growthRate;
|
||||
updated.communitySize = Math.max(0, updated.communitySize + updated.communitySize * growthRate);
|
||||
const logisticDamping = Math.max(0, 1 - updated.communitySize / Math.max(1, eraCap));
|
||||
updated.communityGrowthRate = growthRate * logisticDamping;
|
||||
updated.communitySize = Math.max(0, updated.communitySize + updated.communitySize * updated.communityGrowthRate);
|
||||
|
||||
if (updated.communitySize < 10 && apiTotalDevs > 0) {
|
||||
updated.communitySize += 1 + apiTotalDevs * 0.1;
|
||||
@@ -47,7 +50,6 @@ export function processDeveloperEcosystem(
|
||||
updated.documentationQuality += (docTarget - updated.documentationQuality) * 0.003;
|
||||
updated.documentationQuality = Math.min(1, Math.max(0, updated.documentationQuality));
|
||||
|
||||
const eraCap = TAM_BASE_SIZES[era].developer;
|
||||
const communityNorm = Math.min(1, updated.communitySize / Math.max(1, eraCap * 0.1));
|
||||
const activeRatio = updated.communitySize > 0
|
||||
? Math.min(1, updated.activeDevelopers / updated.communitySize)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GameState, ResearchState, ComputeState } from '@ai-tycoon/shared';
|
||||
import type { GameState, ResearchState, ActiveResearch, ComputeState } from '@ai-tycoon/shared';
|
||||
import { TECH_TREE } from '../data/techTree';
|
||||
|
||||
export interface ResearchTickResult {
|
||||
@@ -6,6 +6,42 @@ export interface ResearchTickResult {
|
||||
researchCompleted: string | null;
|
||||
}
|
||||
|
||||
function promoteFromQueue(
|
||||
research: ResearchState,
|
||||
state: GameState,
|
||||
): ResearchState {
|
||||
const queue = research.researchQueue;
|
||||
if (queue.length === 0) return research;
|
||||
|
||||
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
const currentEraIdx = eraOrder.indexOf(state.meta.currentEra);
|
||||
const completed = research.completedResearch;
|
||||
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const id = queue[i];
|
||||
const node = TECH_TREE.find(n => n.id === id);
|
||||
if (!node) continue;
|
||||
if (eraOrder.indexOf(node.era) > currentEraIdx) continue;
|
||||
if (node.prerequisites.some(p => !completed.includes(p))) continue;
|
||||
|
||||
const active: ActiveResearch = {
|
||||
researchId: id,
|
||||
progressTicks: 0,
|
||||
totalTicks: node.cost.ticks,
|
||||
allocatedResearchers: state.talent.departments.research.headcount,
|
||||
allocatedCompute: node.cost.compute,
|
||||
};
|
||||
|
||||
return {
|
||||
...research,
|
||||
activeResearch: active,
|
||||
researchQueue: [...queue.slice(0, i), ...queue.slice(i + 1)],
|
||||
};
|
||||
}
|
||||
|
||||
return research;
|
||||
}
|
||||
|
||||
export function processResearch(state: GameState, compute: ComputeState): ResearchTickResult {
|
||||
const active = state.research.activeResearch;
|
||||
if (!active) return { research: state.research, researchCompleted: null };
|
||||
@@ -17,13 +53,18 @@ export function processResearch(state: GameState, compute: ComputeState): Resear
|
||||
const newProgress = active.progressTicks + speedMultiplier;
|
||||
|
||||
if (newProgress >= active.totalTicks) {
|
||||
return {
|
||||
research: {
|
||||
const completedResearch = {
|
||||
...state.research,
|
||||
completedResearch: [...state.research.completedResearch, active.researchId],
|
||||
activeResearch: null,
|
||||
researchPoints: state.research.researchPoints + 1,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
research: promoteFromQueue(completedResearch, {
|
||||
...state,
|
||||
research: completedResearch,
|
||||
}),
|
||||
researchCompleted: active.researchId,
|
||||
};
|
||||
}
|
||||
@@ -40,10 +81,12 @@ export function processResearch(state: GameState, compute: ComputeState): Resear
|
||||
export function getAvailableResearch(state: GameState): typeof TECH_TREE {
|
||||
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
const currentEraIdx = eraOrder.indexOf(state.meta.currentEra);
|
||||
const queuedIds = new Set(state.research.researchQueue);
|
||||
|
||||
return TECH_TREE.filter(node => {
|
||||
if (state.research.completedResearch.includes(node.id)) return false;
|
||||
if (state.research.activeResearch?.researchId === node.id) return false;
|
||||
if (queuedIds.has(node.id)) return false;
|
||||
if (eraOrder.indexOf(node.era) > currentEraIdx) return false;
|
||||
if (node.prerequisites.some(p => !state.research.completedResearch.includes(p))) return false;
|
||||
if (node.cost.researchPoints > state.research.researchPoints) return false;
|
||||
|
||||
@@ -2,14 +2,19 @@ import type { GameState, ActiveResearch } from '@ai-tycoon/shared';
|
||||
import { TECH_TREE } from '@ai-tycoon/game-engine';
|
||||
|
||||
export function startResearch(state: GameState, research: ActiveResearch): boolean {
|
||||
if (state.research.activeResearch) return false;
|
||||
|
||||
const node = TECH_TREE.find(n => n.id === research.researchId);
|
||||
if (!node) return false;
|
||||
|
||||
const rpCost = node.cost.researchPoints ?? 0;
|
||||
if (rpCost > state.research.researchPoints) return false;
|
||||
|
||||
if (state.research.activeResearch) {
|
||||
if (state.research.researchQueue.includes(research.researchId)) return false;
|
||||
state.research.researchQueue.push(research.researchId);
|
||||
state.research.researchPoints -= rpCost;
|
||||
return true;
|
||||
}
|
||||
|
||||
state.research.activeResearch = research;
|
||||
state.research.researchPoints -= rpCost;
|
||||
return true;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Era } from './gameState';
|
||||
export interface ResearchState {
|
||||
completedResearch: string[];
|
||||
activeResearch: ActiveResearch | null;
|
||||
researchQueue: string[];
|
||||
researchPoints: number;
|
||||
}
|
||||
|
||||
@@ -41,5 +42,6 @@ export interface ResearchEffect {
|
||||
export const INITIAL_RESEARCH: ResearchState = {
|
||||
completedResearch: [],
|
||||
activeResearch: null,
|
||||
researchQueue: [],
|
||||
researchPoints: 0,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user