Merge feature: mystery markdown content migration

Move Halfstreet game content (rooms, items, encounter narration, endings)
from TypeScript object literals into markdown files editable in Obsidian.
Engine, UI, and the public World type are unchanged. Three-room prototype
verified end-to-end via tests and manual playthrough.

Includes code-review followups: locked-exit requires validation,
endings-completeness check, and clear errors for malformed section headers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 11:13:38 -05:00
22 changed files with 1049 additions and 96 deletions
+6
View File
@@ -0,0 +1,6 @@
{
"alwaysUpdateLinks": true,
"newLinkFormat": "shortest",
"useMarkdownLinks": false,
"attachmentFolderPath": "_attachments"
}
+11
View File
@@ -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
}
+50
View File
@@ -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)
})
})
+4 -3
View File
@@ -1,4 +1,5 @@
import type { EncounterDef } from './types' import type { EncounterDef } from './types'
import { narration } from './loader'
export const encounters: Record<string, EncounterDef> = { export const encounters: Record<string, EncounterDef> = {
rat: { rat: {
@@ -7,17 +8,17 @@ export const encounters: Record<string, EncounterDef> = {
initialPhase: 'lurking', initialPhase: 'lurking',
phases: { phases: {
lurking: { lurking: {
description: 'A heavy rat watches you from the third step. Its eyes catch the light.', description: narration('rat', 'lurking'),
transitions: [ transitions: [
{ {
verb: 'attack', verb: 'attack',
target: 'rat', target: 'rat',
narration: 'You stamp. The rat squeals and is gone into the dark.', narration: narration('rat', 'attack-rat-resolved'),
to: 'resolved', to: 'resolved',
}, },
{ {
verb: 'wait', verb: 'wait',
narration: 'The rat does not move. Neither do you.', narration: narration('rat', 'wait-stays'),
to: 'lurking', to: 'lurking',
}, },
], ],
+14
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
id: bad
whenFlags: {}
---
+7
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
id: wrong
whenFlags: {}
---
+120 -4
View File
@@ -1,8 +1,124 @@
import type { World } from './types' import type { World, Room, Item } from './types'
import { rooms } from './rooms' import {
import { items } from './items' 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 { encounters } from './encounters'
import { endings } from './story'
const roomFiles = import.meta.glob<string>('./rooms/*.md', {
eager: true, query: '?raw', import: 'default',
})
const itemFiles = import.meta.glob<string>('./items/*.md', {
eager: true, query: '?raw', import: 'default',
})
const endingFiles = import.meta.glob<string>('./endings/*.md', {
eager: true, query: '?raw', import: 'default',
})
const encounterFiles = import.meta.glob<string>('./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<string, Room> = {}
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<string, Item> = {}
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: '' },
}
const seenEndings = new Set<string>()
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<string>()
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!]) {
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`)
}
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.
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 = { export const world: World = {
startingRoom: 'foyer', startingRoom: 'foyer',
-28
View File
@@ -1,28 +0,0 @@
import type { Item } from './types'
export const items: Record<string, Item> = {
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,
},
}
+10
View File
@@ -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.
+8
View File
@@ -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."
+8
View File
@@ -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.
+364
View File
@@ -0,0 +1,364 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { parseRoom, parseItem, parseEnding, parseEncounterNarration, narration, registerEncounterNarrations, _resetEncounterNarrationRegistry } 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.' },
})
})
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/)
})
})
// 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)
})
})
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('')
})
})
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)
})
})
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)
})
})
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"/)
})
})
+233
View File
@@ -0,0 +1,233 @@
import { parse as parseYaml } from 'yaml'
interface ParsedFile {
data: Record<string, unknown>
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<string, unknown>
return { data, content }
}
import type { Room, RoomDescriptions, Item } from './types'
import type { Direction } from '../engine/types'
import { roomFrontmatterSchema, itemFrontmatterSchema, endingFrontmatterSchema, encounterFrontmatterSchema } 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> = {}
// 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 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 && 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 }
}
}
}
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
}
export function parseItem(raw: string, sourcePath: string): Item {
const parsed = matter(raw)
const frontmatter = stripWikilink(parsed.data) as Record<string, unknown>
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,
}
}
export interface ParsedEnding {
id: 'true' | 'wrong' | 'bad'
ending: { whenFlags: Record<string, string | boolean | number>; 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() },
}
}
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,
}
}
const encounterNarrationRegistry = new Map<string, Map<string, string>>()
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<string>('./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()
-44
View File
@@ -1,44 +0,0 @@
import type { Room } from './types'
export const rooms: Record<string, Room> = {
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',
},
}
+21
View File
@@ -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.
+23
View File
@@ -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.
+22
View File
@@ -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.
+81
View File
@@ -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()
})
})
+57
View File
@@ -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<typeof roomFrontmatterSchema>
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<typeof itemFrontmatterSchema>
export const endingFrontmatterSchema = z.object({
id: z.enum(['true', 'wrong', 'bad']),
whenFlags: stateRecordSchema.default({}),
})
export type EndingFrontmatter = z.infer<typeof endingFrontmatterSchema>
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<typeof encounterFrontmatterSchema>
-17
View File
@@ -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
},
}