From a230a46850550df621db4bf6af5395950a0b1d82 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Thu, 11 Jun 2026 18:55:08 -0500 Subject: [PATCH] feat(state): story trigger orchestration and auto-open rules --- .../__tests__/storyOrchestration.test.ts | 15 ++++++ src/state/storyOrchestration.ts | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 src/state/__tests__/storyOrchestration.test.ts create mode 100644 src/state/storyOrchestration.ts diff --git a/src/state/__tests__/storyOrchestration.test.ts b/src/state/__tests__/storyOrchestration.test.ts new file mode 100644 index 0000000..4245fad --- /dev/null +++ b/src/state/__tests__/storyOrchestration.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { content } from '../../content'; +import { createGameState } from '../../engine/game'; +import { initStory } from '../../engine/story'; +import { getPrefs } from '../prefs'; +import { processStoryTriggers } from '../storyOrchestration'; + +describe('processStoryTriggers()', () => { + it('returns entered nodes on boot', () => { + const state = createGameState(content); + initStory(state, content); + const result = processStoryTriggers(state, content, { reason: 'boot' }, getPrefs()); + expect(result.enteredNodeIds.length).toBeGreaterThan(0); + }); +}); diff --git a/src/state/storyOrchestration.ts b/src/state/storyOrchestration.ts new file mode 100644 index 0000000..6f253e3 --- /dev/null +++ b/src/state/storyOrchestration.ts @@ -0,0 +1,49 @@ +import type { GameContent } from '../content/index'; +import type { GameState } from '../engine/game'; +import { evaluateTriggers, type StoryEvent, type TriggerContext } from '../engine/story'; +import type { GamePrefs } from './prefs'; +import type { StoryLogEntry } from './store'; + +export interface StoryUiEffect { + enteredNodeIds: string[]; + logEntries: StoryLogEntry[]; + shouldOpenPanel: boolean; + eventLogLines: string[]; +} + +export function storyEventsToLogEntries(events: StoryEvent[]): StoryLogEntry[] { + return events + .filter((e) => e.kind === 'enter') + .map((e) => ({ nodeId: e.nodeId, prose: e.prose, choiceLabel: e.choiceLabel })); +} + +export function shouldAutoOpenPanel( + prefs: GamePrefs, + nodeId: string, + content: GameContent, +): boolean { + const node = content.storyNodesById[nodeId]; + if (!node) return false; + if (prefs.storyOpenMode === 'manual') return false; + if (prefs.storyOpenMode === 'auto') return true; + return (node.choices?.length ?? 0) > 0; +} + +export function processStoryTriggers( + state: GameState, + content: GameContent, + ctx: TriggerContext, + prefs: GamePrefs, +): StoryUiEffect { + const { enteredNodeIds, events } = evaluateTriggers(state, content, ctx); + const logEntries = storyEventsToLogEntries(events); + const shouldOpenPanel = + enteredNodeIds.length > 0 && + enteredNodeIds.some((id) => shouldAutoOpenPanel(prefs, id, content)); + const eventLogLines = logEntries.map((e) => + e.choiceLabel + ? `Story: ${e.choiceLabel}` + : `Story: ${content.storyNodesById[e.nodeId]?.prose.slice(0, 40)}…`, + ); + return { enteredNodeIds, logEntries, shouldOpenPanel, eventLogLines }; +}