import type { Content } from '../content/schema'; /** * Core game state and per-tick simulation. * * Pure TS — no React, no DOM, no wall clock. The tick loop (tickLoop.ts) drives * `tickGame` once per tick; `now`/scheduling lives entirely outside this module. */ export interface GameState { /** resourceId -> current amount. */ resources: Record; /** The action currently running, or null. */ activeActionId: string | null; /** Progress of the active action, in milliseconds. */ actionElapsedMs: number; /** Action ids waiting to run after the active action finishes. */ actionQueue: string[]; /** Story progression flags set by the narrative graph. */ storyFlags: Record; /** Id of the story node currently displayed, or empty when none. */ currentStoryNodeId: string; /** Story node ids the player has already seen. */ seenStoryNodeIds: string[]; } export function createGameState(content: Content): GameState { const resources: Record = {}; for (const resource of content.resources) { resources[resource.id] = resource.startAmount; } return { resources, activeActionId: null, actionElapsedMs: 0, actionQueue: [], storyFlags: {}, currentStoryNodeId: '', seenStoryNodeIds: [], }; } function assertKnownAction(content: Content, actionId: string): void { if (!content.actionsById[actionId]) { throw new Error(`Unknown action "${actionId}"`); } } export function canAffordAction(state: GameState, content: Content, actionId: string): boolean { const action = content.actionsById[actionId]; if (!action) return false; return action.costs.every((cost) => (state.resources[cost.resourceId] ?? 0) >= cost.amount); } export function canUnlockAction(state: GameState, content: Content, actionId: string): boolean { const action = content.actionsById[actionId]; if (!action) return false; const unlock = action.unlock; if (!unlock) return true; if (unlock.minResources) { for (const [resourceId, min] of Object.entries(unlock.minResources)) { if ((state.resources[resourceId] ?? 0) < min) return false; } } if (unlock.requireStoryFlags) { for (const flag of unlock.requireStoryFlags) { if (!state.storyFlags[flag]) return false; } } return true; } export function isActionAvailable(state: GameState, content: Content, actionId: string): boolean { return canAffordAction(state, content, actionId) && canUnlockAction(state, content, actionId); } function deductCosts(state: GameState, content: Content, actionId: string): void { const action = content.actionsById[actionId]; if (!action) return; for (const cost of action.costs) { state.resources[cost.resourceId] -= cost.amount; } } function grantYields(state: GameState, content: Content, actionId: string): void { const action = content.actionsById[actionId]; if (!action) return; for (const y of action.yields) { state.resources[y.resourceId] = (state.resources[y.resourceId] ?? 0) + y.amount; } } function beginAction(state: GameState, content: Content, actionId: string): void { assertKnownAction(content, actionId); deductCosts(state, content, actionId); state.activeActionId = actionId; state.actionElapsedMs = 0; } function startNextFromQueue(state: GameState, content: Content): void { while (state.actionQueue.length > 0) { const nextId = state.actionQueue.shift(); if (!nextId) { break; } if (isActionAvailable(state, content, nextId)) { beginAction(state, content, nextId); return; } } state.activeActionId = null; state.actionElapsedMs = 0; } export interface TickResult { completedActionIds: string[]; } /** * Begin running an action, resetting its progress. Throws on an unknown id. * * @deprecated Prefer `enqueueAction` — it starts immediately when idle and queues otherwise. */ export function startAction(state: GameState, content: Content, actionId: string): void { assertKnownAction(content, actionId); state.activeActionId = actionId; state.actionElapsedMs = 0; } /** * Start an action immediately when idle, otherwise append it to the queue. * Costs deduct on start (D-0012). Throws when unknown, unaffordable, or locked. */ export function enqueueAction(state: GameState, content: Content, actionId: string): void { assertKnownAction(content, actionId); if (!isActionAvailable(state, content, actionId)) { throw new Error(`Cannot enqueue action "${actionId}"`); } if (state.activeActionId === null) { beginAction(state, content, actionId); } else { state.actionQueue.push(actionId); } } /** Remove a queued action by index. Throws RangeError when out of range. */ export function cancelQueuedAction(state: GameState, index: number): void { if (index < 0 || index >= state.actionQueue.length) { throw new RangeError(`Queue index ${index} is out of range`); } state.actionQueue.splice(index, 1); } /** Clear all queued actions without stopping the active action. */ export function clearQueue(state: GameState): void { state.actionQueue.length = 0; } /** * Advance the active action by `tickMs`. On completion, grants yields and * advances the queue — actions do not auto-repeat when the queue is empty. */ export function tickGame(state: GameState, content: Content, tickMs: number): TickResult { const completedActionIds: string[] = []; if (!state.activeActionId) return { completedActionIds }; state.actionElapsedMs += tickMs; while (state.activeActionId) { const actionId = state.activeActionId; const action = content.actionsById[actionId]; if (!action) return { completedActionIds }; if (state.actionElapsedMs < action.durationMs) return { completedActionIds }; state.actionElapsedMs -= action.durationMs; grantYields(state, content, actionId); completedActionIds.push(actionId); startNextFromQueue(state, content); } return { completedActionIds }; }