Add research queue: queue multiple projects, auto-promote on completion, RP refund on dequeue
Balance Check / multi-run-balance (push) Has been cancelled
Balance Check / balance-simulation (push) Has been cancelled
CI / build-and-push (push) Successful in 28s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 08:16:16 -04:00
parent 626ca51041
commit 5885e33531
5 changed files with 164 additions and 20 deletions
+68 -10
View File
@@ -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 { TutorialHint } from '@/components/game/TutorialHint';
import { useGameStore } from '@/store'; import { useGameStore } from '@/store';
import { formatDuration, formatPercent, formatNumber } from '@ai-tycoon/shared'; import { formatDuration, formatPercent, formatNumber } from '@ai-tycoon/shared';
@@ -24,13 +24,17 @@ const CATEGORY_LABELS: Record<string, string> = {
export function ResearchPage() { export function ResearchPage() {
const completedResearch = useGameStore((s) => s.research.completedResearch); const completedResearch = useGameStore((s) => s.research.completedResearch);
const activeResearch = useGameStore((s) => s.research.activeResearch); const activeResearch = useGameStore((s) => s.research.activeResearch);
const researchQueue = useGameStore((s) => s.research.researchQueue);
const researchPoints = useGameStore((s) => s.research.researchPoints); const researchPoints = useGameStore((s) => s.research.researchPoints);
const startResearch = useGameStore((s) => s.startResearch); 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 era = useGameStore((s) => s.meta.currentEra);
const state = useGameStore.getState(); const state = useGameStore.getState();
const available = getAvailableResearch(state); const available = getAvailableResearch(state);
const availableIds = new Set(available.map(n => n.id)); const availableIds = new Set(available.map(n => n.id));
const queuedIds = new Set(researchQueue);
const handleStart = (node: ResearchNode) => { const handleStart = (node: ResearchNode) => {
if (activeResearch) return; 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))]; const categories = [...new Set(TECH_TREE.map(n => n.category))];
return ( return (
@@ -60,7 +68,7 @@ export function ResearchPage() {
</div> </div>
<TutorialHint id="research-intro"> <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> </TutorialHint>
{activeResearch && ( {activeResearch && (
@@ -88,6 +96,36 @@ export function ResearchPage() {
</div> </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 => { {categories.map(category => {
const nodes = TECH_TREE.filter(n => n.category === category); const nodes = TECH_TREE.filter(n => n.category === category);
return ( return (
@@ -99,24 +137,29 @@ export function ResearchPage() {
{nodes.map(node => { {nodes.map(node => {
const isCompleted = completedResearch.includes(node.id); const isCompleted = completedResearch.includes(node.id);
const isActive = activeResearch?.researchId === node.id; const isActive = activeResearch?.researchId === node.id;
const isQueued = queuedIds.has(node.id);
const isAvailable = availableIds.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 ( return (
<div <div
key={node.id} key={node.id}
onClick={() => isAvailable && !activeResearch && handleStart(node)} onClick={() => canStart ? handleStart(node) : canQueue ? handleQueue(node) : undefined}
className={`rounded-xl border p-4 transition-all ${ className={`rounded-xl border p-4 transition-all ${
isCompleted ? 'border-success/50 bg-success/5 opacity-70' : isCompleted ? 'border-success/50 bg-success/5 opacity-70' :
isActive ? 'border-accent/50 bg-accent/5' : 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` : isQueued ? 'border-amber-500/50 bg-amber-500/5' :
isAvailable ? `${CATEGORY_COLORS[category]}` : (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' 'border-surface-700 bg-surface-900 opacity-50'
}`} }`}
> >
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<h4 className="font-medium text-sm">{node.name}</h4> <h4 className="font-medium text-sm">{node.name}</h4>
{isCompleted && <Check size={16} className="text-success flex-shrink-0" />} {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" />} {isLocked && <Lock size={14} className="text-surface-500 flex-shrink-0" />}
</div> </div>
<p className="text-xs text-surface-400 mb-3">{node.description}</p> <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 {formatDuration(node.cost.ticks)} · {formatNumber(node.cost.compute)} compute
{node.cost.researchPoints > 0 && ` · ${node.cost.researchPoints} RP`} {node.cost.researchPoints > 0 && ` · ${node.cost.researchPoints} RP`}
</div> </div>
{isAvailable && !activeResearch && ( {canStart && (
<button <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" className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-2 py-1 text-xs"
> >
<Play size={12} /> <Play size={12} />
Start Start
</button> </button>
)} )}
{isAvailable && activeResearch && ( {canQueue && (
<span className="text-xs text-surface-500 italic">Queue after current</span> <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> </div>
{node.prerequisites.length > 0 && isLocked && ( {node.prerequisites.length > 0 && isLocked && (
+37 -1
View File
@@ -138,6 +138,8 @@ interface Actions {
setProductPricing: (productLineId: string, field: string, value: number) => void; setProductPricing: (productLineId: string, field: string, value: number) => void;
toggleProductLine: (productLineId: string) => void; toggleProductLine: (productLineId: string) => void;
startResearch: (research: ActiveResearch) => void; startResearch: (research: ActiveResearch) => void;
queueResearch: (researchId: string) => void;
removeFromResearchQueue: (researchId: string) => void;
hireDepartment: (departmentId: string, count: number) => void; hireDepartment: (departmentId: string, count: number) => void;
purchaseDataset: (dataset: OwnedDataset, cost: number) => void; purchaseDataset: (dataset: OwnedDataset, cost: number) => void;
raiseFunding: (roundType: FundingRoundType) => 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) => { hireDepartment: (departmentId, count) => set((s) => {
const costPerHire = 2000; const costPerHire = 2000;
const totalCost = costPerHire * count; const totalCost = costPerHire * count;
@@ -1450,7 +1481,12 @@ export const useGameStore = create<Store>()(
infraNav: { level: 'clusters' }, infraNav: { level: 'clusters' },
} as unknown as Store; } 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;
}, },
}, },
), ),
@@ -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'; import { TECH_TREE } from '../data/techTree';
export interface ResearchTickResult { export interface ResearchTickResult {
@@ -6,6 +6,42 @@ export interface ResearchTickResult {
researchCompleted: string | null; 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 { export function processResearch(state: GameState, compute: ComputeState): ResearchTickResult {
const active = state.research.activeResearch; const active = state.research.activeResearch;
if (!active) return { research: state.research, researchCompleted: null }; 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; const newProgress = active.progressTicks + speedMultiplier;
if (newProgress >= active.totalTicks) { if (newProgress >= active.totalTicks) {
return { const completedResearch = {
research: {
...state.research, ...state.research,
completedResearch: [...state.research.completedResearch, active.researchId], completedResearch: [...state.research.completedResearch, active.researchId],
activeResearch: null, activeResearch: null,
researchPoints: state.research.researchPoints + 1, researchPoints: state.research.researchPoints + 1,
}, };
return {
research: promoteFromQueue(completedResearch, {
...state,
research: completedResearch,
}),
researchCompleted: active.researchId, researchCompleted: active.researchId,
}; };
} }
@@ -40,10 +81,12 @@ export function processResearch(state: GameState, compute: ComputeState): Resear
export function getAvailableResearch(state: GameState): typeof TECH_TREE { export function getAvailableResearch(state: GameState): typeof TECH_TREE {
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi']; const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
const currentEraIdx = eraOrder.indexOf(state.meta.currentEra); const currentEraIdx = eraOrder.indexOf(state.meta.currentEra);
const queuedIds = new Set(state.research.researchQueue);
return TECH_TREE.filter(node => { return TECH_TREE.filter(node => {
if (state.research.completedResearch.includes(node.id)) return false; if (state.research.completedResearch.includes(node.id)) return false;
if (state.research.activeResearch?.researchId === 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 (eraOrder.indexOf(node.era) > currentEraIdx) return false;
if (node.prerequisites.some(p => !state.research.completedResearch.includes(p))) return false; if (node.prerequisites.some(p => !state.research.completedResearch.includes(p))) return false;
if (node.cost.researchPoints > state.research.researchPoints) 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'; import { TECH_TREE } from '@ai-tycoon/game-engine';
export function startResearch(state: GameState, research: ActiveResearch): boolean { export function startResearch(state: GameState, research: ActiveResearch): boolean {
if (state.research.activeResearch) return false;
const node = TECH_TREE.find(n => n.id === research.researchId); const node = TECH_TREE.find(n => n.id === research.researchId);
if (!node) return false; if (!node) return false;
const rpCost = node.cost.researchPoints ?? 0; const rpCost = node.cost.researchPoints ?? 0;
if (rpCost > state.research.researchPoints) return false; 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.activeResearch = research;
state.research.researchPoints -= rpCost; state.research.researchPoints -= rpCost;
return true; return true;
+2
View File
@@ -3,6 +3,7 @@ import type { Era } from './gameState';
export interface ResearchState { export interface ResearchState {
completedResearch: string[]; completedResearch: string[];
activeResearch: ActiveResearch | null; activeResearch: ActiveResearch | null;
researchQueue: string[];
researchPoints: number; researchPoints: number;
} }
@@ -41,5 +42,6 @@ export interface ResearchEffect {
export const INITIAL_RESEARCH: ResearchState = { export const INITIAL_RESEARCH: ResearchState = {
completedResearch: [], completedResearch: [],
activeResearch: null, activeResearch: null,
researchQueue: [],
researchPoints: 0, researchPoints: 0,
}; };