feat(engine): add performAction dispatcher by kind
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user