diff --git a/src/world/loader.test.ts b/src/world/loader.test.ts index 2254c85..0d8d04f 100644 --- a/src/world/loader.test.ts +++ b/src/world/loader.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest' -import { parseRoom, parseItem, parseEnding, parseEncounterNarration } from './loader' +import { describe, it, expect, beforeEach } from 'vitest' +import { parseRoom, parseItem, parseEnding, parseEncounterNarration, narration, registerEncounterNarrations, _resetEncounterNarrationRegistry } from './loader' const FOYER_MD = `--- id: foyer @@ -286,3 +286,27 @@ initialPhase: p expect(() => parseEncounterNarration(md, 'encounters/x.md')).toThrow(/no narration sections/i) }) }) + +describe('narration registry', () => { + beforeEach(() => { + _resetEncounterNarrationRegistry() + }) + + it('returns prose for a registered encounter and key', () => { + registerEncounterNarrations([ + { id: 'rat', startsIn: 'cellar-stair', initialPhase: 'lurking', narrations: { lurking: 'watches' } }, + ]) + expect(narration('rat', 'lurking')).toBe('watches') + }) + + it('throws with available keys when key is missing', () => { + registerEncounterNarrations([ + { id: 'rat', startsIn: 'cellar-stair', initialPhase: 'lurking', narrations: { lurking: 'a', resolved: 'b' } }, + ]) + expect(() => narration('rat', 'sleping')).toThrow(/no matching section.*Available: lurking, resolved/i) + }) + + it('throws when encounter is unknown', () => { + expect(() => narration('ghost', 'whatever')).toThrow(/unknown encounter id "ghost"/i) + }) +}) diff --git a/src/world/loader.ts b/src/world/loader.ts index d90589e..0f17b63 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -154,3 +154,48 @@ export function parseEncounterNarration(raw: string, sourcePath: string): Parsed narrations, } } + +const encounterNarrationRegistry = new Map>() + +export function registerEncounterNarrations(docs: ParsedEncounterNarration[]): void { + for (const doc of docs) { + encounterNarrationRegistry.set(doc.id, new Map(Object.entries(doc.narrations))) + } +} + +export function _resetEncounterNarrationRegistry(autoReregister: boolean = false): void { + encounterNarrationRegistry.clear() + if (autoReregister) autoRegisterEncounters() +} + +export function narration(encounterId: string, key: string): string { + const map = encounterNarrationRegistry.get(encounterId) + if (!map) { + throw new Error(`narration(): unknown encounter id "${encounterId}"`) + } + const value = map.get(key) + if (value === undefined) { + const available = [...map.keys()].join(', ') + throw new Error( + `narration(): no matching section "## ${key}" for encounter "${encounterId}". Available: ${available}`, + ) + } + return value +} + +// Auto-register encounter narrations from co-located markdown files at module init. +// This populates the registry BEFORE encounters.ts is evaluated (ESM evaluates dependencies first), +// so encounters.ts can call narration() at top level without explicit ordering. +// While src/mystery/world/encounters/ does not yet exist (Task 8 creates it), this is a no-op. +const _encounterFiles = import.meta.glob('./encounters/*.md', { + eager: true, query: '?raw', import: 'default', +}) + +function autoRegisterEncounters(): void { + for (const [path, raw] of Object.entries(_encounterFiles)) { + const doc = parseEncounterNarration(raw, path) + encounterNarrationRegistry.set(doc.id, new Map(Object.entries(doc.narrations))) + } +} + +autoRegisterEncounters()