From 717af1ef517490f7b33ad3ad984f53a6cf45b6aa Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:50:34 -0500 Subject: [PATCH] feat(engine): story outcomes and enterStoryNode --- src/engine/__tests__/story.test.ts | 57 +++++++++++++++++++++++ src/engine/story.ts | 72 ++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/engine/__tests__/story.test.ts create mode 100644 src/engine/story.ts diff --git a/src/engine/__tests__/story.test.ts b/src/engine/__tests__/story.test.ts new file mode 100644 index 0000000..3d0f9ba --- /dev/null +++ b/src/engine/__tests__/story.test.ts @@ -0,0 +1,57 @@ +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'; + +function gameContent() { + 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' }], + enterOutcomes: [{ type: 'grantResource', resourceId: 'coin', amount: 1 }], + }, + ], + base.actionsById, + base.resourcesById, + ); + return { ...base, ...story }; +} + +describe('enterStoryNode()', () => { + it('sets current node, marks seen, applies enterOutcomes', () => { + const content = gameContent(); + const state = createGameState(content); + const events = enterStoryNode(state, content, 'boot_intro'); + expect(state.currentStoryNodeId).toBe('boot_intro'); + expect(state.seenStoryNodeIds).toContain('boot_intro'); + expect(state.resources.coin).toBe(1); + expect(events.length).toBeGreaterThan(0); + }); +}); + +describe('initStory()', () => { + it('leaves currentStoryNodeId empty until triggers run', () => { + const content = gameContent(); + const state = createGameState(content); + initStory(state, content); + expect(state.currentStoryNodeId).toBe(''); + }); +}); diff --git a/src/engine/story.ts b/src/engine/story.ts new file mode 100644 index 0000000..5695a38 --- /dev/null +++ b/src/engine/story.ts @@ -0,0 +1,72 @@ +import type { Content } from '../content/schema'; +import type { StoryContent, StoryOutcome } from '../content/storySchema'; +import type { GameState } from './game'; + +type GameContent = Content & StoryContent; + +export interface StoryEvent { + kind: 'enter' | 'log'; + nodeId: string; + prose: string; + choiceLabel?: string; +} + +export function applyOutcomes( + state: GameState, + _content: GameContent, + outcomes: StoryOutcome[], + events: StoryEvent[], +): void { + for (const outcome of outcomes) { + switch (outcome.type) { + case 'setFlag': + state.storyFlags[outcome.flag] = true; + break; + case 'clearFlag': + state.storyFlags[outcome.flag] = false; + break; + case 'grantResource': + state.resources[outcome.resourceId] = + (state.resources[outcome.resourceId] ?? 0) + outcome.amount; + break; + case 'consumeResource': { + const current = state.resources[outcome.resourceId] ?? 0; + if (current < outcome.amount) { + throw new Error( + `Cannot consume ${outcome.amount} ${outcome.resourceId} (have ${current})`, + ); + } + state.resources[outcome.resourceId] = current - outcome.amount; + break; + } + case 'log': + events.push({ kind: 'log', nodeId: state.currentStoryNodeId, prose: outcome.text }); + break; + } + } +} + +export function enterStoryNode( + state: GameState, + content: GameContent, + nodeId: string, +): StoryEvent[] { + const node = content.storyNodesById[nodeId]; + if (!node) throw new Error(`Unknown story node "${nodeId}"`); + const events: StoryEvent[] = []; + if (!state.seenStoryNodeIds.includes(nodeId)) { + state.seenStoryNodeIds.push(nodeId); + } + state.currentStoryNodeId = nodeId; + applyOutcomes(state, content, node.enterOutcomes ?? [], events); + events.unshift({ kind: 'enter', nodeId, prose: node.prose }); + return events; +} + +export function initStory(state: GameState, _content: GameContent): void { + state.storyFlags = state.storyFlags ?? {}; + state.seenStoryNodeIds = state.seenStoryNodeIds ?? []; + if (!state.currentStoryNodeId) { + state.currentStoryNodeId = ''; + } +}