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,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} />;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)[]) {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Competitor } from '@ai-tycoon/shared';
|
||||
|
||||
/**
|
||||
* Initial rival AI companies that compete with the player from the start.
|
||||
* Names are fictional parodies -- any resemblance to real companies is purely satirical.
|
||||
*/
|
||||
export const INITIAL_RIVALS: Competitor[] = [
|
||||
// ── Safety-first lab (Anthropic parody) ──────────────────────────────
|
||||
{
|
||||
id: 'competitor_prometheus',
|
||||
name: 'Prometheus AI',
|
||||
archetype: 'safety-first',
|
||||
personality: {
|
||||
aggression: 0.2,
|
||||
safetyFocus: 0.95,
|
||||
openSourceTendency: 0.3,
|
||||
marketingFocus: 0.25,
|
||||
researchFocus: 0.85,
|
||||
riskTolerance: 0.15,
|
||||
},
|
||||
status: 'active',
|
||||
estimatedCapability: 18,
|
||||
estimatedRevenue: 50,
|
||||
estimatedUsers: 1_200,
|
||||
reputation: 70,
|
||||
latestModelName: 'Aegis-1',
|
||||
completedMilestones: [],
|
||||
nextMilestoneAtTick: 300,
|
||||
},
|
||||
|
||||
// ── Move-fast startup (xAI / Musk parody) ────────────────────────────
|
||||
{
|
||||
id: 'competitor_nexus',
|
||||
name: 'Nexus Labs',
|
||||
archetype: 'move-fast',
|
||||
personality: {
|
||||
aggression: 0.85,
|
||||
safetyFocus: 0.15,
|
||||
openSourceTendency: 0.4,
|
||||
marketingFocus: 0.7,
|
||||
researchFocus: 0.6,
|
||||
riskTolerance: 0.9,
|
||||
},
|
||||
status: 'active',
|
||||
estimatedCapability: 14,
|
||||
estimatedRevenue: 30,
|
||||
estimatedUsers: 3_500,
|
||||
reputation: 45,
|
||||
latestModelName: 'Blitz-0.9',
|
||||
completedMilestones: [],
|
||||
nextMilestoneAtTick: 300,
|
||||
},
|
||||
|
||||
// ── Big-tech giant (Google parody) ────────────────────────────────────
|
||||
{
|
||||
id: 'competitor_titan',
|
||||
name: 'Titan Computing',
|
||||
archetype: 'big-tech',
|
||||
personality: {
|
||||
aggression: 0.5,
|
||||
safetyFocus: 0.5,
|
||||
openSourceTendency: 0.35,
|
||||
marketingFocus: 0.55,
|
||||
researchFocus: 0.65,
|
||||
riskTolerance: 0.4,
|
||||
},
|
||||
status: 'active',
|
||||
estimatedCapability: 22,
|
||||
estimatedRevenue: 200,
|
||||
estimatedUsers: 15_000,
|
||||
reputation: 60,
|
||||
latestModelName: 'Colossus 2.0',
|
||||
completedMilestones: [],
|
||||
nextMilestoneAtTick: 300,
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
import type { DepartmentId } from '@ai-tycoon/shared';
|
||||
|
||||
/**
|
||||
* A recruitable key hire as it appears in the available pool.
|
||||
* `hiredAtTick` is omitted because the hire hasn't been recruited yet.
|
||||
*/
|
||||
export interface KeyHireTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
department: DepartmentId;
|
||||
specialAbility: string;
|
||||
description: string;
|
||||
requiredEra: string;
|
||||
effects: { type: string; value: number }[];
|
||||
salary: number;
|
||||
loyalty: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master pool of key hires the player can recruit throughout the game.
|
||||
* Salary is per-tick. Effect values are fractional multipliers (0.20 = +20%).
|
||||
*/
|
||||
export const KEY_HIRE_POOL: KeyHireTemplate[] = [
|
||||
// ── Research ──────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'hire_elena_vasquez',
|
||||
name: 'Dr. Elena Vasquez',
|
||||
department: 'research',
|
||||
specialAbility: 'Scaling Law Whisperer',
|
||||
description:
|
||||
'Former theoretical physicist who discovered three novel scaling laws. Her intuition for optimal compute allocation is uncanny.',
|
||||
requiredEra: 'foundation',
|
||||
effects: [{ type: 'research_speed', value: 0.2 }],
|
||||
salary: 3,
|
||||
loyalty: 0.7,
|
||||
},
|
||||
{
|
||||
id: 'hire_raj_patel',
|
||||
name: 'Dr. Raj Patel',
|
||||
department: 'research',
|
||||
specialAbility: 'Alignment Prodigy',
|
||||
description:
|
||||
'Published the seminal paper on constitutional training at age 24. Governments call him before passing AI regulation.',
|
||||
requiredEra: 'growth',
|
||||
effects: [
|
||||
{ type: 'research_speed', value: 0.1 },
|
||||
{ type: 'reputation', value: 0.15 },
|
||||
],
|
||||
salary: 4,
|
||||
loyalty: 0.85,
|
||||
},
|
||||
|
||||
// ── Engineering ───────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'hire_marcus_chen',
|
||||
name: 'Marcus Chen',
|
||||
department: 'engineering',
|
||||
specialAbility: 'Infrastructure Guru',
|
||||
description:
|
||||
'Built the internal orchestration layer at three hyperscalers. Can squeeze 40% more throughput out of any GPU cluster before lunch.',
|
||||
requiredEra: 'foundation',
|
||||
effects: [{ type: 'cost_reduction', value: 0.15 }],
|
||||
salary: 3,
|
||||
loyalty: 0.6,
|
||||
},
|
||||
{
|
||||
id: 'hire_yuki_tanaka',
|
||||
name: 'Yuki Tanaka',
|
||||
department: 'engineering',
|
||||
specialAbility: 'Latency Assassin',
|
||||
description:
|
||||
'Obsessed with shaving milliseconds. Once rewrote an entire inference stack on a red-eye flight and deployed it before landing.',
|
||||
requiredEra: 'foundation',
|
||||
effects: [
|
||||
{ type: 'training_speed', value: 0.15 },
|
||||
{ type: 'capability_boost', value: 0.05 },
|
||||
],
|
||||
salary: 2,
|
||||
loyalty: 0.55,
|
||||
},
|
||||
{
|
||||
id: 'hire_omar_hassan',
|
||||
name: 'Omar Hassan',
|
||||
department: 'engineering',
|
||||
specialAbility: 'Compiler Whisperer',
|
||||
description:
|
||||
'Wrote a custom ML compiler that rival labs tried to acqui-hire him just to get access to. His kernel optimizations are legendary.',
|
||||
requiredEra: 'growth',
|
||||
effects: [
|
||||
{ type: 'training_speed', value: 0.2 },
|
||||
{ type: 'cost_reduction', value: 0.1 },
|
||||
],
|
||||
salary: 4,
|
||||
loyalty: 0.65,
|
||||
},
|
||||
|
||||
// ── Operations ────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'hire_diana_okafor',
|
||||
name: 'Diana Okafor',
|
||||
department: 'operations',
|
||||
specialAbility: 'Talent Magnet',
|
||||
description:
|
||||
'Her recruiting network spans every top CS program on the planet. Candidates accept offers just because she asked.',
|
||||
requiredEra: 'foundation',
|
||||
effects: [{ type: 'hiring_speed', value: 0.25 }],
|
||||
salary: 2,
|
||||
loyalty: 0.75,
|
||||
},
|
||||
{
|
||||
id: 'hire_liam_frost',
|
||||
name: 'Liam Frost',
|
||||
department: 'operations',
|
||||
specialAbility: 'Supply Chain Sorcerer',
|
||||
description:
|
||||
'Secured GPU allocations during the Great Chip Shortage when everyone else was on a two-year waitlist. Knows every fab manager by first name.',
|
||||
requiredEra: 'growth',
|
||||
effects: [
|
||||
{ type: 'cost_reduction', value: 0.1 },
|
||||
{ type: 'capability_boost', value: 0.1 },
|
||||
],
|
||||
salary: 3,
|
||||
loyalty: 0.7,
|
||||
},
|
||||
|
||||
// ── Sales ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'hire_sarah_kim',
|
||||
name: 'Sarah Kim',
|
||||
department: 'sales',
|
||||
specialAbility: 'Enterprise Closer',
|
||||
description:
|
||||
'Closed the largest SaaS deal in history before turning 30. Fortune 500 CIOs have her on speed dial.',
|
||||
requiredEra: 'foundation',
|
||||
effects: [{ type: 'revenue_boost', value: 0.25 }],
|
||||
salary: 3,
|
||||
loyalty: 0.5,
|
||||
},
|
||||
{
|
||||
id: 'hire_alex_reeves',
|
||||
name: 'Alex Reeves',
|
||||
department: 'sales',
|
||||
specialAbility: 'Developer Evangelist',
|
||||
description:
|
||||
'Their conference talks go viral every time. Open-source communities worship the ground they commit on.',
|
||||
requiredEra: 'foundation',
|
||||
effects: [
|
||||
{ type: 'reputation', value: 0.2 },
|
||||
{ type: 'revenue_boost', value: 0.1 },
|
||||
],
|
||||
salary: 2,
|
||||
loyalty: 0.6,
|
||||
},
|
||||
{
|
||||
id: 'hire_isabella_marquez',
|
||||
name: 'Isabella Marquez',
|
||||
department: 'sales',
|
||||
specialAbility: 'Government Liaison',
|
||||
description:
|
||||
'Former deputy tech advisor to three heads of state. Opens doors to public-sector contracts no one else can touch.',
|
||||
requiredEra: 'growth',
|
||||
effects: [
|
||||
{ type: 'revenue_boost', value: 0.15 },
|
||||
{ type: 'reputation', value: 0.1 },
|
||||
],
|
||||
salary: 5,
|
||||
loyalty: 0.8,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { ResearchNode } from '@ai-tycoon/shared';
|
||||
|
||||
export const TECH_TREE: ResearchNode[] = [
|
||||
// === COMPUTE / INFRASTRUCTURE ===
|
||||
{
|
||||
id: 'advanced-cooling',
|
||||
name: 'Advanced Cooling',
|
||||
description: 'Liquid cooling systems reduce energy costs by 25%.',
|
||||
era: 'startup',
|
||||
category: 'infrastructure',
|
||||
prerequisites: [],
|
||||
cost: { researchPoints: 0, compute: 5, ticks: 60 },
|
||||
effects: [{ type: 'cost_reduction', target: 'energy', value: 0.25 }],
|
||||
},
|
||||
{
|
||||
id: 'redundancy-protocols',
|
||||
name: 'Redundancy Protocols',
|
||||
description: 'Fault-tolerant architectures cut GPU failure rates in half.',
|
||||
era: 'startup',
|
||||
category: 'infrastructure',
|
||||
prerequisites: [],
|
||||
cost: { researchPoints: 0, compute: 5, ticks: 60 },
|
||||
effects: [{ type: 'cost_reduction', target: 'failure_rate', value: 0.5 }],
|
||||
},
|
||||
{
|
||||
id: 'advanced-gpu-arch',
|
||||
name: 'Advanced GPU Architecture',
|
||||
description: 'Unlocks procurement of NVIDIA A100 datacenter GPUs.',
|
||||
era: 'startup',
|
||||
category: 'infrastructure',
|
||||
prerequisites: [],
|
||||
cost: { researchPoints: 0, compute: 10, ticks: 90 },
|
||||
effects: [{ type: 'unlock_gpu', target: 'a100', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'next-gen-gpu',
|
||||
name: 'Next-Gen GPU Architecture',
|
||||
description: 'Unlocks procurement of NVIDIA H100 GPUs.',
|
||||
era: 'scaleup',
|
||||
category: 'infrastructure',
|
||||
prerequisites: ['advanced-gpu-arch'],
|
||||
cost: { researchPoints: 2, compute: 40, ticks: 240 },
|
||||
effects: [{ type: 'unlock_gpu', target: 'h100', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'frontier-compute',
|
||||
name: 'Frontier Compute',
|
||||
description: 'Unlocks procurement of NVIDIA B200 GPUs.',
|
||||
era: 'bigtech',
|
||||
category: 'infrastructure',
|
||||
prerequisites: ['next-gen-gpu'],
|
||||
cost: { researchPoints: 5, compute: 200, ticks: 480 },
|
||||
effects: [{ type: 'unlock_gpu', target: 'b200', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'custom-silicon',
|
||||
name: 'Custom Silicon Design',
|
||||
description: 'Design and fabricate custom AI ASICs for maximum efficiency.',
|
||||
era: 'agi',
|
||||
category: 'infrastructure',
|
||||
prerequisites: ['frontier-compute'],
|
||||
cost: { researchPoints: 10, compute: 500, ticks: 900 },
|
||||
effects: [{ type: 'unlock_gpu', target: 'custom', value: 1 }],
|
||||
},
|
||||
{
|
||||
id: 'distributed-training',
|
||||
name: 'Distributed Training',
|
||||
description: 'Train models across multiple data centers simultaneously. +20% training speed.',
|
||||
era: 'scaleup',
|
||||
category: 'infrastructure',
|
||||
prerequisites: ['advanced-gpu-arch'],
|
||||
cost: { researchPoints: 2, compute: 30, ticks: 180 },
|
||||
effects: [{ type: 'efficiency_boost', target: 'training_speed', value: 0.2 }],
|
||||
},
|
||||
|
||||
// === EFFICIENCY ===
|
||||
{
|
||||
id: 'quantization',
|
||||
name: 'Quantization Research',
|
||||
description: 'INT8/INT4 inference reduces compute costs. +15% inference efficiency.',
|
||||
era: 'startup',
|
||||
category: 'efficiency',
|
||||
prerequisites: [],
|
||||
cost: { researchPoints: 0, compute: 8, ticks: 75 },
|
||||
effects: [{ type: 'efficiency_boost', target: 'inference', value: 0.15 }],
|
||||
},
|
||||
{
|
||||
id: 'distillation',
|
||||
name: 'Knowledge Distillation',
|
||||
description: 'Train smaller models that retain teacher quality. +10% model capability.',
|
||||
era: 'scaleup',
|
||||
category: 'efficiency',
|
||||
prerequisites: ['quantization'],
|
||||
cost: { researchPoints: 2, compute: 25, ticks: 180 },
|
||||
effects: [{ type: 'capability_boost', target: 'all', value: 5 }],
|
||||
},
|
||||
{
|
||||
id: 'inference-optimization',
|
||||
name: 'Inference Optimization',
|
||||
description: 'Optimized kernels and batching. +30% tokens per FLOP.',
|
||||
era: 'scaleup',
|
||||
category: 'efficiency',
|
||||
prerequisites: ['quantization'],
|
||||
cost: { researchPoints: 2, compute: 20, ticks: 150 },
|
||||
effects: [{ type: 'efficiency_boost', target: 'tokens_per_flop', value: 0.3 }],
|
||||
},
|
||||
|
||||
// === MODEL CAPABILITIES ===
|
||||
{
|
||||
id: 'transformer-v2',
|
||||
name: 'Advanced Architectures',
|
||||
description: 'Mixture-of-experts and improved attention. +10 base capability.',
|
||||
era: 'startup',
|
||||
category: 'generation',
|
||||
prerequisites: [],
|
||||
cost: { researchPoints: 0, compute: 10, ticks: 90 },
|
||||
effects: [{ type: 'capability_boost', target: 'all', value: 10 }],
|
||||
},
|
||||
{
|
||||
id: 'reasoning-enhancement',
|
||||
name: 'Chain-of-Thought Training',
|
||||
description: 'Enhanced reasoning through structured thinking. +15 reasoning.',
|
||||
era: 'scaleup',
|
||||
category: 'specialization',
|
||||
branch: 'reasoning',
|
||||
prerequisites: ['transformer-v2'],
|
||||
cost: { researchPoints: 3, compute: 40, ticks: 240 },
|
||||
effects: [{ type: 'capability_boost', target: 'reasoning', value: 15 }],
|
||||
},
|
||||
{
|
||||
id: 'code-generation',
|
||||
name: 'Code Generation',
|
||||
description: 'Specialized code understanding and generation. +15 coding.',
|
||||
era: 'scaleup',
|
||||
category: 'specialization',
|
||||
branch: 'coding',
|
||||
prerequisites: ['transformer-v2'],
|
||||
cost: { researchPoints: 3, compute: 35, ticks: 210 },
|
||||
effects: [{ type: 'capability_boost', target: 'coding', value: 15 }],
|
||||
},
|
||||
{
|
||||
id: 'creative-systems',
|
||||
name: 'Creative Expression',
|
||||
description: 'Enhanced creative writing and artistic understanding. +15 creative.',
|
||||
era: 'scaleup',
|
||||
category: 'specialization',
|
||||
branch: 'creative',
|
||||
prerequisites: ['transformer-v2'],
|
||||
cost: { researchPoints: 3, compute: 30, ticks: 210 },
|
||||
effects: [{ type: 'capability_boost', target: 'creative', value: 15 }],
|
||||
},
|
||||
{
|
||||
id: 'multimodal-fusion',
|
||||
name: 'Multi-Modal Fusion',
|
||||
description: 'Vision-language integration for image understanding. +20 multimodal. Unlocks Image product.',
|
||||
era: 'scaleup',
|
||||
category: 'specialization',
|
||||
branch: 'multimodal',
|
||||
prerequisites: ['transformer-v2'],
|
||||
cost: { researchPoints: 4, compute: 50, ticks: 300 },
|
||||
effects: [
|
||||
{ type: 'capability_boost', target: 'multimodal', value: 20 },
|
||||
{ type: 'unlock_product_line', target: 'image', value: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'agentic-architecture',
|
||||
name: 'Agentic Architecture',
|
||||
description: 'Tool use, planning, and autonomous execution. +20 agents. Unlocks Agents product.',
|
||||
era: 'bigtech',
|
||||
category: 'specialization',
|
||||
branch: 'agents',
|
||||
prerequisites: ['reasoning-enhancement', 'code-generation'],
|
||||
cost: { researchPoints: 6, compute: 100, ticks: 480 },
|
||||
effects: [
|
||||
{ type: 'capability_boost', target: 'agents', value: 20 },
|
||||
{ type: 'unlock_product_line', target: 'agents', value: 1 },
|
||||
],
|
||||
},
|
||||
|
||||
// === SAFETY ===
|
||||
{
|
||||
id: 'alignment-research',
|
||||
name: 'Alignment Research',
|
||||
description: 'RLHF and value alignment techniques. +10 safety, +5 reputation.',
|
||||
era: 'startup',
|
||||
category: 'safety',
|
||||
prerequisites: [],
|
||||
cost: { researchPoints: 0, compute: 8, ticks: 90 },
|
||||
effects: [
|
||||
{ type: 'safety_boost', target: 'models', value: 10 },
|
||||
{ type: 'capability_boost', target: 'reputation', value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'interpretability',
|
||||
name: 'Interpretability',
|
||||
description: 'Understand model reasoning and detect failure modes. +10 safety, +5 reputation.',
|
||||
era: 'scaleup',
|
||||
category: 'safety',
|
||||
prerequisites: ['alignment-research'],
|
||||
cost: { researchPoints: 3, compute: 40, ticks: 240 },
|
||||
effects: [
|
||||
{ type: 'safety_boost', target: 'models', value: 10 },
|
||||
{ type: 'capability_boost', target: 'reputation', value: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'constitutional-ai',
|
||||
name: 'Constitutional AI',
|
||||
description: 'Self-supervised alignment at scale. +15 safety, +10 reputation.',
|
||||
era: 'bigtech',
|
||||
category: 'safety',
|
||||
prerequisites: ['interpretability'],
|
||||
cost: { researchPoints: 5, compute: 80, ticks: 420 },
|
||||
effects: [
|
||||
{ type: 'safety_boost', target: 'models', value: 15 },
|
||||
{ type: 'capability_boost', target: 'reputation', value: 10 },
|
||||
],
|
||||
},
|
||||
|
||||
// === DATA ===
|
||||
{
|
||||
id: 'data-pipeline',
|
||||
name: 'Data Pipeline Optimization',
|
||||
description: 'Automated data cleaning and deduplication. +20% data quality.',
|
||||
era: 'startup',
|
||||
category: 'efficiency',
|
||||
prerequisites: [],
|
||||
cost: { researchPoints: 0, compute: 5, ticks: 60 },
|
||||
effects: [{ type: 'efficiency_boost', target: 'data_quality', value: 0.2 }],
|
||||
},
|
||||
];
|
||||
@@ -1,3 +1,8 @@
|
||||
export { GameEngine } from './engine';
|
||||
export { processTick } from './tick';
|
||||
export { processTick, setEventDefinitions } from './tick';
|
||||
export type { TickNotification } from './tick';
|
||||
export { getAvailableResearch, getResearchNode } from './systems/researchSystem';
|
||||
export { TECH_TREE } from './data/techTree';
|
||||
export { INITIAL_RIVALS } from './data/competitors';
|
||||
export { KEY_HIRE_POOL } from './data/keyHires';
|
||||
export { EVENT_DEFINITIONS } from './data/events';
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { GameState, CompetitorState } from '@ai-tycoon/shared';
|
||||
|
||||
export function processCompetitors(state: GameState): CompetitorState {
|
||||
const tick = state.meta.tickCount;
|
||||
const rivals = state.competitors.rivals.map(rival => {
|
||||
if (rival.status !== 'active') return rival;
|
||||
|
||||
if (tick < rival.nextMilestoneAtTick) return rival;
|
||||
|
||||
const { personality } = rival;
|
||||
const capGrowth = (2 + personality.researchFocus * 5 + personality.riskTolerance * 3) *
|
||||
(1 + tick * 0.00005);
|
||||
const revenueGrowth = rival.estimatedRevenue * (0.02 + personality.marketingFocus * 0.03);
|
||||
const userGrowth = rival.estimatedUsers * (0.01 + personality.marketingFocus * 0.02);
|
||||
|
||||
const newCapability = Math.min(95, rival.estimatedCapability + capGrowth);
|
||||
const newRevenue = rival.estimatedRevenue + revenueGrowth + 50;
|
||||
const newUsers = rival.estimatedUsers + userGrowth + 100;
|
||||
|
||||
const repChange = personality.safetyFocus > 0.6
|
||||
? 1
|
||||
: personality.riskTolerance > 0.7 ? -1 : 0;
|
||||
|
||||
const modelNames = [
|
||||
'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon',
|
||||
'Nova', 'Quantum', 'Nexus', 'Apex', 'Zenith',
|
||||
];
|
||||
const modelIdx = Math.floor(newCapability / 10);
|
||||
const latestModelName = `${rival.name.split(' ')[0]}-${modelNames[Math.min(modelIdx, modelNames.length - 1)]}`;
|
||||
|
||||
const milestoneInterval = 200 + Math.floor(Math.random() * 200);
|
||||
|
||||
return {
|
||||
...rival,
|
||||
estimatedCapability: newCapability,
|
||||
estimatedRevenue: newRevenue,
|
||||
estimatedUsers: Math.floor(newUsers),
|
||||
reputation: Math.min(100, Math.max(0, rival.reputation + repChange)),
|
||||
latestModelName,
|
||||
nextMilestoneAtTick: tick + milestoneInterval,
|
||||
};
|
||||
});
|
||||
|
||||
const allCaps = [
|
||||
...rivals.filter(r => r.status === 'active').map(r => r.estimatedCapability),
|
||||
state.models.trainedModels.reduce((best, m) => Math.max(best, m.benchmarkScore), 0),
|
||||
];
|
||||
const industryBenchmark = allCaps.length > 0 ? Math.max(...allCaps) : 0;
|
||||
|
||||
return { rivals, industryBenchmark };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { GameState, DataState } from '@ai-tycoon/shared';
|
||||
|
||||
export function processData(state: GameState): DataState {
|
||||
const subscribers = state.market.consumers.totalSubscribers;
|
||||
const userDataRate = subscribers * 0.5;
|
||||
|
||||
const partnershipTokens = state.data.partnerships.reduce((sum, p) => sum + p.tokensPerTick, 0);
|
||||
|
||||
const newTokens = userDataRate + partnershipTokens;
|
||||
const totalTrainingTokens = state.data.totalTrainingTokens + newTokens;
|
||||
|
||||
return {
|
||||
...state.data,
|
||||
userDataGenerationRate: userDataRate,
|
||||
totalTrainingTokens,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { GameState, Era } from '@ai-tycoon/shared';
|
||||
import { ERA_THRESHOLDS } from '@ai-tycoon/shared';
|
||||
|
||||
export function checkEraTransition(state: GameState): Era | null {
|
||||
const current = state.meta.currentEra;
|
||||
const eraOrder: Era[] = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
const currentIdx = eraOrder.indexOf(current);
|
||||
const nextEra = eraOrder[currentIdx + 1];
|
||||
if (!nextEra) return null;
|
||||
|
||||
const thresholds = ERA_THRESHOLDS[nextEra as keyof typeof ERA_THRESHOLDS];
|
||||
if (!thresholds) return null;
|
||||
|
||||
const bestModel = state.models.trainedModels.reduce(
|
||||
(best, m) => Math.max(best, m.benchmarkScore), 0,
|
||||
);
|
||||
|
||||
if (
|
||||
state.economy.totalRevenue >= thresholds.revenue &&
|
||||
bestModel >= thresholds.capability &&
|
||||
state.reputation.score >= thresholds.reputation
|
||||
) {
|
||||
return nextEra;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { GameState, EventState, ActiveEvent, EventDefinition, EventCondition } from '@ai-tycoon/shared';
|
||||
|
||||
export interface EventTickResult {
|
||||
events: EventState;
|
||||
newEvents: ActiveEvent[];
|
||||
}
|
||||
|
||||
export function processEvents(
|
||||
state: GameState,
|
||||
definitions: EventDefinition[],
|
||||
): EventTickResult {
|
||||
const tick = state.meta.tickCount;
|
||||
const events = { ...state.events };
|
||||
const newEvents: ActiveEvent[] = [];
|
||||
|
||||
// Remove expired events (auto-choose default)
|
||||
const stillActive: ActiveEvent[] = [];
|
||||
for (const event of events.activeEvents) {
|
||||
if (tick >= event.expiresAtTick) {
|
||||
events.eventHistory = [
|
||||
...events.eventHistory,
|
||||
{
|
||||
eventId: event.eventId,
|
||||
instanceId: event.instanceId,
|
||||
title: event.title,
|
||||
category: event.category,
|
||||
tick,
|
||||
chosenOptionIndex: event.defaultChoiceIndex,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
stillActive.push(event);
|
||||
}
|
||||
}
|
||||
events.activeEvents = stillActive;
|
||||
|
||||
if (events.eventHistory.length > 50) {
|
||||
events.eventHistory = events.eventHistory.slice(-50);
|
||||
}
|
||||
|
||||
// Only try to fire a new event every 30 ticks, and max 1 active at a time
|
||||
if (tick % 30 !== 0 || events.activeEvents.length > 0) {
|
||||
return { events, newEvents };
|
||||
}
|
||||
|
||||
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
const currentEraIdx = eraOrder.indexOf(state.meta.currentEra);
|
||||
|
||||
const eligible = definitions.filter(def => {
|
||||
if (!def.eras.some(e => eraOrder.indexOf(e) <= currentEraIdx)) return false;
|
||||
const occ = events.eventOccurrences[def.id] ?? 0;
|
||||
if (occ >= def.maxOccurrences) return false;
|
||||
const cooldownEnd = events.eventCooldowns[def.id] ?? 0;
|
||||
if (tick < cooldownEnd) return false;
|
||||
if (def.prerequisites.some(p => !state.research.completedResearch.includes(p))) return false;
|
||||
if (!def.conditions.every(c => evaluateCondition(state, c))) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (eligible.length === 0) return { events, newEvents };
|
||||
|
||||
const totalWeight = eligible.reduce((s, d) => s + d.weight, 0);
|
||||
let roll = Math.random() * totalWeight;
|
||||
let chosen: EventDefinition | null = null;
|
||||
for (const def of eligible) {
|
||||
roll -= def.weight;
|
||||
if (roll <= 0) { chosen = def; break; }
|
||||
}
|
||||
if (!chosen) return { events, newEvents };
|
||||
|
||||
// Only fire with 30% probability per check to space events out
|
||||
if (Math.random() > 0.3) return { events, newEvents };
|
||||
|
||||
const activeEvent: ActiveEvent = {
|
||||
eventId: chosen.id,
|
||||
instanceId: crypto.randomUUID(),
|
||||
triggeredAtTick: tick,
|
||||
expiresAtTick: tick + chosen.expiryTicks,
|
||||
title: chosen.title,
|
||||
description: chosen.descriptionTemplate,
|
||||
category: chosen.category,
|
||||
choices: chosen.choices,
|
||||
defaultChoiceIndex: chosen.defaultChoiceIndex,
|
||||
};
|
||||
|
||||
events.activeEvents = [...events.activeEvents, activeEvent];
|
||||
events.eventCooldowns = { ...events.eventCooldowns, [chosen.id]: tick + chosen.cooldownTicks };
|
||||
events.eventOccurrences = {
|
||||
...events.eventOccurrences,
|
||||
[chosen.id]: (events.eventOccurrences[chosen.id] ?? 0) + 1,
|
||||
};
|
||||
newEvents.push(activeEvent);
|
||||
|
||||
return { events, newEvents };
|
||||
}
|
||||
|
||||
function evaluateCondition(state: GameState, condition: EventCondition): boolean {
|
||||
const value = getNestedValue(state, condition.field);
|
||||
if (value === undefined) return false;
|
||||
switch (condition.operator) {
|
||||
case 'gt': return value > condition.value;
|
||||
case 'lt': return value < condition.value;
|
||||
case 'gte': return value >= condition.value;
|
||||
case 'lte': return value <= condition.value;
|
||||
case 'eq': return value === condition.value;
|
||||
}
|
||||
}
|
||||
|
||||
function getNestedValue(obj: object, path: string): number | undefined {
|
||||
const parts = path.split('.');
|
||||
let current: unknown = obj;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return undefined;
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return typeof current === 'number' ? current : undefined;
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { GameState, ResearchState, ComputeState } from '@ai-tycoon/shared';
|
||||
import { TECH_TREE } from '../data/techTree';
|
||||
|
||||
export function processResearch(state: GameState, compute: ComputeState): ResearchState {
|
||||
export interface ResearchTickResult {
|
||||
research: ResearchState;
|
||||
researchCompleted: string | null;
|
||||
}
|
||||
|
||||
export function processResearch(state: GameState, compute: ComputeState): ResearchTickResult {
|
||||
const active = state.research.activeResearch;
|
||||
if (!active) return state.research;
|
||||
if (!active) return { research: state.research, researchCompleted: null };
|
||||
|
||||
const researcherBoost = state.talent.departments.research.headcount *
|
||||
state.talent.departments.research.effectiveness;
|
||||
@@ -12,18 +18,38 @@ export function processResearch(state: GameState, compute: ComputeState): Resear
|
||||
|
||||
if (newProgress >= active.totalTicks) {
|
||||
return {
|
||||
...state.research,
|
||||
completedResearch: [...state.research.completedResearch, active.researchId],
|
||||
activeResearch: null,
|
||||
researchPoints: state.research.researchPoints + 1,
|
||||
research: {
|
||||
...state.research,
|
||||
completedResearch: [...state.research.completedResearch, active.researchId],
|
||||
activeResearch: null,
|
||||
researchPoints: state.research.researchPoints + 1,
|
||||
},
|
||||
researchCompleted: active.researchId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state.research,
|
||||
activeResearch: {
|
||||
...active,
|
||||
progressTicks: newProgress,
|
||||
research: {
|
||||
...state.research,
|
||||
activeResearch: { ...active, progressTicks: newProgress },
|
||||
},
|
||||
researchCompleted: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAvailableResearch(state: GameState): typeof TECH_TREE {
|
||||
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||
const currentEraIdx = eraOrder.indexOf(state.meta.currentEra);
|
||||
|
||||
return TECH_TREE.filter(node => {
|
||||
if (state.research.completedResearch.includes(node.id)) return false;
|
||||
if (state.research.activeResearch?.researchId === node.id) return false;
|
||||
if (eraOrder.indexOf(node.era) > currentEraIdx) return false;
|
||||
if (node.prerequisites.some(p => !state.research.completedResearch.includes(p))) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function getResearchNode(id: string) {
|
||||
return TECH_TREE.find(n => n.id === id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GameState } from '@ai-tycoon/shared';
|
||||
import type { GameState, EventDefinition } from '@ai-tycoon/shared';
|
||||
import { processEconomy } from './systems/economySystem';
|
||||
import { processInfrastructure } from './systems/infrastructureSystem';
|
||||
import { processCompute } from './systems/computeSystem';
|
||||
@@ -7,6 +7,10 @@ import { processModels } from './systems/modelSystem';
|
||||
import { processMarket } from './systems/marketSystem';
|
||||
import { processReputation } from './systems/reputationSystem';
|
||||
import { processTalent } from './systems/talentSystem';
|
||||
import { processEvents } from './systems/eventSystem';
|
||||
import { processCompetitors } from './systems/competitorSystem';
|
||||
import { processData } from './systems/dataSystem';
|
||||
import { checkEraTransition } from './systems/eraSystem';
|
||||
|
||||
export interface TickResult {
|
||||
state: Partial<GameState>;
|
||||
@@ -19,6 +23,12 @@ export interface TickNotification {
|
||||
type: 'info' | 'success' | 'warning' | 'danger';
|
||||
}
|
||||
|
||||
let cachedEventDefs: EventDefinition[] | null = null;
|
||||
|
||||
export function setEventDefinitions(defs: EventDefinition[]) {
|
||||
cachedEventDefs = defs;
|
||||
}
|
||||
|
||||
export function processTick(state: GameState): Partial<GameState> {
|
||||
const notifications: TickNotification[] = [];
|
||||
|
||||
@@ -46,27 +56,65 @@ export function processTick(state: GameState): Partial<GameState> {
|
||||
|
||||
const talent = processTalent(stateWithModels);
|
||||
const stateWithTalent = { ...stateWithModels, talent };
|
||||
const research = processResearch(stateWithTalent, compute);
|
||||
const researchResult = processResearch(stateWithTalent, compute);
|
||||
|
||||
if (researchResult.researchCompleted) {
|
||||
notifications.push({
|
||||
title: 'Research Complete',
|
||||
message: `${researchResult.researchCompleted} has been unlocked!`,
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
const reputation = processReputation(stateWithTalent);
|
||||
const economy = processEconomy(stateWithTalent, market, infrastructure);
|
||||
const data = processData(stateWithTalent);
|
||||
const competitors = processCompetitors(stateWithTalent);
|
||||
|
||||
const eventResult = cachedEventDefs
|
||||
? processEvents(stateWithTalent, cachedEventDefs)
|
||||
: { events: state.events, newEvents: [] };
|
||||
|
||||
for (const evt of eventResult.newEvents) {
|
||||
notifications.push({
|
||||
title: evt.title,
|
||||
message: evt.description,
|
||||
type: evt.category === 'regulatory' ? 'warning' : 'info',
|
||||
});
|
||||
}
|
||||
|
||||
const tickCount = state.meta.tickCount + 1;
|
||||
|
||||
let meta = {
|
||||
...state.meta,
|
||||
tickCount,
|
||||
lastTickTimestamp: Date.now(),
|
||||
totalPlayTime: state.meta.totalPlayTime + 1,
|
||||
};
|
||||
|
||||
const newEra = checkEraTransition({ ...stateWithTalent, economy, reputation, research: researchResult.research });
|
||||
if (newEra) {
|
||||
meta = { ...meta, currentEra: newEra };
|
||||
notifications.push({
|
||||
title: 'Era Transition!',
|
||||
message: `Your company has entered the ${newEra === 'scaleup' ? 'Scale-up' : newEra === 'bigtech' ? 'Big Tech' : 'AGI'} era!`,
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
const result: Partial<GameState> = {
|
||||
meta: {
|
||||
...state.meta,
|
||||
tickCount,
|
||||
lastTickTimestamp: Date.now(),
|
||||
totalPlayTime: state.meta.totalPlayTime + 1,
|
||||
},
|
||||
meta,
|
||||
economy,
|
||||
infrastructure,
|
||||
compute,
|
||||
research,
|
||||
research: researchResult.research,
|
||||
models: modelResult.modelsState,
|
||||
market: market.marketState,
|
||||
talent,
|
||||
reputation,
|
||||
data,
|
||||
competitors,
|
||||
events: eventResult.events,
|
||||
};
|
||||
|
||||
(result as Record<string, unknown>)['_notifications'] = notifications;
|
||||
|
||||
Reference in New Issue
Block a user