# 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.