From da7b6fac8397849e7cfb52f217ec79b97e4c5578 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 08:56:44 -0500 Subject: [PATCH 01/15] feat(mystery): add Zod schemas for markdown frontmatter Defines runtime validation schemas (roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema, encounterFrontmatterSchema) and their inferred TypeScript types. All 8 TDD tests pass. Co-Authored-By: Claude Sonnet 4.6 --- src/world/schema.test.ts | 81 ++++++++++++++++++++++++++++++++++++++++ src/world/schema.ts | 57 ++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 src/world/schema.test.ts create mode 100644 src/world/schema.ts diff --git a/src/world/schema.test.ts b/src/world/schema.test.ts new file mode 100644 index 0000000..f3f581a --- /dev/null +++ b/src/world/schema.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest' +import { roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema, encounterFrontmatterSchema } from './schema' + +describe('roomFrontmatterSchema', () => { + it('accepts a fully populated room', () => { + const data = { + id: 'foyer', + title: '[ Foyer ]', + exitN: 'hallway', + exitS: null, + exitE: null, + exitW: null, + exitU: null, + exitD: null, + items: ['letter'], + encounter: null, + safe: true, + } + expect(() => roomFrontmatterSchema.parse(data)).not.toThrow() + }) + + it('accepts a locked exit with sibling fields', () => { + const data = { + id: 'hall', + title: '[ Hall ]', + exitN: null, exitS: null, exitE: null, exitW: null, exitU: null, + exitD: 'vault', + exitDRequires: 'rusted-key', + exitDLockedText: 'The door is locked.', + items: [], + } + expect(() => roomFrontmatterSchema.parse(data)).not.toThrow() + }) + + it('rejects a room missing a required exit field', () => { + const data = { id: 'r', title: '[ R ]', exitN: null, items: [] } + expect(() => roomFrontmatterSchema.parse(data)).toThrow() + }) +}) + +describe('itemFrontmatterSchema', () => { + it('accepts an item with state', () => { + const data = { + id: 'lamp', + names: ['lamp', 'oil lamp'], + short: 'an oil lamp', + takeable: true, + initialState: { lit: false }, + } + expect(() => itemFrontmatterSchema.parse(data)).not.toThrow() + }) + + it('accepts an item without state (defaults to {})', () => { + const parsed = itemFrontmatterSchema.parse({ + id: 'letter', + names: ['letter'], + short: 'a letter', + takeable: true, + }) + expect(parsed.initialState).toEqual({}) + }) +}) + +describe('endingFrontmatterSchema', () => { + it('accepts true ending shape', () => { + const data = { id: 'true', whenFlags: { ratGone: true } } + expect(() => endingFrontmatterSchema.parse(data)).not.toThrow() + }) + + it('rejects unknown ending id', () => { + const data = { id: 'mercy', whenFlags: {} } + expect(() => endingFrontmatterSchema.parse(data)).toThrow() + }) +}) + +describe('encounterFrontmatterSchema', () => { + it('accepts an encounter narration doc', () => { + const data = { id: 'rat', startsIn: 'cellar-stair', initialPhase: 'lurking' } + expect(() => encounterFrontmatterSchema.parse(data)).not.toThrow() + }) +}) diff --git a/src/world/schema.ts b/src/world/schema.ts new file mode 100644 index 0000000..f93f343 --- /dev/null +++ b/src/world/schema.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' + +const stateValueSchema = z.union([z.string(), z.boolean(), z.number()]) +const stateRecordSchema = z.record(z.string(), stateValueSchema) + +export const roomFrontmatterSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + exitN: z.string().nullable(), + exitS: z.string().nullable(), + exitE: z.string().nullable(), + exitW: z.string().nullable(), + exitU: z.string().nullable(), + exitD: z.string().nullable(), + exitNRequires: z.string().optional(), + exitNLockedText: z.string().optional(), + exitSRequires: z.string().optional(), + exitSLockedText: z.string().optional(), + exitERequires: z.string().optional(), + exitELockedText: z.string().optional(), + exitWRequires: z.string().optional(), + exitWLockedText: z.string().optional(), + exitURequires: z.string().optional(), + exitULockedText: z.string().optional(), + exitDRequires: z.string().optional(), + exitDLockedText: z.string().optional(), + items: z.array(z.string()).default([]), + encounter: z.string().nullable().optional(), + safe: z.boolean().optional(), +}) + +export type RoomFrontmatter = z.infer + +export const itemFrontmatterSchema = z.object({ + id: z.string().min(1), + names: z.array(z.string().min(1)).min(1), + short: z.string().min(1), + takeable: z.boolean(), + initialState: stateRecordSchema.default({}), +}) + +export type ItemFrontmatter = z.infer + +export const endingFrontmatterSchema = z.object({ + id: z.enum(['true', 'wrong', 'bad']), + whenFlags: stateRecordSchema.default({}), +}) + +export type EndingFrontmatter = z.infer + +export const encounterFrontmatterSchema = z.object({ + id: z.string().min(1), + startsIn: z.string().min(1), + initialPhase: z.string().min(1), +}) + +export type EncounterFrontmatter = z.infer From 5f3356ffb5b6383a41922c9bd9a1a82320adadfa Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:00:35 -0500 Subject: [PATCH 02/15] =?UTF-8?q?feat(mystery):=20parseRoom=20=E2=80=94=20?= =?UTF-8?q?markdown=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 +} From cf257c040a3c05e5683f477608e119a06bfe26be Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:09:38 -0500 Subject: [PATCH 03/15] fix(mystery): handle aliased wikilinks; symmetric locked-exit validation Co-Authored-By: Claude Sonnet 4.6 --- src/world/loader.test.ts | 75 ++++++++++++++++++++++++++++++++++++++++ src/world/loader.ts | 14 +++++--- 2 files changed, 84 insertions(+), 5 deletions(-) diff --git a/src/world/loader.test.ts b/src/world/loader.test.ts index f562084..e6114da 100644 --- a/src/world/loader.test.ts +++ b/src/world/loader.test.ts @@ -85,4 +85,79 @@ items: [] d: { requires: 'rusted-key', lockedNarration: 'The door is locked.' }, }) }) + + it('strips aliased wikilinks like [[id|display text]] to just the id', () => { + const md = `--- +id: foyer +title: "[ Foyer ]" +exitN: "[[hallway|the long hallway]]" +exitS: null +exitE: null +exitW: null +exitU: null +exitD: null +items: + - "[[letter|the folded letter]]" +encounter: null +--- + +## first-visit +. +## revisit +. +## examined +. +` + const room = parseRoom(md, 'rooms/foyer.md') + expect(room.exits).toEqual({ n: 'hallway' }) + expect(room.items).toEqual(['letter']) + }) + + it('throws when locked text is set without requires', () => { + const md = `--- +id: r +title: "[ R ]" +exitN: null +exitS: null +exitE: null +exitW: null +exitU: null +exitD: "[[vault]]" +exitDLockedText: The door is locked. +items: [] +--- + +## first-visit +. +## revisit +. +## examined +. +` + expect(() => parseRoom(md, 'rooms/r.md')).toThrow(/exitDLockedText is set but exitDRequires is missing/) + }) + + it('throws when requires is set without locked text', () => { + const md = `--- +id: r +title: "[ R ]" +exitN: null +exitS: null +exitE: null +exitW: null +exitU: null +exitD: "[[vault]]" +exitDRequires: "[[rusted-key]]" +items: [] +--- + +## first-visit +. +## revisit +. +## examined +. +` + expect(() => parseRoom(md, 'rooms/r.md')).toThrow(/exitDRequires is set but exitDLockedText is missing/) + }) }) diff --git a/src/world/loader.ts b/src/world/loader.ts index 20d4240..100469e 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -3,7 +3,7 @@ import type { Room, RoomDescriptions } from './types' import type { Direction } from '../engine/types' import { roomFrontmatterSchema } from './schema' -const WIKILINK = /^\[\[(.+)\]\]$/ +const WIKILINK = /^\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$/ function stripWikilink(value: unknown): unknown { if (typeof value === 'string') { @@ -21,6 +21,7 @@ function stripWikilink(value: unknown): unknown { function splitSections(body: string): Record { const sections: Record = {} + // Section names use only [A-Za-z0-9_-]; headers with spaces or dots are silently skipped. const re = /^##\s+([\w-]+)\s*$/gm const matches = [...body.matchAll(re)] for (let i = 0; i < matches.length; i++) { @@ -72,10 +73,13 @@ export function parseRoom(raw: string, sourcePath: string): Room { 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`) - } + if (req !== undefined && locked === undefined) { + throw new Error(`${sourcePath}: ${keys.requires} is set but ${keys.locked} is missing`) + } + if (locked !== undefined && req === undefined) { + throw new Error(`${sourcePath}: ${keys.locked} is set but ${keys.requires} is missing`) + } + if (req !== undefined && locked !== undefined) { lockedExits[dir] = { requires: req, lockedNarration: locked } } } From e108ca16e0694b995226d9f94ae8c184a4be604a Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:13:51 -0500 Subject: [PATCH 04/15] =?UTF-8?q?feat(mystery):=20parseItem=20=E2=80=94=20?= =?UTF-8?q?markdown=20to=20typed=20Item?= 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 | 55 +++++++++++++++++++++++++++++++++++++++- src/world/loader.ts | 22 ++++++++++++++-- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/src/world/loader.test.ts b/src/world/loader.test.ts index e6114da..56b677d 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 } from './loader' +import { parseRoom, parseItem } from './loader' const FOYER_MD = `--- id: foyer @@ -161,3 +161,56 @@ items: [] expect(() => parseRoom(md, 'rooms/r.md')).toThrow(/exitDRequires is set but exitDLockedText is missing/) }) }) + +// Add to the bottom of loader.test.ts + +const LAMP_MD = `--- +id: lamp +names: [lamp, oil lamp, torch] +short: an oil lamp +takeable: true +initialState: + lit: false +--- + +An iron oil lamp with a glass chimney. Currently unlit. +` + +describe('parseItem', () => { + it('parses an item with state', () => { + const item = parseItem(LAMP_MD, 'items/lamp.md') + expect(item).toEqual({ + id: 'lamp', + names: ['lamp', 'oil lamp', 'torch'], + short: 'an oil lamp', + long: 'An iron oil lamp with a glass chimney. Currently unlit.', + initialState: { lit: false }, + takeable: true, + }) + }) + + it('uses empty initialState when omitted', () => { + const md = `--- +id: x +names: [x] +short: x +takeable: false +--- + +The long description. +` + const item = parseItem(md, 'items/x.md') + expect(item.initialState).toEqual({}) + }) + + it('throws when body is empty', () => { + const md = `--- +id: x +names: [x] +short: x +takeable: false +--- +` + expect(() => parseItem(md, 'items/x.md')).toThrow(/empty long description/i) + }) +}) diff --git a/src/world/loader.ts b/src/world/loader.ts index 100469e..86ff6e6 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -1,7 +1,7 @@ import matter from 'gray-matter' -import type { Room, RoomDescriptions } from './types' +import type { Room, RoomDescriptions, Item } from './types' import type { Direction } from '../engine/types' -import { roomFrontmatterSchema } from './schema' +import { roomFrontmatterSchema, itemFrontmatterSchema } from './schema' const WIKILINK = /^\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$/ @@ -97,3 +97,21 @@ export function parseRoom(raw: string, sourcePath: string): Room { if (fm.safe) room.safe = fm.safe return room } + +export function parseItem(raw: string, sourcePath: string): Item { + const parsed = matter(raw) + const frontmatter = stripWikilink(parsed.data) as Record + const fm = itemFrontmatterSchema.parse(frontmatter) + const long = parsed.content.trim() + if (long.length === 0) { + throw new Error(`${sourcePath}: empty long description`) + } + return { + id: fm.id, + names: fm.names, + short: fm.short, + long, + initialState: fm.initialState, + takeable: fm.takeable, + } +} From e60844a93704175a5cfd16f719ca049c679ef7a9 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:17:11 -0500 Subject: [PATCH 05/15] =?UTF-8?q?feat(mystery):=20parseEnding=20=E2=80=94?= =?UTF-8?q?=20markdown=20to=20typed=20Ending?= 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 | 34 +++++++++++++++++++++++++++++++++- src/world/loader.ts | 18 +++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/world/loader.test.ts b/src/world/loader.test.ts index 56b677d..11b882e 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 } from './loader' +import { parseRoom, parseItem, parseEnding } from './loader' const FOYER_MD = `--- id: foyer @@ -214,3 +214,35 @@ takeable: false expect(() => parseItem(md, 'items/x.md')).toThrow(/empty long description/i) }) }) + +const TRUE_ENDING_MD = `--- +id: true +whenFlags: + ratGone: true +--- + +You stand at the top of the stair. The thing below has settled. + +The door behind you opens, and outside, finally, is morning. +` + +describe('parseEnding', () => { + it('parses an ending with flags and prose', () => { + const result = parseEnding(TRUE_ENDING_MD, 'endings/true.md') + expect(result.id).toBe('true') + expect(result.ending.whenFlags).toEqual({ ratGone: true }) + expect(result.ending.narration).toBe( + 'You stand at the top of the stair. The thing below has settled.\n\nThe door behind you opens, and outside, finally, is morning.', + ) + }) + + it('accepts an empty body (unreachable ending stub)', () => { + const md = `--- +id: wrong +whenFlags: {} +--- +` + const result = parseEnding(md, 'endings/wrong.md') + expect(result.ending.narration).toBe('') + }) +}) diff --git a/src/world/loader.ts b/src/world/loader.ts index 86ff6e6..6e80081 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -1,7 +1,7 @@ import matter from 'gray-matter' import type { Room, RoomDescriptions, Item } from './types' import type { Direction } from '../engine/types' -import { roomFrontmatterSchema, itemFrontmatterSchema } from './schema' +import { roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema } from './schema' const WIKILINK = /^\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$/ @@ -115,3 +115,19 @@ export function parseItem(raw: string, sourcePath: string): Item { takeable: fm.takeable, } } + +export interface ParsedEnding { + id: 'true' | 'wrong' | 'bad' + ending: { whenFlags: Record; narration: string } +} + +export function parseEnding(raw: string, _sourcePath: string): ParsedEnding { + const parsed = matter(raw) + // YAML parses bare `true` as boolean; coerce id to string before schema validation. + const data = { ...parsed.data, id: String(parsed.data.id) } + const fm = endingFrontmatterSchema.parse(data) + return { + id: fm.id, + ending: { whenFlags: fm.whenFlags, narration: parsed.content.trim() }, + } +} From bf8a693949311425e38825d2c1ede6dd67047c41 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:21:29 -0500 Subject: [PATCH 06/15] =?UTF-8?q?feat(mystery):=20parseEncounterNarration?= =?UTF-8?q?=20=E2=80=94=20phase=20and=20transition=20prose?= 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 | 42 +++++++++++++++++++++++++++++++++++++++- src/world/loader.ts | 25 +++++++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/world/loader.test.ts b/src/world/loader.test.ts index 11b882e..2254c85 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 } from './loader' +import { parseRoom, parseItem, parseEnding, parseEncounterNarration } from './loader' const FOYER_MD = `--- id: foyer @@ -246,3 +246,43 @@ whenFlags: {} 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) + }) +}) diff --git a/src/world/loader.ts b/src/world/loader.ts index 6e80081..d90589e 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -1,7 +1,7 @@ import matter from 'gray-matter' import type { Room, RoomDescriptions, Item } from './types' import type { Direction } from '../engine/types' -import { roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema } from './schema' +import { roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema, encounterFrontmatterSchema } from './schema' const WIKILINK = /^\[\[([^\]|]+)(?:\|[^\]]*)?\]\]$/ @@ -131,3 +131,26 @@ export function parseEnding(raw: string, _sourcePath: string): ParsedEnding { ending: { whenFlags: fm.whenFlags, narration: parsed.content.trim() }, } } + +export interface ParsedEncounterNarration { + id: string + startsIn: string + initialPhase: string + narrations: Record +} + +export function parseEncounterNarration(raw: string, sourcePath: string): ParsedEncounterNarration { + const parsed = matter(raw) + const frontmatter = stripWikilink(parsed.data) as Record + 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, + } +} From d3a2f4e1d712272345eb245834d8c625c7975773 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:23:37 -0500 Subject: [PATCH 07/15] feat(mystery): narration() helper and encounter narration registry Co-Authored-By: Claude Sonnet 4.6 --- src/world/loader.test.ts | 28 +++++++++++++++++++++++-- src/world/loader.ts | 45 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) 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() From bbea3f44739efeb4b3a5c5bc27140be185e53639 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:26:02 -0500 Subject: [PATCH 08/15] feat(mystery): migration script and produced markdown content Adds scripts/migrate-mystery-content.ts which reads rooms, items, encounters, and endings from TypeScript source and emits byte-identical markdown files under src/mystery/world/{rooms,items,encounters,endings}/. Installs tsx as a devDep to support .ts imports across src/ during the one-shot run. Co-Authored-By: Claude Sonnet 4.6 --- src/world/encounters/rat.md | 14 ++++++++++++++ src/world/endings/bad.md | 5 +++++ src/world/endings/true.md | 7 +++++++ src/world/endings/wrong.md | 5 +++++ src/world/items/lamp.md | 10 ++++++++++ src/world/items/letter.md | 8 ++++++++ src/world/items/matches.md | 8 ++++++++ src/world/rooms/cellar-stair.md | 21 +++++++++++++++++++++ src/world/rooms/foyer.md | 23 +++++++++++++++++++++++ src/world/rooms/hallway.md | 22 ++++++++++++++++++++++ 10 files changed, 123 insertions(+) create mode 100644 src/world/encounters/rat.md create mode 100644 src/world/endings/bad.md create mode 100644 src/world/endings/true.md create mode 100644 src/world/endings/wrong.md create mode 100644 src/world/items/lamp.md create mode 100644 src/world/items/letter.md create mode 100644 src/world/items/matches.md create mode 100644 src/world/rooms/cellar-stair.md create mode 100644 src/world/rooms/foyer.md create mode 100644 src/world/rooms/hallway.md diff --git a/src/world/encounters/rat.md b/src/world/encounters/rat.md new file mode 100644 index 0000000..a9a64bd --- /dev/null +++ b/src/world/encounters/rat.md @@ -0,0 +1,14 @@ +--- +id: rat +startsIn: "[[cellar-stair]]" +initialPhase: lurking +--- + +## lurking +A heavy rat watches you from the third step. Its eyes catch the light. + +## attack-rat-resolved +You stamp. The rat squeals and is gone into the dark. + +## wait-stays +The rat does not move. Neither do you. diff --git a/src/world/endings/bad.md b/src/world/endings/bad.md new file mode 100644 index 0000000..2ecd732 --- /dev/null +++ b/src/world/endings/bad.md @@ -0,0 +1,5 @@ +--- +id: bad +whenFlags: {} +--- + diff --git a/src/world/endings/true.md b/src/world/endings/true.md new file mode 100644 index 0000000..231e052 --- /dev/null +++ b/src/world/endings/true.md @@ -0,0 +1,7 @@ +--- +id: true +whenFlags: + ratGone: true +--- + +You stand at the top of the stair. The thing below has settled. The door behind you opens, and outside, finally, is morning. diff --git a/src/world/endings/wrong.md b/src/world/endings/wrong.md new file mode 100644 index 0000000..e78c958 --- /dev/null +++ b/src/world/endings/wrong.md @@ -0,0 +1,5 @@ +--- +id: wrong +whenFlags: {} +--- + diff --git a/src/world/items/lamp.md b/src/world/items/lamp.md new file mode 100644 index 0000000..201fa6c --- /dev/null +++ b/src/world/items/lamp.md @@ -0,0 +1,10 @@ +--- +id: lamp +names: ["lamp", "oil lamp", "torch"] +short: "an oil lamp" +takeable: true +initialState: + lit: false +--- + +An iron oil lamp with a glass chimney. Currently unlit. diff --git a/src/world/items/letter.md b/src/world/items/letter.md new file mode 100644 index 0000000..2823332 --- /dev/null +++ b/src/world/items/letter.md @@ -0,0 +1,8 @@ +--- +id: letter +names: ["letter", "folded letter", "paper"] +short: "a folded letter" +takeable: true +--- + +A folded letter on yellowed paper. The hand is unfamiliar. It reads: "Come at once. The thing in the cellar is waking." diff --git a/src/world/items/matches.md b/src/world/items/matches.md new file mode 100644 index 0000000..808ddac --- /dev/null +++ b/src/world/items/matches.md @@ -0,0 +1,8 @@ +--- +id: matches +names: ["matches", "safety matches", "box"] +short: "a box of safety matches" +takeable: true +--- + +A small cardboard box of safety matches. Half-full. diff --git a/src/world/rooms/cellar-stair.md b/src/world/rooms/cellar-stair.md new file mode 100644 index 0000000..99231ec --- /dev/null +++ b/src/world/rooms/cellar-stair.md @@ -0,0 +1,21 @@ +--- +id: cellar-stair +title: "[ Cellar Stair ]" +exitN: null +exitS: null +exitE: null +exitW: "[[hallway]]" +exitU: null +exitD: null +items: [] +encounter: "[[rat]]" +--- + +## first-visit +The stair drops into wet stone. The hallway is west. Something at the bottom is breathing. + +## revisit +The stair to the cellar. + +## examined +The stairs are stone, slick with damp. You can hear water below, and something else. diff --git a/src/world/rooms/foyer.md b/src/world/rooms/foyer.md new file mode 100644 index 0000000..cbdd70a --- /dev/null +++ b/src/world/rooms/foyer.md @@ -0,0 +1,23 @@ +--- +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 of a house you do not remember entering. The door behind you has closed without sound. A folded letter lies on a small table. A hallway leads north. + +## revisit +The foyer. The door behind you is still closed. + +## examined +A foyer with peeling paper. A small table holds nothing but the letter. The air smells of cold stone. A hallway leads north. diff --git a/src/world/rooms/hallway.md b/src/world/rooms/hallway.md new file mode 100644 index 0000000..7e094f1 --- /dev/null +++ b/src/world/rooms/hallway.md @@ -0,0 +1,22 @@ +--- +id: hallway +title: "[ Hallway ]" +exitN: null +exitS: "[[foyer]]" +exitE: "[[cellar-stair]]" +exitW: null +exitU: null +exitD: null +items: + - "[[lamp]]" +encounter: null +--- + +## first-visit +A long hallway, lit by nothing. An iron oil lamp sits on a side table. The foyer is south. A stair descends east. + +## revisit +The long hallway. + +## examined +The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. The oil lamp is on the side table. From 0523158e611b4a2d5ae7b5d79ee71cdc9ab6d219 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:27:39 -0500 Subject: [PATCH 09/15] test(mystery): round-trip verification of migrated markdown Co-Authored-By: Claude Sonnet 4.6 --- src/world/roundtrip.test.ts | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/world/roundtrip.test.ts diff --git a/src/world/roundtrip.test.ts b/src/world/roundtrip.test.ts new file mode 100644 index 0000000..6d9368f --- /dev/null +++ b/src/world/roundtrip.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { parseRoom, parseItem, parseEnding, parseEncounterNarration } from './loader' +import { rooms } from './rooms' +import { items } from './items' +import { encounters } from './encounters' +import { endings } from './story' + +const roomFiles = import.meta.glob('./rooms/*.md', { + eager: true, query: '?raw', import: 'default', +}) +const itemFiles = import.meta.glob('./items/*.md', { + eager: true, query: '?raw', import: 'default', +}) +const endingFiles = import.meta.glob('./endings/*.md', { + eager: true, query: '?raw', import: 'default', +}) +const encounterFiles = import.meta.glob('./encounters/*.md', { + eager: true, query: '?raw', import: 'default', +}) + +describe('round-trip: rooms', () => { + it('parses each room file back to the original Room', () => { + for (const [path, raw] of Object.entries(roomFiles)) { + const parsed = parseRoom(raw, path) + const original = rooms[parsed.id] + expect(original, `room ${parsed.id} missing in source TS`).toBeDefined() + expect(parsed).toEqual(original) + } + }) +}) + +describe('round-trip: items', () => { + it('parses each item file back to the original Item', () => { + for (const [path, raw] of Object.entries(itemFiles)) { + const parsed = parseItem(raw, path) + const original = items[parsed.id] + expect(original, `item ${parsed.id} missing in source TS`).toBeDefined() + expect(parsed).toEqual(original) + } + }) +}) + +describe('round-trip: endings', () => { + it('parses each ending file back to the original ending', () => { + for (const [path, raw] of Object.entries(endingFiles)) { + const { id, ending } = parseEnding(raw, path) + const original = endings[id] + expect(ending.whenFlags).toEqual(original.whenFlags) + expect(ending.narration).toEqual(original.narration) + } + }) +}) + +describe('round-trip: encounters narration', () => { + it('captures every inline narration string', () => { + for (const [path, raw] of Object.entries(encounterFiles)) { + const doc = parseEncounterNarration(raw, path) + const original = encounters[doc.id] + expect(original).toBeDefined() + for (const [phaseName, phase] of Object.entries(original.phases)) { + expect(doc.narrations[phaseName], `phase ${phaseName} narration missing`).toBe(phase.description) + } + const allNarrations = new Set(Object.values(doc.narrations)) + for (const phase of Object.values(original.phases)) { + for (const t of phase.transitions) { + expect(allNarrations.has(t.narration), `transition narration missing: "${t.narration}"`).toBe(true) + } + } + } + }) +}) From c0c1a7e9304d36f153d6b104f317ed597a13ec73 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:34:27 -0500 Subject: [PATCH 10/15] feat(mystery): assemble World from markdown via import.meta.glob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rooms, items, and endings now come from .md files under world/{rooms,items,endings}/. Encounters still come from encounters.ts (Tasks 11–12 will complete that leg). Cross-reference validation at module init ensures exits, item refs, and encounter refs are all consistent. Deletes rooms.ts, items.ts, roundtrip.test.ts, and the one-shot migration script (whose output is already committed). Co-Authored-By: Claude Sonnet 4.6 --- src/world/buildWorld.test.ts | 50 ++++++++++++++++++++ src/world/index.ts | 91 ++++++++++++++++++++++++++++++++++-- src/world/items.ts | 28 ----------- src/world/rooms.ts | 44 ----------------- src/world/roundtrip.test.ts | 71 ---------------------------- 5 files changed, 137 insertions(+), 147 deletions(-) create mode 100644 src/world/buildWorld.test.ts delete mode 100644 src/world/items.ts delete mode 100644 src/world/rooms.ts delete mode 100644 src/world/roundtrip.test.ts diff --git a/src/world/buildWorld.test.ts b/src/world/buildWorld.test.ts new file mode 100644 index 0000000..1493574 --- /dev/null +++ b/src/world/buildWorld.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { world } from './index' + +describe('assembled world', () => { + it('contains all three rooms', () => { + expect(Object.keys(world.rooms).sort()).toEqual(['cellar-stair', 'foyer', 'hallway']) + }) + + it('contains all three items', () => { + expect(Object.keys(world.items).sort()).toEqual(['lamp', 'letter', 'matches']) + }) + + it('all room exits resolve to known rooms', () => { + for (const room of Object.values(world.rooms)) { + for (const dest of Object.values(room.exits)) { + expect(world.rooms[dest], `${room.id} → ${dest}`).toBeDefined() + } + } + }) + + it('all room item refs resolve to known items', () => { + for (const room of Object.values(world.rooms)) { + for (const itemId of room.items) { + expect(world.items[itemId], `${room.id} item ${itemId}`).toBeDefined() + } + } + }) + + it('all room encounter refs resolve to known encounters', () => { + for (const room of Object.values(world.rooms)) { + if (room.encounter) { + expect(world.encounters[room.encounter]).toBeDefined() + } + } + }) + + it('startingRoom is a known room', () => { + expect(world.rooms[world.startingRoom]).toBeDefined() + }) + + it('startingInventory items are known', () => { + for (const itemId of world.startingInventory) { + expect(world.items[itemId]).toBeDefined() + } + }) + + it('endings have non-empty narration where the original did', () => { + expect(world.endings.true.narration.length).toBeGreaterThan(0) + }) +}) diff --git a/src/world/index.ts b/src/world/index.ts index a0118ba..9e0acab 100644 --- a/src/world/index.ts +++ b/src/world/index.ts @@ -1,8 +1,91 @@ -import type { World } from './types' -import { rooms } from './rooms' -import { items } from './items' +import type { World, Room, Item } from './types' +import { + parseRoom, + parseItem, + parseEnding, + parseEncounterNarration, +} from './loader' +// Importing loader (above) triggers auto-registration of encounter narrations. +// ESM evaluates dependencies first, so by the time encounters.ts is evaluated below, +// narration() can resolve all keys. import { encounters } from './encounters' -import { endings } from './story' + +const roomFiles = import.meta.glob('./rooms/*.md', { + eager: true, query: '?raw', import: 'default', +}) +const itemFiles = import.meta.glob('./items/*.md', { + eager: true, query: '?raw', import: 'default', +}) +const endingFiles = import.meta.glob('./endings/*.md', { + eager: true, query: '?raw', import: 'default', +}) +const encounterFiles = import.meta.glob('./encounters/*.md', { + eager: true, query: '?raw', import: 'default', +}) + +// Re-parse encounter docs here so we can validate startsIn / initialPhase against encounters.ts. +// (The loader already auto-registered narrations from these same files at module init.) +const encounterDocs = Object.entries(encounterFiles).map(([path, raw]) => + parseEncounterNarration(raw, path), +) + +// Build rooms map. +const rooms: Record = {} +for (const [path, raw] of Object.entries(roomFiles)) { + const room = parseRoom(raw, path) + if (rooms[room.id]) throw new Error(`${path}: duplicate room id "${room.id}"`) + rooms[room.id] = room +} + +// Build items map. +const items: Record = {} +for (const [path, raw] of Object.entries(itemFiles)) { + const item = parseItem(raw, path) + if (items[item.id]) throw new Error(`${path}: duplicate item id "${item.id}"`) + items[item.id] = item +} + +// Build endings. +const endings: World['endings'] = { + true: { whenFlags: {}, narration: '' }, + wrong: { whenFlags: {}, narration: '' }, + bad: { whenFlags: {}, narration: '' }, +} +for (const [path, raw] of Object.entries(endingFiles)) { + const { id, ending } = parseEnding(raw, path) + endings[id] = ending +} + +// Cross-reference validation. +for (const room of Object.values(rooms)) { + for (const [dir, dest] of Object.entries(room.exits)) { + if (!rooms[dest!]) { + throw new Error(`rooms/${room.id}.md: exit${dir.toUpperCase()} references "${dest}" but no such room exists.`) + } + } + for (const itemId of room.items) { + if (!items[itemId]) { + throw new Error(`rooms/${room.id}.md: items[] references unknown item "${itemId}"`) + } + } + if (room.encounter && !encounters[room.encounter]) { + throw new Error(`rooms/${room.id}.md: encounter "${room.encounter}" is not defined`) + } +} + +// Validate encounter narration registry: every encounter in TS has a markdown doc. +for (const enc of Object.values(encounters)) { + const doc = encounterDocs.find(d => d.id === enc.id) + if (!doc) { + throw new Error(`encounters/${enc.id}.md: missing narration markdown for encounter "${enc.id}"`) + } + if (doc.startsIn !== enc.startsIn) { + throw new Error(`encounters/${enc.id}.md: startsIn "${doc.startsIn}" does not match encounters.ts "${enc.startsIn}"`) + } + if (doc.initialPhase !== enc.initialPhase) { + throw new Error(`encounters/${enc.id}.md: initialPhase "${doc.initialPhase}" does not match encounters.ts "${enc.initialPhase}"`) + } +} export const world: World = { startingRoom: 'foyer', diff --git a/src/world/items.ts b/src/world/items.ts deleted file mode 100644 index 43b5915..0000000 --- a/src/world/items.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { Item } from './types' - -export const items: Record = { - matches: { - id: 'matches', - names: ['matches', 'safety matches', 'box'], - short: 'a box of safety matches', - long: 'A small cardboard box of safety matches. Half-full.', - initialState: {}, - takeable: true, - }, - lamp: { - id: 'lamp', - names: ['lamp', 'oil lamp', 'torch'], - short: 'an oil lamp', - long: 'An iron oil lamp with a glass chimney. Currently unlit.', - initialState: { lit: false }, - takeable: true, - }, - letter: { - id: 'letter', - names: ['letter', 'folded letter', 'paper'], - short: 'a folded letter', - long: 'A folded letter on yellowed paper. The hand is unfamiliar. It reads: "Come at once. The thing in the cellar is waking."', - initialState: {}, - takeable: true, - }, -} diff --git a/src/world/rooms.ts b/src/world/rooms.ts deleted file mode 100644 index 06efd65..0000000 --- a/src/world/rooms.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Room } from './types' - -export const rooms: Record = { - foyer: { - id: 'foyer', - title: '[ Foyer ]', - descriptions: { - firstVisit: - 'You stand in the foyer of a house you do not remember entering. The door behind you has closed without sound. A folded letter lies on a small table. A hallway leads north.', - revisit: 'The foyer. The door behind you is still closed.', - examined: - 'A foyer with peeling paper. A small table holds nothing but the letter. The air smells of cold stone. A hallway leads north.', - }, - exits: { n: 'hallway' }, - items: ['letter'], - safe: true, - }, - hallway: { - id: 'hallway', - title: '[ Hallway ]', - descriptions: { - firstVisit: - 'A long hallway, lit by nothing. An iron oil lamp sits on a side table. The foyer is south. A stair descends east.', - revisit: 'The long hallway.', - examined: - 'The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. The oil lamp is on the side table.', - }, - exits: { s: 'foyer', e: 'cellar-stair' }, - items: ['lamp'], - }, - 'cellar-stair': { - id: 'cellar-stair', - title: '[ Cellar Stair ]', - descriptions: { - firstVisit: - 'The stair drops into wet stone. The hallway is west. Something at the bottom is breathing.', - revisit: 'The stair to the cellar.', - examined: 'The stairs are stone, slick with damp. You can hear water below, and something else.', - }, - exits: { w: 'hallway' }, - items: [], - encounter: 'rat', - }, -} diff --git a/src/world/roundtrip.test.ts b/src/world/roundtrip.test.ts deleted file mode 100644 index 6d9368f..0000000 --- a/src/world/roundtrip.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { parseRoom, parseItem, parseEnding, parseEncounterNarration } from './loader' -import { rooms } from './rooms' -import { items } from './items' -import { encounters } from './encounters' -import { endings } from './story' - -const roomFiles = import.meta.glob('./rooms/*.md', { - eager: true, query: '?raw', import: 'default', -}) -const itemFiles = import.meta.glob('./items/*.md', { - eager: true, query: '?raw', import: 'default', -}) -const endingFiles = import.meta.glob('./endings/*.md', { - eager: true, query: '?raw', import: 'default', -}) -const encounterFiles = import.meta.glob('./encounters/*.md', { - eager: true, query: '?raw', import: 'default', -}) - -describe('round-trip: rooms', () => { - it('parses each room file back to the original Room', () => { - for (const [path, raw] of Object.entries(roomFiles)) { - const parsed = parseRoom(raw, path) - const original = rooms[parsed.id] - expect(original, `room ${parsed.id} missing in source TS`).toBeDefined() - expect(parsed).toEqual(original) - } - }) -}) - -describe('round-trip: items', () => { - it('parses each item file back to the original Item', () => { - for (const [path, raw] of Object.entries(itemFiles)) { - const parsed = parseItem(raw, path) - const original = items[parsed.id] - expect(original, `item ${parsed.id} missing in source TS`).toBeDefined() - expect(parsed).toEqual(original) - } - }) -}) - -describe('round-trip: endings', () => { - it('parses each ending file back to the original ending', () => { - for (const [path, raw] of Object.entries(endingFiles)) { - const { id, ending } = parseEnding(raw, path) - const original = endings[id] - expect(ending.whenFlags).toEqual(original.whenFlags) - expect(ending.narration).toEqual(original.narration) - } - }) -}) - -describe('round-trip: encounters narration', () => { - it('captures every inline narration string', () => { - for (const [path, raw] of Object.entries(encounterFiles)) { - const doc = parseEncounterNarration(raw, path) - const original = encounters[doc.id] - expect(original).toBeDefined() - for (const [phaseName, phase] of Object.entries(original.phases)) { - expect(doc.narrations[phaseName], `phase ${phaseName} narration missing`).toBe(phase.description) - } - const allNarrations = new Set(Object.values(doc.narrations)) - for (const phase of Object.values(original.phases)) { - for (const t of phase.transitions) { - expect(allNarrations.has(t.narration), `transition narration missing: "${t.narration}"`).toBe(true) - } - } - } - }) -}) From 1b992642ec5032642268f9dc1dc9be24fb596dbf Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:35:41 -0500 Subject: [PATCH 11/15] feat(mystery): encounters.ts uses narration() helper for prose --- src/world/encounters.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/world/encounters.ts b/src/world/encounters.ts index 5d646b7..f387898 100644 --- a/src/world/encounters.ts +++ b/src/world/encounters.ts @@ -1,4 +1,5 @@ import type { EncounterDef } from './types' +import { narration } from './loader' export const encounters: Record = { rat: { @@ -7,17 +8,17 @@ export const encounters: Record = { initialPhase: 'lurking', phases: { lurking: { - description: 'A heavy rat watches you from the third step. Its eyes catch the light.', + description: narration('rat', 'lurking'), transitions: [ { verb: 'attack', target: 'rat', - narration: 'You stamp. The rat squeals and is gone into the dark.', + narration: narration('rat', 'attack-rat-resolved'), to: 'resolved', }, { verb: 'wait', - narration: 'The rat does not move. Neither do you.', + narration: narration('rat', 'wait-stays'), to: 'lurking', }, ], From 506e36b80188cb3b6663aaaeb1f534805a451915 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:36:28 -0500 Subject: [PATCH 12/15] refactor(mystery): remove story.ts; endings live in markdown --- src/world/story.ts | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 src/world/story.ts diff --git a/src/world/story.ts b/src/world/story.ts deleted file mode 100644 index 5fdcf9e..0000000 --- a/src/world/story.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { World } from './types' - -export const endings: World['endings'] = { - true: { - whenFlags: { ratGone: true }, - narration: - 'You stand at the top of the stair. The thing below has settled. The door behind you opens, and outside, finally, is morning.', - }, - wrong: { - whenFlags: {}, - narration: '', // unreachable in sample world - }, - bad: { - whenFlags: {}, - narration: '', // unreachable in sample world - }, -} From 20619cec0954bb9218b5f1e5ca9099b877714dfe Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:43:53 -0500 Subject: [PATCH 13/15] chore(mystery): commit minimal Obsidian vault config; ignore workspace cache --- src/world/.obsidian/app.json | 6 ++++++ src/world/.obsidian/graph.json | 11 +++++++++++ 2 files changed, 17 insertions(+) create mode 100644 src/world/.obsidian/app.json create mode 100644 src/world/.obsidian/graph.json diff --git a/src/world/.obsidian/app.json b/src/world/.obsidian/app.json new file mode 100644 index 0000000..796a37e --- /dev/null +++ b/src/world/.obsidian/app.json @@ -0,0 +1,6 @@ +{ + "alwaysUpdateLinks": true, + "newLinkFormat": "shortest", + "useMarkdownLinks": false, + "attachmentFolderPath": "_attachments" +} diff --git a/src/world/.obsidian/graph.json b/src/world/.obsidian/graph.json new file mode 100644 index 0000000..ae91038 --- /dev/null +++ b/src/world/.obsidian/graph.json @@ -0,0 +1,11 @@ +{ + "showTags": false, + "showAttachments": false, + "showOrphans": true, + "collapse-filter": false, + "collapse-color-groups": false, + "collapse-display": false, + "collapse-forces": false, + "lineSizeMultiplier": 1, + "nodeSizeMultiplier": 1.2 +} From 4b8ebafe6fd7402c6f7886f21fb7a1c0ecfd60e5 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 09:52:21 -0500 Subject: [PATCH 14/15] fix(mystery): swap gray-matter for yaml package (browser-safe) gray-matter eagerly loads Node's Buffer API path even when only matter(rawString) is called, crashing browser bundles. Replace it with an inline frontmatter parser backed by the browser-safe yaml package. All 84 mystery tests pass; build is clean. Co-Authored-By: Claude Sonnet 4.6 --- src/world/loader.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/world/loader.ts b/src/world/loader.ts index 0f17b63..22d7cd2 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -1,4 +1,23 @@ -import matter from 'gray-matter' +import { parse as parseYaml } from 'yaml' + +interface ParsedFile { + data: Record + content: string +} + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/ + +function matter(raw: string): ParsedFile { + const match = raw.match(FRONTMATTER_RE) + if (!match) { + return { data: {}, content: raw } + } + const yamlSrc = match[1] ?? '' + const content = match[2] ?? '' + const parsed = parseYaml(yamlSrc) + const data = (parsed && typeof parsed === 'object' ? parsed : {}) as Record + return { data, content } +} import type { Room, RoomDescriptions, Item } from './types' import type { Direction } from '../engine/types' import { roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema, encounterFrontmatterSchema } from './schema' From 1f472402fd6180e9abb04f8ad164f66e45acf198 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 11:12:26 -0500 Subject: [PATCH 15/15] fix(mystery): code-review followups (locked-exit, endings, headers) - Validate lockedExits[*].requires resolves to a known item or flag - Throw if any of true/wrong/bad ending markdown files are missing - Detect malformed ## headers (spaces, dots, etc.) and throw a clear error rather than silently dropping the section Tests: 86 passing. Co-Authored-By: Claude Opus 4.7 --- src/world/index.ts | 33 +++++++++++++++++++++++++ src/world/loader.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++ src/world/loader.ts | 17 +++++++++++-- 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/world/index.ts b/src/world/index.ts index 9e0acab..fc38f95 100644 --- a/src/world/index.ts +++ b/src/world/index.ts @@ -51,12 +51,31 @@ const endings: World['endings'] = { wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' }, } +const seenEndings = new Set() for (const [path, raw] of Object.entries(endingFiles)) { const { id, ending } = parseEnding(raw, path) endings[id] = ending + seenEndings.add(id) +} +const requiredEndings = ['true', 'wrong', 'bad'] as const +for (const id of requiredEndings) { + if (!seenEndings.has(id)) { + throw new Error(`endings/${id}.md is missing — every ending id must have a markdown file.`) + } } // Cross-reference validation. +// Build set of all known flag names from encounter setFlags and ending whenFlags. +const knownFlags = new Set() +for (const enc of Object.values(encounters)) { + if (enc.onResolved?.setFlags) { + for (const flagName of Object.keys(enc.onResolved.setFlags)) knownFlags.add(flagName) + } +} +for (const ending of Object.values(endings)) { + for (const flagName of Object.keys(ending.whenFlags)) knownFlags.add(flagName) +} + for (const room of Object.values(rooms)) { for (const [dir, dest] of Object.entries(room.exits)) { if (!rooms[dest!]) { @@ -71,6 +90,20 @@ for (const room of Object.values(rooms)) { if (room.encounter && !encounters[room.encounter]) { throw new Error(`rooms/${room.id}.md: encounter "${room.encounter}" is not defined`) } + if (room.lockedExits) { + for (const [dir, lock] of Object.entries(room.lockedExits)) { + const isItem = items[lock.requires] !== undefined + const isFlag = knownFlags.has(lock.requires) + if (!isItem && !isFlag) { + const knownItemList = Object.keys(items).join(', ') || '(none)' + const knownFlagList = [...knownFlags].join(', ') || '(none)' + throw new Error( + `rooms/${room.id}.md: exit${dir.toUpperCase()}Requires "${lock.requires}" matches no known item or flag. ` + + `Known items: ${knownItemList}. Known flags: ${knownFlagList}.`, + ) + } + } + } } // Validate encounter narration registry: every encounter in TS has a markdown doc. diff --git a/src/world/loader.test.ts b/src/world/loader.test.ts index 0d8d04f..90e39f2 100644 --- a/src/world/loader.test.ts +++ b/src/world/loader.test.ts @@ -310,3 +310,55 @@ describe('narration registry', () => { expect(() => narration('ghost', 'whatever')).toThrow(/unknown encounter id "ghost"/i) }) }) + +describe('parseRoom invalid headers', () => { + it('throws a clear error when a header has spaces', () => { + const md = `--- +id: r +title: "[ R ]" +exitN: null +exitS: null +exitE: null +exitW: null +exitU: null +exitD: null +items: [] +--- + +## first visit +This is the first visit. +## revisit +. +## examined +. +` + expect(() => parseRoom(md, 'rooms/r.md')).toThrow( + /invalid section header "## first visit".*letters, digits, hyphens/, + ) + }) + + it('throws a clear error when a header has dots', () => { + const md = `--- +id: r +title: "[ R ]" +exitN: null +exitS: null +exitE: null +exitW: null +exitU: null +exitD: null +items: [] +--- + +## v2.0 +. +## first-visit +. +## revisit +. +## examined +. +` + expect(() => parseRoom(md, 'rooms/r.md')).toThrow(/invalid section header "## v2\.0"/) + }) +}) diff --git a/src/world/loader.ts b/src/world/loader.ts index 22d7cd2..75880bb 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -40,13 +40,26 @@ function stripWikilink(value: unknown): unknown { function splitSections(body: string): Record { const sections: Record = {} - // Section names use only [A-Za-z0-9_-]; headers with spaces or dots are silently skipped. + // First pass: detect ANY ## line that doesn't match the strict pattern. + // Section names use only [a-zA-Z0-9_-]; headers with spaces or other characters + // would silently fail without this check. + const looseHeader = /^##[ \t]+(.+?)[ \t]*$/gm + const strictHeader = /^([\w-]+)$/ + for (const m of body.matchAll(looseHeader)) { + const headerText = m[1]! + if (!strictHeader.test(headerText)) { + throw new Error( + `invalid section header "## ${headerText}": section names must contain only letters, digits, hyphens, and underscores`, + ) + } + } + // Second pass: extract sections (re-runs strict regex; same result as before). 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 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() }