feat(engine): add performAction dispatcher by kind

This commit is contained in:
ginnoir
2026-06-11 20:53:55 -05:00
parent 0de61d3e41
commit 163f710f4c
2 changed files with 137 additions and 0 deletions
+93
View File
@@ -8,9 +8,13 @@ import {
enqueueAction, enqueueAction,
executeInstant, executeInstant,
maybeStartLoopAction, maybeStartLoopAction,
performAction,
startAction, startAction,
tickGame, tickGame,
} from '../game'; } 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' }; const DEFAULT_GROUP = { id: 'test', label: 'Test' };
@@ -446,3 +450,92 @@ describe('loop idle runner', () => {
expect(state.activeActionId).toBeNull(); 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/);
});
});
+44
View File
@@ -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 * Advance the active action by `tickMs`. On completion, grants yields and
* advances the queue — actions do not auto-repeat when the queue is empty. * advances the queue — actions do not auto-repeat when the queue is empty.