Add research queue: queue multiple projects, auto-promote on completion, RP refund on dequeue
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user