diff --git a/src/engine/__tests__/story.test.ts b/src/engine/__tests__/story.test.ts index 2686dd2..60de407 100644 --- a/src/engine/__tests__/story.test.ts +++ b/src/engine/__tests__/story.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it } from 'vitest'; import { buildContent } from '../../content/schema'; import { buildStoryContent } from '../../content/storySchema'; import { createGameState } from '../game'; -import { enterStoryNode, evaluateTriggers, initStory } from '../story'; +import { + applyChoice, + enterStoryNode, + evaluateTriggers, + getAvailableChoices, + initStory, +} from '../story'; function gameContent() { const base = buildContent({ @@ -113,6 +119,106 @@ function gameContentWithThreshold() { return { ...base, ...story }; } +function gameContentWithFork() { + const base = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin', startAmount: 0 }, + ], + actions: [ + { + id: 'scout_path', + name: 'Scout', + durationMs: 1000, + costs: [], + yields: [{ resourceId: 'coin', amount: 1 }], + }, + ], + }); + const story = buildStoryContent( + [ + { + id: 'boot_intro', + prose: 'You wake at the crossroads.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'fork_choice', + prose: 'Which way?', + choices: [ + { + id: 'pick_a', + label: 'High road', + outcomes: [{ type: 'setFlag', flag: 'route_a' }], + targetNodeId: 'route_a_beat', + }, + { + id: 'pick_b', + label: 'Low road', + outcomes: [{ type: 'setFlag', flag: 'route_b' }], + targetNodeId: 'route_b_beat', + }, + { + id: 'pick_b_if_excluded', + label: 'Low road (excluded after A)', + requirements: { excludeStoryFlags: ['route_a'] }, + outcomes: [{ type: 'setFlag', flag: 'route_b' }], + targetNodeId: 'route_b_beat', + }, + ], + }, + { id: 'route_a_beat', prose: 'The high road.' }, + { id: 'route_b_beat', prose: 'The low road.' }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; +} + +function gameContentWithGatedChoice() { + const base = buildContent({ + resources: [ + { id: 'supplies', name: 'Supplies', startAmount: 10 }, + { id: 'coin', name: 'Coin', startAmount: 0 }, + ], + actions: [ + { + id: 'scout_path', + name: 'Scout', + durationMs: 1000, + costs: [], + yields: [{ resourceId: 'coin', amount: 1 }], + }, + ], + }); + const story = buildStoryContent( + [ + { + id: 'boot_intro', + prose: 'Boot.', + triggers: [{ type: 'boot', targetNodeId: 'boot_intro' }], + }, + { + id: 'gated', + prose: 'A gated choice.', + choices: [ + { + id: 'needs_coin', + label: 'Pay the toll', + requirements: { minResources: { coin: 5 } }, + outcomes: [], + targetNodeId: 'boot_intro', + }, + ], + }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; +} + describe('enterStoryNode()', () => { it('sets current node, marks seen, applies enterOutcomes', () => { const content = gameContent(); @@ -170,3 +276,32 @@ describe('evaluateTriggers()', () => { expect(second.enteredNodeIds).toEqual([]); }); }); + +describe('applyChoice()', () => { + it('applies outcomes and advances on fork', () => { + const content = gameContentWithFork(); + const state = createGameState(content); + enterStoryNode(state, content, 'fork_choice'); + applyChoice(state, content, 'pick_a'); + expect(state.storyFlags.route_a).toBe(true); + expect(state.currentStoryNodeId).toBe('route_a_beat'); + }); + + it('throws when requirements not met', () => { + const content = gameContentWithGatedChoice(); + const state = createGameState(content); + enterStoryNode(state, content, 'gated'); + expect(() => applyChoice(state, content, 'needs_coin')).toThrow(/requirements/i); + }); +}); + +describe('getAvailableChoices()', () => { + it('hides choices blocked by excludeStoryFlags', () => { + const content = gameContentWithFork(); + const state = createGameState(content); + state.storyFlags.route_a = true; + enterStoryNode(state, content, 'fork_choice'); + const choices = getAvailableChoices(state, content); + expect(choices.map((c) => c.id)).not.toContain('pick_b_if_excluded'); + }); +}); diff --git a/src/engine/story.ts b/src/engine/story.ts index e2e40a2..3256cad 100644 --- a/src/engine/story.ts +++ b/src/engine/story.ts @@ -1,5 +1,5 @@ import type { Content } from '../content/schema'; -import type { StoryContent, StoryOutcome } from '../content/storySchema'; +import type { StoryChoice, StoryContent, StoryOutcome } from '../content/storySchema'; import type { GameState } from './game'; type GameContent = Content & StoryContent; @@ -130,3 +130,56 @@ export function evaluateTriggers( return { enteredNodeIds, events }; } + +function meetsChoiceRequirements( + state: GameState, + requirements: StoryChoice['requirements'], +): boolean { + if (!requirements) return true; + if (requirements.minResources) { + for (const [id, min] of Object.entries(requirements.minResources)) { + if ((state.resources[id] ?? 0) < min) return false; + } + } + if (requirements.requireStoryFlags) { + for (const flag of requirements.requireStoryFlags) { + if (!state.storyFlags[flag]) return false; + } + } + if (requirements.excludeStoryFlags) { + for (const flag of requirements.excludeStoryFlags) { + if (state.storyFlags[flag]) return false; + } + } + return true; +} + +export function getCurrentNode(state: GameState, content: GameContent) { + return content.storyNodesById[state.currentStoryNodeId] ?? null; +} + +export function getAvailableChoices(state: GameState, content: GameContent): StoryChoice[] { + const node = getCurrentNode(state, content); + if (!node?.choices) return []; + return node.choices.filter((c) => meetsChoiceRequirements(state, c.requirements)); +} + +export function applyChoice( + state: GameState, + content: GameContent, + choiceId: string, +): StoryEvent[] { + const node = getCurrentNode(state, content); + if (!node?.choices) throw new Error(`Node "${node?.id}" has no choices`); + const choice = node.choices.find((c) => c.id === choiceId); + if (!choice) throw new Error(`Unknown choice "${choiceId}"`); + if (!meetsChoiceRequirements(state, choice.requirements)) { + throw new Error(`Choice "${choiceId}" requirements not met`); + } + const events: StoryEvent[] = []; + applyOutcomes(state, content, choice.outcomes, events); + events.push(...enterStoryNode(state, content, choice.targetNodeId)); + const last = events.find((e) => e.kind === 'enter'); + if (last) last.choiceLabel = choice.label; + return events; +}