Files
halfstreet/docs/superpowers/specs/2026-05-09-mystery-markdown-migration-design.md
T
ejlewis 1a283076ac docs(mystery): spec for markdown content migration
Move rooms, items, encounter narration, and endings from TypeScript
object literals to markdown files editable in Obsidian. Engine code,
tests, and the World shape are unchanged. Tonal refinement is a
separate spec that follows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 08:36:38 -05:00

325 lines
12 KiB
Markdown

# Mystery — Markdown Content Migration
**Date:** 2026-05-09
**Scope:** Move all Halfstreet game content (rooms, items, encounter narration, endings) from TypeScript object literals into markdown files editable in Obsidian. Engine code, tests, and the public game shape are unchanged.
This spec is a format migration. No prose is rewritten. Tonal refinement is a separate spec that comes after.
---
## Why
The current source of truth for game content lives in TypeScript object literals at `src/mystery/world/{rooms,items,encounters,story}.ts`. Authoring prose inside escaped string literals fights every aesthetic instinct: no markdown preview, no Obsidian links, no graph view, no easy cross-referencing between rooms.
Halfstreet has roughly twenty-five rooms, fifteen encounters, ten items, and five endings, all of which still need their final prose written. Writing that volume of text inside `.ts` files is the wrong tool. Markdown files in an Obsidian vault give the author proper prose tooling and let the house's structure surface as a graph.
---
## File Layout
```
src/mystery/world/
index.ts # assembles markdown into the World object (existing export, same shape)
types.ts # existing typed shapes, unchanged
schema.ts # NEW: Zod schemas for runtime validation
loader.ts # NEW: parse one markdown file into a typed object
rooms/ # one file per room
foyer.md
hallway.md
...
items/ # one file per item
lamp.md
...
encounters/ # narration only; state machine stays in TS
rat.md
basilisk.md
...
encounters.ts # state machine; references narration by id via narration() helper
endings/ # one file per ending
true.md
wrong.md
bad.md
mercy.md
replacement.md
story.ts # endings index + flag definitions; references endings/*.md by id
```
The Obsidian vault opens on `src/mystery/world/`. The bible at `docs/superpowers/specs/halfstreet-bible.md` stays put as a separate planning document and a separate vault.
---
## Frontmatter Conventions
### Wikilinks for cross-references
Any frontmatter field that points to another markdown file uses a quoted Obsidian wikilink.
```yaml
exit_n: "[[hallway]]"
items:
- "[[letter]]"
encounter: "[[rat]]"
```
The quotes are required. `[[hallway]]` unquoted parses as a nested YAML list. The loader strips `[[ ]]` to recover the plain id at parse time.
Plain strings — flag names, phase names, narration keys — stay plain. They don't have their own files.
### Flat exits
Every room declares all six directions, with `null` for absent exits:
```yaml
exit_n: "[[hallway]]"
exit_s: null
exit_e: null
exit_w: null
exit_u: null
exit_d: null
```
Locked exits extend the flat pattern. The unlocked-exit form is `exit_<dir>: "[[room]]"`. A locked exit adds two sibling fields, present only when the exit is locked:
```yaml
exit_d: "[[vault]]"
exit_d_requires: "[[rusted-key]]"
exit_d_locked_text: The door is locked.
```
`exit_<dir>_requires` may also reference a flag name (plain string, no wikilink) instead of an item.
---
## Section Conventions
The markdown body holds prose. Sections are introduced by `## key-name` headers; the header text is the section key, lowercase with hyphens.
### Rooms
Three required sections per room: `first-visit`, `revisit`, `examined`.
```md
---
id: foyer
title: "[ Foyer ]"
exit_n: "[[hallway]]"
exit_s: null
exit_e: null
exit_w: null
exit_u: null
exit_d: null
items:
- "[[letter]]"
encounter: null
safe: true
---
## first-visit
You stand in the foyer of a house you do not remember entering. The door behind you has closed without sound. A folded letter lies on a small table. A hallway leads north.
## revisit
The foyer. The door behind you is still closed.
## examined
A foyer with peeling paper. A small table holds nothing but the letter. The air smells of cold stone. A hallway leads north.
```
### Items
`short` is one line in frontmatter. The body is the long description. State and metadata live in frontmatter.
```md
---
id: lamp
names: [lamp, oil lamp]
short: An iron oil lamp.
takeable: true
initial_state:
lit: false
---
An iron oil lamp, heavy enough to swing if it came to it. The wick is dry. The reservoir sloshes faintly.
```
**Frontmatter naming convention:** all keys are `snake_case`. The loader maps to `camelCase` TS field names (`initial_state``initialState`, `starts_in``startsIn`, `when_flags``whenFlags`). Consumers in engine code see the existing camelCase shape unchanged.
### Endings
One file per ending. Conditions in frontmatter, single prose body.
```md
---
id: true
when_flags:
woofReturned: true
basiliskSpared: true
houseAcceptedYou: true
---
You stand in the vault. What is buried at Halfstreet is buried because it was loved, and grieved, and finally let go.
You set the lamp beside it.
You speak the name aloud.
The house settles around you like a long exhalation.
Outside, the road exists again.
```
---
## Encounter Narration: Hybrid Shape
Encounter state machines stay in TypeScript at `src/mystery/world/encounters.ts`. Encounter prose moves to `encounters/<id>.md`. The TS code references narration by `(encounterId, key)` through a `narration()` helper.
`encounters/basilisk.md`:
```md
---
id: basilisk
starts_in: "[[chapel]]"
initial_phase: sleeping
---
## sleeping
Something large is coiled beneath the altar.
You become aware of the eye first.
Not glowing. Merely open.
## resolved
The eye closes. The creature withdraws beneath the chapel stones.
## failed-direct-look
You meet its eye too long. Something in you gives.
```
`encounters.ts`:
```ts
export const encounters: Record<EncounterId, EncounterDef> = {
basilisk: {
id: 'basilisk',
startsIn: 'chapel', // mirrored from frontmatter; loader checks they match
initialPhase: 'sleeping',
phases: {
sleeping: {
description: narration('basilisk', 'sleeping'),
transitions: [
{
verb: 'pour',
target: 'basilisk',
requires: { item: 'silver-vial' },
to: 'resolved',
narration: narration('basilisk', 'resolved'),
},
],
},
},
onFailed: {
narration: narration('basilisk', 'failed-direct-look'),
retreatTo: 'chapel',
},
},
}
```
`narration(encounterId, key)` looks up a parsed section from the loaded markdown at world-build time and returns the prose string. If the section is missing, the loader throws.
---
## Loader and Validation
`world/loader.ts` parses one markdown file into a typed object. It uses `gray-matter` for frontmatter, splits the body into sections by `## key` headers, recursively strips `[[ ]]` from frontmatter values, and runs the result through a Zod schema from `world/schema.ts`.
`world/index.ts` uses Vite's `import.meta.glob` to load all markdown files synchronously at module init:
```ts
const roomFiles = import.meta.glob('./rooms/*.md', { eager: true, query: '?raw', import: 'default' })
```
The index then assembles a single `World` value by validating each file and resolving cross-references. The `world` export shape is unchanged — engine code and tests continue importing `{ world } from '../world'` without modification.
### Validation passes
After all files are parsed, `buildWorld` runs cross-reference checks and throws on the first failure with a precise message:
- Every `exit_<dir>` destination resolves to a known room id.
- Every `exit_<dir>_requires` resolves to a known item id or a declared flag name.
- Every room `items[]` entry resolves to a known item id.
- Every room `encounter` resolves to a known encounter id.
- Every encounter `starts_in` resolves to a known room id, and matches the encounter's `startsIn` in `encounters.ts`.
- Every `narration(id, key)` call in `encounters.ts` finds a matching `## key` section in `encounters/<id>.md`.
- Every `## key` section in encounter markdown is referenced by at least one `narration()` call (catches orphaned prose).
- Every required room section (`first-visit`, `revisit`, `examined`) is present.
- Every ending `when_flags` entry uses a flag name declared in `story.ts`.
Example failure messages:
```
rooms/parlor.md: exit_n references "[[hallwayy]]" but no such room exists. Did you mean "hallway"?
encounters.ts: narration('basilisk', 'sleping') has no matching section in encounters/basilisk.md. Available: sleeping, resolved, failed-direct-look.
rooms/foyer.md: missing required section "## first-visit".
```
### Schema source of truth
Zod schemas in `world/schema.ts` validate at runtime. The static types in `world/types.ts` remain authoritative for engine consumers — keeping engine code free of any dependency on Zod. The two are kept in sync by hand. (Single source of truth via `z.infer` is possible later but is not part of this migration.)
---
## Migration Script
`scripts/migrate-mystery-content.ts` runs once. It imports the existing world via the current TypeScript modules and emits one markdown file per object with the agreed frontmatter and body shape, wikilinks applied to cross-references, prose copied byte-for-byte.
Hand-converting twenty-five-plus files invites dropped fields and silent prose loss. The script guarantees every existing string lands somewhere.
The script is committed alongside its output. It may be deleted in a follow-up commit, or kept for reference.
---
## Cutover
One PR, ordered:
1. Add `gray-matter` to `package.json` dependencies.
2. Add `world/loader.ts` and `world/schema.ts`.
3. Add the `narration()` helper to `world/loader.ts`. The helper is defined but unused until step 5.
4. Run the migration script. Commit the produced markdown files.
5. Refactor `world/index.ts` to assemble from markdown via `import.meta.glob`. Delete `world/rooms.ts` and `world/items.ts`. Refactor `world/encounters.ts` to call `narration()` in place of its inline strings; delete the inline narrations (now in `encounters/*.md`). Refactor `world/story.ts` to reference `endings/*.md`.
6. Run the existing test suite. `playthrough.test.ts`, `encounters.test.ts`, `dispatcher.test.ts`, `chips.test.ts` should pass unchanged because the `World` shape is identical.
7. Manually walk the game in dev mode to confirm no prose drift.
The diff for step 5 should show one-for-one prose moves only. Any byte-level deviation is a migration bug, not an intentional rewrite.
---
## Dev Experience
Editing any `.md` file in Obsidian triggers Vite HMR and the browser reloads with new prose. No build step. Validation errors surface in the browser overlay during HMR and fail CI on commit.
---
## Obsidian Vault
The vault opens on `src/mystery/world/`. With wikilinks in place, the graph view shows rooms connected by exits, items linked to the rooms that hold them, and encounters linked to their starting rooms — a literal map of the house and its inhabitants.
A minimal `.obsidian/` directory is committed for shared graph and tag-pane settings. Workspace-local files (`workspace.json`, cache files, plugin state) are gitignored.
---
## Out of Scope
- **Tonal refinement.** This migration preserves every existing string verbatim. Authoring richer prose into the new markdown files is a separate spec that follows immediately after.
- **Bible reorganization.** The bible currently duplicates structural data (room summaries, item lists) that becomes redundant once the markdown files exist. Slimming the bible to voice, themes, and high-level structure is a follow-on task tied to the tonal refinement spec.
- **Single-source-of-truth types.** `types.ts` and `schema.ts` remain hand-synced. Deriving one from the other is a possible later cleanup.
- **Astro content collections.** The mystery game is client-side bundled; using `import.meta.glob` keeps the loader self-contained and avoids restructuring the page hydration path. Astro content collections may be a fit later if mystery content ever needs server-rendered routes.
---
## Follow-on Risks
The bible will become stale once room prose lives in markdown. The author has flagged that the markdown files are the working surface and the bible will need ongoing maintenance to stay current. The tonal refinement spec should treat the bible's room descriptors as deprecated upon successful migration, and propose either deleting them or auto-generating a digest from the markdown.