import type { Content } from '../content/schema'; import type { StoryContent } from '../content/storySchema'; import { applyChoice, isStoryChoiceAvailable } from './story'; type GameContent = Content & StoryContent; /** * 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[]; /** loop-kind action id -> whether the player has enabled it for idle running. */ enabledLoopActionIds: Record; } 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: [], enabledLoopActionIds: {}, }; } 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; } /** * Execute an instant action immediately, deducting costs and granting yields * without occupying a queue slot or requiring a duration. * Throws if the action is not of kind 'instant' or is unavailable. */ export function executeInstant(state: GameState, content: Content, actionId: string): void { const action = content.actionsById[actionId]; if (!action || action.kind !== 'instant') { throw new Error(`Action "${actionId}" is not instant`); } if (!isActionAvailable(state, content, actionId)) { throw new Error(`Cannot perform instant action "${actionId}"`); } deductCosts(state, content, actionId); grantYields(state, content, actionId); } /** * Execute a story action by resolving its storyChoiceId and applying it. * Throws if the action is not of kind 'story', has no storyChoiceId, or the * choice is not currently available. */ export function executeStoryAction( state: GameState, content: GameContent, actionId: string, ): void { const action = content.actionsById[actionId]; if (!action || action.kind !== 'story' || !action.storyChoiceId) { throw new Error(`Action "${actionId}" is not a story action`); } if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) { throw new Error(`Story choice "${action.storyChoiceId}" is not available`); } applyChoice(state, content, action.storyChoiceId); } /** * Start the highest-priority enabled, available loop action when the game is idle. * Invoked by the runtime AFTER each live tick — never from `tickGame`, so loop * actions do not run during offline catch-up (which replays `tickGame` directly). */ export function maybeStartLoopAction(state: GameState, content: Content): void { if (state.activeActionId !== null || state.actionQueue.length > 0) return; const candidates = content.actions .filter((a) => a.kind === 'loop' && state.enabledLoopActionIds[a.id]) .sort((a, b) => (a.loopPriority ?? 0) - (b.loopPriority ?? 0)); for (const action of candidates) { if (isActionAvailable(state, content, action.id)) { beginAction(state, content, action.id); return; } } } /** * Single entry point for all player-initiated action dispatch. * * Dispatches by action.kind to the appropriate per-kind function: * - instant: execute immediately (costs/yields, no queue slot) * - timed: enqueue (start if idle, queue otherwise) * - loop: toggle player enable preference; start runner if just enabled * - story: resolve storyChoiceId and apply the choice * - context: not implemented in M1 — throws * * Note on loop toggle: disabling is always allowed, even when the action is * currently unaffordable. Affordability is the runner's concern (maybeStartLoopAction * re-checks each tick). Throwing on unaffordable before toggling would wrongly * block the player from DISABLING an active but now-unaffordable loop. */ export function performAction(state: GameState, content: GameContent, actionId: string): void { const action = content.actionsById[actionId]; if (!action) throw new Error(`Unknown action "${actionId}"`); switch (action.kind) { case 'instant': executeInstant(state, content, actionId); break; case 'timed': enqueueAction(state, content, actionId); break; case 'loop': { const willEnable = !state.enabledLoopActionIds[actionId]; state.enabledLoopActionIds[actionId] = willEnable; if (willEnable) { maybeStartLoopAction(state, content); } break; } case 'story': executeStoryAction(state, content, actionId); break; case 'context': throw new Error(`Context action "${actionId}" is not implemented`); default: throw new Error(`Unknown action kind "${(action as { kind: string }).kind}"`); } } /** * 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 (action.durationMs === undefined) 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 }; }