diff --git a/src/engine/__tests__/story.test.ts b/src/engine/__tests__/story.test.ts index 3d0f9ba..2686dd2 100644 --- a/src/engine/__tests__/story.test.ts +++ b/src/engine/__tests__/story.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { buildContent } from '../../content/schema'; import { buildStoryContent } from '../../content/storySchema'; import { createGameState } from '../game'; -import { enterStoryNode, initStory } from '../story'; +import { enterStoryNode, evaluateTriggers, initStory } from '../story'; function gameContent() { const base = buildContent({ @@ -35,6 +35,84 @@ function gameContent() { return { ...base, ...story }; } +function gameContentWithActionTrigger() { + 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: 'scout_aftermath', + prose: 'After scouting.', + triggers: [ + { type: 'actionComplete', actionId: 'scout_path', targetNodeId: 'scout_aftermath' }, + ], + }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; +} + +function gameContentWithThreshold() { + 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: 'merchant_flavor', + prose: 'Merchant.', + triggers: [ + { + type: 'minResources', + minResources: { coin: 3 }, + targetNodeId: 'merchant_flavor', + }, + ], + }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; +} + describe('enterStoryNode()', () => { it('sets current node, marks seen, applies enterOutcomes', () => { const content = gameContent(); @@ -55,3 +133,40 @@ describe('initStory()', () => { expect(state.currentStoryNodeId).toBe(''); }); }); + +describe('evaluateTriggers()', () => { + it('fires boot trigger on boot reason', () => { + const content = gameContent(); + const state = createGameState(content); + initStory(state, content); + const { enteredNodeIds } = evaluateTriggers(state, content, { reason: 'boot' }); + expect(enteredNodeIds).toEqual(['boot_intro']); + expect(state.currentStoryNodeId).toBe('boot_intro'); + }); + + it('fires actionComplete when scout_path finishes', () => { + const content = gameContentWithActionTrigger(); + const state = createGameState(content); + const { enteredNodeIds } = evaluateTriggers(state, content, { + reason: 'actionComplete', + actionId: 'scout_path', + }); + expect(enteredNodeIds).toEqual(['scout_aftermath']); + }); + + it('fires minResources on publish when thresholds met', () => { + const content = gameContentWithThreshold(); + const state = createGameState(content); + state.resources.coin = 3; + const { enteredNodeIds } = evaluateTriggers(state, content, { reason: 'publish' }); + expect(enteredNodeIds).toEqual(['merchant_flavor']); + }); + + it('does not re-fire once-only triggers for seen targets', () => { + const content = gameContent(); + const state = createGameState(content); + evaluateTriggers(state, content, { reason: 'boot' }); + const second = evaluateTriggers(state, content, { reason: 'boot' }); + expect(second.enteredNodeIds).toEqual([]); + }); +}); diff --git a/src/engine/story.ts b/src/engine/story.ts index 5695a38..e2e40a2 100644 --- a/src/engine/story.ts +++ b/src/engine/story.ts @@ -70,3 +70,63 @@ export function initStory(state: GameState, _content: GameContent): void { state.currentStoryNodeId = ''; } } + +export type TriggerContext = { + reason: 'boot' | 'publish' | 'actionComplete'; + actionId?: string; +}; + +export interface TriggerResult { + enteredNodeIds: string[]; + events: StoryEvent[]; +} + +function meetsMinResources(state: GameState, minResources: Record): boolean { + return Object.entries(minResources).every( + ([id, min]) => (state.resources[id] ?? 0) >= min, + ); +} + +function shouldSkipTrigger(state: GameState, trigger: { targetNodeId: string; once: boolean }): boolean { + return trigger.once !== false && state.seenStoryNodeIds.includes(trigger.targetNodeId); +} + +export function evaluateTriggers( + state: GameState, + content: GameContent, + ctx: TriggerContext, +): TriggerResult { + const enteredNodeIds: string[] = []; + const events: StoryEvent[] = []; + + for (const node of content.storyNodes) { + for (const trigger of node.triggers ?? []) { + if (shouldSkipTrigger(state, trigger)) continue; + + let matches = false; + if (ctx.reason === 'boot' && trigger.type === 'boot') matches = true; + if ( + ctx.reason === 'actionComplete' && + trigger.type === 'actionComplete' && + trigger.actionId === ctx.actionId + ) { + matches = true; + } + if ( + (ctx.reason === 'publish' || ctx.reason === 'actionComplete') && + trigger.type === 'minResources' && + trigger.minResources && + meetsMinResources(state, trigger.minResources) + ) { + matches = true; + } + + if (matches) { + enteredNodeIds.push(trigger.targetNodeId); + events.push(...enterStoryNode(state, content, trigger.targetNodeId)); + } + } + } + + return { enteredNodeIds, events }; +}