import { content } from '../content'; import { enqueueAction as engineEnqueueAction, type GameState, tickGame } from '../engine/game'; import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop'; import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence'; import { useGameStore } from './store'; import { formatOfflineDuration, toView } from './viewModel'; /** * The game runtime: the one place that owns the engine state and the wall clock. * * It drives the fixed-timestep tick loop off requestAnimationFrame, mirrors a * view snapshot into the Zustand store at ~10 fps, autosaves on an interval, and * persists on tab-hide / unload. Boot loads the save and credits offline time. * All environment coupling (RAF, Date.now, DOM events, IndexedDB) lives here so * the engine stays pure. */ const PUBLISH_INTERVAL_MS = 100; // ~10 fps view refresh const AUTOSAVE_INTERVAL_MS = 10_000; class GameRuntime { private state: GameState | null = null; private readonly loop: TickLoop = createTickLoop(); private readonly backend: SaveBackend = createDefaultBackend(); private rafId: number | null = null; private lastPublishAt = 0; private lastSaveAt = 0; private booted = false; async boot(): Promise { if (this.booted) { return; } this.booted = true; const now = Date.now(); const { state, offlineMs } = await loadGame(content, this.backend, now); this.state = state; this.lastSaveAt = now; const store = useGameStore.getState(); store.appendLog( offlineMs >= 1000 ? `Welcome back — credited ${formatOfflineDuration(offlineMs)} of offline progress.` : 'A new tale begins. Choose an action.', ); this.publish(); this.installLifecycleHooks(); this.rafId = requestAnimationFrame(this.frame); } /** Stop the loop. Used on teardown (e.g. HMR dispose); not needed in normal play. */ stop(): void { if (this.rafId !== null) { cancelAnimationFrame(this.rafId); this.rafId = null; } } enqueueAction(actionId: string): void { const state = this.state; if (!state) { return; } try { engineEnqueueAction(state, content, actionId); const action = content.actionsById[actionId]; if (action) { const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started'; useGameStore.getState().appendLog(`${verb}: ${action.name}.`); } } catch (err) { const msg = err instanceof Error ? err.message : 'Cannot start action'; useGameStore.getState().appendLog(msg); } this.publish(); } private readonly frame = (monoNow: number): void => { const state = this.state; if (state) { advance(this.loop, monoNow, () => tickGame(state, content, TICK_MS)); if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) { this.publish(); this.lastPublishAt = monoNow; } const wall = Date.now(); if (wall - this.lastSaveAt >= AUTOSAVE_INTERVAL_MS) { void this.save(wall); } } this.rafId = requestAnimationFrame(this.frame); }; private publish(): void { const state = this.state; if (state) { useGameStore.getState().setView(toView(state, content)); } } private async save(now: number = Date.now()): Promise { const state = this.state; if (!state) { return; } this.lastSaveAt = now; await saveGame(state, this.backend, now); } private installLifecycleHooks(): void { document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { void this.save(); } }); window.addEventListener('beforeunload', () => { void this.save(); }); } } export const gameRuntime = new GameRuntime();