Add floating dev/debug menu for QA testing (Ctrl+D)
CI / build-and-push (push) Successful in 30s

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:
2026-04-24 23:43:41 -04:00
parent c799f2e359
commit 9c49a10b31
7 changed files with 738 additions and 0 deletions
+72
View File
@@ -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;