From 163f710f4c773cfe37120bff080e857595ebff60 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 20:53:55 -0500 Subject: [PATCH] feat(engine): add performAction dispatcher by kind --- src/engine/__tests__/game.test.ts | 93 +++++++++++++++++++++++++++++++ src/engine/game.ts | 44 +++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/src/engine/__tests__/game.test.ts b/src/engine/__tests__/game.test.ts index 2e0091d..71a4b51 100644 --- a/src/engine/__tests__/game.test.ts +++ b/src/engine/__tests__/game.test.ts @@ -8,9 +8,13 @@ import { enqueueAction, executeInstant, maybeStartLoopAction, + performAction, startAction, tickGame, } from '../game'; +import { content as gameContent } from '../../content/index'; +import { buildStoryContent } from '../../content/storySchema'; +import { enterStoryNode } from '../story'; const DEFAULT_GROUP = { id: 'test', label: 'Test' }; @@ -446,3 +450,92 @@ describe('loop idle runner', () => { expect(state.activeActionId).toBeNull(); }); }); + +describe('performAction()', () => { + it('dispatches timed actions through enqueueAction', () => { + const state = createGameState(gameContent); + performAction(state, gameContent, 'gather_supplies'); + expect(state.activeActionId).toBe('gather_supplies'); + }); + + it('toggles loop actions and starts them when idle', () => { + const state = createGameState(gameContent); + performAction(state, gameContent, 'rest'); // enable + expect(state.enabledLoopActionIds.rest).toBe(true); + expect(state.activeActionId).toBe('rest'); // started because idle + available + performAction(state, gameContent, 'rest'); // disable + expect(state.enabledLoopActionIds.rest).toBe(false); + }); + + it('dispatches story actions through executeStoryAction', () => { + const state = createGameState(gameContent); + enterStoryNode(state, gameContent, 'fork_choice'); + performAction(state, gameContent, 'pick_high_road'); + expect(state.storyFlags.route_a).toBe(true); + }); + + it('executes instant actions immediately', () => { + const base = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 0 }, + { id: 'coin', name: 'Coin', startAmount: 5 }, + ], + actions: [ + { + id: 'buy_supply', + name: 'Buy supply', + kind: 'instant', + group: { id: 'buy', label: 'Buy' }, + costs: [{ resourceId: 'coin', amount: 2 }], + yields: [{ resourceId: 'supplies', amount: 1 }], + }, + { + id: 'enter_cave', + name: 'Enter cave', + kind: 'context', + group: { id: 'travel', label: 'Travel' }, + contextId: 'cave', + }, + ], + }); + const story = buildStoryContent( + [{ id: 'boot', prose: 'x', triggers: [{ type: 'boot', targetNodeId: 'boot' }] }], + base.actionsById, + base.resourcesById, + ); + const fixture = { ...base, ...story }; + const state = createGameState(fixture); + performAction(state, fixture, 'buy_supply'); + expect(state.resources.coin).toBe(3); + expect(state.resources.supplies).toBe(1); + expect(state.activeActionId).toBeNull(); + }); + + it('throws for context actions (not implemented in M1)', () => { + const base = buildContent({ + resources: [{ id: 'supplies', name: 'Supplies', startAmount: 0 }], + actions: [ + { + id: 'enter_cave', + name: 'Enter cave', + kind: 'context', + group: { id: 'travel', label: 'Travel' }, + contextId: 'cave', + }, + ], + }); + const story = buildStoryContent( + [{ id: 'boot', prose: 'x', triggers: [{ type: 'boot', targetNodeId: 'boot' }] }], + base.actionsById, + base.resourcesById, + ); + const fixture = { ...base, ...story }; + const state = createGameState(fixture); + expect(() => performAction(state, fixture, 'enter_cave')).toThrow(/not implemented/i); + }); + + it('throws for unknown actions', () => { + const state = createGameState(gameContent); + expect(() => performAction(state, gameContent, 'nope')).toThrow(/[Uu]nknown/); + }); +}); diff --git a/src/engine/game.ts b/src/engine/game.ts index 04fccc3..9b56bdb 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -220,6 +220,50 @@ export function maybeStartLoopAction(state: GameState, content: Content): void { } } +/** + * 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.