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,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 { formatMoney, formatNumber } from '@ai-tycoon/shared';
|
||||
import type { Era } from '@ai-tycoon/shared';
|
||||
|
||||
const ARCHETYPE_LABELS: Record<string, string> = {
|
||||
'safety-first': 'Safety-First Lab',
|
||||
@@ -24,6 +25,10 @@ export function CompetitorsPage() {
|
||||
const playerBest = useGameStore((s) =>
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
@@ -75,13 +80,26 @@ export function CompetitorsPage() {
|
||||
{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 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 ${
|
||||
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>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
DollarSign, Server, Brain, Users, TrendingUp,
|
||||
TrendingDown, Minus, Cpu, Zap, Shield,
|
||||
} 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() {
|
||||
const money = useGameStore((s) => s.economy.money);
|
||||
@@ -26,6 +27,24 @@ export function DashboardPage() {
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
<StatCard
|
||||
icon={DollarSign}
|
||||
@@ -115,6 +134,62 @@ export function DashboardPage() {
|
||||
</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 && (
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatMoney, formatPercent } from '@ai-tycoon/shared';
|
||||
import { TrendingUp, TrendingDown, DollarSign, PiggyBank, BarChart3 } from 'lucide-react';
|
||||
import { AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, BarChart, Bar, CartesianGrid } from 'recharts';
|
||||
import { formatMoney, formatPercent, formatNumber, FUNDING_ROUNDS } from '@ai-tycoon/shared';
|
||||
import type { FundingRoundType } from '@ai-tycoon/shared';
|
||||
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() {
|
||||
const money = useGameStore((s) => s.economy.money);
|
||||
@@ -13,6 +16,16 @@ export function FinancePage() {
|
||||
const history = useGameStore((s) => s.economy.financialHistory);
|
||||
const infrastructure = useGameStore((s) => s.infrastructure);
|
||||
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 burnRate = expensesPerTick > revenuePerTick ? expensesPerTick - revenuePerTick : 0;
|
||||
@@ -73,38 +86,60 @@ export function FinancePage() {
|
||||
</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">Income Statement (per second)</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-surface-300">Revenue</span>
|
||||
<span className="font-mono text-success">{formatMoney(revenuePerTick)}</span>
|
||||
</div>
|
||||
<div className="border-t border-surface-700" />
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-surface-400 pl-4">Infrastructure</span>
|
||||
<span className="font-mono text-danger">-{formatMoney(infraCosts)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-surface-400 pl-4">Talent</span>
|
||||
<span className="font-mono text-danger">-{formatMoney(talentCosts)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-surface-400 pl-4">Total Expenses</span>
|
||||
<span className="font-mono text-danger">-{formatMoney(expensesPerTick)}</span>
|
||||
</div>
|
||||
<div className="border-t border-surface-700" />
|
||||
<div className="flex justify-between text-sm font-semibold">
|
||||
<span>Net Income</span>
|
||||
<span className={`font-mono ${netIncome >= 0 ? 'text-success' : 'text-danger'}`}>
|
||||
{formatMoney(netIncome)}
|
||||
</span>
|
||||
<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">
|
||||
<h3 className="text-sm font-medium text-surface-400 mb-4">Income Statement (per second)</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-surface-300">Revenue</span>
|
||||
<span className="font-mono text-success">{formatMoney(revenuePerTick)}</span>
|
||||
</div>
|
||||
<div className="border-t border-surface-700" />
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-surface-400 pl-4">Infrastructure</span>
|
||||
<span className="font-mono text-danger">-{formatMoney(infraCosts)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-surface-400 pl-4">Talent</span>
|
||||
<span className="font-mono text-danger">-{formatMoney(talentCosts)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-surface-400 pl-4">Total Expenses</span>
|
||||
<span className="font-mono text-danger">-{formatMoney(expensesPerTick)}</span>
|
||||
</div>
|
||||
<div className="border-t border-surface-700" />
|
||||
<div className="flex justify-between text-sm font-semibold">
|
||||
<span>Net Income</span>
|
||||
<span className={`font-mono ${netIncome >= 0 ? 'text-success' : 'text-danger'}`}>
|
||||
{formatMoney(netIncome)}
|
||||
</span>
|
||||
</div>
|
||||
</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">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="text-sm">
|
||||
Founder Equity: <span className="font-mono font-semibold">{formatPercent(funding.founderEquity)}</span>
|
||||
@@ -112,14 +147,41 @@ export function FinancePage() {
|
||||
<div className="text-sm">
|
||||
Total Raised: <span className="font-mono font-semibold">{formatMoney(funding.totalRaised)}</span>
|
||||
</div>
|
||||
{funding.isPublic && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-accent/20 text-accent-light">Public</span>
|
||||
)}
|
||||
</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 ? (
|
||||
<p className="text-sm text-surface-500">No funding rounds completed yet.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{funding.completedRounds.map((round, i) => (
|
||||
<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">
|
||||
<span className="font-mono text-success">{formatMoney(round.amount)}</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 tokensDemand = useGameStore((s) => s.compute.tokensPerSecondDemand);
|
||||
const setProductPricing = useGameStore((s) => s.setProductPricing);
|
||||
const setOverloadPolicy = useGameStore((s) => s.setOverloadPolicy);
|
||||
|
||||
const chatProduct = productLines.find(p => p.type === 'chat-product');
|
||||
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">
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<Settings2 size={16} />
|
||||
API Contracts
|
||||
Overload Policy
|
||||
</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 ? (
|
||||
<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 { Brain, Play, Rocket } from 'lucide-react';
|
||||
import { Brain, Play, Rocket, Globe, SlidersHorizontal, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useGameStore } from '@/store';
|
||||
import { formatNumber, formatPercent, formatDuration } from '@ai-tycoon/shared';
|
||||
import type { TuningPreset } from '@ai-tycoon/shared';
|
||||
|
||||
export function ModelsPage() {
|
||||
const trainedModels = useGameStore((s) => s.models.trainedModels);
|
||||
@@ -13,8 +14,14 @@ export function ModelsPage() {
|
||||
const startTraining = useGameStore((s) => s.startTraining);
|
||||
const deployModel = useGameStore((s) => s.deployModel);
|
||||
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 [expandedModel, setExpandedModel] = useState<string | null>(null);
|
||||
|
||||
const trainingFlops = totalFlops * trainingAlloc;
|
||||
const estimatedTicks = trainingFlops > 0 ? Math.max(30, Math.ceil(120 / (1 + trainingFlops * 0.1))) : Infinity;
|
||||
@@ -122,31 +129,102 @@ export function ModelsPage() {
|
||||
{trainedModels.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold">Trained Models</h3>
|
||||
{trainedModels.map(model => (
|
||||
<div key={model.id} className="bg-surface-900 border border-surface-700 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">{model.name}</h4>
|
||||
<div className="text-xs text-surface-400">
|
||||
Gen {model.generation} · Benchmark: {model.benchmarkScore.toFixed(1)}/100 · Safety: {model.safetyScore.toFixed(0)}/100
|
||||
{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 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>
|
||||
<h4 className="font-medium">{model.name}</h4>
|
||||
<div className="text-xs text-surface-400">
|
||||
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 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 ? (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-success/20 text-success">Deployed</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => deployModel(model.id)}
|
||||
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Rocket size={12} />
|
||||
Deploy
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{model.isDeployed ? (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-success/20 text-success">Deployed</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => deployModel(model.id)}
|
||||
className="flex items-center gap-1 bg-accent hover:bg-accent-dark text-white rounded px-3 py-1.5 text-xs"
|
||||
>
|
||||
<Rocket size={12} />
|
||||
Deploy
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -171,3 +249,22 @@ export function ModelsPage() {
|
||||
</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() {
|
||||
const settings = useGameStore((s) => s.meta.settings);
|
||||
const companyName = useGameStore((s) => s.meta.companyName);
|
||||
const updateState = useGameStore((s) => s.updateState);
|
||||
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 = () => {
|
||||
if (confirm('Are you sure you want to reset all progress? This cannot be undone.')) {
|
||||
localStorage.removeItem('ai-tycoon-save');
|
||||
@@ -62,15 +71,22 @@ export function SettingsPage() {
|
||||
<div className="text-sm">Sound Effects</div>
|
||||
<div className="text-xs text-surface-400">Play UI sounds and notifications</div>
|
||||
</div>
|
||||
<ToggleSwitch checked={settings.soundEnabled} onChange={() => {}} />
|
||||
<ToggleSwitch checked={settings.soundEnabled} onChange={toggleSound} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm">Music</div>
|
||||
<div className="text-xs text-surface-400">Background music (coming soon)</div>
|
||||
<div className="text-sm">Music Volume</div>
|
||||
<div className="text-xs text-surface-400">Background music level</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user