From 5885e335317068ed00ce0a006742a0db130aa0e2 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 26 Apr 2026 08:16:16 -0400 Subject: [PATCH] Add research queue: queue multiple projects, auto-promote on completion, RP refund on dequeue Co-Authored-By: Claude Opus 4.6 --- apps/web/src/pages/ResearchPage.tsx | 78 ++++++++++++++++--- apps/web/src/store/index.ts | 38 ++++++++- .../game-engine/src/systems/researchSystem.ts | 57 ++++++++++++-- .../game-simulation/src/actions/research.ts | 9 ++- packages/shared/src/types/research.ts | 2 + 5 files changed, 164 insertions(+), 20 deletions(-) diff --git a/apps/web/src/pages/ResearchPage.tsx b/apps/web/src/pages/ResearchPage.tsx index e9032f0..fd1f2db 100644 --- a/apps/web/src/pages/ResearchPage.tsx +++ b/apps/web/src/pages/ResearchPage.tsx @@ -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 = { 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() { - 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. {activeResearch && ( @@ -88,6 +96,36 @@ export function ResearchPage() { )} + {researchQueue.length > 0 && ( +
+
+ + Queue ({researchQueue.length}) +
+
+ {researchQueue.map((id, idx) => { + const node = TECH_TREE.find(n => n.id === id); + if (!node) return null; + return ( +
+
+ {idx + 1}. + {node.name} + {formatDuration(node.cost.ticks)} +
+ +
+ ); + })} +
+
+ )} + {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 (
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' }`} >

{node.name}

{isCompleted && } + {isQueued && #{queuePosition}} {isLocked && }

{node.description}

@@ -125,17 +168,32 @@ export function ResearchPage() { {formatDuration(node.cost.ticks)} · {formatNumber(node.cost.compute)} compute {node.cost.researchPoints > 0 && ` · ${node.cost.researchPoints} RP`}
- {isAvailable && !activeResearch && ( + {canStart && ( )} - {isAvailable && activeResearch && ( - Queue after current + {canQueue && ( + + )} + {isQueued && ( + )} {node.prerequisites.length > 0 && isLocked && ( diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts index 517503a..d362570 100644 --- a/apps/web/src/store/index.ts +++ b/apps/web/src/store/index.ts @@ -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()( }; }), + 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()( infraNav: { level: 'clusters' }, } as unknown as Store; } - return _persisted as Store; + const s = _persisted as Record; + const research = s.research as Record; + if (!research.researchQueue) { + s.research = { ...research, researchQueue: [] }; + } + return s as unknown as Store; }, }, ), diff --git a/packages/game-engine/src/systems/researchSystem.ts b/packages/game-engine/src/systems/researchSystem.ts index 3f8e106..2c6b730 100644 --- a/packages/game-engine/src/systems/researchSystem.ts +++ b/packages/game-engine/src/systems/researchSystem.ts @@ -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) { + const completedResearch = { + ...state.research, + completedResearch: [...state.research.completedResearch, active.researchId], + activeResearch: null, + researchPoints: state.research.researchPoints + 1, + }; + return { - research: { - ...state.research, - completedResearch: [...state.research.completedResearch, active.researchId], - activeResearch: null, - researchPoints: state.research.researchPoints + 1, - }, + 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; diff --git a/packages/game-simulation/src/actions/research.ts b/packages/game-simulation/src/actions/research.ts index 7a3e9eb..68a6244 100644 --- a/packages/game-simulation/src/actions/research.ts +++ b/packages/game-simulation/src/actions/research.ts @@ -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; diff --git a/packages/shared/src/types/research.ts b/packages/shared/src/types/research.ts index 4e9bff9..8ac9950 100644 --- a/packages/shared/src/types/research.ts +++ b/packages/shared/src/types/research.ts @@ -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, };