diff --git a/apps/web/src/components/dev/DevMenu.tsx b/apps/web/src/components/dev/DevMenu.tsx new file mode 100644 index 0000000..b6ea9cf --- /dev/null +++ b/apps/web/src/components/dev/DevMenu.tsx @@ -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('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 ( +
+
+
+ + Dev Menu + Ctrl+D +
+ +
+ +
+ {TABS.map(({ id, label }) => ( + + ))} +
+ +
+ {activeTab === 'resources' && } + {activeTab === 'time' && } + {activeTab === 'inspect' && } + {activeTab === 'events' && } +
+
+ ); +} diff --git a/apps/web/src/components/dev/EventTriggersTab.tsx b/apps/web/src/components/dev/EventTriggersTab.tsx new file mode 100644 index 0000000..60865f5 --- /dev/null +++ b/apps/web/src/components/dev/EventTriggersTab.tsx @@ -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 ( + + ); +} + +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 ( +
+
+
Reputation Events
+ Trigger Safety Incident +
+ +
+
+ Infrastructure Events {totalFailedRacks > 0 && ({totalFailedRacks} failed)} +
+
+ 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} + /> + triggerRackFailure(Number(failCount) || 10)} variant="danger">Fail Racks + Reset All Failures +
+
+ +
+
Market Events
+
+ triggerMarketBoom(Number(boomMultiplier) || 2)} variant="warning">Market Boom + x + 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} + /> +
+
+ +
+
Force Funding
+
+ {fundingRounds.map(({ type, label }) => ( + forceFunding(type)} + variant={completedTypes.has(type) ? 'default' : 'success'} + > + {label} {completedTypes.has(type) && '✓'} + + ))} +
+
+
+ ); +} diff --git a/apps/web/src/components/dev/ResourcesTab.tsx b/apps/web/src/components/dev/ResourcesTab.tsx new file mode 100644 index 0000000..330e85c --- /dev/null +++ b/apps/web/src/components/dev/ResourcesTab.tsx @@ -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 ( + + ); +} + +function DevInput({ value, onChange, placeholder, className = '' }: { + value: string; + onChange: (v: string) => void; + placeholder?: string; + className?: string; +}) { + return ( + 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 ( +
+
+
+ Money ({formatMoney(money)}) +
+
+ addMoney(100_000)} variant="success">+100K + addMoney(1_000_000)} variant="success">+1M + addMoney(10_000_000)} variant="success">+10M + addMoney(1_000_000_000)} variant="success">+1B +
+
+ + { if (customMoney) setMoney(Number(customMoney)); }}>Set + { if (customMoney) addMoney(Number(customMoney)); }} variant="success">Add +
+
+ +
+
+ Reputation (score: {reputation.score}) +
+
+ {([ + ['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]) => ( +
+ {label} + setRepField(field, Number(e.target.value))} + className="flex-1 h-1 accent-accent" + /> + {Math.round(val)} +
+ ))} +
+
+ +
+
+ Research Points ({researchPoints.toFixed(1)}) +
+
+ addResearchPoints(5)} variant="success">+5 + addResearchPoints(25)} variant="success">+25 + addResearchPoints(100)} variant="success">+100 + + { if (customRP) addResearchPoints(Number(customRP)); }} variant="success">Add +
+
+ +
+
+ Training Tokens ({(totalTrainingTokens / 1e9).toFixed(1)}B) +
+
+ addTokens(1_000_000_000)} variant="success">+1B + addTokens(10_000_000_000)} variant="success">+10B + addTokens(100_000_000_000)} variant="success">+100B + + { if (customTokens) addTokens(Number(customTokens)); }} variant="success">Add +
+
+
+ ); +} diff --git a/apps/web/src/components/dev/StateInspectionTab.tsx b/apps/web/src/components/dev/StateInspectionTab.tsx new file mode 100644 index 0000000..4901cf6 --- /dev/null +++ b/apps/web/src/components/dev/StateInspectionTab.tsx @@ -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 ( +
+
{title}
+
{children}
+
+ ); +} + +function Stat({ label, value }: { label: string; value: string | number }) { + return ( + <> + {label} + {value} + + ); +} + +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 ( +
+
+ + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + +
+ +
+ + + + + + m.isDeployed).length} /> +
+
+ ); +} diff --git a/apps/web/src/components/dev/TimeCompletionTab.tsx b/apps/web/src/components/dev/TimeCompletionTab.tsx new file mode 100644 index 0000000..7e77e0b --- /dev/null +++ b/apps/web/src/components/dev/TimeCompletionTab.tsx @@ -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 ( + + ); +} + +function extractGameState(store: ReturnType): 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)['_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 ( +
+
+
Skip Ticks
+
+ 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} + /> + skipTicks(Number(tickCount) || 100)} variant="warning">Go + skipTicks(10)}>+10 + skipTicks(100)}>+100 + skipTicks(1000)}>+1000 +
+
Pauses game, processes ticks, resumes
+
+ +
+
Instant Complete
+
+ + Construction {constructingCount + pipelineCount > 0 && `(${constructingCount + pipelineCount})`} + + + Research {activeResearch && `(${activeResearch.researchId})`} + + + Training {activeTraining && `(${activeTraining.modelName})`} + +
+
+ +
+
+ Force Era (current: {currentEra}) +
+
+ {(['startup', 'scaleup', 'bigtech', 'agi'] as Era[]).map((era) => ( + forceEra(era)} + variant={era === currentEra ? 'success' : 'default'} + > + {era} + + ))} +
+
+ +
+
Research
+ Unlock All Research ({TECH_TREE.length} nodes) +
+
+ ); +} diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index a97a657..2292182 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -1,6 +1,7 @@ import { Sidebar } from './Sidebar'; import { TopBar } from './TopBar'; import { ToastContainer } from '@/components/common/ToastContainer'; +import { DevMenu } from '@/components/dev/DevMenu'; import { useGameStore } from '@/store'; import { useHashRouter } from '@/hooks/useHashRouter'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; @@ -32,6 +33,7 @@ export function MainLayout() { + ); } diff --git a/apps/web/src/hooks/useKeyboardShortcuts.ts b/apps/web/src/hooks/useKeyboardShortcuts.ts index 00982a0..d764338 100644 --- a/apps/web/src/hooks/useKeyboardShortcuts.ts +++ b/apps/web/src/hooks/useKeyboardShortcuts.ts @@ -5,6 +5,12 @@ import type { GameSpeed } from '@ai-tycoon/shared'; export function useKeyboardShortcuts() { useEffect(() => { 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; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;