From 8d9484171f6f7d5e26086ccea69d7d41ae16ff19 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 20:36:07 -0500 Subject: [PATCH] feat(engine): execute story actions via storyChoiceId --- src/engine/__tests__/storyActions.test.ts | 22 +++++++++++++++++++++ src/engine/game.ts | 24 +++++++++++++++++++++++ src/engine/story.ts | 12 ++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 src/engine/__tests__/storyActions.test.ts diff --git a/src/engine/__tests__/storyActions.test.ts b/src/engine/__tests__/storyActions.test.ts new file mode 100644 index 0000000..03b5ad2 --- /dev/null +++ b/src/engine/__tests__/storyActions.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { content } from '../../content/index'; +import { createGameState, executeStoryAction } from '../game'; +import { enterStoryNode } from '../story'; + +describe('executeStoryAction()', () => { + it('applies the linked story choice', () => { + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + executeStoryAction(state, content, 'pick_high_road'); + expect(state.storyFlags.route_a).toBe(true); + expect(state.currentStoryNodeId).toBe('route_a_beat'); + }); + + it('throws when the choice is not available', () => { + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + executeStoryAction(state, content, 'pick_high_road'); + // after taking route A, the current node has no choices, so follow_river (pick_b) is unavailable + expect(() => executeStoryAction(state, content, 'follow_river')).toThrow(); + }); +}); diff --git a/src/engine/game.ts b/src/engine/game.ts index c7110a8..0f6501b 100644 --- a/src/engine/game.ts +++ b/src/engine/game.ts @@ -1,4 +1,8 @@ import type { Content } from '../content/schema'; +import type { StoryContent } from '../content/storySchema'; +import { applyChoice, isStoryChoiceAvailable } from './story'; + +type GameContent = Content & StoryContent; /** * Core game state and per-tick simulation. @@ -173,6 +177,26 @@ export function executeInstant(state: GameState, content: Content, actionId: str grantYields(state, content, actionId); } +/** + * Execute a story action by resolving its storyChoiceId and applying it. + * Throws if the action is not of kind 'story', has no storyChoiceId, or the + * choice is not currently available. + */ +export function executeStoryAction( + state: GameState, + content: GameContent, + actionId: string, +): void { + const action = content.actionsById[actionId]; + if (!action || action.kind !== 'story' || !action.storyChoiceId) { + throw new Error(`Action "${actionId}" is not a story action`); + } + if (!isStoryChoiceAvailable(state, content, action.storyChoiceId)) { + throw new Error(`Story choice "${action.storyChoiceId}" is not available`); + } + applyChoice(state, content, action.storyChoiceId); +} + /** * Advance the active action by `tickMs`. On completion, grants yields and * advances the queue — actions do not auto-repeat when the queue is empty. diff --git a/src/engine/story.ts b/src/engine/story.ts index 2d6714c..ad5899f 100644 --- a/src/engine/story.ts +++ b/src/engine/story.ts @@ -165,6 +165,18 @@ export function getAvailableChoices(state: GameState, content: GameContent): Sto return node.choices.filter((c) => meetsChoiceRequirements(state, c.requirements)); } +export function isStoryChoiceAvailable( + state: GameState, + content: GameContent, + storyChoiceId: string, +): boolean { + const node = getCurrentNode(state, content); + if (!node?.choices) return false; + const choice = node.choices.find((c) => c.id === storyChoiceId); + if (!choice) return false; + return meetsChoiceRequirements(state, choice.requirements); +} + export function applyChoice( state: GameState, content: GameContent,