import { content } from '../content'; import { cancelQueuedAction as engineCancelQueuedAction, type GameState, maybeStartLoopAction, performAction as performActionEngine, tickGame, } from '../engine/game'; import { applyChoice as engineApplyChoice, enterStoryNode, initStory } from '../engine/story'; import { advance, createTickLoop, TICK_MS, type TickLoop } from '../engine/tickLoop'; import { createDefaultBackend, loadGame, type SaveBackend, saveGame } from './persistence'; import { getPrefs } from './prefs'; import { type ActivePanel, useGameStore } from './store'; import { processStoryTriggers, type StoryUiEffect, shouldAutoNavigateToStory, storyEventsToLogEntries, } from './storyOrchestration'; 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; export 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; private readonly visibilityChangeListener = (): void => { if (document.visibilityState === 'hidden') { void this.save(); } }; private readonly beforeUnloadListener = (): void => { void this.save(); }; 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.', ); initStory(this.state, content); const prefs = getPrefs(); store.setPrefs(prefs); const bootEffect = processStoryTriggers(this.state, content, { reason: 'boot' }, prefs); this.applyStoryUiEffect(bootEffect); 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; } document.removeEventListener('visibilitychange', this.visibilityChangeListener); window.removeEventListener('beforeunload', this.beforeUnloadListener); this.booted = false; } performAction(actionId: string): void { const state = this.state; if (!state) return; const action = content.actionsById[actionId]; if (!action) return; try { const isStory = action.kind === 'story'; const events = performActionEngine(state, content, actionId); const store = useGameStore.getState(); // Append any custom log outcomes for (const event of events.filter((e) => e.kind === 'log')) { store.appendLog(event.prose); } if (isStory) { const entries = storyEventsToLogEntries(events); for (const entry of entries) { store.appendStoryLog(entry); } const choiceLabel = entries.at(-1)?.choiceLabel; if (choiceLabel) { store.appendLog(`Story: ${choiceLabel}`); } this.runPublishTriggers(); } else if (action.kind === 'timed') { const verb = state.actionQueue.includes(actionId) ? 'Queued' : 'Started'; store.appendLog(`${verb}: ${action.name}.`); } this.publish(); } catch (err) { useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Action failed'); } } setActivePanel(panel: ActivePanel): void { const store = useGameStore.getState(); store.setActivePanel(panel); if (panel === 'story') { store.setStoryHasUnread(false); } } toggleActionGroupCollapsed(groupKey: string): void { useGameStore.getState().toggleActionGroupCollapsed(groupKey); } enqueueAction(actionId: string): void { this.performAction(actionId); } applyStoryChoice(choiceId: string): void { const action = content.actions.find((a) => a.storyChoiceId === choiceId); if (action) { this.performAction(action.id); } else { const state = this.state; if (!state) return; try { const events = engineApplyChoice(state, content, choiceId); const entries = storyEventsToLogEntries(events); const store = useGameStore.getState(); // Append any custom log outcomes for (const event of events.filter((e) => e.kind === 'log')) { store.appendLog(event.prose); } for (const entry of entries) store.appendStoryLog(entry); const choiceLabel = entries.at(-1)?.choiceLabel; if (choiceLabel) store.appendLog(`Story: ${choiceLabel}`); this.runPublishTriggers(); this.publish(); } catch (err) { useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Choice failed'); } } } cancelQueuedAction(index: number): void { const state = this.state; if (!state) return; try { engineCancelQueuedAction(state, index); useGameStore.getState().appendLog('Removed queued action.'); this.publish(); } catch (err) { useGameStore.getState().appendLog(err instanceof Error ? err.message : 'Cancel failed'); } } continueStory(): void { const state = this.state; if (!state) return; if (state.currentStoryNodeId === 'boot_intro') { const events = enterStoryNode(state, content, 'fork_choice'); const entries = storyEventsToLogEntries(events); const store = useGameStore.getState(); for (const entry of entries) store.appendStoryLog(entry); for (const entry of entries) { store.appendLog( `Story: ${(content.storyNodesById[entry.nodeId]?.prose ?? '').slice(0, 40)}…`, ); } const prefs = store.prefs; if (shouldAutoNavigateToStory(prefs, 'fork_choice', content)) { this.setActivePanel('story'); } else { store.setStoryHasUnread(true); } this.publish(); return; } this.setActivePanel('play'); this.publish(); } private applyStoryUiEffect(effect: StoryUiEffect): void { const store = useGameStore.getState(); const prefs = store.prefs; for (const entry of effect.logEntries) store.appendStoryLog(entry); for (const line of effect.eventLogLines) store.appendLog(line); if (effect.shouldOpenPanel) { if (prefs.storyOpenMode === 'auto') { store.setActivePanel('story'); store.setStoryHasUnread(false); } else { store.setStoryHasUnread(true); } } else if (effect.enteredNodeIds.length > 0) { store.setStoryHasUnread(true); } } private runPublishTriggers(): void { const state = this.state; if (!state) return; const prefs = useGameStore.getState().prefs; const effect = processStoryTriggers(state, content, { reason: 'publish' }, prefs); this.applyStoryUiEffect(effect); } private readonly frame = (monoNow: number): void => { const state = this.state; if (state) { advance(this.loop, monoNow, () => { const result = tickGame(state, content, TICK_MS); for (const actionId of result.completedActionIds) { const prefs = useGameStore.getState().prefs; const effect = processStoryTriggers( state, content, { reason: 'actionComplete', actionId }, prefs, ); this.applyStoryUiEffect(effect); } maybeStartLoopAction(state, content); }); if (monoNow - this.lastPublishAt >= PUBLISH_INTERVAL_MS) { this.runPublishTriggers(); 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', this.visibilityChangeListener); window.addEventListener('beforeunload', this.beforeUnloadListener); } } export const gameRuntime = new GameRuntime(); if (import.meta.hot) { import.meta.hot.dispose(() => { gameRuntime.stop(); }); }