Add Week 3 polish and late-game features
VC funding system (seed through IPO with requirements gating), 15 achievements with engine checker, model tuning presets and unlockable sliders, overload policy controls, open-source mechanic with reputation boost, enhanced Recharts analytics (subscriber/reputation/revenue vs expenses charts), M&A acquisition system, sidebar NEW badges on era transitions, tutorial hints, and wired-up settings toggles. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { X, Lightbulb } from 'lucide-react';
|
||||||
|
|
||||||
|
const DISMISSED_KEY = 'ai-tycoon-dismissed-hints';
|
||||||
|
|
||||||
|
function getDismissed(): Set<string> {
|
||||||
|
try {
|
||||||
|
return new Set(JSON.parse(localStorage.getItem(DISMISSED_KEY) || '[]'));
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(id: string) {
|
||||||
|
const d = getDismissed();
|
||||||
|
d.add(id);
|
||||||
|
localStorage.setItem(DISMISSED_KEY, JSON.stringify([...d]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TutorialHint({ id, children }: { id: string; children: React.ReactNode }) {
|
||||||
|
const [visible, setVisible] = useState(!getDismissed().has(id));
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-accent/10 border border-accent/30 rounded-lg p-3 flex items-start gap-3 mb-4">
|
||||||
|
<Lightbulb size={16} className="text-accent-light mt-0.5 shrink-0" />
|
||||||
|
<div className="text-sm text-surface-200 flex-1">{children}</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { dismiss(id); setVisible(false); }}
|
||||||
|
className="text-surface-400 hover:text-surface-200"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { FinancePage } from '@/pages/FinancePage';
|
|||||||
import { TalentPage } from '@/pages/TalentPage';
|
import { TalentPage } from '@/pages/TalentPage';
|
||||||
import { DataPage } from '@/pages/DataPage';
|
import { DataPage } from '@/pages/DataPage';
|
||||||
import { CompetitorsPage } from '@/pages/CompetitorsPage';
|
import { CompetitorsPage } from '@/pages/CompetitorsPage';
|
||||||
|
import { AchievementsPage } from '@/pages/AchievementsPage';
|
||||||
|
|
||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
const activePage = useGameStore((s) => s.activePage);
|
const activePage = useGameStore((s) => s.activePage);
|
||||||
@@ -43,6 +44,7 @@ function PageRouter({ page }: { page: string }) {
|
|||||||
case 'talent': return <TalentPage />;
|
case 'talent': return <TalentPage />;
|
||||||
case 'data': return <DataPage />;
|
case 'data': return <DataPage />;
|
||||||
case 'competitors': return <CompetitorsPage />;
|
case 'competitors': return <CompetitorsPage />;
|
||||||
|
case 'achievements': return <AchievementsPage />;
|
||||||
case 'settings': return <SettingsPage />;
|
case 'settings': return <SettingsPage />;
|
||||||
default: return <PlaceholderPage name={page} />;
|
default: return <PlaceholderPage name={page} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Server, FlaskConical, Brain,
|
LayoutDashboard, Server, FlaskConical, Brain,
|
||||||
TrendingUp, Users, Database, Swords, DollarSign, Settings,
|
TrendingUp, Users, Database, Swords, DollarSign, Settings, Trophy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useGameStore, type ActivePage } from '@/store';
|
import { useGameStore, type ActivePage } from '@/store';
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ const NAV_ITEMS: { page: ActivePage; label: string; icon: typeof LayoutDashboard
|
|||||||
{ page: 'talent', label: 'Talent', icon: Users, era: 'scaleup' },
|
{ page: 'talent', label: 'Talent', icon: Users, era: 'scaleup' },
|
||||||
{ page: 'data', label: 'Data', icon: Database, era: 'scaleup' },
|
{ page: 'data', label: 'Data', icon: Database, era: 'scaleup' },
|
||||||
{ page: 'competitors', label: 'Competitors', icon: Swords, era: 'scaleup' },
|
{ page: 'competitors', label: 'Competitors', icon: Swords, era: 'scaleup' },
|
||||||
|
{ page: 'achievements', label: 'Achievements', icon: Trophy },
|
||||||
{ page: 'settings', label: 'Settings', icon: Settings },
|
{ page: 'settings', label: 'Settings', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -26,6 +28,32 @@ export function Sidebar() {
|
|||||||
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
const eraOrder = ['startup', 'scaleup', 'bigtech', 'agi'];
|
||||||
const currentEraIdx = eraOrder.indexOf(era);
|
const currentEraIdx = eraOrder.indexOf(era);
|
||||||
|
|
||||||
|
const seenEraRef = useRef(era);
|
||||||
|
const [newPages, setNewPages] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (era !== seenEraRef.current) {
|
||||||
|
const oldIdx = eraOrder.indexOf(seenEraRef.current);
|
||||||
|
const newIdx = eraOrder.indexOf(era);
|
||||||
|
if (newIdx > oldIdx) {
|
||||||
|
const newlyVisible = NAV_ITEMS
|
||||||
|
.filter(item => item.era && eraOrder.indexOf(item.era) > oldIdx && eraOrder.indexOf(item.era) <= newIdx)
|
||||||
|
.map(item => item.page);
|
||||||
|
setNewPages(prev => new Set([...prev, ...newlyVisible]));
|
||||||
|
}
|
||||||
|
seenEraRef.current = era;
|
||||||
|
}
|
||||||
|
}, [era]);
|
||||||
|
|
||||||
|
const handleNavClick = (page: ActivePage) => {
|
||||||
|
setActivePage(page);
|
||||||
|
setNewPages(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(page);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="w-56 bg-surface-900 border-r border-surface-700 flex flex-col h-screen">
|
<aside className="w-56 bg-surface-900 border-r border-surface-700 flex flex-col h-screen">
|
||||||
<div className="p-4 border-b border-surface-700">
|
<div className="p-4 border-b border-surface-700">
|
||||||
@@ -40,10 +68,11 @@ export function Sidebar() {
|
|||||||
if (requiredEra && eraOrder.indexOf(requiredEra) > currentEraIdx) return null;
|
if (requiredEra && eraOrder.indexOf(requiredEra) > currentEraIdx) return null;
|
||||||
|
|
||||||
const isActive = activePage === page;
|
const isActive = activePage === page;
|
||||||
|
const isNew = newPages.has(page);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={page}
|
key={page}
|
||||||
onClick={() => setActivePage(page)}
|
onClick={() => handleNavClick(page)}
|
||||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-accent/10 text-accent-light border-r-2 border-accent'
|
? 'bg-accent/10 text-accent-light border-r-2 border-accent'
|
||||||
@@ -52,6 +81,9 @@ export function Sidebar() {
|
|||||||
>
|
>
|
||||||
<Icon size={18} />
|
<Icon size={18} />
|
||||||
{label}
|
{label}
|
||||||
|
{isNew && (
|
||||||
|
<span className="ml-auto text-[10px] font-bold bg-accent text-white px-1.5 py-0.5 rounded">NEW</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { GameEngine, setEventDefinitions, EVENT_DEFINITIONS } from '@ai-tycoon/game-engine';
|
import { GameEngine, setEventDefinitions, setAchievementDefinitions, EVENT_DEFINITIONS, ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
|
||||||
import type { TickNotification } from '@ai-tycoon/game-engine';
|
import type { TickNotification } from '@ai-tycoon/game-engine';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ export function useGameLoop(skip = false) {
|
|||||||
if (!companyName || skip) return;
|
if (!companyName || skip) return;
|
||||||
|
|
||||||
setEventDefinitions(EVENT_DEFINITIONS);
|
setEventDefinitions(EVENT_DEFINITIONS);
|
||||||
|
setAchievementDefinitions(ACHIEVEMENT_DEFINITIONS);
|
||||||
|
|
||||||
const engine = new GameEngine({
|
const engine = new GameEngine({
|
||||||
getState: () => {
|
getState: () => {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useGameStore } from '@/store';
|
||||||
|
import { ACHIEVEMENT_DEFINITIONS } from '@ai-tycoon/game-engine';
|
||||||
|
import {
|
||||||
|
Trophy, Lock, Server, Brain, Rocket, DollarSign, Sprout, Users,
|
||||||
|
Globe, Sparkles, TrendingUp, Building2, Atom, Cpu, FlaskConical,
|
||||||
|
GitBranch, Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const ICON_MAP: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||||
|
Trophy, Server, Brain, Rocket, DollarSign, Sprout, Users,
|
||||||
|
Globe, Sparkles, TrendingUp, Building2, Atom, Cpu, FlaskConical,
|
||||||
|
GitBranch, Zap,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AchievementsPage() {
|
||||||
|
const unlocked = useGameStore((s) => s.achievements.unlocked);
|
||||||
|
const unlockedIds = new Set(unlocked.map(a => a.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-bold">Achievements</h2>
|
||||||
|
<span className="text-sm text-surface-400">
|
||||||
|
{unlocked.length} / {ACHIEVEMENT_DEFINITIONS.length} unlocked
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{ACHIEVEMENT_DEFINITIONS.map(def => {
|
||||||
|
const isUnlocked = unlockedIds.has(def.id);
|
||||||
|
const IconComponent = ICON_MAP[def.icon] ?? Trophy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={def.id}
|
||||||
|
className={`rounded-xl border p-4 transition-all ${
|
||||||
|
isUnlocked
|
||||||
|
? 'bg-surface-900 border-accent/40 shadow-lg shadow-accent/5'
|
||||||
|
: 'bg-surface-900/50 border-surface-700 opacity-60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${isUnlocked ? 'bg-accent/20 text-accent-light' : 'bg-surface-800 text-surface-500'}`}>
|
||||||
|
{isUnlocked ? <IconComponent size={20} /> : <Lock size={20} />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className={`font-semibold text-sm ${isUnlocked ? '' : 'text-surface-400'}`}>{def.name}</h4>
|
||||||
|
<p className="text-xs text-surface-400 mt-0.5">{def.description}</p>
|
||||||
|
{isUnlocked && (
|
||||||
|
<p className="text-xs text-accent mt-1">Unlocked</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Swords, TrendingUp, Shield, Users, Brain } from 'lucide-react';
|
import { Swords, TrendingUp, Shield, Users, Brain, ShoppingCart } from 'lucide-react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatMoney, formatNumber } from '@ai-tycoon/shared';
|
import { formatMoney, formatNumber } from '@ai-tycoon/shared';
|
||||||
|
import type { Era } from '@ai-tycoon/shared';
|
||||||
|
|
||||||
const ARCHETYPE_LABELS: Record<string, string> = {
|
const ARCHETYPE_LABELS: Record<string, string> = {
|
||||||
'safety-first': 'Safety-First Lab',
|
'safety-first': 'Safety-First Lab',
|
||||||
@@ -24,6 +25,10 @@ export function CompetitorsPage() {
|
|||||||
const playerBest = useGameStore((s) =>
|
const playerBest = useGameStore((s) =>
|
||||||
s.models.trainedModels.reduce((best, m) => Math.max(best, m.benchmarkScore), 0),
|
s.models.trainedModels.reduce((best, m) => Math.max(best, m.benchmarkScore), 0),
|
||||||
);
|
);
|
||||||
|
const era = useGameStore((s) => s.meta.currentEra);
|
||||||
|
const money = useGameStore((s) => s.economy.money);
|
||||||
|
const acquireCompetitor = useGameStore((s) => s.acquireCompetitor);
|
||||||
|
const canAcquire = (era: Era) => era === 'bigtech' || era === 'agi';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -75,6 +80,18 @@ export function CompetitorsPage() {
|
|||||||
{ARCHETYPE_LABELS[rival.archetype]}
|
{ARCHETYPE_LABELS[rival.archetype]}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{canAcquire(era) && rival.status === 'active' && (
|
||||||
|
<button
|
||||||
|
onClick={() => acquireCompetitor(rival.id)}
|
||||||
|
disabled={money < rival.estimatedRevenue * 500 + rival.estimatedCapability * 100_000}
|
||||||
|
className="flex items-center gap-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-600/30 rounded px-3 py-1.5 text-xs disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
title={`Cost: ${formatMoney(rival.estimatedRevenue * 500 + rival.estimatedCapability * 100_000)}`}
|
||||||
|
>
|
||||||
|
<ShoppingCart size={12} />
|
||||||
|
Acquire
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<span className={`text-xs px-2 py-1 rounded-full ${
|
<span className={`text-xs px-2 py-1 rounded-full ${
|
||||||
rival.status === 'active' ? 'bg-success/20 text-success' :
|
rival.status === 'active' ? 'bg-success/20 text-success' :
|
||||||
rival.status === 'acquired' ? 'bg-blue-500/20 text-blue-400' :
|
rival.status === 'acquired' ? 'bg-blue-500/20 text-blue-400' :
|
||||||
@@ -83,6 +100,7 @@ export function CompetitorsPage() {
|
|||||||
{rival.status}
|
{rival.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<Stat icon={Brain} label="Capability" value={rival.estimatedCapability.toFixed(1)} sub={rival.latestModelName} />
|
<Stat icon={Brain} label="Capability" value={rival.estimatedCapability.toFixed(1)} sub={rival.latestModelName} />
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
DollarSign, Server, Brain, Users, TrendingUp,
|
DollarSign, Server, Brain, Users, TrendingUp,
|
||||||
TrendingDown, Minus, Cpu, Zap, Shield,
|
TrendingDown, Minus, Cpu, Zap, Shield,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts';
|
import { XAxis, YAxis, Tooltip, ResponsiveContainer, Area, AreaChart } from 'recharts';
|
||||||
|
import { TutorialHint } from '@/components/game/TutorialHint';
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const money = useGameStore((s) => s.economy.money);
|
const money = useGameStore((s) => s.economy.money);
|
||||||
@@ -26,6 +27,24 @@ export function DashboardPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-2xl font-bold">Dashboard</h2>
|
<h2 className="text-2xl font-bold">Dashboard</h2>
|
||||||
|
|
||||||
|
{dataCenters.length === 0 && (
|
||||||
|
<TutorialHint id="welcome">
|
||||||
|
Welcome to AI Tycoon! Start by building a data center in the Infrastructure tab, then buy GPUs to begin training your first AI model.
|
||||||
|
</TutorialHint>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dataCenters.length > 0 && trainedModels.length === 0 && !activeTraining && (
|
||||||
|
<TutorialHint id="train-first-model">
|
||||||
|
You have compute available! Head to the Models tab to allocate compute for training and start your first model.
|
||||||
|
</TutorialHint>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{trainedModels.length > 0 && !trainedModels.some(m => m.isDeployed) && (
|
||||||
|
<TutorialHint id="deploy-model">
|
||||||
|
Your model is trained! Deploy it from the Models tab to start serving customers and earning revenue.
|
||||||
|
</TutorialHint>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
@@ -115,6 +134,62 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||||
|
<h3 className="text-sm font-medium text-surface-400 mb-4">Subscribers Over Time</h3>
|
||||||
|
{(useGameStore.getState().market.subscriberHistory?.length ?? 0) > 1 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<AreaChart data={useGameStore.getState().market.subscriberHistory}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="subsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#f97316" stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor="#f97316" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="tick" hide />
|
||||||
|
<YAxis hide />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||||
|
formatter={(value: number) => [formatNumber(value), 'Subscribers']}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="subscribers" stroke="#f97316" fill="url(#subsGrad)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-[180px] flex items-center justify-center text-surface-500 text-sm">
|
||||||
|
No subscriber data yet
|
||||||
|
</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-4">Reputation Over Time</h3>
|
||||||
|
{(useGameStore.getState().reputation.reputationHistory?.length ?? 0) > 1 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<AreaChart data={useGameStore.getState().reputation.reputationHistory}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="repGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#a855f7" stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor="#a855f7" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="tick" hide />
|
||||||
|
<YAxis hide domain={[0, 100]} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||||
|
formatter={(value: number) => [`${value}/100`, 'Reputation']}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="score" stroke="#a855f7" fill="url(#repGrad)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-[180px] flex items-center justify-center text-surface-500 text-sm">
|
||||||
|
No reputation data yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{dataCenters.length === 0 && (
|
{dataCenters.length === 0 && (
|
||||||
<div className="bg-surface-900 border border-accent/30 rounded-xl p-6 text-center">
|
<div className="bg-surface-900 border border-accent/30 rounded-xl p-6 text-center">
|
||||||
<h3 className="text-lg font-semibold mb-2">Get Started</h3>
|
<h3 className="text-lg font-semibold mb-2">Get Started</h3>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatMoney, formatPercent } from '@ai-tycoon/shared';
|
import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@ai-tycoon/shared';
|
||||||
import { TrendingUp, TrendingDown, DollarSign, PiggyBank, BarChart3 } from 'lucide-react';
|
import type { FundingRoundType } from '@ai-tycoon/shared';
|
||||||
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, BarChart, Bar, CartesianGrid } from 'recharts';
|
import { TrendingUp, TrendingDown, DollarSign, PiggyBank, BarChart3, Rocket } from 'lucide-react';
|
||||||
|
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, LineChart, Line } from 'recharts';
|
||||||
|
import { canRaiseFunding } from '@ai-tycoon/game-engine';
|
||||||
|
import type { GameState } from '@ai-tycoon/shared';
|
||||||
|
|
||||||
export function FinancePage() {
|
export function FinancePage() {
|
||||||
const money = useGameStore((s) => s.economy.money);
|
const money = useGameStore((s) => s.economy.money);
|
||||||
@@ -13,6 +16,16 @@ export function FinancePage() {
|
|||||||
const history = useGameStore((s) => s.economy.financialHistory);
|
const history = useGameStore((s) => s.economy.financialHistory);
|
||||||
const infrastructure = useGameStore((s) => s.infrastructure);
|
const infrastructure = useGameStore((s) => s.infrastructure);
|
||||||
const talent = useGameStore((s) => s.talent);
|
const talent = useGameStore((s) => s.talent);
|
||||||
|
const raiseFunding = useGameStore((s) => s.raiseFunding);
|
||||||
|
|
||||||
|
const gameStateForFunding: GameState = useGameStore((s) => ({
|
||||||
|
meta: s.meta, economy: s.economy, infrastructure: s.infrastructure,
|
||||||
|
compute: s.compute, research: s.research, models: s.models,
|
||||||
|
market: s.market, competitors: s.competitors, talent: s.talent,
|
||||||
|
data: s.data, reputation: s.reputation, events: s.events,
|
||||||
|
achievements: s.achievements,
|
||||||
|
}));
|
||||||
|
const fundingStatus = canRaiseFunding(gameStateForFunding);
|
||||||
|
|
||||||
const netIncome = revenuePerTick - expensesPerTick;
|
const netIncome = revenuePerTick - expensesPerTick;
|
||||||
const burnRate = expensesPerTick > revenuePerTick ? expensesPerTick - revenuePerTick : 0;
|
const burnRate = expensesPerTick > revenuePerTick ? expensesPerTick - revenuePerTick : 0;
|
||||||
@@ -72,6 +85,29 @@ export function FinancePage() {
|
|||||||
)}
|
)}
|
||||||
</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-4">Revenue vs Expenses</h3>
|
||||||
|
{history.length > 1 ? (
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<LineChart data={history}>
|
||||||
|
<XAxis dataKey="tick" hide />
|
||||||
|
<YAxis hide />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: '8px' }}
|
||||||
|
formatter={(v: number) => [formatMoney(v)]}
|
||||||
|
/>
|
||||||
|
<Line type="monotone" dataKey="revenue" stroke="#22c55e" dot={false} strokeWidth={2} />
|
||||||
|
<Line type="monotone" dataKey="expenses" stroke="#ef4444" dot={false} strokeWidth={2} />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="h-[200px] flex items-center justify-center text-surface-500 text-sm">
|
||||||
|
Data will appear as time passes
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||||
<h3 className="text-sm font-medium text-surface-400 mb-4">Income Statement (per second)</h3>
|
<h3 className="text-sm font-medium text-surface-400 mb-4">Income Statement (per second)</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -101,10 +137,9 @@ export function FinancePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||||
<h3 className="text-sm font-medium text-surface-400 mb-3">Funding History</h3>
|
<h3 className="text-sm font-medium text-surface-400 mb-3">Funding</h3>
|
||||||
<div className="flex items-center gap-4 mb-3">
|
<div className="flex items-center gap-4 mb-3">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
Founder Equity: <span className="font-mono font-semibold">{formatPercent(funding.founderEquity)}</span>
|
Founder Equity: <span className="font-mono font-semibold">{formatPercent(funding.founderEquity)}</span>
|
||||||
@@ -112,14 +147,41 @@ export function FinancePage() {
|
|||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
Total Raised: <span className="font-mono font-semibold">{formatMoney(funding.totalRaised)}</span>
|
Total Raised: <span className="font-mono font-semibold">{formatMoney(funding.totalRaised)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{funding.isPublic && (
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full bg-accent/20 text-accent-light">Public</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{fundingStatus.nextRound && (
|
||||||
|
<div className="bg-surface-800 rounded-lg p-4 mb-4 border border-surface-600">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h4 className="font-semibold text-sm capitalize">
|
||||||
|
{fundingStatus.nextRound === 'ipo' ? 'IPO' : fundingStatus.nextRound.replace('series', 'Series ')}
|
||||||
|
</h4>
|
||||||
|
{fundingStatus.canRaise ? (
|
||||||
|
<button
|
||||||
|
onClick={() => raiseFunding(fundingStatus.nextRound!)}
|
||||||
|
className="flex items-center gap-1.5 bg-accent hover:bg-accent-dark text-white rounded-lg px-4 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
<Rocket size={14} />
|
||||||
|
Raise {formatMoney(FUNDING_ROUNDS[fundingStatus.nextRound! as FundingRoundType].amount)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-warning">{fundingStatus.reason}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{funding.completedRounds.length === 0 ? (
|
{funding.completedRounds.length === 0 ? (
|
||||||
<p className="text-sm text-surface-500">No funding rounds completed yet.</p>
|
<p className="text-sm text-surface-500">No funding rounds completed yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{funding.completedRounds.map((round, i) => (
|
{funding.completedRounds.map((round, i) => (
|
||||||
<div key={i} className="bg-surface-800 rounded-lg p-3 flex items-center justify-between">
|
<div key={i} className="bg-surface-800 rounded-lg p-3 flex items-center justify-between">
|
||||||
<span className="text-sm font-medium capitalize">{round.type}</span>
|
<span className="text-sm font-medium capitalize">
|
||||||
|
{round.type === 'ipo' ? 'IPO' : round.type.replace('series', 'Series ')}
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<span className="font-mono text-success">{formatMoney(round.amount)}</span>
|
<span className="font-mono text-success">{formatMoney(round.amount)}</span>
|
||||||
<span className="text-surface-400">{formatPercent(round.dilution)} dilution</span>
|
<span className="text-surface-400">{formatPercent(round.dilution)} dilution</span>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export function MarketPage() {
|
|||||||
const tokensCapacity = useGameStore((s) => s.compute.tokensPerSecondCapacity);
|
const tokensCapacity = useGameStore((s) => s.compute.tokensPerSecondCapacity);
|
||||||
const tokensDemand = useGameStore((s) => s.compute.tokensPerSecondDemand);
|
const tokensDemand = useGameStore((s) => s.compute.tokensPerSecondDemand);
|
||||||
const setProductPricing = useGameStore((s) => s.setProductPricing);
|
const setProductPricing = useGameStore((s) => s.setProductPricing);
|
||||||
|
const setOverloadPolicy = useGameStore((s) => s.setOverloadPolicy);
|
||||||
|
|
||||||
const chatProduct = productLines.find(p => p.type === 'chat-product');
|
const chatProduct = productLines.find(p => p.type === 'chat-product');
|
||||||
const textApi = productLines.find(p => p.type === 'text-api');
|
const textApi = productLines.find(p => p.type === 'text-api');
|
||||||
@@ -122,8 +123,56 @@ export function MarketPage() {
|
|||||||
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-3">
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-3">
|
||||||
<h3 className="font-semibold flex items-center gap-2">
|
<h3 className="font-semibold flex items-center gap-2">
|
||||||
<Settings2 size={16} />
|
<Settings2 size={16} />
|
||||||
API Contracts
|
Overload Policy
|
||||||
</h3>
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-surface-400 mb-1">Max Queue Depth</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={overloadPolicy.maxQueueDepth}
|
||||||
|
onChange={(e) => setOverloadPolicy({ maxQueueDepth: Number(e.target.value) })}
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||||
|
min={10}
|
||||||
|
step={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-surface-400 mb-1">Rate Limit / Customer (tok/s)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={overloadPolicy.rateLimitPerCustomer}
|
||||||
|
onChange={(e) => setOverloadPolicy({ rateLimitPerCustomer: Number(e.target.value) })}
|
||||||
|
className="w-full bg-surface-800 border border-surface-600 rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
|
||||||
|
min={100}
|
||||||
|
step={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={overloadPolicy.degradeQualityUnderLoad}
|
||||||
|
onChange={(e) => setOverloadPolicy({ degradeQualityUnderLoad: e.target.checked })}
|
||||||
|
className="accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-surface-300">Degrade quality under load</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={overloadPolicy.prioritizeEnterprise}
|
||||||
|
onChange={(e) => setOverloadPolicy({ prioritizeEnterprise: e.target.checked })}
|
||||||
|
className="accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-surface-300">Prioritize enterprise</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-surface-900 border border-surface-700 rounded-xl p-4 space-y-3">
|
||||||
|
<h3 className="font-semibold">Enterprise Contracts</h3>
|
||||||
{enterprise.activeContracts.length === 0 ? (
|
{enterprise.activeContracts.length === 0 ? (
|
||||||
<p className="text-sm text-surface-500">No enterprise contracts yet. Improve your model quality and reputation to attract enterprise customers.</p>
|
<p className="text-sm text-surface-500">No enterprise contracts yet. Improve your model quality and reputation to attract enterprise customers.</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Brain, Play, Rocket } from 'lucide-react';
|
import { Brain, Play, Rocket, Globe, SlidersHorizontal, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared';
|
import { formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared';
|
||||||
|
import type { TuningPreset } from '@ai-tycoon/shared';
|
||||||
|
|
||||||
export function ModelsPage() {
|
export function ModelsPage() {
|
||||||
const trainedModels = useGameStore((s) => s.models.trainedModels);
|
const trainedModels = useGameStore((s) => s.models.trainedModels);
|
||||||
@@ -13,8 +14,14 @@ export function ModelsPage() {
|
|||||||
const startTraining = useGameStore((s) => s.startTraining);
|
const startTraining = useGameStore((s) => s.startTraining);
|
||||||
const deployModel = useGameStore((s) => s.deployModel);
|
const deployModel = useGameStore((s) => s.deployModel);
|
||||||
const setTrainingAllocation = useGameStore((s) => s.setTrainingAllocation);
|
const setTrainingAllocation = useGameStore((s) => s.setTrainingAllocation);
|
||||||
|
const openSourceModel = useGameStore((s) => s.openSourceModel);
|
||||||
|
const setModelTuning = useGameStore((s) => s.setModelTuning);
|
||||||
|
const openSourcedModels = useGameStore((s) => s.market.openSourcedModels);
|
||||||
|
const completedResearch = useGameStore((s) => s.research.completedResearch);
|
||||||
|
const hasTuningSliders = completedResearch.includes('alignment-research');
|
||||||
|
|
||||||
const [modelName, setModelName] = useState('');
|
const [modelName, setModelName] = useState('');
|
||||||
|
const [expandedModel, setExpandedModel] = useState<string | null>(null);
|
||||||
|
|
||||||
const trainingFlops = totalFlops * trainingAlloc;
|
const trainingFlops = totalFlops * trainingAlloc;
|
||||||
const estimatedTicks = trainingFlops > 0 ? Math.max(30, Math.ceil(120 / (1 + trainingFlops * 0.1))) : Infinity;
|
const estimatedTicks = trainingFlops > 0 ? Math.max(30, Math.ceil(120 / (1 + trainingFlops * 0.1))) : Infinity;
|
||||||
@@ -122,16 +129,35 @@ export function ModelsPage() {
|
|||||||
{trainedModels.length > 0 && (
|
{trainedModels.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h3 className="font-semibold">Trained Models</h3>
|
<h3 className="font-semibold">Trained Models</h3>
|
||||||
{trainedModels.map(model => (
|
{trainedModels.map(model => {
|
||||||
|
const isExpanded = expandedModel === model.id;
|
||||||
|
const isOpenSourced = openSourcedModels.includes(model.id);
|
||||||
|
|
||||||
|
return (
|
||||||
<div key={model.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
<div key={model.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button onClick={() => setExpandedModel(isExpanded ? null : model.id)} className="text-surface-400 hover:text-surface-200">
|
||||||
|
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium">{model.name}</h4>
|
<h4 className="font-medium">{model.name}</h4>
|
||||||
<div className="text-xs text-surface-400">
|
<div className="text-xs text-surface-400">
|
||||||
Gen {model.generation} · Benchmark: {model.benchmarkScore.toFixed(1)}/100 · Safety: {model.safetyScore.toFixed(0)}/100
|
Gen {model.generation} · Benchmark: {model.benchmarkScore.toFixed(1)}/100 · Safety: {model.safetyScore.toFixed(0)}/100
|
||||||
|
{isOpenSourced && <span className="ml-2 text-blue-400">Open Source</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{!isOpenSourced && model.isDeployed && (
|
||||||
|
<button
|
||||||
|
onClick={() => openSourceModel(model.id)}
|
||||||
|
className="flex items-center gap-1 bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 border border-blue-600/30 rounded px-3 py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<Globe size={12} />
|
||||||
|
Open Source
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{model.isDeployed ? (
|
{model.isDeployed ? (
|
||||||
<span className="text-xs px-2 py-1 rounded-full bg-success/20 text-success">Deployed</span>
|
<span className="text-xs px-2 py-1 rounded-full bg-success/20 text-success">Deployed</span>
|
||||||
) : (
|
) : (
|
||||||
@@ -145,9 +171,61 @@ export function ModelsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-surface-700 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<SlidersHorizontal size={14} className="text-surface-400" />
|
||||||
|
<span className="text-sm font-medium">Model Tuning</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-surface-400 mb-1">Preset</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{(['helpful-safe', 'max-capability', 'enterprise', 'creative'] as TuningPreset[]).map(preset => (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
onClick={() => setModelTuning(model.id, { preset })}
|
||||||
|
className={`px-3 py-1.5 rounded text-xs border transition-colors ${
|
||||||
|
model.tuning.preset === preset
|
||||||
|
? 'bg-accent/20 border-accent text-accent-light'
|
||||||
|
: 'bg-surface-800 border-surface-600 text-surface-300 hover:border-surface-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{preset === 'helpful-safe' ? 'Helpful & Safe' : preset === 'max-capability' ? 'Max Capability' : preset === 'enterprise' ? 'Enterprise' : 'Creative'}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasTuningSliders && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<TuningSlider label="Safety Level" value={model.tuning.safetyLevel ?? 0.7} onChange={(v) => setModelTuning(model.id, { safetyLevel: v })} />
|
||||||
|
<TuningSlider label="Creativity" value={model.tuning.creativity ?? 0.5} onChange={(v) => setModelTuning(model.id, { creativity: v })} />
|
||||||
|
<TuningSlider label="Verbosity" value={model.tuning.verbosity ?? 0.5} onChange={(v) => setModelTuning(model.id, { verbosity: v })} />
|
||||||
|
<TuningSlider label="Speed vs Quality" value={model.tuning.speedQuality ?? 0.5} onChange={(v) => setModelTuning(model.id, { speedQuality: v })} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-xs">
|
||||||
|
<div className="bg-surface-800 rounded-lg p-2">
|
||||||
|
<span className="text-surface-400">Reasoning</span>
|
||||||
|
<div className="font-mono mt-0.5">{model.capabilities.reasoning.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-800 rounded-lg p-2">
|
||||||
|
<span className="text-surface-400">Coding</span>
|
||||||
|
<div className="font-mono mt-0.5">{model.capabilities.coding.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-surface-800 rounded-lg p-2">
|
||||||
|
<span className="text-surface-400">Creative</span>
|
||||||
|
<div className="font-mono mt-0.5">{model.capabilities.creative.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -171,3 +249,22 @@ export function ModelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TuningSlider({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between text-xs mb-1">
|
||||||
|
<span className="text-surface-400">{label}</span>
|
||||||
|
<span className="font-mono text-surface-300">{(value * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={value * 100}
|
||||||
|
onChange={(e) => onChange(Number(e.target.value) / 100)}
|
||||||
|
className="w-full accent-accent h-1.5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,17 @@ import { useGameStore } from '@/store';
|
|||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const settings = useGameStore((s) => s.meta.settings);
|
const settings = useGameStore((s) => s.meta.settings);
|
||||||
const companyName = useGameStore((s) => s.meta.companyName);
|
const companyName = useGameStore((s) => s.meta.companyName);
|
||||||
|
const updateState = useGameStore((s) => s.updateState);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const toggleSound = () => {
|
||||||
|
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, soundEnabled: !settings.soundEnabled } } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMusicVolume = (v: number) => {
|
||||||
|
updateState({ meta: { ...useGameStore.getState().meta, settings: { ...settings, musicVolume: v } } });
|
||||||
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
if (confirm('Are you sure you want to reset all progress? This cannot be undone.')) {
|
if (confirm('Are you sure you want to reset all progress? This cannot be undone.')) {
|
||||||
localStorage.removeItem('ai-tycoon-save');
|
localStorage.removeItem('ai-tycoon-save');
|
||||||
@@ -62,15 +71,22 @@ export function SettingsPage() {
|
|||||||
<div className="text-sm">Sound Effects</div>
|
<div className="text-sm">Sound Effects</div>
|
||||||
<div className="text-xs text-surface-400">Play UI sounds and notifications</div>
|
<div className="text-xs text-surface-400">Play UI sounds and notifications</div>
|
||||||
</div>
|
</div>
|
||||||
<ToggleSwitch checked={settings.soundEnabled} onChange={() => {}} />
|
<ToggleSwitch checked={settings.soundEnabled} onChange={toggleSound} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm">Music</div>
|
<div className="text-sm">Music Volume</div>
|
||||||
<div className="text-xs text-surface-400">Background music (coming soon)</div>
|
<div className="text-xs text-surface-400">Background music level</div>
|
||||||
</div>
|
</div>
|
||||||
<ToggleSwitch checked={settings.soundEnabled} onChange={() => {}} />
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={settings.musicVolume * 100}
|
||||||
|
onChange={(e) => setMusicVolume(Number(e.target.value) / 100)}
|
||||||
|
className="w-32 accent-accent"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
DataCenter, GpuType, GpuInventory, TrainingJob,
|
DataCenter, GpuType, GpuInventory, TrainingJob,
|
||||||
ActiveResearch, EventConsequence, OwnedDataset,
|
ActiveResearch, EventConsequence, OwnedDataset,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@ai-tycoon/shared';
|
||||||
|
import type { FundingRoundType, OverloadPolicy, TuningPreset, ModelTuning } from '@ai-tycoon/shared';
|
||||||
import {
|
import {
|
||||||
INITIAL_SETTINGS, SAVE_VERSION,
|
INITIAL_SETTINGS, SAVE_VERSION,
|
||||||
INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE,
|
INITIAL_ECONOMY, INITIAL_INFRASTRUCTURE, INITIAL_COMPUTE,
|
||||||
@@ -16,11 +17,13 @@ import {
|
|||||||
INITIAL_COMPETITORS, INITIAL_TALENT, INITIAL_DATA,
|
INITIAL_COMPETITORS, INITIAL_TALENT, INITIAL_DATA,
|
||||||
INITIAL_REPUTATION, INITIAL_EVENTS, INITIAL_ACHIEVEMENTS,
|
INITIAL_REPUTATION, INITIAL_EVENTS, INITIAL_ACHIEVEMENTS,
|
||||||
GPU_CONFIGS,
|
GPU_CONFIGS,
|
||||||
|
FUNDING_ROUNDS,
|
||||||
|
OPEN_SOURCE_REPUTATION_BOOST,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@ai-tycoon/shared';
|
||||||
import { INITIAL_RIVALS } from '@ai-tycoon/game-engine';
|
import { INITIAL_RIVALS } from '@ai-tycoon/game-engine';
|
||||||
|
|
||||||
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
|
export type ActivePage = 'dashboard' | 'infrastructure' | 'research' | 'models'
|
||||||
| 'market' | 'talent' | 'data' | 'competitors' | 'finance' | 'settings';
|
| 'market' | 'talent' | 'data' | 'competitors' | 'finance' | 'achievements' | 'settings';
|
||||||
|
|
||||||
interface UIState {
|
interface UIState {
|
||||||
activePage: ActivePage;
|
activePage: ActivePage;
|
||||||
@@ -54,6 +57,11 @@ interface Actions {
|
|||||||
resolveEvent: (instanceId: string, choiceIndex: number) => void;
|
resolveEvent: (instanceId: string, choiceIndex: number) => void;
|
||||||
hireDepartment: (departmentId: string, count: number) => void;
|
hireDepartment: (departmentId: string, count: number) => void;
|
||||||
purchaseDataset: (dataset: OwnedDataset, cost: number) => void;
|
purchaseDataset: (dataset: OwnedDataset, cost: number) => void;
|
||||||
|
raiseFunding: (roundType: FundingRoundType) => void;
|
||||||
|
openSourceModel: (modelId: string) => void;
|
||||||
|
setOverloadPolicy: (policy: Partial<OverloadPolicy>) => void;
|
||||||
|
setModelTuning: (modelId: string, tuning: Partial<ModelTuning>) => void;
|
||||||
|
acquireCompetitor: (competitorId: string) => void;
|
||||||
updateState: (partial: Partial<GameState>) => void;
|
updateState: (partial: Partial<GameState>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +314,84 @@ export const useGameStore = create<Store>()(
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
raiseFunding: (roundType) => set((s) => {
|
||||||
|
const config = FUNDING_ROUNDS[roundType];
|
||||||
|
if (!config) return s;
|
||||||
|
const amount = config.amount;
|
||||||
|
const dilution = config.dilution;
|
||||||
|
return {
|
||||||
|
economy: {
|
||||||
|
...s.economy,
|
||||||
|
money: s.economy.money + amount,
|
||||||
|
funding: {
|
||||||
|
...s.economy.funding,
|
||||||
|
totalRaised: s.economy.funding.totalRaised + amount,
|
||||||
|
founderEquity: s.economy.funding.founderEquity * (1 - dilution),
|
||||||
|
completedRounds: [
|
||||||
|
...s.economy.funding.completedRounds,
|
||||||
|
{ type: roundType, amount, dilution, completedAtTick: s.meta.tickCount },
|
||||||
|
],
|
||||||
|
isPublic: roundType === 'ipo',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
openSourceModel: (modelId) => set((s) => {
|
||||||
|
if (s.market.openSourcedModels.includes(modelId)) return s;
|
||||||
|
return {
|
||||||
|
market: {
|
||||||
|
...s.market,
|
||||||
|
openSourcedModels: [...s.market.openSourcedModels, modelId],
|
||||||
|
},
|
||||||
|
reputation: {
|
||||||
|
...s.reputation,
|
||||||
|
score: Math.min(100, s.reputation.score + OPEN_SOURCE_REPUTATION_BOOST),
|
||||||
|
publicPerception: Math.min(100, s.reputation.publicPerception + OPEN_SOURCE_REPUTATION_BOOST),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
setOverloadPolicy: (policy) => set((s) => ({
|
||||||
|
market: {
|
||||||
|
...s.market,
|
||||||
|
overloadPolicy: { ...s.market.overloadPolicy, ...policy },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
setModelTuning: (modelId, tuning) => set((s) => ({
|
||||||
|
models: {
|
||||||
|
...s.models,
|
||||||
|
trainedModels: s.models.trainedModels.map(m =>
|
||||||
|
m.id === modelId ? { ...m, tuning: { ...m.tuning, ...tuning } } : m,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
|
||||||
|
acquireCompetitor: (competitorId) => set((s) => {
|
||||||
|
const rival = s.competitors.rivals.find(r => r.id === competitorId);
|
||||||
|
if (!rival || rival.status === 'acquired') return s;
|
||||||
|
const cost = rival.estimatedRevenue * 500 + rival.estimatedCapability * 100_000;
|
||||||
|
if (s.economy.money < cost) return s;
|
||||||
|
return {
|
||||||
|
economy: { ...s.economy, money: s.economy.money - cost },
|
||||||
|
competitors: {
|
||||||
|
...s.competitors,
|
||||||
|
rivals: s.competitors.rivals.map(r =>
|
||||||
|
r.id === competitorId ? { ...r, status: 'acquired' as const } : r,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
talent: {
|
||||||
|
...s.talent,
|
||||||
|
departments: {
|
||||||
|
...s.talent.departments,
|
||||||
|
research: { ...s.talent.departments.research, headcount: s.talent.departments.research.headcount + 5 },
|
||||||
|
engineering: { ...s.talent.departments.engineering, headcount: s.talent.departments.engineering.headcount + 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
updateState: (partial) => set((s) => {
|
updateState: (partial) => set((s) => {
|
||||||
const newState: Partial<Store> = {};
|
const newState: Partial<Store> = {};
|
||||||
for (const key of Object.keys(partial) as (keyof GameState)[]) {
|
for (const key of Object.keys(partial) as (keyof GameState)[]) {
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { AchievementDefinition } from '@ai-tycoon/shared';
|
||||||
|
|
||||||
|
export const ACHIEVEMENT_DEFINITIONS: AchievementDefinition[] = [
|
||||||
|
{
|
||||||
|
id: 'first-dc',
|
||||||
|
name: 'First Steps',
|
||||||
|
description: 'Build your first data center.',
|
||||||
|
icon: 'Server',
|
||||||
|
condition: { field: 'infrastructure.dataCenters.length', operator: 'gte', value: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'first-model',
|
||||||
|
name: 'Hello World',
|
||||||
|
description: 'Train your first AI model.',
|
||||||
|
icon: 'Brain',
|
||||||
|
condition: { field: 'models.trainedModels.length', operator: 'gte', value: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'first-deploy',
|
||||||
|
name: 'Going Live',
|
||||||
|
description: 'Deploy a model to production.',
|
||||||
|
icon: 'Rocket',
|
||||||
|
condition: { field: 'meta._deployedModelCount', operator: 'gte', value: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'first-revenue',
|
||||||
|
name: 'First Dollar',
|
||||||
|
description: 'Earn your first revenue.',
|
||||||
|
icon: 'DollarSign',
|
||||||
|
condition: { field: 'economy.totalRevenue', operator: 'gt', value: 0 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'seed-funded',
|
||||||
|
name: 'Seed Funded',
|
||||||
|
description: 'Complete your seed funding round.',
|
||||||
|
icon: 'Sprout',
|
||||||
|
condition: { field: 'economy.funding.completedRounds.length', operator: 'gte', value: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '1k-subscribers',
|
||||||
|
name: '1K Club',
|
||||||
|
description: 'Reach 1,000 subscribers.',
|
||||||
|
icon: 'Users',
|
||||||
|
condition: { field: 'market.consumers.totalSubscribers', operator: 'gte', value: 1_000 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '100k-subscribers',
|
||||||
|
name: 'Mass Adoption',
|
||||||
|
description: 'Reach 100,000 subscribers.',
|
||||||
|
icon: 'Globe',
|
||||||
|
condition: { field: 'market.consumers.totalSubscribers', operator: 'gte', value: 100_000 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'unicorn',
|
||||||
|
name: 'Unicorn',
|
||||||
|
description: 'Reach a $1B valuation.',
|
||||||
|
icon: 'Sparkles',
|
||||||
|
condition: { field: 'economy.funding.valuation', operator: 'gte', value: 1_000_000_000 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'era-scaleup',
|
||||||
|
name: 'Scaling Up',
|
||||||
|
description: 'Enter the Scale-up era.',
|
||||||
|
icon: 'TrendingUp',
|
||||||
|
condition: { field: 'meta._eraIndex', operator: 'gte', value: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'era-bigtech',
|
||||||
|
name: 'Big League',
|
||||||
|
description: 'Enter the Big Tech era.',
|
||||||
|
icon: 'Building2',
|
||||||
|
condition: { field: 'meta._eraIndex', operator: 'gte', value: 2 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'era-agi',
|
||||||
|
name: 'The Singularity',
|
||||||
|
description: 'Enter the AGI era.',
|
||||||
|
icon: 'Atom',
|
||||||
|
condition: { field: 'meta._eraIndex', operator: 'gte', value: 3 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gpu-hoarder',
|
||||||
|
name: 'GPU Hoarder',
|
||||||
|
description: 'Own 100 or more GPUs across all data centers.',
|
||||||
|
icon: 'Cpu',
|
||||||
|
condition: { field: 'infrastructure._totalGpuCount', operator: 'gte', value: 100 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'research-pioneer',
|
||||||
|
name: 'Research Pioneer',
|
||||||
|
description: 'Complete 10 research projects.',
|
||||||
|
icon: 'FlaskConical',
|
||||||
|
condition: { field: 'research.completedResearch.length', operator: 'gte', value: 10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-source-champion',
|
||||||
|
name: 'Open Source Champion',
|
||||||
|
description: 'Open-source 3 models.',
|
||||||
|
icon: 'GitBranch',
|
||||||
|
condition: { field: 'market.openSourcedModels.length', operator: 'gte', value: 3 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'speed-demon',
|
||||||
|
name: 'Speed Demon',
|
||||||
|
description: 'Reach 1 million tokens/second inference capacity.',
|
||||||
|
icon: 'Zap',
|
||||||
|
condition: { field: 'compute.tokensPerSecondCapacity', operator: 'gte', value: 1_000_000 },
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
export { GameEngine } from './engine';
|
export { GameEngine } from './engine';
|
||||||
export { processTick, setEventDefinitions } from './tick';
|
export { processTick, setEventDefinitions, setAchievementDefinitions } from './tick';
|
||||||
export type { TickNotification } from './tick';
|
export type { TickNotification } from './tick';
|
||||||
export { getAvailableResearch, getResearchNode } from './systems/researchSystem';
|
export { getAvailableResearch, getResearchNode } from './systems/researchSystem';
|
||||||
|
export { canRaiseFunding, getNextFundingRound, computeValuation } from './systems/fundingSystem';
|
||||||
export { TECH_TREE } from './data/techTree';
|
export { TECH_TREE } from './data/techTree';
|
||||||
export { INITIAL_RIVALS } from './data/competitors';
|
export { INITIAL_RIVALS } from './data/competitors';
|
||||||
export { KEY_HIRE_POOL } from './data/keyHires';
|
export { KEY_HIRE_POOL } from './data/keyHires';
|
||||||
export { EVENT_DEFINITIONS } from './data/events';
|
export { EVENT_DEFINITIONS } from './data/events';
|
||||||
|
export { ACHIEVEMENT_DEFINITIONS } from './data/achievements';
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { GameState, AchievementState, AchievementDefinition } from '@ai-tycoon/shared';
|
||||||
|
|
||||||
|
export interface AchievementTickResult {
|
||||||
|
achievements: AchievementState;
|
||||||
|
newAchievements: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERA_INDEX: Record<string, number> = { startup: 0, scaleup: 1, bigtech: 2, agi: 3 };
|
||||||
|
|
||||||
|
function getFieldValue(state: GameState, field: string): number {
|
||||||
|
if (field === 'meta._eraIndex') return ERA_INDEX[state.meta.currentEra] ?? 0;
|
||||||
|
if (field === 'meta._deployedModelCount') return state.models.trainedModels.filter(m => m.isDeployed).length;
|
||||||
|
if (field === 'infrastructure._totalGpuCount') {
|
||||||
|
return state.infrastructure.dataCenters.reduce(
|
||||||
|
(sum, dc) => sum + dc.gpus.reduce((s, g) => s + g.count, 0), 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = field.split('.');
|
||||||
|
let current: unknown = state;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current == null || typeof current !== 'object') return 0;
|
||||||
|
current = (current as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
return typeof current === 'number' ? current : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCondition(state: GameState, def: AchievementDefinition): boolean {
|
||||||
|
const value = getFieldValue(state, def.condition.field);
|
||||||
|
switch (def.condition.operator) {
|
||||||
|
case 'gt': return value > def.condition.value;
|
||||||
|
case 'gte': return value >= def.condition.value;
|
||||||
|
case 'eq': return value === def.condition.value;
|
||||||
|
default: return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processAchievements(
|
||||||
|
state: GameState,
|
||||||
|
definitions: AchievementDefinition[],
|
||||||
|
): AchievementTickResult {
|
||||||
|
if (state.meta.tickCount % 10 !== 0) {
|
||||||
|
return { achievements: state.achievements, newAchievements: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlockedIds = new Set(state.achievements.unlocked.map(a => a.id));
|
||||||
|
const newAchievements: string[] = [];
|
||||||
|
const unlocked = [...state.achievements.unlocked];
|
||||||
|
|
||||||
|
for (const def of definitions) {
|
||||||
|
if (unlockedIds.has(def.id)) continue;
|
||||||
|
if (checkCondition(state, def)) {
|
||||||
|
unlocked.push({ id: def.id, unlockedAtTick: state.meta.tickCount });
|
||||||
|
newAchievements.push(def.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
achievements: { ...state.achievements, unlocked },
|
||||||
|
newAchievements,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { GameState, FundingState, FundingRoundType } from '@ai-tycoon/shared';
|
||||||
|
import { FUNDING_ROUNDS } from '@ai-tycoon/shared';
|
||||||
|
|
||||||
|
const ROUND_ORDER: FundingRoundType[] = ['seed', 'seriesA', 'seriesB', 'seriesC', 'seriesD', 'ipo'];
|
||||||
|
|
||||||
|
export function getNextFundingRound(funding: FundingState): FundingRoundType | null {
|
||||||
|
if (funding.isPublic) return null;
|
||||||
|
const completedTypes = new Set(funding.completedRounds.map(r => r.type));
|
||||||
|
for (const type of ROUND_ORDER) {
|
||||||
|
if (!completedTypes.has(type)) return type;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canRaiseFunding(state: GameState): { canRaise: boolean; nextRound: FundingRoundType | null; reason?: string } {
|
||||||
|
const nextRound = getNextFundingRound(state.economy.funding);
|
||||||
|
if (!nextRound) return { canRaise: false, nextRound: null, reason: 'No more funding rounds available' };
|
||||||
|
|
||||||
|
const config = FUNDING_ROUNDS[nextRound];
|
||||||
|
const reqs = config.requirements;
|
||||||
|
|
||||||
|
if (reqs.minRevenue && state.economy.totalRevenue < reqs.minRevenue) {
|
||||||
|
return { canRaise: false, nextRound, reason: `Need $${reqs.minRevenue.toLocaleString()} total revenue` };
|
||||||
|
}
|
||||||
|
if (reqs.minUsers && state.market.consumers.totalSubscribers < reqs.minUsers) {
|
||||||
|
return { canRaise: false, nextRound, reason: `Need ${reqs.minUsers.toLocaleString()} subscribers` };
|
||||||
|
}
|
||||||
|
if (reqs.minReputation && state.reputation.score < reqs.minReputation) {
|
||||||
|
return { canRaise: false, nextRound, reason: `Need ${reqs.minReputation} reputation` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { canRaise: true, nextRound };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeValuation(state: GameState): number {
|
||||||
|
const revenueMultiple = state.economy.revenuePerTick * 86400 * 365;
|
||||||
|
const subscriberValue = state.market.consumers.totalSubscribers * 500;
|
||||||
|
const capabilityValue = Math.pow(
|
||||||
|
Math.max(...state.models.trainedModels.map(m => m.benchmarkScore), 0),
|
||||||
|
2,
|
||||||
|
) * 1000;
|
||||||
|
return Math.max(100_000, revenueMultiple * 10 + subscriberValue + capabilityValue);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
CONSUMER_QUALITY_GROWTH_MULTIPLIER,
|
CONSUMER_QUALITY_GROWTH_MULTIPLIER,
|
||||||
CONSUMER_BASE_CHURN,
|
CONSUMER_BASE_CHURN,
|
||||||
API_TOKENS_PER_REQUEST,
|
API_TOKENS_PER_REQUEST,
|
||||||
|
OPEN_SOURCE_REVENUE_PENALTY,
|
||||||
|
OPEN_SOURCE_TALENT_ATTRACTION,
|
||||||
} from '@ai-tycoon/shared';
|
} from '@ai-tycoon/shared';
|
||||||
|
|
||||||
export interface MarketTickResult {
|
export interface MarketTickResult {
|
||||||
@@ -84,13 +86,35 @@ export function processMarket(state: GameState, compute: ComputeState): MarketTi
|
|||||||
consumers.totalSubscribers * 100 +
|
consumers.totalSubscribers * 100 +
|
||||||
enterprise.activeContracts.reduce((s, c) => s + c.tokensPerTick, 0);
|
enterprise.activeContracts.reduce((s, c) => s + c.tokensPerTick, 0);
|
||||||
|
|
||||||
|
const openSourceCount = state.market.openSourcedModels.length;
|
||||||
|
if (openSourceCount > 0) {
|
||||||
|
const growthBoost = 1 + openSourceCount * OPEN_SOURCE_TALENT_ATTRACTION;
|
||||||
|
consumers.totalSubscribers *= growthBoost > 1 ? 1 + (growthBoost - 1) * 0.01 : 1;
|
||||||
|
apiRevenue *= 1 - openSourceCount * OPEN_SOURCE_REVENUE_PENALTY * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const policy = state.market.overloadPolicy;
|
||||||
|
if (policy.degradeQualityUnderLoad && compute.inferenceUtilization > 0.85) {
|
||||||
|
consumers.satisfaction = Math.max(0, consumers.satisfaction - 0.02);
|
||||||
|
}
|
||||||
|
if (policy.prioritizeEnterprise && compute.inferenceUtilization > 0.9) {
|
||||||
|
consumers.satisfaction = Math.max(0, consumers.satisfaction - 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscriberHistory = [...(state.market.subscriberHistory || [])];
|
||||||
|
if (state.meta.tickCount % 60 === 0) {
|
||||||
|
subscriberHistory.push({ tick: state.meta.tickCount, subscribers: consumers.totalSubscribers });
|
||||||
|
if (subscriberHistory.length > 500) subscriberHistory.shift();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
marketState: {
|
marketState: {
|
||||||
...state.market,
|
...state.market,
|
||||||
consumers,
|
consumers,
|
||||||
enterprise,
|
enterprise,
|
||||||
|
subscriberHistory,
|
||||||
},
|
},
|
||||||
apiRevenue,
|
apiRevenue: Math.max(0, apiRevenue),
|
||||||
subscriptionRevenue,
|
subscriptionRevenue,
|
||||||
totalTokenDemand,
|
totalTokenDemand,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { GameState, EventDefinition } from '@ai-tycoon/shared';
|
import type { GameState, EventDefinition, AchievementDefinition } from '@ai-tycoon/shared';
|
||||||
import { processEconomy } from './systems/economySystem';
|
import { processEconomy } from './systems/economySystem';
|
||||||
import { processInfrastructure } from './systems/infrastructureSystem';
|
import { processInfrastructure } from './systems/infrastructureSystem';
|
||||||
import { processCompute } from './systems/computeSystem';
|
import { processCompute } from './systems/computeSystem';
|
||||||
@@ -11,6 +11,8 @@ import { processEvents } from './systems/eventSystem';
|
|||||||
import { processCompetitors } from './systems/competitorSystem';
|
import { processCompetitors } from './systems/competitorSystem';
|
||||||
import { processData } from './systems/dataSystem';
|
import { processData } from './systems/dataSystem';
|
||||||
import { checkEraTransition } from './systems/eraSystem';
|
import { checkEraTransition } from './systems/eraSystem';
|
||||||
|
import { processAchievements } from './systems/achievementSystem';
|
||||||
|
import { computeValuation } from './systems/fundingSystem';
|
||||||
|
|
||||||
export interface TickResult {
|
export interface TickResult {
|
||||||
state: Partial<GameState>;
|
state: Partial<GameState>;
|
||||||
@@ -24,11 +26,16 @@ export interface TickNotification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cachedEventDefs: EventDefinition[] | null = null;
|
let cachedEventDefs: EventDefinition[] | null = null;
|
||||||
|
let cachedAchievementDefs: AchievementDefinition[] | null = null;
|
||||||
|
|
||||||
export function setEventDefinitions(defs: EventDefinition[]) {
|
export function setEventDefinitions(defs: EventDefinition[]) {
|
||||||
cachedEventDefs = defs;
|
cachedEventDefs = defs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setAchievementDefinitions(defs: AchievementDefinition[]) {
|
||||||
|
cachedAchievementDefs = defs;
|
||||||
|
}
|
||||||
|
|
||||||
export function processTick(state: GameState): Partial<GameState> {
|
export function processTick(state: GameState): Partial<GameState> {
|
||||||
const notifications: TickNotification[] = [];
|
const notifications: TickNotification[] = [];
|
||||||
|
|
||||||
@@ -102,9 +109,43 @@ export function processTick(state: GameState): Partial<GameState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const valuation = computeValuation({ ...stateWithTalent, economy, reputation, research: researchResult.research });
|
||||||
|
const updatedEconomy = {
|
||||||
|
...economy,
|
||||||
|
funding: { ...economy.funding, valuation },
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateForAchievements: GameState = {
|
||||||
|
...stateWithTalent,
|
||||||
|
meta,
|
||||||
|
economy: updatedEconomy,
|
||||||
|
infrastructure,
|
||||||
|
compute,
|
||||||
|
research: researchResult.research,
|
||||||
|
models: modelResult.modelsState,
|
||||||
|
market: market.marketState,
|
||||||
|
reputation,
|
||||||
|
data,
|
||||||
|
competitors,
|
||||||
|
events: eventResult.events,
|
||||||
|
achievements: state.achievements,
|
||||||
|
};
|
||||||
|
|
||||||
|
const achievementResult = cachedAchievementDefs
|
||||||
|
? processAchievements(stateForAchievements, cachedAchievementDefs)
|
||||||
|
: { achievements: state.achievements, newAchievements: [] };
|
||||||
|
|
||||||
|
for (const name of achievementResult.newAchievements) {
|
||||||
|
notifications.push({
|
||||||
|
title: 'Achievement Unlocked!',
|
||||||
|
message: name,
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const result: Partial<GameState> = {
|
const result: Partial<GameState> = {
|
||||||
meta,
|
meta,
|
||||||
economy,
|
economy: updatedEconomy,
|
||||||
infrastructure,
|
infrastructure,
|
||||||
compute,
|
compute,
|
||||||
research: researchResult.research,
|
research: researchResult.research,
|
||||||
@@ -115,6 +156,7 @@ export function processTick(state: GameState): Partial<GameState> {
|
|||||||
data,
|
data,
|
||||||
competitors,
|
competitors,
|
||||||
events: eventResult.events,
|
events: eventResult.events,
|
||||||
|
achievements: achievementResult.achievements,
|
||||||
};
|
};
|
||||||
|
|
||||||
(result as Record<string, unknown>)['_notifications'] = notifications;
|
(result as Record<string, unknown>)['_notifications'] = notifications;
|
||||||
|
|||||||
@@ -40,3 +40,16 @@ export const ERA_THRESHOLDS = {
|
|||||||
export const GPU_PRICE_VOLATILITY = 0.02;
|
export const GPU_PRICE_VOLATILITY = 0.02;
|
||||||
export const GPU_FAILURE_RATE_BASE = 0.0001;
|
export const GPU_FAILURE_RATE_BASE = 0.0001;
|
||||||
export const REDUNDANCY_FAILURE_REDUCTION = 0.5;
|
export const REDUNDANCY_FAILURE_REDUCTION = 0.5;
|
||||||
|
|
||||||
|
export const FUNDING_ROUNDS = {
|
||||||
|
seed: { amount: 100_000, dilution: 0.10, requirements: { minRevenue: 100, minUsers: 0, minReputation: 0 } },
|
||||||
|
seriesA: { amount: 500_000, dilution: 0.15, requirements: { minRevenue: 500, minUsers: 100, minReputation: 20 } },
|
||||||
|
seriesB: { amount: 2_000_000, dilution: 0.12, requirements: { minRevenue: 5_000, minUsers: 1_000, minReputation: 30 } },
|
||||||
|
seriesC: { amount: 10_000_000, dilution: 0.10, requirements: { minRevenue: 50_000, minUsers: 10_000, minReputation: 40 } },
|
||||||
|
seriesD: { amount: 50_000_000, dilution: 0.08, requirements: { minRevenue: 500_000, minUsers: 50_000, minReputation: 50 } },
|
||||||
|
ipo: { amount: 200_000_000, dilution: 0.20, requirements: { minRevenue: 5_000_000, minUsers: 100_000, minReputation: 60 } },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const OPEN_SOURCE_REPUTATION_BOOST = 8;
|
||||||
|
export const OPEN_SOURCE_TALENT_ATTRACTION = 0.15;
|
||||||
|
export const OPEN_SOURCE_REVENUE_PENALTY = 0.10;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface MarketState {
|
|||||||
enterprise: EnterpriseMarket;
|
enterprise: EnterpriseMarket;
|
||||||
overloadPolicy: OverloadPolicy;
|
overloadPolicy: OverloadPolicy;
|
||||||
openSourcedModels: string[];
|
openSourcedModels: string[];
|
||||||
|
subscriberHistory: { tick: number; subscribers: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConsumerMarket {
|
export interface ConsumerMarket {
|
||||||
@@ -70,4 +71,5 @@ export const INITIAL_MARKET: MarketState = {
|
|||||||
prioritizeEnterprise: true,
|
prioritizeEnterprise: true,
|
||||||
},
|
},
|
||||||
openSourcedModels: [],
|
openSourcedModels: [],
|
||||||
|
subscriberHistory: [],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user