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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user