From 2ad81f356ad7c93de485dc100f8dc77cf250a494 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 08:50:58 -0500 Subject: [PATCH] docs(mystery): implementation plan for markdown content migration 14-task plan covering: gray-matter+zod setup, schemas, four pure parsers (room/item/ending/encounter), narration() helper with auto-registration, one-shot migration script, round-trip verification, world assembly cutover, encounters.ts refactor, manual playthrough, and Obsidian vault config. Co-Authored-By: Claude Opus 4.7 --- .../2026-05-09-mystery-markdown-migration.md | 1828 +++++++++++++++++ 1 file changed, 1828 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-09-mystery-markdown-migration.md diff --git a/docs/superpowers/plans/2026-05-09-mystery-markdown-migration.md b/docs/superpowers/plans/2026-05-09-mystery-markdown-migration.md new file mode 100644 index 0000000..0f97a7f --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-mystery-markdown-migration.md @@ -0,0 +1,1828 @@ +# Mystery Markdown Migration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate Halfstreet game content (rooms, items, encounter narration, endings) from TypeScript object literals to markdown files editable in Obsidian, with no behavioral change to the game. + +**Architecture:** Markdown files live under `src/mystery/world/{rooms,items,encounters,endings}/`. Each file has YAML frontmatter for structural data (camelCase keys, wikilinks for cross-references) and section-headered prose body. A pure-string loader (`world/loader.ts`) parses one file at a time. `world/index.ts` discovers files via Vite's `import.meta.glob` (eager, raw query), assembles the typed `World` value with cross-reference validation, and exports it. Engine and tests keep importing `{ world } from '../world'` unchanged. + +**Tech Stack:** TypeScript, Astro 6 (Vite under the hood), Vitest, gray-matter for frontmatter, Zod for runtime validation. + +**Spec:** `docs/superpowers/specs/2026-05-09-mystery-markdown-migration-design.md` + +--- + +## File Structure + +**New files:** +- `src/mystery/world/schema.ts` — Zod schemas for Room, Item, EncounterNarrationDoc, Ending +- `src/mystery/world/loader.ts` — pure string-in / typed-object-out parsers, plus `narration()` helper and registry +- `src/mystery/world/loader.test.ts` — TDD coverage for loader +- `src/mystery/world/buildWorld.test.ts` — cross-reference validation tests +- `src/mystery/world/rooms/foyer.md` +- `src/mystery/world/rooms/hallway.md` +- `src/mystery/world/rooms/cellar-stair.md` +- `src/mystery/world/items/matches.md` +- `src/mystery/world/items/lamp.md` +- `src/mystery/world/items/letter.md` +- `src/mystery/world/encounters/rat.md` +- `src/mystery/world/endings/true.md` +- `src/mystery/world/endings/wrong.md` +- `src/mystery/world/endings/bad.md` +- `src/mystery/world/.obsidian/app.json` — minimal vault config +- `scripts/migrate-mystery-content.ts` — one-shot migration script (deleted after run, but committed once) + +**Modified files:** +- `src/mystery/world/index.ts` — assemble World from markdown +- `src/mystery/world/encounters.ts` — use `narration()` references instead of inline strings +- `src/mystery/world/story.ts` — load endings prose from markdown +- `package.json` — add `gray-matter`, `zod` deps +- `.gitignore` — ignore Obsidian workspace cache + +**Deleted files:** +- `src/mystery/world/rooms.ts` +- `src/mystery/world/items.ts` + +**Unchanged files (verification only):** +- `src/mystery/world/types.ts` +- `src/mystery/engine/**` (all) +- `src/mystery/ui/**` (all) +- All existing test files + +--- + +## Task 1: Setup — dependencies and raw markdown smoke test + +**Files:** +- Modify: `package.json` +- Create: `src/mystery/world/__smoke__/smoke.md` +- Create: `src/mystery/world/__smoke__/smoke.test.ts` + +Verify the technical approach works in this Astro+Vite+Vitest setup before building anything on top of it: can we (a) install gray-matter and zod, (b) load a markdown file's raw contents via `import.meta.glob` with `?raw` in a Vitest test? If either fails, we need to know now. + +- [ ] **Step 1: Install dependencies** + +```bash +npm install gray-matter zod +``` + +Expected: `package.json` updated with both deps; `package-lock.json` updated; no errors. + +- [ ] **Step 2: Verify deps appear in package.json** + +```bash +grep -E '"(gray-matter|zod)"' package.json +``` + +Expected: two lines, both with version specifiers. + +- [ ] **Step 3: Create a smoke-test markdown fixture** + +Create `src/mystery/world/__smoke__/smoke.md` with literally: + +```md +--- +id: smoke +--- + +## body +hello +``` + +- [ ] **Step 4: Create a smoke test that loads it raw and parses frontmatter** + +Create `src/mystery/world/__smoke__/smoke.test.ts`: + +```ts +import { describe, it, expect } from 'vitest' +import matter from 'gray-matter' + +const files = import.meta.glob('./*.md', { + eager: true, + query: '?raw', + import: 'default', +}) + +describe('raw markdown smoke test', () => { + it('loads the smoke file as a raw string', () => { + const entries = Object.entries(files) + expect(entries.length).toBe(1) + const [, raw] = entries[0] + expect(typeof raw).toBe('string') + expect(raw).toContain('## body') + }) + + it('parses frontmatter with gray-matter', () => { + const [, raw] = Object.entries(files)[0] + const parsed = matter(raw) + expect(parsed.data).toEqual({ id: 'smoke' }) + expect(parsed.content.trim().startsWith('## body')).toBe(true) + }) +}) +``` + +- [ ] **Step 5: Run the smoke test** + +```bash +npm test -- src/mystery/world/__smoke__/smoke.test.ts +``` + +Expected: PASS (2 tests). If FAIL, stop and report the error before proceeding — the architecture needs revision. + +- [ ] **Step 6: Delete the smoke test directory** + +```bash +rm -rf src/mystery/world/__smoke__ +``` + +Smoke test was a one-off verification; the real loader tests come in later tasks. + +- [ ] **Step 7: Commit** + +```bash +git add package.json package-lock.json +git commit -m "feat(mystery): add gray-matter and zod for markdown content pipeline" +``` + +--- + +## Task 2: Zod schemas for content shapes + +**Files:** +- Create: `src/mystery/world/schema.ts` +- Test: `src/mystery/world/schema.test.ts` + +Zod schemas validate parsed content at runtime. Field names match the existing TypeScript shapes (camelCase). Schemas accept the *post-wikilink-stripped* form, since the loader strips `[[ ]]` before validating. + +- [ ] **Step 1: Write failing tests** + +Create `src/mystery/world/schema.test.ts`: + +```ts +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() + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npm test -- src/mystery/world/schema.test.ts +``` + +Expected: FAIL — module `./schema` does not exist. + +- [ ] **Step 3: Write the schemas** + +Create `src/mystery/world/schema.ts`: + +```ts +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 +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npm test -- src/mystery/world/schema.test.ts +``` + +Expected: PASS (8 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mystery/world/schema.ts src/mystery/world/schema.test.ts +git commit -m "feat(mystery): add Zod schemas for markdown frontmatter" +``` + +--- + +## Task 3: parseRoom — markdown string to typed Room + +**Files:** +- Create: `src/mystery/world/loader.ts` +- Create: `src/mystery/world/loader.test.ts` + +Pure function: `(rawMarkdown, sourcePath) → Room`. Parses frontmatter, strips wikilinks, splits sections by `## key` headers, validates with Zod, assembles into a `Room` matching `world/types.ts`. + +The `Room.descriptions` shape (`firstVisit`, `revisit`, `examined`) requires us to map section keys: markdown `## first-visit` → TS `firstVisit`. Hyphen-to-camel for the three required sections. + +The `Room.exits` shape is the existing `Partial>`. Frontmatter has six flat fields; we collapse them into the existing nested shape on parse. + +The `Room.lockedExits` shape similarly collapses sibling fields. + +- [ ] **Step 1: Write failing tests** + +Create `src/mystery/world/loader.test.ts`: + +```ts +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.' }, + }) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npm test -- src/mystery/world/loader.test.ts +``` + +Expected: FAIL — module `./loader` does not export `parseRoom`. + +- [ ] **Step 3: Implement parseRoom** + +Create `src/mystery/world/loader.ts`: + +```ts +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 +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npm test -- src/mystery/world/loader.test.ts +``` + +Expected: PASS (5 tests). + +- [ ] **Step 5: Commit** + +```bash +git add src/mystery/world/loader.ts src/mystery/world/loader.test.ts +git commit -m "feat(mystery): parseRoom — markdown to typed Room" +``` + +--- + +## Task 4: parseItem — markdown string to typed Item + +**Files:** +- Modify: `src/mystery/world/loader.ts` +- Modify: `src/mystery/world/loader.test.ts` + +Item markdown has `short` in frontmatter and the *long* description as the entire body (no section headers). Frontmatter holds `names`, `takeable`, `initialState`. + +- [ ] **Step 1: Append failing tests to loader.test.ts** + +Add to the bottom of `src/mystery/world/loader.test.ts`: + +```ts +import { parseItem } from './loader' + +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) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npm test -- src/mystery/world/loader.test.ts +``` + +Expected: FAIL — `parseItem` is not exported. + +- [ ] **Step 3: Append parseItem to loader.ts** + +Add to `src/mystery/world/loader.ts`: + +```ts +import type { Item } from './types' +import { itemFrontmatterSchema } from './schema' + +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, + } +} +``` + +(Merge the new `import` lines with the existing imports at the top of the file.) + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npm test -- src/mystery/world/loader.test.ts +``` + +Expected: PASS (8 tests total). + +- [ ] **Step 5: Commit** + +```bash +git add src/mystery/world/loader.ts src/mystery/world/loader.test.ts +git commit -m "feat(mystery): parseItem — markdown to typed Item" +``` + +--- + +## Task 5: parseEnding — markdown string to typed Ending + +**Files:** +- Modify: `src/mystery/world/loader.ts` +- Modify: `src/mystery/world/loader.test.ts` + +Ending markdown has `id` and `whenFlags` in frontmatter; the body is the narration prose. The `World['endings']` type is `{ true: {...}, wrong: {...}, bad: {...} }` where each entry has `whenFlags` and `narration`. `parseEnding` returns `{ id, ending: { whenFlags, narration } }` so the assembler can build the keyed record. + +- [ ] **Step 1: Append failing tests** + +Add to `src/mystery/world/loader.test.ts`: + +```ts +import { parseEnding } from './loader' + +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('') + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npm test -- src/mystery/world/loader.test.ts +``` + +Expected: FAIL — `parseEnding` is not exported. + +- [ ] **Step 3: Append parseEnding to loader.ts** + +```ts +import { endingFrontmatterSchema } from './schema' + +export interface ParsedEnding { + id: 'true' | 'wrong' | 'bad' + ending: { whenFlags: Record; narration: string } +} + +export function parseEnding(raw: string, sourcePath: string): ParsedEnding { + const parsed = matter(raw) + const fm = endingFrontmatterSchema.parse(parsed.data) + return { + id: fm.id, + ending: { whenFlags: fm.whenFlags, narration: parsed.content.trim() }, + } +} +``` + +(Merge schema import with existing imports.) + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npm test -- src/mystery/world/loader.test.ts +``` + +Expected: PASS (10 tests total). + +- [ ] **Step 5: Commit** + +```bash +git add src/mystery/world/loader.ts src/mystery/world/loader.test.ts +git commit -m "feat(mystery): parseEnding — markdown to typed Ending" +``` + +--- + +## Task 6: parseEncounterNarration — markdown string to phase/transition narration map + +**Files:** +- Modify: `src/mystery/world/loader.ts` +- Modify: `src/mystery/world/loader.test.ts` + +Encounter markdown holds *only* prose — phase descriptions and transition narrations. The state machine stays in TypeScript. The body has `## key` sections. Each section's prose is a narration string keyed by header text. + +`parseEncounterNarration(raw, sourcePath)` returns `{ id, startsIn, initialPhase, narrations: Record }`. + +- [ ] **Step 1: Append failing tests** + +```ts +import { parseEncounterNarration } from './loader' + +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) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npm test -- src/mystery/world/loader.test.ts +``` + +Expected: FAIL — `parseEncounterNarration` not exported. + +- [ ] **Step 3: Append parseEncounterNarration to loader.ts** + +```ts +import { encounterFrontmatterSchema } from './schema' + +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, + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npm test -- src/mystery/world/loader.test.ts +``` + +Expected: PASS (12 tests total). + +- [ ] **Step 5: Commit** + +```bash +git add src/mystery/world/loader.ts src/mystery/world/loader.test.ts +git commit -m "feat(mystery): parseEncounterNarration — phase and transition prose" +``` + +--- + +## Task 7: narration() helper and registry + +**Files:** +- Modify: `src/mystery/world/loader.ts` +- Modify: `src/mystery/world/loader.test.ts` + +`encounters.ts` will reference narration like `narration('rat', 'attack-resolved')` and get back the prose string. The helper looks up a registry that the world index initializes from parsed encounter docs. + +The registry is a module-level `Map>` populated by `registerEncounterNarrations(docs)`. `narration(id, key)` reads from it. Throws if the encounter or key is missing, with available keys listed. + +- [ ] **Step 1: Append failing tests** + +```ts +import { narration, registerEncounterNarrations, _resetEncounterNarrationRegistry } from './loader' + +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) + }) +}) +``` + +(Add `import { beforeEach } from 'vitest'` to the existing vitest import line.) + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +npm test -- src/mystery/world/loader.test.ts +``` + +Expected: FAIL — none of the registry exports exist yet. + +- [ ] **Step 3: Append registry and auto-registration to loader.ts** + +```ts +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(): void { + encounterNarrationRegistry.clear() + 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. +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() +``` + +**Note on `_resetEncounterNarrationRegistry`:** it both clears manually-registered test data AND re-runs the auto-registration. Tests that want a fully empty registry should call `encounterNarrationRegistry.clear()` directly via the registry's escape hatch — but no current test needs this. The reset-then-auto-reregister behavior keeps tests close to production state. + +For the existing Task 7 tests to still pass after auto-registration is added, `_resetEncounterNarrationRegistry` must produce an empty registry from the test's perspective. Adjust the implementation to take an optional flag: + +```ts +export function _resetEncounterNarrationRegistry(autoReregister: boolean = false): void { + encounterNarrationRegistry.clear() + if (autoReregister) autoRegisterEncounters() +} +``` + +The Task 7 tests pass no argument, getting a clean registry. World assembly tests can pass `true` to reset to production state. (No test currently does this; the option exists for future use.) + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +npm test -- src/mystery/world/loader.test.ts +``` + +Expected: PASS (15 tests total). + +- [ ] **Step 5: Commit** + +```bash +git add src/mystery/world/loader.ts src/mystery/world/loader.test.ts +git commit -m "feat(mystery): narration() helper and encounter narration registry" +``` + +--- + +## Task 8: Migration script + +**Files:** +- Create: `scripts/migrate-mystery-content.ts` + +One-shot script. Reads existing world data, writes markdown files to `src/mystery/world/{rooms,items,encounters,endings}/`. Run with `npx tsx scripts/migrate-mystery-content.ts` (or `node --import tsx/esm` depending on setup; verify in step 2). + +The script must produce byte-identical prose strings — the migration's correctness depends on this. + +For the encounter, the script invents narration *keys* for each transition: + +- `'attack' → 'resolved'` becomes key `attack-resolved` +- `'wait' → 'lurking'` becomes key `wait-stays` (loop back to same phase; we use `stays` to disambiguate from a hypothetical `wait-resolved`) +- Phase descriptions use the phase name as the key (e.g. `lurking`) + +The script logs each file it writes; a no-op rerun is acceptable (it overwrites with identical content). + +- [ ] **Step 1: Write the script** + +Create `scripts/migrate-mystery-content.ts`: + +```ts +import { writeFileSync, mkdirSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { rooms } from '../src/mystery/world/rooms' +import { items } from '../src/mystery/world/items' +import { encounters } from '../src/mystery/world/encounters' +import { endings } from '../src/mystery/world/story' +import type { Direction } from '../src/mystery/engine/types' + +const ROOT = resolve(process.cwd(), 'src/mystery/world') +const DIRS: Direction[] = ['n', 's', 'e', 'w', 'u', 'd'] +const DIR_FIELD: Record = { n: 'N', s: 'S', e: 'E', w: 'W', u: 'U', d: 'D' } + +function write(path: string, content: string): void { + const abs = resolve(ROOT, path) + mkdirSync(dirname(abs), { recursive: true }) + writeFileSync(abs, content, 'utf8') + console.log(`wrote ${path}`) +} + +function wikilink(id: string): string { + return `"[[${id}]]"` +} + +function yamlList(items: string[]): string { + if (items.length === 0) return '[]' + return '\n' + items.map(s => ` - ${wikilink(s)}`).join('\n') +} + +function emitRoom(room: typeof rooms[string]): string { + const lines: string[] = [] + lines.push('---') + lines.push(`id: ${room.id}`) + lines.push(`title: "${room.title}"`) + for (const dir of DIRS) { + const dest = room.exits[dir] + lines.push(`exit${DIR_FIELD[dir]}: ${dest ? wikilink(dest) : 'null'}`) + const locked = room.lockedExits?.[dir] + if (locked) { + lines.push(`exit${DIR_FIELD[dir]}Requires: ${wikilink(locked.requires)}`) + lines.push(`exit${DIR_FIELD[dir]}LockedText: ${JSON.stringify(locked.lockedNarration)}`) + } + } + lines.push(`items:${yamlList(room.items)}`) + lines.push(`encounter: ${room.encounter ? wikilink(room.encounter) : 'null'}`) + if (room.safe) lines.push(`safe: true`) + lines.push('---') + lines.push('') + lines.push('## first-visit') + lines.push(room.descriptions.firstVisit) + lines.push('') + lines.push('## revisit') + lines.push(room.descriptions.revisit) + lines.push('') + lines.push('## examined') + lines.push(room.descriptions.examined) + lines.push('') + return lines.join('\n') +} + +function emitItem(item: typeof items[string]): string { + const lines: string[] = [] + lines.push('---') + lines.push(`id: ${item.id}`) + lines.push(`names: [${item.names.map(n => JSON.stringify(n)).join(', ')}]`) + lines.push(`short: ${JSON.stringify(item.short)}`) + lines.push(`takeable: ${item.takeable}`) + if (Object.keys(item.initialState).length > 0) { + lines.push('initialState:') + for (const [k, v] of Object.entries(item.initialState)) { + lines.push(` ${k}: ${JSON.stringify(v)}`) + } + } + lines.push('---') + lines.push('') + lines.push(item.long) + lines.push('') + return lines.join('\n') +} + +function transitionKey(verb: string, target: string | undefined, to: string): string { + // verb-resolved | verb-target-resolved | verb-stays | verb-target-newPhase + const parts = [verb] + if (target && target !== '*') parts.push(target) + if (to === 'resolved') parts.push('resolved') + else if (to === 'failed') parts.push('failed') + else parts.push(to) + return parts.join('-') +} + +function emitEncounter(enc: typeof encounters[string]): { md: string; keyMap: Record } { + const lines: string[] = [] + lines.push('---') + lines.push(`id: ${enc.id}`) + lines.push(`startsIn: ${wikilink(enc.startsIn)}`) + lines.push(`initialPhase: ${enc.initialPhase}`) + lines.push('---') + lines.push('') + const keyMap: Record = {} + // Phase descriptions + for (const [phaseName, phase] of Object.entries(enc.phases)) { + lines.push(`## ${phaseName}`) + lines.push(phase.description) + lines.push('') + keyMap[`phase:${phaseName}`] = phaseName + for (const t of phase.transitions) { + // Loop-back transitions (to === current phase) use a 'stays' suffix to disambiguate + const effectiveTo = t.to === phaseName ? 'stays' : t.to + const key = transitionKey(t.verb, t.target, effectiveTo) + lines.push(`## ${key}`) + lines.push(t.narration) + lines.push('') + keyMap[`transition:${phaseName}:${t.verb}:${t.target ?? ''}:${t.to}`] = key + } + } + if (enc.onFailed?.narration) { + const key = `failed` + lines.push(`## ${key}`) + lines.push(enc.onFailed.narration) + lines.push('') + keyMap['onFailed'] = key + } + return { md: lines.join('\n'), keyMap } +} + +function emitEnding(id: string, e: { whenFlags: Record; narration: string }): string { + const lines: string[] = [] + lines.push('---') + lines.push(`id: ${id}`) + if (Object.keys(e.whenFlags).length === 0) { + lines.push('whenFlags: {}') + } else { + lines.push('whenFlags:') + for (const [k, v] of Object.entries(e.whenFlags)) { + lines.push(` ${k}: ${JSON.stringify(v)}`) + } + } + lines.push('---') + lines.push('') + if (e.narration) lines.push(e.narration) + lines.push('') + return lines.join('\n') +} + +// Run +for (const room of Object.values(rooms)) { + write(`rooms/${room.id}.md`, emitRoom(room)) +} +for (const item of Object.values(items)) { + write(`items/${item.id}.md`, emitItem(item)) +} +const encounterKeyMaps: Record> = {} +for (const enc of Object.values(encounters)) { + const { md, keyMap } = emitEncounter(enc) + write(`encounters/${enc.id}.md`, md) + encounterKeyMaps[enc.id] = keyMap +} +for (const [id, e] of Object.entries(endings)) { + write(`endings/${id}.md`, emitEnding(id, e)) +} +console.log('\nEncounter key maps (use these in encounters.ts narration() calls):') +console.log(JSON.stringify(encounterKeyMaps, null, 2)) +``` + +- [ ] **Step 2: Verify the script runs by checking package scripts and tsx availability** + +The project uses `node >=22.12.0`. Node 22 supports `--experimental-strip-types` for direct `.ts` execution. Try that first: + +```bash +node --experimental-strip-types scripts/migrate-mystery-content.ts +``` + +If that fails (e.g. missing tsx/ts-node and strip-types doesn't handle ESM imports of `.ts` from `src/`), install tsx as a dev dep and retry: + +```bash +npm install --save-dev tsx +npx tsx scripts/migrate-mystery-content.ts +``` + +Expected output: a list of `wrote rooms/foyer.md` lines and an `Encounter key maps:` JSON block at the end. Save the JSON block — Task 11 needs it. + +- [ ] **Step 3: Verify produced files exist** + +```bash +ls src/mystery/world/rooms src/mystery/world/items src/mystery/world/encounters src/mystery/world/endings +``` + +Expected: +- `rooms/`: `foyer.md`, `hallway.md`, `cellar-stair.md` +- `items/`: `matches.md`, `lamp.md`, `letter.md` +- `encounters/`: `rat.md` +- `endings/`: `true.md`, `wrong.md`, `bad.md` + +- [ ] **Step 4: Commit script and produced markdown** + +```bash +git add scripts/migrate-mystery-content.ts \ + src/mystery/world/rooms \ + src/mystery/world/items \ + src/mystery/world/encounters \ + src/mystery/world/endings \ + package.json package-lock.json +git commit -m "feat(mystery): migration script and produced markdown content" +``` + +--- + +## Task 9: Round-trip verification — parse produced markdown back to objects + +**Files:** +- Create: `src/mystery/world/roundtrip.test.ts` + +Before wiring the markdown into the live world, prove the produced files round-trip correctly: parse each `.md` file with the new loader and verify the result deep-equals the original TypeScript data. If this passes, the migration is byte-correct and we can safely cut over. + +- [ ] **Step 1: Write the round-trip test** + +Create `src/mystery/world/roundtrip.test.ts`: + +```ts +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() + // Phase descriptions + for (const [phaseName, phase] of Object.entries(original.phases)) { + expect(doc.narrations[phaseName], `phase ${phaseName} narration missing`).toBe(phase.description) + } + // Transition narrations: every original narration string must appear somewhere in doc.narrations.values() + 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) + } + } + } + }) +}) +``` + +- [ ] **Step 2: Run the test** + +```bash +npm test -- src/mystery/world/roundtrip.test.ts +``` + +Expected: PASS (4 tests). If anything fails, the migration script (Task 8) has a bug — fix it there, regenerate the markdown, and re-run. + +- [ ] **Step 3: Commit** + +```bash +git add src/mystery/world/roundtrip.test.ts +git commit -m "test(mystery): round-trip verification of migrated markdown" +``` + +--- + +## Task 10: Switch world/index.ts to assemble from markdown + +**Files:** +- Modify: `src/mystery/world/index.ts` +- Create: `src/mystery/world/buildWorld.test.ts` +- Delete: `src/mystery/world/rooms.ts` +- Delete: `src/mystery/world/items.ts` +- Delete: `src/mystery/world/roundtrip.test.ts` (no longer meaningful once `rooms.ts`/`items.ts` are gone) + +The index now uses `import.meta.glob` to load markdown files, parses each, registers encounter narrations, validates cross-references, and exposes `world` with the existing `World` shape. + +The endings narrations come from markdown but the TS `endings` export retains its current shape (the engine reads `world.endings.true.narration` as a string). We override the narrations on the existing `endings` object using `parseEnding` outputs. + +The encounters export keeps its TS state machine shape (Task 11 adds `narration()` calls); for now, the index continues to import `encounters` from `./encounters.ts` as-is. The `narration()` registry must be initialized *before* `encounters.ts` is evaluated. Achieve this by initializing the registry at the top of `index.ts` and importing `encounters` lazily (after registration). + +Cross-reference validation runs at module load. If any check fails, the import throws — Vite's HMR overlay shows the error in dev; CI fails on the test suite. + +- [ ] **Step 1: Write cross-reference validation tests** + +Create `src/mystery/world/buildWorld.test.ts`: + +```ts +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) + }) +}) +``` + +- [ ] **Step 2: Run the test to confirm it fails (or passes for wrong reasons)** + +```bash +npm test -- src/mystery/world/buildWorld.test.ts +``` + +Expected: PASS — the existing world (loaded via TS data) already satisfies these. The test exists to lock in correctness for the post-migration assembly. + +- [ ] **Step 3: Replace src/mystery/world/index.ts** + +```ts +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' + +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). +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 = { + true: { whenFlags: {} as Record, narration: '' }, + wrong: { whenFlags: {} as Record, narration: '' }, + bad: { whenFlags: {} as Record, 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`) + } + if (room.lockedExits) { + for (const [, lock] of Object.entries(room.lockedExits)) { + // requires may be an item id or a flag name; only validate if it matches a known item shape + // (flag names cannot be cross-checked here without a flag registry; left as future work) + if (items[lock.requires] === undefined) { + // Not a known item — assume flag name. Skip. + } + } + } +} + +// 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', + startingInventory: ['matches'], + rooms, + items, + encounters, + endings, +} +``` + +- [ ] **Step 4: Delete obsolete data files** + +```bash +rm src/mystery/world/rooms.ts src/mystery/world/items.ts src/mystery/world/roundtrip.test.ts +``` + +The round-trip test referenced the deleted modules; its job is done. + +- [ ] **Step 5: Run all mystery tests** + +```bash +npm test -- src/mystery +``` + +Expected: PASS for all suites (`buildWorld`, `loader`, `schema`, plus the existing `playthrough`, `chips`, `dispatcher`, `encounters` tests). If any existing test fails, the world assembly has drifted from the original — investigate before committing. + +- [ ] **Step 6: Run astro check (type safety)** + +```bash +npm run build +``` + +Expected: build succeeds. If type errors surface, fix them before committing. + +- [ ] **Step 7: Commit** + +```bash +git add src/mystery/world/index.ts src/mystery/world/buildWorld.test.ts +git rm src/mystery/world/rooms.ts src/mystery/world/items.ts src/mystery/world/roundtrip.test.ts +git commit -m "feat(mystery): assemble World from markdown via import.meta.glob" +``` + +--- + +## Task 11: Refactor encounters.ts to use narration() helper + +**Files:** +- Modify: `src/mystery/world/encounters.ts` + +Replace inline narration strings with `narration('rat', 'key')` calls. The keys come from the migration script's output JSON (printed in Task 8 step 2). For the rat encounter, those keys are `lurking`, `attack-resolved`, `wait-stays`. + +- [ ] **Step 1: Replace encounters.ts contents** + +```ts +import type { EncounterDef } from './types' +import { narration } from './loader' + +export const encounters: Record = { + rat: { + id: 'rat', + startsIn: 'cellar-stair', + initialPhase: 'lurking', + phases: { + lurking: { + description: narration('rat', 'lurking'), + transitions: [ + { + verb: 'attack', + target: 'rat', + narration: narration('rat', 'attack-resolved'), + to: 'resolved', + }, + { + verb: 'wait', + narration: narration('rat', 'wait-stays'), + to: 'lurking', + }, + ], + }, + }, + onResolved: { setFlags: { ratGone: true } }, + defaultWrongVerbNarration: 'The rat watches.', + }, +} +``` + +`defaultWrongVerbNarration` is a one-off and not currently in the markdown. We'll either move it to the markdown in a follow-up, or leave it here. Leaving it: it's not a piece of prose the author needs to revise often. + +- [ ] **Step 2: Run all mystery tests** + +```bash +npm test -- src/mystery +``` + +Expected: PASS. The narrations resolve to the same strings they did before (Task 9 verified this), so tests asserting specific narration text will still pass. + +- [ ] **Step 3: Run build** + +```bash +npm run build +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add src/mystery/world/encounters.ts +git commit -m "feat(mystery): encounters.ts uses narration() helper for prose" +``` + +--- + +## Task 12: Refactor story.ts to use endings markdown + +**Files:** +- Modify: `src/mystery/world/story.ts` + +`story.ts` becomes a near-empty file. The endings narration and flag conditions now come from markdown via `world/index.ts`. We keep `story.ts` to host any non-ending story constants if/when we add them, but for now it's a minimal stub. The simplest move is to delete it. + +Check whether anything still imports from `./story`: + +- [ ] **Step 1: Find imports of story.ts** + +```bash +grep -rn "from.*['\"].*world/story" src/ scripts/ 2>/dev/null +``` + +If only `scripts/migrate-mystery-content.ts` imports it (which is now historical) and the new `world/index.ts` does NOT, story.ts can be deleted. The migration script imported `endings` from story.ts; the script is one-shot and its job is done. We can either delete the script or leave it as a historical artifact — leave it for one cycle, delete in a follow-up commit. + +Expected output: a small list. If `world/index.ts` no longer references it (it shouldn't, per Task 10), proceed with deletion. + +- [ ] **Step 2: Delete story.ts** + +```bash +rm src/mystery/world/story.ts +``` + +- [ ] **Step 3: Run build to ensure nothing else broke** + +```bash +npm run build +``` + +Expected: PASS. + +- [ ] **Step 4: Run all mystery tests** + +```bash +npm test -- src/mystery +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git rm src/mystery/world/story.ts +git commit -m "refactor(mystery): remove story.ts; endings live in markdown" +``` + +--- + +## Task 13: Manual playthrough verification + +**Files:** none + +Type-checking and tests verify code correctness, not feature correctness. The mystery is a UI-driven game; we have to actually play it. Walk the golden path: arrive in the foyer, read the letter, take the lamp, descend, encounter the rat, attack, reach the ending. + +- [ ] **Step 1: Start dev server** + +```bash +npm run dev +``` + +Expected: Astro dev server starts, prints a localhost URL. + +- [ ] **Step 2: Open the mystery in a browser** + +Navigate to `http://localhost:4321/mystery` (port may differ; use what Astro printed). + +- [ ] **Step 3: Verify initial render** + +Expected: terminal-style UI shows `[ Foyer ]` title and the foyer's first-visit prose. Inventory contains `matches`. + +- [ ] **Step 4: Walk the golden path** + +Type each command and verify the response prose matches what the game produced before the migration. The expected strings are exactly the originals from `rooms.ts`/`items.ts`/`encounters.ts` (now in the markdown files). + +``` +> read letter +> take lamp +> n +> e +> attack rat +``` + +Expected ending: "You stand at the top of the stair. The thing below has settled..." + +- [ ] **Step 5: Test edge interactions** + +``` +> look +> inventory +> wait +> examine letter +> drop lamp +``` + +Each should respond with prose identical to pre-migration behavior. + +- [ ] **Step 6: Stop the dev server (Ctrl-C) and record findings** + +If anything is wrong, stop. Do NOT commit further until the bug is found and fixed. The most likely source of drift is whitespace/newline handling in the loader — multi-paragraph `firstVisit` strings, in particular. + +If everything works, no commit needed for this task — manual verification is its own confirmation. + +--- + +## Task 14: Obsidian vault config and gitignore + +**Files:** +- Create: `src/mystery/world/.obsidian/app.json` +- Create: `src/mystery/world/.obsidian/graph.json` +- Modify: `.gitignore` + +Minimal Obsidian config so the vault opens with sensible defaults (graph view enabled, attachments folder configured) and workspace-local cache files don't pollute git. + +- [ ] **Step 1: Create app.json** + +`src/mystery/world/.obsidian/app.json`: + +```json +{ + "alwaysUpdateLinks": true, + "newLinkFormat": "shortest", + "useMarkdownLinks": false, + "attachmentFolderPath": "_attachments" +} +``` + +- [ ] **Step 2: Create graph.json with a useful default view** + +`src/mystery/world/.obsidian/graph.json`: + +```json +{ + "showTags": false, + "showAttachments": false, + "showOrphans": true, + "collapse-filter": false, + "collapse-color-groups": false, + "collapse-display": false, + "collapse-forces": false, + "lineSizeMultiplier": 1, + "nodeSizeMultiplier": 1.2 +} +``` + +- [ ] **Step 3: Update .gitignore** + +Append to the project's existing `.gitignore` (create the file if it doesn't exist): + +``` +# Obsidian workspace cache (per-machine, not for source control) +src/mystery/world/.obsidian/workspace.json +src/mystery/world/.obsidian/workspace-mobile.json +src/mystery/world/.obsidian/workspace.json.bak +src/mystery/world/.obsidian/cache +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/mystery/world/.obsidian/app.json src/mystery/world/.obsidian/graph.json .gitignore +git commit -m "chore(mystery): commit minimal Obsidian vault config; ignore workspace cache" +``` + +--- + +## Done + +Final state: +- All Halfstreet game content for the existing three rooms lives in markdown under `src/mystery/world/{rooms,items,encounters,endings}/`. +- `import { world } from '../world'` returns the same `World` shape as before. +- All existing tests pass unchanged. +- Editing any `.md` file triggers Vite HMR; the browser reloads with new prose. +- Obsidian opens the vault and renders the wikilinks as graph edges. + +The follow-on spec — tonal refinement — can now author additional rooms and revise existing prose entirely in Obsidian.