Implements executeInstant, which applies an instant action's costs and yields immediately with no queue slot and no duration requirement.
199 lines
6.6 KiB
TypeScript
199 lines
6.6 KiB
TypeScript
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<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[];
|
|
}
|
|
|
|
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: [],
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
}
|