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
+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>
);
}