Four-tab panel with resource manipulation, time controls, state inspection, and event triggers to accelerate testing across all game systems. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { X, Bug } from 'lucide-react';
|
||||||
|
import { ResourcesTab } from './ResourcesTab';
|
||||||
|
import { TimeCompletionTab } from './TimeCompletionTab';
|
||||||
|
import { StateInspectionTab } from './StateInspectionTab';
|
||||||
|
import { EventTriggersTab } from './EventTriggersTab';
|
||||||
|
|
||||||
|
type Tab = 'resources' | 'time' | 'inspect' | 'events';
|
||||||
|
|
||||||
|
const TABS: { id: Tab; label: string }[] = [
|
||||||
|
{ id: 'resources', label: 'Resources' },
|
||||||
|
{ id: 'time', label: 'Time' },
|
||||||
|
{ id: 'inspect', label: 'Inspect' },
|
||||||
|
{ id: 'events', label: 'Events' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DevMenu() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('resources');
|
||||||
|
|
||||||
|
const isEnabled = import.meta.env.DEV || localStorage.getItem('ai-tycoon-dev-menu') === 'true';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEnabled) return;
|
||||||
|
const handler = () => setIsOpen((o) => !o);
|
||||||
|
window.addEventListener('toggle-dev-menu', handler);
|
||||||
|
return () => window.removeEventListener('toggle-dev-menu', handler);
|
||||||
|
}, [isEnabled]);
|
||||||
|
|
||||||
|
if (!isEnabled || !isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 w-[440px] max-h-[520px] flex flex-col bg-surface-900 border border-surface-700 rounded-lg shadow-2xl">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-surface-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bug className="w-4 h-4 text-amber-400" />
|
||||||
|
<span className="text-sm font-semibold text-surface-100">Dev Menu</span>
|
||||||
|
<span className="text-[10px] text-surface-500">Ctrl+D</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="p-0.5 rounded hover:bg-surface-700 text-surface-400 hover:text-surface-200"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex border-b border-surface-700">
|
||||||
|
{TABS.map(({ id, label }) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => setActiveTab(id)}
|
||||||
|
className={`flex-1 px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
|
activeTab === id
|
||||||
|
? 'text-amber-400 border-b-2 border-amber-400 bg-surface-800/50'
|
||||||
|
: 'text-surface-400 hover:text-surface-200 hover:bg-surface-800/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
{activeTab === 'resources' && <ResourcesTab />}
|
||||||
|
{activeTab === 'time' && <TimeCompletionTab />}
|
||||||
|
{activeTab === 'inspect' && <StateInspectionTab />}
|
||||||
|
{activeTab === 'events' && <EventTriggersTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useGameStore } from '@/store';
|
||||||
|
import type { FundingRoundType } from '@ai-tycoon/shared';
|
||||||
|
|
||||||
|
function DevButton({ onClick, children, variant = 'default' }: {
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: 'default' | 'success' | 'danger' | 'warning';
|
||||||
|
}) {
|
||||||
|
const cls = {
|
||||||
|
default: 'bg-surface-800 hover:bg-surface-700 border-surface-600 text-surface-300',
|
||||||
|
success: 'bg-emerald-500/20 hover:bg-emerald-500/30 border-emerald-500/50 text-emerald-400',
|
||||||
|
danger: 'bg-red-500/20 hover:bg-red-500/30 border-red-500/50 text-red-400',
|
||||||
|
warning: 'bg-amber-500/20 hover:bg-amber-500/30 border-amber-500/50 text-amber-400',
|
||||||
|
}[variant];
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className={`px-2 py-1 rounded text-xs border ${cls}`}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerSafetyIncident() {
|
||||||
|
useGameStore.setState((s) => ({
|
||||||
|
reputation: {
|
||||||
|
...s.reputation,
|
||||||
|
safetyRecord: Math.max(0, s.reputation.safetyRecord - 15),
|
||||||
|
publicPerception: Math.max(0, s.reputation.publicPerception - 7.5),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
useGameStore.getState().addNotification({
|
||||||
|
title: '[DEV] Safety Incident',
|
||||||
|
message: 'Manually triggered safety incident. Safety -15, Public -7.5.',
|
||||||
|
type: 'danger',
|
||||||
|
tick: useGameStore.getState().meta.tickCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerRackFailure(count: number) {
|
||||||
|
useGameStore.setState((s) => {
|
||||||
|
let remaining = count;
|
||||||
|
const newClusters = s.infrastructure.clusters.map((cluster) => ({
|
||||||
|
...cluster,
|
||||||
|
campuses: cluster.campuses.map((campus) => ({
|
||||||
|
...campus,
|
||||||
|
dataCenters: campus.dataCenters.map((dc) => {
|
||||||
|
if (remaining <= 0 || dc.computeRacksOnline <= 0) return dc;
|
||||||
|
const toFail = Math.min(remaining, dc.computeRacksOnline);
|
||||||
|
remaining -= toFail;
|
||||||
|
return {
|
||||||
|
...dc,
|
||||||
|
computeRacksOnline: dc.computeRacksOnline - toFail,
|
||||||
|
computeRacksFailed: dc.computeRacksFailed + toFail,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
return { infrastructure: { ...s.infrastructure, clusters: newClusters } };
|
||||||
|
});
|
||||||
|
useGameStore.getState().addNotification({
|
||||||
|
title: '[DEV] Rack Failure',
|
||||||
|
message: `Manually failed ${count} racks across data centers.`,
|
||||||
|
type: 'warning',
|
||||||
|
tick: useGameStore.getState().meta.tickCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRackFailures() {
|
||||||
|
useGameStore.setState((s) => {
|
||||||
|
const newClusters = s.infrastructure.clusters.map((cluster) => ({
|
||||||
|
...cluster,
|
||||||
|
campuses: cluster.campuses.map((campus) => ({
|
||||||
|
...campus,
|
||||||
|
dataCenters: campus.dataCenters.map((dc) => ({
|
||||||
|
...dc,
|
||||||
|
computeRacksOnline: dc.computeRacksOnline + dc.computeRacksFailed,
|
||||||
|
computeRacksFailed: 0,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
return { infrastructure: { ...s.infrastructure, clusters: newClusters } };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerMarketBoom(multiplier: number) {
|
||||||
|
useGameStore.setState((s) => ({
|
||||||
|
market: {
|
||||||
|
...s.market,
|
||||||
|
consumers: {
|
||||||
|
...s.market.consumers,
|
||||||
|
totalSubscribers: Math.round(s.market.consumers.totalSubscribers * multiplier),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
useGameStore.getState().addNotification({
|
||||||
|
title: '[DEV] Market Boom',
|
||||||
|
message: `Subscribers multiplied by ${multiplier}x.`,
|
||||||
|
type: 'success',
|
||||||
|
tick: useGameStore.getState().meta.tickCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceFunding(roundType: FundingRoundType) {
|
||||||
|
useGameStore.getState().raiseFunding(roundType);
|
||||||
|
useGameStore.getState().addNotification({
|
||||||
|
title: '[DEV] Funding',
|
||||||
|
message: `Force-raised ${roundType} funding round.`,
|
||||||
|
type: 'success',
|
||||||
|
tick: useGameStore.getState().meta.tickCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventTriggersTab() {
|
||||||
|
const [failCount, setFailCount] = useState('10');
|
||||||
|
const [boomMultiplier, setBoomMultiplier] = useState('2');
|
||||||
|
|
||||||
|
const completedRounds = useGameStore((s) => s.economy.funding.completedRounds);
|
||||||
|
const totalFailedRacks = useGameStore((s) =>
|
||||||
|
s.infrastructure.clusters.reduce((sum, cl) =>
|
||||||
|
sum + cl.campuses.reduce((s2, ca) =>
|
||||||
|
s2 + ca.dataCenters.reduce((s3, dc) => s3 + dc.computeRacksFailed, 0), 0), 0));
|
||||||
|
|
||||||
|
const fundingRounds: { type: FundingRoundType; label: string }[] = [
|
||||||
|
{ type: 'seed', label: 'Seed ($500K)' },
|
||||||
|
{ type: 'seriesA', label: 'A ($2M)' },
|
||||||
|
{ type: 'seriesB', label: 'B ($10M)' },
|
||||||
|
{ type: 'seriesC', label: 'C ($50M)' },
|
||||||
|
{ type: 'seriesD', label: 'D ($200M)' },
|
||||||
|
{ type: 'ipo', label: 'IPO ($1B)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const completedTypes = new Set(completedRounds.map((r) => r.type));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-surface-500 mb-1.5 font-semibold">Reputation Events</div>
|
||||||
|
<DevButton onClick={triggerSafetyIncident} variant="danger">Trigger Safety Incident</DevButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-surface-500 mb-1.5 font-semibold">
|
||||||
|
Infrastructure Events {totalFailedRacks > 0 && <span className="text-red-400">({totalFailedRacks} failed)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 items-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={failCount}
|
||||||
|
onChange={(e) => setFailCount(e.target.value)}
|
||||||
|
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-xs text-surface-100 w-16"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
<DevButton onClick={() => triggerRackFailure(Number(failCount) || 10)} variant="danger">Fail Racks</DevButton>
|
||||||
|
<DevButton onClick={resetRackFailures} variant="success">Reset All Failures</DevButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-surface-500 mb-1.5 font-semibold">Market Events</div>
|
||||||
|
<div className="flex gap-1.5 items-center">
|
||||||
|
<DevButton onClick={() => triggerMarketBoom(Number(boomMultiplier) || 2)} variant="warning">Market Boom</DevButton>
|
||||||
|
<span className="text-xs text-surface-400">x</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={boomMultiplier}
|
||||||
|
onChange={(e) => setBoomMultiplier(e.target.value)}
|
||||||
|
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-xs text-surface-100 w-12"
|
||||||
|
min={1}
|
||||||
|
step={0.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-surface-500 mb-1.5 font-semibold">Force Funding</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{fundingRounds.map(({ type, label }) => (
|
||||||
|
<DevButton
|
||||||
|
key={type}
|
||||||
|
onClick={() => forceFunding(type)}
|
||||||
|
variant={completedTypes.has(type) ? 'default' : 'success'}
|
||||||
|
>
|
||||||
|
{label} {completedTypes.has(type) && '✓'}
|
||||||
|
</DevButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useGameStore } from '@/store';
|
||||||
|
import { formatMoney } from '@ai-tycoon/shared';
|
||||||
|
|
||||||
|
function DevButton({ onClick, children, variant = 'default' }: {
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: 'default' | 'success';
|
||||||
|
}) {
|
||||||
|
const cls = variant === 'success'
|
||||||
|
? 'bg-emerald-500/20 hover:bg-emerald-500/30 border-emerald-500/50 text-emerald-400'
|
||||||
|
: 'bg-surface-800 hover:bg-surface-700 border-surface-600 text-surface-300';
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className={`px-2 py-1 rounded text-xs border ${cls}`}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DevInput({ value, onChange, placeholder, className = '' }: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`bg-surface-800 border border-surface-600 rounded px-2 py-1 text-xs text-surface-100 w-24 ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourcesTab() {
|
||||||
|
const money = useGameStore((s) => s.economy.money);
|
||||||
|
const reputation = useGameStore((s) => s.reputation);
|
||||||
|
const researchPoints = useGameStore((s) => s.research.researchPoints);
|
||||||
|
const totalTrainingTokens = useGameStore((s) => s.data.totalTrainingTokens);
|
||||||
|
|
||||||
|
const [customMoney, setCustomMoney] = useState('');
|
||||||
|
const [customTokens, setCustomTokens] = useState('');
|
||||||
|
const [customRP, setCustomRP] = useState('');
|
||||||
|
|
||||||
|
const addMoney = (amount: number) => {
|
||||||
|
useGameStore.setState((s) => ({ economy: { ...s.economy, money: s.economy.money + amount } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setMoney = (amount: number) => {
|
||||||
|
useGameStore.setState((s) => ({ economy: { ...s.economy, money: amount } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const setRepField = (field: 'safetyRecord' | 'publicPerception' | 'employeeSatisfaction' | 'regulatoryStanding', value: number) => {
|
||||||
|
const clamped = Math.max(0, Math.min(100, value));
|
||||||
|
useGameStore.setState((s) => ({ reputation: { ...s.reputation, [field]: clamped } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addResearchPoints = (amount: number) => {
|
||||||
|
useGameStore.setState((s) => ({ research: { ...s.research, researchPoints: s.research.researchPoints + amount } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTokens = (amount: number) => {
|
||||||
|
useGameStore.setState((s) => ({ data: { ...s.data, totalTrainingTokens: s.data.totalTrainingTokens + amount } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-surface-500 mb-1.5 font-semibold">
|
||||||
|
Money <span className="text-surface-400 normal-case">({formatMoney(money)})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<DevButton onClick={() => addMoney(100_000)} variant="success">+100K</DevButton>
|
||||||
|
<DevButton onClick={() => addMoney(1_000_000)} variant="success">+1M</DevButton>
|
||||||
|
<DevButton onClick={() => addMoney(10_000_000)} variant="success">+10M</DevButton>
|
||||||
|
<DevButton onClick={() => addMoney(1_000_000_000)} variant="success">+1B</DevButton>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 mt-1.5">
|
||||||
|
<DevInput value={customMoney} onChange={setCustomMoney} placeholder="Amount" />
|
||||||
|
<DevButton onClick={() => { if (customMoney) setMoney(Number(customMoney)); }}>Set</DevButton>
|
||||||
|
<DevButton onClick={() => { if (customMoney) addMoney(Number(customMoney)); }} variant="success">Add</DevButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-surface-500 mb-1.5 font-semibold">
|
||||||
|
Reputation <span className="text-surface-400 normal-case">(score: {reputation.score})</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{([
|
||||||
|
['safetyRecord', 'Safety Record', reputation.safetyRecord],
|
||||||
|
['publicPerception', 'Public Perception', reputation.publicPerception],
|
||||||
|
['employeeSatisfaction', 'Employee Satisfaction', reputation.employeeSatisfaction],
|
||||||
|
['regulatoryStanding', 'Regulatory Standing', reputation.regulatoryStanding],
|
||||||
|
] as const).map(([field, label, val]) => (
|
||||||
|
<div key={field} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-surface-400 w-32">{label}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={Math.round(val)}
|
||||||
|
onChange={(e) => setRepField(field, Number(e.target.value))}
|
||||||
|
className="flex-1 h-1 accent-accent"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-mono text-surface-100 w-8 text-right">{Math.round(val)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-surface-500 mb-1.5 font-semibold">
|
||||||
|
Research Points <span className="text-surface-400 normal-case">({researchPoints.toFixed(1)})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<DevButton onClick={() => addResearchPoints(5)} variant="success">+5</DevButton>
|
||||||
|
<DevButton onClick={() => addResearchPoints(25)} variant="success">+25</DevButton>
|
||||||
|
<DevButton onClick={() => addResearchPoints(100)} variant="success">+100</DevButton>
|
||||||
|
<DevInput value={customRP} onChange={setCustomRP} placeholder="Amount" />
|
||||||
|
<DevButton onClick={() => { if (customRP) addResearchPoints(Number(customRP)); }} variant="success">Add</DevButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-surface-500 mb-1.5 font-semibold">
|
||||||
|
Training Tokens <span className="text-surface-400 normal-case">({(totalTrainingTokens / 1e9).toFixed(1)}B)</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<DevButton onClick={() => addTokens(1_000_000_000)} variant="success">+1B</DevButton>
|
||||||
|
<DevButton onClick={() => addTokens(10_000_000_000)} variant="success">+10B</DevButton>
|
||||||
|
<DevButton onClick={() => addTokens(100_000_000_000)} variant="success">+100B</DevButton>
|
||||||
|
<DevInput value={customTokens} onChange={setCustomTokens} placeholder="Amount" />
|
||||||
|
<DevButton onClick={() => { if (customTokens) addTokens(Number(customTokens)); }} variant="success">Add</DevButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useGameStore } from '@/store';
|
||||||
|
import { formatMoney, formatNumber, formatFlops, formatPercent } from '@ai-tycoon/shared';
|
||||||
|
|
||||||
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-surface-500 mb-1 font-semibold">{title}</div>
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-surface-400">{label}</span>
|
||||||
|
<span className="text-xs font-mono text-surface-100 text-right">{value}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StateInspectionTab() {
|
||||||
|
const meta = useGameStore((s) => s.meta);
|
||||||
|
const economy = useGameStore((s) => s.economy);
|
||||||
|
const compute = useGameStore((s) => s.compute);
|
||||||
|
const market = useGameStore((s) => s.market);
|
||||||
|
const infrastructure = useGameStore((s) => s.infrastructure);
|
||||||
|
const reputation = useGameStore((s) => s.reputation);
|
||||||
|
const research = useGameStore((s) => s.research);
|
||||||
|
const models = useGameStore((s) => s.models);
|
||||||
|
|
||||||
|
const totalFailedRacks = infrastructure.clusters.reduce((sum, cl) =>
|
||||||
|
sum + cl.campuses.reduce((s2, ca) =>
|
||||||
|
s2 + ca.dataCenters.reduce((s3, dc) => s3 + dc.computeRacksFailed, 0), 0), 0);
|
||||||
|
|
||||||
|
const totalOnlineRacks = infrastructure.clusters.reduce((sum, cl) =>
|
||||||
|
sum + cl.campuses.reduce((s2, ca) =>
|
||||||
|
s2 + ca.dataCenters.reduce((s3, dc) => s3 + dc.computeRacksOnline, 0), 0), 0);
|
||||||
|
|
||||||
|
const pipelineRacks = infrastructure.clusters.reduce((sum, cl) =>
|
||||||
|
sum + cl.campuses.reduce((s2, ca) =>
|
||||||
|
s2 + ca.dataCenters.reduce((s3, dc) =>
|
||||||
|
s3 + dc.deploymentCohorts.reduce((s4, co) => s4 + co.count, 0), 0), 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Section title="Meta">
|
||||||
|
<Stat label="Era" value={meta.currentEra} />
|
||||||
|
<Stat label="Tick" value={formatNumber(meta.tickCount)} />
|
||||||
|
<Stat label="Speed" value={`${meta.gameSpeed}x`} />
|
||||||
|
<Stat label="Paused" value={meta.isPaused ? 'Yes' : 'No'} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Economy">
|
||||||
|
<Stat label="Money" value={formatMoney(economy.money)} />
|
||||||
|
<Stat label="Revenue/tick" value={formatMoney(economy.revenuePerTick)} />
|
||||||
|
<Stat label="Expenses/tick" value={formatMoney(economy.expensesPerTick)} />
|
||||||
|
<Stat label="Total Revenue" value={formatMoney(economy.totalRevenue)} />
|
||||||
|
<Stat label="Valuation" value={formatMoney(economy.funding.valuation)} />
|
||||||
|
<Stat label="Equity" value={formatPercent(economy.funding.founderEquity)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Compute">
|
||||||
|
<Stat label="Total FLOPS" value={formatFlops(compute.totalFlops)} />
|
||||||
|
<Stat label="Utilization" value={formatPercent(compute.inferenceUtilization)} />
|
||||||
|
<Stat label="Training" value={formatPercent(compute.trainingAllocation)} />
|
||||||
|
<Stat label="Inference" value={formatPercent(compute.inferenceAllocation)} />
|
||||||
|
<Stat label="Capacity" value={`${formatNumber(compute.tokensPerSecondCapacity)} tok/s`} />
|
||||||
|
<Stat label="Demand" value={`${formatNumber(compute.tokensPerSecondDemand)} tok/s`} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Market">
|
||||||
|
<Stat label="Subscribers" value={formatNumber(market.consumers.totalSubscribers)} />
|
||||||
|
<Stat label="Satisfaction" value={formatPercent(market.consumers.satisfaction)} />
|
||||||
|
<Stat label="Growth/tick" value={market.consumers.growthRatePerTick.toFixed(4)} />
|
||||||
|
<Stat label="Churn/tick" value={market.consumers.churnRatePerTick.toFixed(4)} />
|
||||||
|
<Stat label="Contracts" value={market.enterprise.activeContracts.length} />
|
||||||
|
<Stat label="API tok/tick" value={formatNumber(market.enterprise.totalApiCallsPerTick)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Infrastructure">
|
||||||
|
<Stat label="Clusters" value={infrastructure.clusters.length} />
|
||||||
|
<Stat label="Data Centers" value={infrastructure.totalDataCenterCount} />
|
||||||
|
<Stat label="Racks Online" value={totalOnlineRacks} />
|
||||||
|
<Stat label="Racks Failed" value={totalFailedRacks} />
|
||||||
|
<Stat label="In Pipeline" value={pipelineRacks} />
|
||||||
|
<Stat label="Total FLOPS" value={formatFlops(infrastructure.totalFlops)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Reputation">
|
||||||
|
<Stat label="Score" value={reputation.score} />
|
||||||
|
<Stat label="Safety" value={reputation.safetyRecord} />
|
||||||
|
<Stat label="Public" value={reputation.publicPerception} />
|
||||||
|
<Stat label="Employee" value={reputation.employeeSatisfaction.toFixed(1)} />
|
||||||
|
<Stat label="Regulatory" value={reputation.regulatoryStanding.toFixed(1)} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Research & Models">
|
||||||
|
<Stat label="Completed" value={research.completedResearch.length} />
|
||||||
|
<Stat label="Points" value={research.researchPoints.toFixed(1)} />
|
||||||
|
<Stat label="Active" value={research.activeResearch?.researchId ?? 'None'} />
|
||||||
|
<Stat label="Models" value={models.trainedModels.length} />
|
||||||
|
<Stat label="Training" value={models.activeTraining?.modelName ?? 'None'} />
|
||||||
|
<Stat label="Deployed" value={models.trainedModels.filter(m => m.isDeployed).length} />
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useGameStore } from '@/store';
|
||||||
|
import { processTick, setAchievementDefinitions, ACHIEVEMENT_DEFINITIONS, TECH_TREE } from '@ai-tycoon/game-engine';
|
||||||
|
import type { GameState, Era } from '@ai-tycoon/shared';
|
||||||
|
|
||||||
|
function DevButton({ onClick, children, variant = 'default' }: {
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
variant?: 'default' | 'success' | 'warning';
|
||||||
|
}) {
|
||||||
|
const cls = variant === 'success'
|
||||||
|
? 'bg-emerald-500/20 hover:bg-emerald-500/30 border-emerald-500/50 text-emerald-400'
|
||||||
|
: variant === 'warning'
|
||||||
|
? 'bg-amber-500/20 hover:bg-amber-500/30 border-amber-500/50 text-amber-400'
|
||||||
|
: 'bg-surface-800 hover:bg-surface-700 border-surface-600 text-surface-300';
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className={`px-2 py-1 rounded text-xs border ${cls}`}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGameState(store: ReturnType<typeof useGameStore.getState>): GameState {
|
||||||
|
return {
|
||||||
|
meta: store.meta,
|
||||||
|
economy: store.economy,
|
||||||
|
infrastructure: store.infrastructure,
|
||||||
|
compute: store.compute,
|
||||||
|
research: store.research,
|
||||||
|
models: store.models,
|
||||||
|
market: store.market,
|
||||||
|
competitors: store.competitors,
|
||||||
|
talent: store.talent,
|
||||||
|
data: store.data,
|
||||||
|
reputation: store.reputation,
|
||||||
|
achievements: store.achievements,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipTicks(n: number) {
|
||||||
|
setAchievementDefinitions(ACHIEVEMENT_DEFINITIONS);
|
||||||
|
const wasPaused = useGameStore.getState().meta.isPaused;
|
||||||
|
if (!wasPaused) useGameStore.getState().togglePause();
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const state = extractGameState(useGameStore.getState());
|
||||||
|
const result = processTick(state);
|
||||||
|
delete (result as Record<string, unknown>)['_notifications'];
|
||||||
|
useGameStore.getState().updateState(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wasPaused) useGameStore.getState().togglePause();
|
||||||
|
}
|
||||||
|
|
||||||
|
function instantCompleteConstruction() {
|
||||||
|
useGameStore.setState((s) => ({
|
||||||
|
infrastructure: {
|
||||||
|
...s.infrastructure,
|
||||||
|
clusters: s.infrastructure.clusters.map((cluster) => {
|
||||||
|
const completedCluster = cluster.status === 'constructing'
|
||||||
|
? { ...cluster, status: 'operational' as const, constructionProgress: cluster.constructionTotal }
|
||||||
|
: cluster;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...completedCluster,
|
||||||
|
campuses: completedCluster.campuses.map((campus) => {
|
||||||
|
const completedCampus = campus.status === 'constructing'
|
||||||
|
? { ...campus, status: 'operational' as const, constructionProgress: campus.constructionTotal }
|
||||||
|
: campus;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...completedCampus,
|
||||||
|
dataCenters: completedCampus.dataCenters.map((dc) => {
|
||||||
|
let updated = dc.status === 'constructing'
|
||||||
|
? { ...dc, status: 'operational' as const, constructionProgress: dc.constructionTotal }
|
||||||
|
: dc;
|
||||||
|
|
||||||
|
if (updated.deploymentCohorts.length > 0) {
|
||||||
|
let onlined = 0;
|
||||||
|
for (const cohort of updated.deploymentCohorts) {
|
||||||
|
if (cohort.stage !== 'decommission') {
|
||||||
|
onlined += cohort.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated = {
|
||||||
|
...updated,
|
||||||
|
computeRacksOnline: updated.computeRacksOnline + onlined,
|
||||||
|
deploymentCohorts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function instantCompleteResearch() {
|
||||||
|
const { activeResearch, completedResearch } = useGameStore.getState().research;
|
||||||
|
if (!activeResearch) return;
|
||||||
|
useGameStore.setState((s) => ({
|
||||||
|
research: {
|
||||||
|
...s.research,
|
||||||
|
completedResearch: [...completedResearch, activeResearch.researchId],
|
||||||
|
activeResearch: null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function instantCompleteTraining() {
|
||||||
|
const { activeTraining } = useGameStore.getState().models;
|
||||||
|
if (!activeTraining) return;
|
||||||
|
useGameStore.setState((s) => ({
|
||||||
|
models: {
|
||||||
|
...s.models,
|
||||||
|
activeTraining: { ...activeTraining, progressTicks: activeTraining.totalTicks },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockAllResearch() {
|
||||||
|
const allIds = TECH_TREE.map((n) => n.id);
|
||||||
|
useGameStore.setState((s) => ({
|
||||||
|
research: { ...s.research, completedResearch: allIds, activeResearch: null },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceEra(era: Era) {
|
||||||
|
useGameStore.setState((s) => ({
|
||||||
|
meta: { ...s.meta, currentEra: era },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeCompletionTab() {
|
||||||
|
const [tickCount, setTickCount] = useState('100');
|
||||||
|
const activeResearch = useGameStore((s) => s.research.activeResearch);
|
||||||
|
const activeTraining = useGameStore((s) => s.models.activeTraining);
|
||||||
|
const currentEra = useGameStore((s) => s.meta.currentEra);
|
||||||
|
|
||||||
|
const pipelineCount = useGameStore((s) =>
|
||||||
|
s.infrastructure.clusters.reduce((sum, cl) =>
|
||||||
|
sum + cl.campuses.reduce((s2, ca) =>
|
||||||
|
s2 + ca.dataCenters.reduce((s3, dc) =>
|
||||||
|
s3 + dc.deploymentCohorts.length, 0), 0), 0));
|
||||||
|
|
||||||
|
const constructingCount = useGameStore((s) =>
|
||||||
|
s.infrastructure.clusters.reduce((sum, cl) => {
|
||||||
|
let count = cl.status === 'constructing' ? 1 : 0;
|
||||||
|
count += cl.campuses.reduce((s2, ca) => {
|
||||||
|
let c2 = ca.status === 'constructing' ? 1 : 0;
|
||||||
|
c2 += ca.dataCenters.filter(dc => dc.status === 'constructing').length;
|
||||||
|
return s2 + c2;
|
||||||
|
}, 0);
|
||||||
|
return sum + count;
|
||||||
|
}, 0));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-surface-500 mb-1.5 font-semibold">Skip Ticks</div>
|
||||||
|
<div className="flex gap-1.5 items-center">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={tickCount}
|
||||||
|
onChange={(e) => setTickCount(e.target.value)}
|
||||||
|
className="bg-surface-800 border border-surface-600 rounded px-2 py-1 text-xs text-surface-100 w-20"
|
||||||
|
min={1}
|
||||||
|
max={10000}
|
||||||
|
/>
|
||||||
|
<DevButton onClick={() => skipTicks(Number(tickCount) || 100)} variant="warning">Go</DevButton>
|
||||||
|
<DevButton onClick={() => skipTicks(10)}>+10</DevButton>
|
||||||
|
<DevButton onClick={() => skipTicks(100)}>+100</DevButton>
|
||||||
|
<DevButton onClick={() => skipTicks(1000)}>+1000</DevButton>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-surface-500 mt-0.5">Pauses game, processes ticks, resumes</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-surface-500 mb-1.5 font-semibold">Instant Complete</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<DevButton onClick={instantCompleteConstruction} variant="success">
|
||||||
|
Construction {constructingCount + pipelineCount > 0 && `(${constructingCount + pipelineCount})`}
|
||||||
|
</DevButton>
|
||||||
|
<DevButton onClick={instantCompleteResearch} variant="success">
|
||||||
|
Research {activeResearch && `(${activeResearch.researchId})`}
|
||||||
|
</DevButton>
|
||||||
|
<DevButton onClick={instantCompleteTraining} variant="success">
|
||||||
|
Training {activeTraining && `(${activeTraining.modelName})`}
|
||||||
|
</DevButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-surface-500 mb-1.5 font-semibold">
|
||||||
|
Force Era <span className="text-surface-400 normal-case">(current: {currentEra})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{(['startup', 'scaleup', 'bigtech', 'agi'] as Era[]).map((era) => (
|
||||||
|
<DevButton
|
||||||
|
key={era}
|
||||||
|
onClick={() => forceEra(era)}
|
||||||
|
variant={era === currentEra ? 'success' : 'default'}
|
||||||
|
>
|
||||||
|
{era}
|
||||||
|
</DevButton>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-surface-500 mb-1.5 font-semibold">Research</div>
|
||||||
|
<DevButton onClick={unlockAllResearch} variant="warning">Unlock All Research ({TECH_TREE.length} nodes)</DevButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Sidebar } from './Sidebar';
|
import { Sidebar } from './Sidebar';
|
||||||
import { TopBar } from './TopBar';
|
import { TopBar } from './TopBar';
|
||||||
import { ToastContainer } from '@/components/common/ToastContainer';
|
import { ToastContainer } from '@/components/common/ToastContainer';
|
||||||
|
import { DevMenu } from '@/components/dev/DevMenu';
|
||||||
import { useGameStore } from '@/store';
|
import { useGameStore } from '@/store';
|
||||||
import { useHashRouter } from '@/hooks/useHashRouter';
|
import { useHashRouter } from '@/hooks/useHashRouter';
|
||||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||||
@@ -32,6 +33,7 @@ export function MainLayout() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
|
<DevMenu />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import type { GameSpeed } from '@ai-tycoon/shared';
|
|||||||
export function useKeyboardShortcuts() {
|
export function useKeyboardShortcuts() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.ctrlKey && e.key === 'd') {
|
||||||
|
e.preventDefault();
|
||||||
|
window.dispatchEvent(new Event('toggle-dev-menu'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user