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