From 5f3356ffb5b6383a41922c9bd9a1a82320adadfa Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:00:35 -0500 Subject: [PATCH] =?UTF-8?q?feat(mystery):=20parseRoom=20=E2=80=94=20markdo?= =?UTF-8?q?wn=20to=20typed=20Room?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/world/loader.test.ts | 88 +++++++++++++++++++++++++++++++++++++ src/world/loader.ts | 95 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 src/world/loader.test.ts create mode 100644 src/world/loader.ts diff --git a/src/world/loader.test.ts b/src/world/loader.test.ts new file mode 100644 index 0000000..f562084 --- /dev/null +++ b/src/world/loader.test.ts @@ -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.' }, + }) + }) +}) diff --git a/src/world/loader.ts b/src/world/loader.ts new file mode 100644 index 0000000..20d4240 --- /dev/null +++ b/src/world/loader.ts @@ -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 = {} + for (const [k, v] of Object.entries(value)) out[k] = stripWikilink(v) + return out + } + return value +} + +function splitSections(body: string): Record { + const sections: Record = {} + 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 = { + 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 + 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> = {} + const lockedExits: NonNullable = {} + for (const dir of DIRS) { + const keys = DIR_KEYS[dir] + const dest = (fm as Record)[keys.exit] as string | null + if (dest !== null && dest !== undefined) { + exits[dir] = dest + const req = (fm as Record)[keys.requires] as string | undefined + const locked = (fm as Record)[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 +}