Add Week 2 depth systems: research, events, competitors, talent, data

Tech tree with 21 research nodes across 5 categories (infrastructure,
efficiency, generation, specialization, safety). Research page with
category-grouped cards, progress tracking, prerequisite gating.

Event engine with 34 events across industry/regulatory/PR/internal/market
categories, weighted random firing, cooldowns, expiry, and choice modal
with consequence preview. Events auto-expire with default choice.

Competitor system with 3 rival AI labs (Prometheus AI, Nexus Labs, Titan
Computing), personality-driven milestone progression, and comparison UI.

Talent page with department hiring, headcount management, and key hire
recruitment from a pool of 10 named characters with special abilities.

Data marketplace with 8 purchasable datasets, user data flywheel from
subscribers, and data system processing in tick loop.

Era transition system checks revenue/capability/reputation thresholds.
All new systems integrated into tick processor with notifications.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 17:30:24 -04:00
parent d1d3eb4bf2
commit 8c9555bc08
19 changed files with 3166 additions and 21 deletions
@@ -0,0 +1,84 @@
import { AlertTriangle, Newspaper, Building2, Users, TrendingUp, X } from 'lucide-react';
import { useGameStore } from '@/store';
import type { ActiveEvent, EventCategory } from '@ai-tycoon/shared';
const CATEGORY_ICONS: Record<EventCategory, typeof AlertTriangle> = {
industry: Newspaper,
regulatory: Building2,
pr: Users,
internal: AlertTriangle,
market: TrendingUp,
};
const CATEGORY_COLORS: Record<EventCategory, string> = {
industry: 'border-blue-500/50 bg-blue-500/5',
regulatory: 'border-yellow-500/50 bg-yellow-500/5',
pr: 'border-purple-500/50 bg-purple-500/5',
internal: 'border-orange-500/50 bg-orange-500/5',
market: 'border-green-500/50 bg-green-500/5',
};
export function EventModal() {
const activeEvents = useGameStore((s) => s.events.activeEvents);
const resolveEvent = useGameStore((s) => s.resolveEvent);
if (activeEvents.length === 0) return null;
const event = activeEvents[0];
const Icon = CATEGORY_ICONS[event.category];
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4">
<div className={`bg-surface-900 border-2 rounded-xl max-w-lg w-full shadow-2xl ${CATEGORY_COLORS[event.category]}`}>
<div className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Icon size={20} className="text-accent-light" />
<h3 className="text-lg font-bold">{event.title}</h3>
</div>
<span className="text-xs text-surface-400 uppercase">{event.category}</span>
</div>
<p className="text-sm text-surface-300 mb-5 leading-relaxed">{event.description}</p>
<div className="space-y-2">
{event.choices.map((choice, idx) => (
<button
key={idx}
onClick={() => resolveEvent(event.instanceId, idx)}
className="w-full text-left bg-surface-800 hover:bg-surface-700 border border-surface-600 hover:border-surface-500 rounded-lg p-3 transition-all group"
>
<div className="text-sm font-medium group-hover:text-accent-light transition-colors">
{choice.label}
</div>
<div className="text-xs text-surface-400 mt-1">{choice.description}</div>
<div className="flex gap-2 mt-2">
{choice.consequences.map((c, i) => (
<ConsequenceTag key={i} type={c.type} value={c.value} />
))}
</div>
</button>
))}
</div>
</div>
</div>
</div>
);
}
function ConsequenceTag({ type, value }: { type: string; value: number }) {
const isPositive = value > 0;
const label = type === 'money' ? `$${Math.abs(value).toLocaleString()}`
: type === 'reputation' ? `${Math.abs(value)} rep`
: type === 'talent' ? `${Math.abs(value)} talent`
: type === 'research_speed' ? `${Math.round(Math.abs(value) * 100)}% R&D`
: `${type}: ${value}`;
return (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${
isPositive ? 'bg-success/20 text-success' : 'bg-danger/20 text-danger'
}`}>
{isPositive ? '+' : '-'}{label}
</span>
);
}
@@ -1,13 +1,18 @@
import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar';
import { ToastContainer } from '@/components/common/ToastContainer';
import { EventModal } from '@/components/game/EventModal';
import { useGameStore } from '@/store';
import { DashboardPage } from '@/pages/DashboardPage';
import { InfrastructurePage } from '@/pages/InfrastructurePage';
import { ResearchPage } from '@/pages/ResearchPage';
import { ModelsPage } from '@/pages/ModelsPage';
import { SettingsPage } from '@/pages/SettingsPage';
import { MarketPage } from '@/pages/MarketPage';
import { FinancePage } from '@/pages/FinancePage';
import { TalentPage } from '@/pages/TalentPage';
import { DataPage } from '@/pages/DataPage';
import { CompetitorsPage } from '@/pages/CompetitorsPage';
export function MainLayout() {
const activePage = useGameStore((s) => s.activePage);
@@ -22,6 +27,7 @@ export function MainLayout() {
</main>
</div>
<ToastContainer />
<EventModal />
</div>
);
}
@@ -30,9 +36,13 @@ function PageRouter({ page }: { page: string }) {
switch (page) {
case 'dashboard': return <DashboardPage />;
case 'infrastructure': return <InfrastructurePage />;
case 'research': return <ResearchPage />;
case 'models': return <ModelsPage />;
case 'market': return <MarketPage />;
case 'finance': return <FinancePage />;
case 'talent': return <TalentPage />;
case 'data': return <DataPage />;
case 'competitors': return <CompetitorsPage />;
case 'settings': return <SettingsPage />;
default: return <PlaceholderPage name={page} />;
}
+3 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef } from 'react';
import { GameEngine } from '@ai-tycoon/game-engine';
import { GameEngine, setEventDefinitions, EVENT_DEFINITIONS } from '@ai-tycoon/game-engine';
import type { TickNotification } from '@ai-tycoon/game-engine';
import { useGameStore } from '@/store';
@@ -11,6 +11,8 @@ export function useGameLoop(skip = false) {
useEffect(() => {
if (!companyName || skip) return;
setEventDefinitions(EVENT_DEFINITIONS);
const engine = new GameEngine({
getState: () => {
const state = useGameStore.getState();
+134
View File
@@ -0,0 +1,134 @@
import { Swords, TrendingUp, Shield, Users, Brain } from 'lucide-react';
import { useGameStore } from '@/store';
import { formatMoney, formatNumber } from '@ai-tycoon/shared';
const ARCHETYPE_LABELS: Record<string, string> = {
'safety-first': 'Safety-First Lab',
'move-fast': 'Move-Fast Startup',
'big-tech': 'Big Tech Giant',
'open-source': 'Open Source Maximalist',
'stealth-startup': 'Stealth Startup',
};
const ARCHETYPE_COLORS: Record<string, string> = {
'safety-first': 'text-green-400',
'move-fast': 'text-red-400',
'big-tech': 'text-blue-400',
'open-source': 'text-orange-400',
'stealth-startup': 'text-purple-400',
};
export function CompetitorsPage() {
const rivals = useGameStore((s) => s.competitors.rivals);
const industryBenchmark = useGameStore((s) => s.competitors.industryBenchmark);
const playerBest = useGameStore((s) =>
s.models.trainedModels.reduce((best, m) => Math.max(best, m.benchmarkScore), 0),
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Competitors</h2>
<div className="text-sm text-surface-400">
Industry Benchmark: <span className="text-surface-100 font-mono">{industryBenchmark.toFixed(1)}</span>
</div>
</div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="text-sm font-medium text-surface-400 mb-3">Your Position</h3>
<div className="flex items-center gap-6">
<div>
<div className="text-xs text-surface-500">Best Model</div>
<div className="text-lg font-mono font-bold">{playerBest.toFixed(1)}/100</div>
</div>
<div className="flex-1">
<div className="h-3 bg-surface-800 rounded-full overflow-hidden relative">
<div
className="absolute h-full bg-accent rounded-full transition-all"
style={{ width: `${playerBest}%` }}
/>
{rivals.filter(r => r.status === 'active').map(rival => (
<div
key={rival.id}
className="absolute top-0 h-full w-0.5 bg-danger"
style={{ left: `${rival.estimatedCapability}%` }}
title={`${rival.name}: ${rival.estimatedCapability.toFixed(1)}`}
/>
))}
</div>
</div>
</div>
</div>
<div className="space-y-4">
{rivals.map(rival => (
<div
key={rival.id}
className={`bg-surface-900 border rounded-xl p-4 ${
rival.status === 'active' ? 'border-surface-700' : 'border-surface-800 opacity-50'
}`}
>
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-semibold text-lg">{rival.name}</h3>
<span className={`text-xs ${ARCHETYPE_COLORS[rival.archetype]}`}>
{ARCHETYPE_LABELS[rival.archetype]}
</span>
</div>
<span className={`text-xs px-2 py-1 rounded-full ${
rival.status === 'active' ? 'bg-success/20 text-success' :
rival.status === 'acquired' ? 'bg-blue-500/20 text-blue-400' :
'bg-surface-700 text-surface-400'
}`}>
{rival.status}
</span>
</div>
<div className="grid grid-cols-4 gap-4">
<Stat icon={Brain} label="Capability" value={rival.estimatedCapability.toFixed(1)} sub={rival.latestModelName} />
<Stat icon={TrendingUp} label="Est. Revenue" value={formatMoney(rival.estimatedRevenue)} sub="/tick" />
<Stat icon={Users} label="Est. Users" value={formatNumber(rival.estimatedUsers)} />
<Stat icon={Shield} label="Reputation" value={`${rival.reputation}/100`} />
</div>
<div className="mt-3 grid grid-cols-6 gap-1">
{Object.entries(rival.personality).map(([key, val]) => (
<div key={key} className="text-center">
<div className="h-1 bg-surface-800 rounded-full overflow-hidden mb-1">
<div className="h-full bg-accent rounded-full" style={{ width: `${(val as number) * 100}%` }} />
</div>
<div className="text-[10px] text-surface-500 capitalize">{key.replace(/([A-Z])/g, ' $1').trim()}</div>
</div>
))}
</div>
</div>
))}
</div>
{rivals.length === 0 && (
<div className="bg-surface-900 border border-surface-700 rounded-xl p-8 text-center text-surface-500">
<Swords size={48} className="mx-auto mb-4 opacity-50" />
<p>No competitors detected yet.</p>
</div>
)}
</div>
);
}
function Stat({ icon: Icon, label, value, sub }: {
icon: typeof Brain;
label: string;
value: string;
sub?: string;
}) {
return (
<div className="bg-surface-800 rounded-lg p-2">
<div className="flex items-center gap-1 mb-1">
<Icon size={12} className="text-surface-400" />
<span className="text-xs text-surface-400">{label}</span>
</div>
<div className="text-sm font-mono font-semibold">{value}</div>
{sub && <div className="text-[10px] text-surface-500">{sub}</div>}
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
import { useState } from 'react';
import { Database, ShoppingCart, Zap } from 'lucide-react';
import { useGameStore } from '@/store';
import { formatNumber, formatMoney } from '@ai-tycoon/shared';
import type { OwnedDataset, DataDomain } from '@ai-tycoon/shared';
interface MarketplaceDataset {
name: string;
domain: DataDomain;
sizeTokens: number;
quality: number;
legalRisk: number;
price: number;
}
const MARKETPLACE: MarketplaceDataset[] = [
{ name: 'Wikipedia Dump', domain: 'web', sizeTokens: 5_000_000_000, quality: 0.6, legalRisk: 0.05, price: 5_000 },
{ name: 'GitHub Code Archive', domain: 'code', sizeTokens: 10_000_000_000, quality: 0.5, legalRisk: 0.15, price: 15_000 },
{ name: 'Scientific Papers Bundle', domain: 'scientific', sizeTokens: 3_000_000_000, quality: 0.8, legalRisk: 0.1, price: 20_000 },
{ name: 'Books3 Collection', domain: 'books', sizeTokens: 8_000_000_000, quality: 0.7, legalRisk: 0.6, price: 8_000 },
{ name: 'Reddit Conversations', domain: 'conversation', sizeTokens: 15_000_000_000, quality: 0.3, legalRisk: 0.3, price: 10_000 },
{ name: 'Multilingual Web Crawl', domain: 'multilingual', sizeTokens: 20_000_000_000, quality: 0.4, legalRisk: 0.2, price: 25_000 },
{ name: 'Synthetic Instruction Set', domain: 'synthetic', sizeTokens: 2_000_000_000, quality: 0.9, legalRisk: 0, price: 30_000 },
{ name: 'Image-Caption Pairs', domain: 'images', sizeTokens: 5_000_000_000, quality: 0.6, legalRisk: 0.25, price: 18_000 },
];
const DOMAIN_COLORS: Record<string, string> = {
web: 'text-blue-400',
code: 'text-green-400',
scientific: 'text-purple-400',
books: 'text-orange-400',
conversation: 'text-yellow-400',
multilingual: 'text-cyan-400',
synthetic: 'text-pink-400',
images: 'text-indigo-400',
video: 'text-red-400',
audio: 'text-teal-400',
};
export function DataPage() {
const ownedDatasets = useGameStore((s) => s.data.ownedDatasets);
const totalTokens = useGameStore((s) => s.data.totalTrainingTokens);
const userDataRate = useGameStore((s) => s.data.userDataGenerationRate);
const money = useGameStore((s) => s.economy.money);
const purchaseDataset = useGameStore((s) => s.purchaseDataset);
const tickCount = useGameStore((s) => s.meta.tickCount);
const ownedNames = new Set(ownedDatasets.map(d => d.name));
const handlePurchase = (item: MarketplaceDataset) => {
const dataset: OwnedDataset = {
id: crypto.randomUUID(),
name: item.name,
domain: item.domain,
sizeTokens: item.sizeTokens,
quality: item.quality,
legalRisk: item.legalRisk,
acquiredAtTick: tickCount,
};
purchaseDataset(dataset, item.price);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Data</h2>
<div className="flex items-center gap-4 text-sm">
<div className="text-surface-400">
Total Tokens: <span className="text-surface-100 font-mono">{formatNumber(totalTokens)}</span>
</div>
<div className="text-surface-400">
User Data: <span className="text-success font-mono">+{formatNumber(userDataRate)}/s</span>
</div>
</div>
</div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="font-semibold mb-3 flex items-center gap-2">
<Database size={16} />
Owned Datasets
</h3>
{ownedDatasets.length > 0 ? (
<div className="grid grid-cols-2 gap-2">
{ownedDatasets.map(ds => (
<div key={ds.id} className="bg-surface-800 rounded-lg p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">{ds.name}</span>
<span className={`text-xs ${DOMAIN_COLORS[ds.domain]}`}>{ds.domain}</span>
</div>
<div className="flex items-center gap-3 text-xs text-surface-400">
<span>{formatNumber(ds.sizeTokens)} tokens</span>
<span>Quality: {Math.round(ds.quality * 100)}%</span>
{ds.legalRisk > 0.3 && (
<span className="text-warning">Risk: {Math.round(ds.legalRisk * 100)}%</span>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-surface-500">No datasets owned.</p>
)}
</div>
{userDataRate > 0 && (
<div className="bg-surface-900 border border-accent/20 rounded-xl p-4">
<h3 className="font-semibold mb-2 flex items-center gap-2">
<Zap size={16} className="text-accent" />
User Data Flywheel
</h3>
<p className="text-sm text-surface-400">
Your product users generate <span className="text-accent-light font-mono">{formatNumber(userDataRate)}</span> tokens
per second of training data. More subscribers = more data = better models.
</p>
</div>
)}
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<h3 className="font-semibold mb-3 flex items-center gap-2">
<ShoppingCart size={16} />
Data Marketplace
</h3>
<div className="space-y-2">
{MARKETPLACE.map(item => {
const owned = ownedNames.has(item.name);
return (
<div
key={item.name}
className={`flex items-center justify-between bg-surface-800 rounded-lg p-3 ${owned ? 'opacity-50' : ''}`}
>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium">{item.name}</span>
<span className={`text-xs ${DOMAIN_COLORS[item.domain]}`}>{item.domain}</span>
</div>
<div className="flex items-center gap-3 text-xs text-surface-400">
<span>{formatNumber(item.sizeTokens)} tokens</span>
<span>Quality: {Math.round(item.quality * 100)}%</span>
{item.legalRisk > 0.3 && (
<span className="text-warning">Legal Risk: {Math.round(item.legalRisk * 100)}%</span>
)}
</div>
</div>
<div className="ml-4">
{owned ? (
<span className="text-xs text-surface-500">Owned</span>
) : (
<button
onClick={() => handlePurchase(item)}
disabled={money < item.price}
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-3 py-1.5 text-xs disabled:opacity-40"
>
{formatMoney(item.price)}
</button>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { FlaskConical, Lock, Check, Play } from 'lucide-react';
import { useGameStore } from '@/store';
import { formatDuration, formatPercent, formatNumber } from '@ai-tycoon/shared';
import { TECH_TREE, getAvailableResearch } from '@ai-tycoon/game-engine';
import type { ResearchNode } from '@ai-tycoon/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 researchPoints = useGameStore((s) => s.research.researchPoints);
const startResearch = useGameStore((s) => s.startResearch);
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 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,
});
};
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>
{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>
)}
{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 isAvailable = availableIds.has(node.id);
const isLocked = !isCompleted && !isActive && !isAvailable;
return (
<div
key={node.id}
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 ? `${CATEGORY_COLORS[category]} hover:brightness-110` :
'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" />}
{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">
{formatDuration(node.cost.ticks)} · {formatNumber(node.cost.compute)} compute
{node.cost.researchPoints > 0 && ` · ${node.cost.researchPoints} RP`}
</div>
{isAvailable && !activeResearch && (
<button
onClick={() => 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>
)}
</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>
);
}
+185
View File
@@ -0,0 +1,185 @@
import { useState } from 'react';
import { Users, Plus, Star, Briefcase } from 'lucide-react';
import { useGameStore } from '@/store';
import { formatMoney } from '@ai-tycoon/shared';
import { KEY_HIRE_POOL } from '@ai-tycoon/game-engine';
import type { DepartmentId } from '@ai-tycoon/shared';
const DEPT_LABELS: Record<string, string> = {
research: 'Research',
engineering: 'Engineering',
operations: 'Operations',
sales: 'Sales',
};
const DEPT_DESCRIPTIONS: Record<string, string> = {
research: 'Improves R&D speed and model quality',
engineering: 'Faster training and better infrastructure',
operations: 'Lower costs and higher uptime',
sales: 'More enterprise contracts and revenue',
};
export function TalentPage() {
const departments = useGameStore((s) => s.talent.departments);
const keyHires = useGameStore((s) => s.talent.keyHires);
const totalSalary = useGameStore((s) => s.talent.totalSalaryPerTick);
const money = useGameStore((s) => s.economy.money);
const era = useGameStore((s) => s.meta.currentEra);
const hireDepartment = useGameStore((s) => s.hireDepartment);
const [showKeyHires, setShowKeyHires] = useState(false);
const hiringCost = 2000;
const canHire = money >= hiringCost;
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
const currentEraIdx = eraOrder.indexOf(era);
const availableKeyHires = KEY_HIRE_POOL.filter(h => {
const hireEraIdx = eraOrder.indexOf(h.requiredEra);
if (hireEraIdx > currentEraIdx) return false;
return !keyHires.some(kh => kh.id === h.id);
});
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Talent</h2>
<div className="text-sm text-surface-400">
Total Salary: <span className="text-danger font-mono">{formatMoney(totalSalary)}/s</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{Object.entries(departments).map(([id, dept]) => (
<div key={id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="font-semibold">{DEPT_LABELS[id]}</h3>
<p className="text-xs text-surface-400">{DEPT_DESCRIPTIONS[id]}</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold font-mono">{dept.headcount}</div>
<div className="text-xs text-surface-500">employees</div>
</div>
</div>
<div className="space-y-2 mb-3">
<StatBar label="Effectiveness" value={dept.effectiveness} />
<StatBar label="Morale" value={dept.morale} />
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-surface-400">
Budget: {formatMoney(dept.budget)}/mo
</span>
<button
onClick={() => hireDepartment(id, 1)}
disabled={!canHire}
className="flex items-center gap-1 bg-surface-800 hover:bg-surface-700 border border-surface-600 rounded px-3 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus size={12} />
Hire ({formatMoney(hiringCost)})
</button>
</div>
</div>
))}
</div>
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold flex items-center gap-2">
<Star size={16} className="text-yellow-400" />
Key Hires
</h3>
<button
onClick={() => setShowKeyHires(!showKeyHires)}
className="text-xs text-accent hover:text-accent-light"
>
{showKeyHires ? 'Hide Available' : `Show Available (${availableKeyHires.length})`}
</button>
</div>
{keyHires.length > 0 && (
<div className="space-y-2 mb-4">
{keyHires.map(hire => (
<div key={hire.id} className="flex items-center justify-between bg-surface-800 rounded-lg p-3">
<div>
<div className="text-sm font-medium">{hire.name}</div>
<div className="text-xs text-surface-400">
{DEPT_LABELS[hire.department]} · {hire.specialAbility}
</div>
</div>
<div className="text-xs text-surface-400">
{formatMoney(hire.salary)}/s
</div>
</div>
))}
</div>
)}
{showKeyHires && (
<div className="space-y-2">
{availableKeyHires.length > 0 ? availableKeyHires.map(hire => (
<div key={hire.id} className="flex items-center justify-between bg-surface-800/50 border border-surface-700 rounded-lg p-3">
<div>
<div className="text-sm font-medium">{hire.name}</div>
<div className="text-xs text-surface-400">{hire.description}</div>
<div className="text-xs text-surface-500">
{DEPT_LABELS[hire.department]} · {formatMoney(hire.salary)}/s
</div>
</div>
<button
onClick={() => {
const store = useGameStore.getState();
const keyHireObj = {
id: hire.id,
name: hire.name,
department: hire.department as DepartmentId,
specialAbility: hire.description,
effects: hire.effects,
salary: hire.salary,
hiredAtTick: store.meta.tickCount,
loyalty: hire.loyalty,
};
store.updateState({
talent: {
...store.talent,
keyHires: [...store.talent.keyHires, keyHireObj],
},
});
}}
disabled={money < 5000}
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-3 py-1.5 text-xs disabled:opacity-40"
>
<Briefcase size={12} />
Recruit ($5K)
</button>
</div>
)) : (
<p className="text-sm text-surface-500 text-center py-2">No key hires available in current era</p>
)}
</div>
)}
{keyHires.length === 0 && !showKeyHires && (
<p className="text-sm text-surface-500">No key hires recruited yet.</p>
)}
</div>
</div>
);
}
function StatBar({ label, value }: { label: string; value: number }) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-surface-500 w-24">{label}</span>
<div className="flex-1 h-1.5 bg-surface-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${value > 0.7 ? 'bg-success' : value > 0.4 ? 'bg-warning' : 'bg-danger'}`}
style={{ width: `${value * 100}%` }}
/>
</div>
<span className="text-xs text-surface-400 font-mono w-8 text-right">{Math.round(value * 100)}%</span>
</div>
);
}
+88
View File
@@ -7,6 +7,7 @@ import type {
CompetitorState, TalentState, DataState,
ReputationState, EventState, AchievementState,
DataCenter, GpuType, GpuInventory, TrainingJob,
ActiveResearch, EventConsequence, OwnedDataset,
} from '@ai-tycoon/shared';
import {
INITIAL_SETTINGS, SAVE_VERSION,
@@ -16,6 +17,7 @@ import {
INITIAL_REPUTATION, INITIAL_EVENTS, INITIAL_ACHIEVEMENTS,
GPU_CONFIGS,
} from '@ai-tycoon/shared';
import { INITIAL_RIVALS } from '@ai-tycoon/game-engine';
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
| 'market' | 'talent' | 'data' | 'competitors' | 'finance' | 'settings';
@@ -48,6 +50,10 @@ interface Actions {
deployModel: (modelId: string) => void;
setProductPricing: (productLineId: string, field: string, value: number) => void;
toggleProductLine: (productLineId: string) => void;
startResearch: (research: ActiveResearch) => void;
resolveEvent: (instanceId: string, choiceIndex: number) => void;
hireDepartment: (departmentId: string, count: number) => void;
purchaseDataset: (dataset: OwnedDataset, cost: number) => void;
updateState: (partial: Partial<GameState>) => void;
}
@@ -111,6 +117,10 @@ export const useGameStore = create<Store>()(
createdAt: Date.now(),
lastTickTimestamp: Date.now(),
},
competitors: {
rivals: INITIAL_RIVALS,
industryBenchmark: 0,
},
activePage: 'dashboard',
notifications: [],
}),
@@ -218,6 +228,84 @@ export const useGameStore = create<Store>()(
},
})),
startResearch: (research) => set((s) => {
if (s.research.activeResearch) return s;
return {
research: { ...s.research, activeResearch: research },
};
}),
resolveEvent: (instanceId, choiceIndex) => set((s) => {
const event = s.events.activeEvents.find(e => e.instanceId === instanceId);
if (!event) return s;
const choice = event.choices[choiceIndex];
if (!choice) return s;
let money = s.economy.money;
let reputation = { ...s.reputation };
const consequences = choice.consequences;
for (const c of consequences) {
switch (c.type) {
case 'money': money += c.value; break;
case 'reputation': reputation = { ...reputation, score: Math.min(100, Math.max(0, reputation.score + c.value)), publicPerception: Math.min(100, Math.max(0, reputation.publicPerception + c.value)) }; break;
}
}
return {
economy: { ...s.economy, money: Math.max(0, money) },
reputation,
events: {
...s.events,
activeEvents: s.events.activeEvents.filter(e => e.instanceId !== instanceId),
eventHistory: [
...s.events.eventHistory,
{
eventId: event.eventId,
instanceId,
title: event.title,
category: event.category,
tick: s.meta.tickCount,
chosenOptionIndex: choiceIndex,
},
],
},
};
}),
hireDepartment: (departmentId, count) => set((s) => {
const costPerHire = 2000;
const totalCost = costPerHire * count;
if (s.economy.money < totalCost) return s;
return {
economy: { ...s.economy, money: s.economy.money - totalCost },
talent: {
...s.talent,
departments: {
...s.talent.departments,
[departmentId]: {
...s.talent.departments[departmentId as keyof typeof s.talent.departments],
headcount: s.talent.departments[departmentId as keyof typeof s.talent.departments].headcount + count,
},
},
},
};
}),
purchaseDataset: (dataset, cost) => set((s) => {
if (s.economy.money < cost) return s;
return {
economy: { ...s.economy, money: s.economy.money - cost },
data: {
...s.data,
ownedDatasets: [...s.data.ownedDatasets, dataset],
totalTrainingTokens: s.data.totalTrainingTokens + dataset.sizeTokens,
},
};
}),
updateState: (partial) => set((s) => {
const newState: Partial<Store> = {};
for (const key of Object.keys(partial) as (keyof GameState)[]) {