feat(mystery): parseEncounterNarration — phase and transition prose

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 09:21:29 -05:00
parent e60844a937
commit bf8a693949
2 changed files with 65 additions and 2 deletions
+41 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { parseRoom, parseItem, parseEnding } from './loader' import { parseRoom, parseItem, parseEnding, parseEncounterNarration } from './loader'
const FOYER_MD = `--- const FOYER_MD = `---
id: foyer id: foyer
@@ -246,3 +246,43 @@ whenFlags: {}
expect(result.ending.narration).toBe('') expect(result.ending.narration).toBe('')
}) })
}) })
const RAT_MD = `---
id: rat
startsIn: "[[cellar-stair]]"
initialPhase: lurking
---
## lurking
A heavy rat watches you from the third step. Its eyes catch the light.
## attack-resolved
You stamp. The rat squeals and is gone into the dark.
## wait-stays
The rat does not move. Neither do you.
`
describe('parseEncounterNarration', () => {
it('parses frontmatter and narration sections', () => {
const doc = parseEncounterNarration(RAT_MD, 'encounters/rat.md')
expect(doc.id).toBe('rat')
expect(doc.startsIn).toBe('cellar-stair')
expect(doc.initialPhase).toBe('lurking')
expect(doc.narrations).toEqual({
lurking: 'A heavy rat watches you from the third step. Its eyes catch the light.',
'attack-resolved': 'You stamp. The rat squeals and is gone into the dark.',
'wait-stays': 'The rat does not move. Neither do you.',
})
})
it('throws when no sections are present', () => {
const md = `---
id: x
startsIn: room
initialPhase: p
---
`
expect(() => parseEncounterNarration(md, 'encounters/x.md')).toThrow(/no narration sections/i)
})
})
+24 -1
View File
@@ -1,7 +1,7 @@
import matter from 'gray-matter' import matter from 'gray-matter'
import type { Room, RoomDescriptions, Item } from './types' import type { Room, RoomDescriptions, Item } from './types'
import type { Direction } from '../engine/types' import type { Direction } from '../engine/types'
import { roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema } from './schema' import { roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema, encounterFrontmatterSchema } from './schema'
const WIKILINK = /^\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$/ const WIKILINK = /^\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$/
@@ -131,3 +131,26 @@ export function parseEnding(raw: string, _sourcePath: string): ParsedEnding {
ending: { whenFlags: fm.whenFlags, narration: parsed.content.trim() }, ending: { whenFlags: fm.whenFlags, narration: parsed.content.trim() },
} }
} }
export interface ParsedEncounterNarration {
id: string
startsIn: string
initialPhase: string
narrations: Record<string, string>
}
export function parseEncounterNarration(raw: string, sourcePath: string): ParsedEncounterNarration {
const parsed = matter(raw)
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
const fm = encounterFrontmatterSchema.parse(frontmatter)
const narrations = splitSections(parsed.content)
if (Object.keys(narrations).length === 0) {
throw new Error(`${sourcePath}: no narration sections found`)
}
return {
id: fm.id,
startsIn: fm.startsIn,
initialPhase: fm.initialPhase,
narrations,
}
}