Files
idlegame/src/engine/game.ts
T

290 lines
10 KiB
TypeScript

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<string, number>;
/** 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<string, boolean>;
/** 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<string, boolean>;
}
export function createGameState(content: Content): GameState {
const resources: Record<string, number> = {};
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 };
}