c1cc70eeb9
Full rebrand: UI display text, package scope (@ai-tycoon/* -> @token-empire/*), localStorage keys, Docker/CI image paths, database names, and documentation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
217 lines
10 KiB
TypeScript
217 lines
10 KiB
TypeScript
import { FlaskConical, Lock, Check, Play, ListOrdered, X } from 'lucide-react';
|
|
import { TutorialHint } from '@/components/game/TutorialHint';
|
|
import { useGameStore } from '@/store';
|
|
import { formatDuration, formatPercent, formatNumber, formatMoney } from '@token-empire/shared';
|
|
import { TECH_TREE, getAvailableResearch } from '@token-empire/game-engine';
|
|
import type { ResearchNode } from '@token-empire/shared';
|
|
|
|
const CATEGORY_COLORS: Record<string, string> = {
|
|
generation: 'border-purple-500/50 bg-purple-500/10',
|
|
efficiency: 'border-blue-500/50 bg-blue-500/10',
|
|
safety: 'border-green-500/50 bg-green-500/10',
|
|
specialization: 'border-orange-500/50 bg-orange-500/10',
|
|
infrastructure: 'border-cyan-500/50 bg-cyan-500/10',
|
|
};
|
|
|
|
const CATEGORY_LABELS: Record<string, string> = {
|
|
generation: 'Model Architecture',
|
|
efficiency: 'Efficiency',
|
|
safety: 'Safety & Alignment',
|
|
specialization: 'Specialization',
|
|
infrastructure: 'Infrastructure',
|
|
};
|
|
|
|
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;
|
|
startResearch({
|
|
researchId: node.id,
|
|
progressTicks: 0,
|
|
totalTicks: node.cost.ticks,
|
|
allocatedResearchers: state.talent.departments.research.headcount,
|
|
allocatedCompute: node.cost.compute,
|
|
moneySpent: 0,
|
|
});
|
|
};
|
|
|
|
const handleQueue = (node: ResearchNode) => {
|
|
queueResearch(node.id);
|
|
};
|
|
|
|
const categories = [...new Set(TECH_TREE.map(n => n.category))];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-2xl font-bold">Research & Development</h2>
|
|
<div className="flex items-center gap-4 text-sm">
|
|
<div className="text-surface-400">
|
|
Completed: <span className="text-surface-100 font-mono">{completedResearch.length}/{TECH_TREE.length}</span>
|
|
</div>
|
|
<div className="text-surface-400">
|
|
Research Points: <span className="text-accent-light font-mono">{researchPoints}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<TutorialHint id="research-intro">
|
|
Queue up multiple research projects to run in sequence. Complete prerequisites to unlock advanced technologies that improve your models and infrastructure.
|
|
</TutorialHint>
|
|
|
|
{activeResearch && (
|
|
<div className="bg-surface-900 border border-accent/30 rounded-xl p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<FlaskConical size={16} className="text-accent" />
|
|
<span className="font-medium">
|
|
{TECH_TREE.find(n => n.id === activeResearch.researchId)?.name ?? activeResearch.researchId}
|
|
</span>
|
|
</div>
|
|
<span className="text-sm text-surface-400">
|
|
{formatPercent(activeResearch.progressTicks / activeResearch.totalTicks)} complete
|
|
</span>
|
|
</div>
|
|
<div className="h-2 bg-surface-800 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-accent rounded-full transition-all duration-300"
|
|
style={{ width: `${(activeResearch.progressTicks / activeResearch.totalTicks) * 100}%` }}
|
|
/>
|
|
</div>
|
|
<div className="text-xs text-surface-500 mt-1">
|
|
ETA: {formatDuration(Math.ceil(activeResearch.totalTicks - activeResearch.progressTicks))}
|
|
</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 => {
|
|
const nodes = TECH_TREE.filter(n => n.category === category);
|
|
return (
|
|
<div key={category}>
|
|
<h3 className="text-sm font-semibold text-surface-400 uppercase tracking-wider mb-3">
|
|
{CATEGORY_LABELS[category] ?? category}
|
|
</h3>
|
|
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
|
|
{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 && !isQueued;
|
|
const canStart = isAvailable && !activeResearch;
|
|
const canQueue = isAvailable && !!activeResearch;
|
|
const queuePosition = isQueued ? researchQueue.indexOf(node.id) + 1 : -1;
|
|
|
|
return (
|
|
<div
|
|
key={node.id}
|
|
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' :
|
|
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>
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-xs text-surface-500">
|
|
{formatMoney(node.cost.money)} · {formatDuration(node.cost.ticks)} · {formatNumber(node.cost.compute)} compute
|
|
{node.cost.researchPoints > 0 && ` · ${node.cost.researchPoints} RP`}
|
|
</div>
|
|
{canStart && (
|
|
<button
|
|
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>
|
|
)}
|
|
{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 && (
|
|
<div className="text-xs text-surface-600 mt-2">
|
|
Requires: {node.prerequisites.map(p =>
|
|
TECH_TREE.find(n => n.id === p)?.name ?? p
|
|
).join(', ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|