feat(mystery): parseRoom — markdown to typed Room
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseRoom } from './loader'
|
||||||
|
|
||||||
|
const FOYER_MD = `---
|
||||||
|
id: foyer
|
||||||
|
title: "[ Foyer ]"
|
||||||
|
exitN: "[[hallway]]"
|
||||||
|
exitS: null
|
||||||
|
exitE: null
|
||||||
|
exitW: null
|
||||||
|
exitU: null
|
||||||
|
exitD: null
|
||||||
|
items:
|
||||||
|
- "[[letter]]"
|
||||||
|
encounter: null
|
||||||
|
safe: true
|
||||||
|
---
|
||||||
|
|
||||||
|
## first-visit
|
||||||
|
You stand in the foyer. A folded letter lies on a table.
|
||||||
|
|
||||||
|
A hallway leads north.
|
||||||
|
|
||||||
|
## revisit
|
||||||
|
The foyer.
|
||||||
|
|
||||||
|
## examined
|
||||||
|
A foyer with peeling paper.
|
||||||
|
`
|
||||||
|
|
||||||
|
describe('parseRoom', () => {
|
||||||
|
it('parses frontmatter and strips wikilinks', () => {
|
||||||
|
const room = parseRoom(FOYER_MD, 'rooms/foyer.md')
|
||||||
|
expect(room.id).toBe('foyer')
|
||||||
|
expect(room.title).toBe('[ Foyer ]')
|
||||||
|
expect(room.exits).toEqual({ n: 'hallway' })
|
||||||
|
expect(room.items).toEqual(['letter'])
|
||||||
|
expect(room.safe).toBe(true)
|
||||||
|
expect(room.encounter).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('captures all three description sections with multi-paragraph prose', () => {
|
||||||
|
const room = parseRoom(FOYER_MD, 'rooms/foyer.md')
|
||||||
|
expect(room.descriptions.firstVisit).toBe(
|
||||||
|
'You stand in the foyer. A folded letter lies on a table.\n\nA hallway leads north.',
|
||||||
|
)
|
||||||
|
expect(room.descriptions.revisit).toBe('The foyer.')
|
||||||
|
expect(room.descriptions.examined).toBe('A foyer with peeling paper.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws when a required section is missing', () => {
|
||||||
|
const incomplete = FOYER_MD.replace('## examined\nA foyer with peeling paper.\n', '')
|
||||||
|
expect(() => parseRoom(incomplete, 'rooms/foyer.md')).toThrow(/missing required section.*examined/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on malformed frontmatter', () => {
|
||||||
|
expect(() => parseRoom('## first-visit\nhi', 'rooms/x.md')).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses a locked exit into lockedExits', () => {
|
||||||
|
const md = `---
|
||||||
|
id: r
|
||||||
|
title: "[ R ]"
|
||||||
|
exitN: null
|
||||||
|
exitS: null
|
||||||
|
exitE: null
|
||||||
|
exitW: null
|
||||||
|
exitU: null
|
||||||
|
exitD: "[[vault]]"
|
||||||
|
exitDRequires: "[[rusted-key]]"
|
||||||
|
exitDLockedText: The door is locked.
|
||||||
|
items: []
|
||||||
|
---
|
||||||
|
|
||||||
|
## first-visit
|
||||||
|
.
|
||||||
|
## revisit
|
||||||
|
.
|
||||||
|
## examined
|
||||||
|
.
|
||||||
|
`
|
||||||
|
const room = parseRoom(md, 'rooms/r.md')
|
||||||
|
expect(room.exits).toEqual({ d: 'vault' })
|
||||||
|
expect(room.lockedExits).toEqual({
|
||||||
|
d: { requires: 'rusted-key', lockedNarration: 'The door is locked.' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import matter from 'gray-matter'
|
||||||
|
import type { Room, RoomDescriptions } from './types'
|
||||||
|
import type { Direction } from '../engine/types'
|
||||||
|
import { roomFrontmatterSchema } from './schema'
|
||||||
|
|
||||||
|
const WIKILINK = /^\[\[(.+)\]\]$/
|
||||||
|
|
||||||
|
function stripWikilink(value: unknown): unknown {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const m = value.match(WIKILINK)
|
||||||
|
return m ? m[1] : value
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.map(stripWikilink)
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const out: Record<string, unknown> = {}
|
||||||
|
for (const [k, v] of Object.entries(value)) out[k] = stripWikilink(v)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSections(body: string): Record<string, string> {
|
||||||
|
const sections: Record<string, string> = {}
|
||||||
|
const re = /^##\s+([\w-]+)\s*$/gm
|
||||||
|
const matches = [...body.matchAll(re)]
|
||||||
|
for (let i = 0; i < matches.length; i++) {
|
||||||
|
const m = matches[i]!
|
||||||
|
const key = m[1]!
|
||||||
|
const start = m.index! + m[0]!.length
|
||||||
|
const end = i + 1 < matches.length ? matches[i + 1]!.index! : body.length
|
||||||
|
sections[key] = body.slice(start, end).trim()
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIRS: Direction[] = ['n', 's', 'e', 'w', 'u', 'd']
|
||||||
|
const DIR_KEYS: Record<Direction, { exit: string; requires: string; locked: string }> = {
|
||||||
|
n: { exit: 'exitN', requires: 'exitNRequires', locked: 'exitNLockedText' },
|
||||||
|
s: { exit: 'exitS', requires: 'exitSRequires', locked: 'exitSLockedText' },
|
||||||
|
e: { exit: 'exitE', requires: 'exitERequires', locked: 'exitELockedText' },
|
||||||
|
w: { exit: 'exitW', requires: 'exitWRequires', locked: 'exitWLockedText' },
|
||||||
|
u: { exit: 'exitU', requires: 'exitURequires', locked: 'exitULockedText' },
|
||||||
|
d: { exit: 'exitD', requires: 'exitDRequires', locked: 'exitDLockedText' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const REQUIRED_ROOM_SECTIONS = ['first-visit', 'revisit', 'examined'] as const
|
||||||
|
|
||||||
|
export function parseRoom(raw: string, sourcePath: string): Room {
|
||||||
|
const parsed = matter(raw)
|
||||||
|
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
|
||||||
|
const fm = roomFrontmatterSchema.parse(frontmatter)
|
||||||
|
|
||||||
|
const sections = splitSections(parsed.content)
|
||||||
|
for (const key of REQUIRED_ROOM_SECTIONS) {
|
||||||
|
if (!(key in sections)) {
|
||||||
|
throw new Error(`${sourcePath}: missing required section "## ${key}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptions: RoomDescriptions = {
|
||||||
|
firstVisit: sections['first-visit']!,
|
||||||
|
revisit: sections['revisit']!,
|
||||||
|
examined: sections['examined']!,
|
||||||
|
}
|
||||||
|
|
||||||
|
const exits: Partial<Record<Direction, string>> = {}
|
||||||
|
const lockedExits: NonNullable<Room['lockedExits']> = {}
|
||||||
|
for (const dir of DIRS) {
|
||||||
|
const keys = DIR_KEYS[dir]
|
||||||
|
const dest = (fm as Record<string, unknown>)[keys.exit] as string | null
|
||||||
|
if (dest !== null && dest !== undefined) {
|
||||||
|
exits[dir] = dest
|
||||||
|
const req = (fm as Record<string, unknown>)[keys.requires] as string | undefined
|
||||||
|
const locked = (fm as Record<string, unknown>)[keys.locked] as string | undefined
|
||||||
|
if (req !== undefined) {
|
||||||
|
if (locked === undefined) {
|
||||||
|
throw new Error(`${sourcePath}: ${keys.requires} is set but ${keys.locked} is missing`)
|
||||||
|
}
|
||||||
|
lockedExits[dir] = { requires: req, lockedNarration: locked }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const room: Room = {
|
||||||
|
id: fm.id,
|
||||||
|
title: fm.title,
|
||||||
|
descriptions,
|
||||||
|
exits,
|
||||||
|
items: fm.items,
|
||||||
|
}
|
||||||
|
if (Object.keys(lockedExits).length > 0) room.lockedExits = lockedExits
|
||||||
|
if (fm.encounter) room.encounter = fm.encounter
|
||||||
|
if (fm.safe) room.safe = fm.safe
|
||||||
|
return room
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user