# Mystery Engine Prereqs 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:** Land the engine work that the Halfstreet content bible depends on — `read` / `light` / `extinguish` / `use` verb handlers, ambiguous-noun disambiguation, ending-screen UI + flag matching — plus the small "should-fix while you're in there" items, so Phase 2 (full bible content draft) has a complete engine to write against. **Architecture:** Item frontmatter gains a few optional flags (`readable`, `lightable`, `lighter`, `lighterUses`) and the markdown body gains optional `## read` / `## lit` / `## extinguished` / `## lighter-empty` sections. The parser is extended with an `ambiguous` command kind and a `verb-target-prep` form (used for `light X with Y` / `use X on Y`). The dispatcher handles four new verbs and evaluates ending-flag conditions after every turn, setting `state.endedWith` and emitting a `kind: 'ending'` transcript event the UI renders specially. **Tech Stack:** TypeScript, Astro 6 + Vite, Vitest, Zod (already in place), gray-matter substitute (custom `matter()` in `loader.ts`). **Spec:** `docs/superpowers/specs/2026-05-09-mystery-engine-prereqs-design.md` --- ## File Structure **Modified files (in dependency order):** | file | responsibility | |---|---| | `src/engine/types.ts` | Add `'ending'` to `TranscriptLine.kind`; widen `RoomState`/`ItemInstance.state` value union to include `string[]`; add `'ambiguous'` variant to `ParsedCommand`; remove `theme` from `GameState`; remove the `Theme` type re-export from this module's public surface. | | `src/world/types.ts` | Add optional `readable` / `lightable` / `lighter` / `lighterUses` to `Item`; add optional `readableText` / `litText` / `extinguishedText` / `lighterEmptyText` strings carrying the body-section prose for those item modes. | | `src/world/schema.ts` | Add the same optional fields to `itemFrontmatterSchema`. | | `src/world/loader.ts` | `parseItem` now extracts optional `## read` / `## lit` / `## extinguished` / `## lighter-empty` sections from the body (the long description is the unsectioned prose, or the body if no sections present). Validate that `readable: true` requires a `## read` section. | | `src/engine/parser.ts` | Strip `at|the|a|an` stop-words from the noun phrase head. Return `'ambiguous'` instead of `'unknown-noun'` when ≥2 visible aliases match. Detect `with` between two recognized nouns and return `verb-target-prep`. | | `src/engine/encounters.ts` | Accept `verb-target-prep` commands (currently only `verb-target` and `verb-only` are inspected). For prep commands: extract verb + target (the encounter's `requires.item` mechanism gates on the instrument being in inventory; mismatch between the typed instrument and the transition's required item yields fallback narration). | | `src/engine/dispatcher.ts` | Drop the `theme` meta-verb branch. Add `read` / `light` / `extinguish` / `use` handlers. Handle the `ambiguous` ParsedCommand by setting `pendingDisambiguation`. Wire `verb-target-prep` (currently unhandled). After every successful state mutation, evaluate ending flags and (on match) set `endedWith` and append a `'ending'` event. Reject further turns once `endedWith !== null`. | | `src/engine/dispatcher.test.ts` | New tests for read/light/extinguish/use, ambiguous→prompt→reply, ending detection, locked-exit synthetic world. | | `src/engine/parser.test.ts` | Tests for stop-word strip, ambiguous variant, `with` separator. | | `src/world/loader.test.ts` | Tests for new item body sections. | | `src/world/items/lamp.md` | Add `lightable: true`; add `## lit` and `## extinguished` body sections. | | `src/world/items/matches.md` | Add `lighter: true`, `lighterUses: 4`; add `## lighter-empty` section. | | `src/world/items/letter.md` | Add `readable: true`; move/duplicate the readable text into a `## read` section (long description stays as-is). | | `src/ui/terminal.ts` | Render `kind: 'ending'` events with separator + dedicated CSS class. When `state.endedWith !== null`, disable input and render restart-only chip footer. Stop dispatching the `halfstreet-toggle-theme` event from the typed-`theme`-trimmed pathway (the engine no longer knows about themes; just dispatch on the meta-verb shortcut). | | `src/ui/crt.css` | `.ending` block styling: separator above, larger gap, italic or bordered, centered. | | `src/engine/save.ts` | No code change needed (extra `theme` field on old saves is ignored at runtime), but a comment notes the schema-evolution choice. | **No new files.** All work modifies existing modules. **Out of scope:** transcript scrolling, cursor blink rate, line fade, scanline accessibility toggle. Tracked in the spec's section 7. --- ## Task 1: Foundation type changes — TranscriptLine 'ending' kind + RoomState widen **Files:** - Modify: `src/engine/types.ts` These are mechanical type-only changes that everything else builds on. They unblock later tasks but don't yet change runtime behavior. - [ ] **Step 1: Read the current types** ```bash cat src/engine/types.ts ``` Note the existing `TranscriptLine`, `GameState`, `ItemInstance`, and `ParsedCommand` shapes. - [ ] **Step 2: Widen `TranscriptLine.kind` and the state value unions** Edit `src/engine/types.ts`: ```ts export interface TranscriptLine { kind: 'narration' | 'player' | 'system' | 'ending' text: string } ``` And: ```ts export interface ItemInstance { id: ItemId /** Per-instance state: lit/unlit, broken/whole, etc. */ state: Record } ``` And in `GameState`: ```ts roomState: Record> flags: Record ``` - [ ] **Step 3: Verify the rest of the project still type-checks** ```bash npm run build ``` Expected: PASS. The widened union accepts every value the existing code stores; no callers should fail. If there are type errors caused by `as` casts that previously narrowed to `string | boolean | number`, fix them inline by removing the redundant cast. - [ ] **Step 4: Run the full test suite** ```bash npm test ``` Expected: PASS unchanged. - [ ] **Step 5: Commit** ```bash git add src/engine/types.ts git commit -m "$(cat <<'EOF' feat(engine): widen state value unions and add 'ending' transcript kind Drops redundant string[] casts in dispatcher paths and prepares the TranscriptLine kind for the ending-screen render path. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 2: Drop redundant `as string[]` casts in the dispatcher **Files:** - Modify: `src/engine/dispatcher.ts` The widened union from Task 1 makes the casts unnecessary. Removing them is verification that the widening worked; it also removes a future trap where a typo cast wrong-types data. - [ ] **Step 1: Find every `as string[]` in dispatcher.ts** ```bash grep -n "as string\[\]" src/engine/dispatcher.ts ``` Expected: ~3 hits in `getItemsInRoom`, `handleTake`, `handleDrop`. - [ ] **Step 2: Remove the casts where reading** Change: ```ts const dropped = (state.roomState[roomId]?.['droppedItems'] as string[] | undefined) ?? [] const taken = (state.roomState[roomId]?.['takenItems'] as string[] | undefined) ?? [] ``` To: ```ts const dropped = (state.roomState[roomId]?.['droppedItems'] ?? []) as string[] const taken = (state.roomState[roomId]?.['takenItems'] ?? []) as string[] ``` (The trailing cast keeps array operations type-safe; the unsafe widening is gone because the union now includes `string[]`.) Apply the same simplification in `handleTake` (`taken`/`dropped` reads) and `handleDrop` (`dropped` read). - [ ] **Step 3: Drop the value cast in `setRoomFlag`** Currently: ```ts function setRoomFlag(state: GameState, roomId: string, key: string, value: string | boolean | number | string[]): GameState { return { ...state, roomState: { ...state.roomState, [roomId]: { ...(state.roomState[roomId] ?? {}), [key]: value as string | boolean | number }, }, } } ``` Drop the `as string | boolean | number` cast on the value — the inner record now accepts `string[]` natively: ```ts function setRoomFlag(state: GameState, roomId: string, key: string, value: string | boolean | number | string[]): GameState { return { ...state, roomState: { ...state.roomState, [roomId]: { ...(state.roomState[roomId] ?? {}), [key]: value }, }, } } ``` - [ ] **Step 4: Build and test** ```bash npm run build && npm test ``` Expected: PASS unchanged. - [ ] **Step 5: Commit** ```bash git add src/engine/dispatcher.ts git commit -m "$(cat <<'EOF' refactor(engine): drop redundant string[] casts now that RoomState includes arrays Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 3: Remove `theme` from GameState **Files:** - Modify: `src/engine/types.ts` - Modify: `src/engine/dispatcher.ts` - Modify: `src/ui/terminal.ts` - Modify: `src/engine/save.ts` (comment only) - Test: existing tests should still pass; one save-format test may need adjustment. The `theme` field is a UI preference, not game state. Today, clicking `[B]/[C]` updates DOM + localStorage but not `state.theme`, so the next `theme` meta-verb toggles from a stale value. The fix is to delete it from `GameState` entirely. Old saves with the field continue to load (extra fields are ignored). - [ ] **Step 1: Inspect `Theme` usage** ```bash grep -rn "GameState\|state\.theme\|theme: " src/engine/types.ts src/engine/dispatcher.ts src/engine/save.ts src/engine/save.test.ts src/ui/terminal.ts src/ui/theme.ts | head -40 ``` Expected: hits in `types.ts` (field declaration), `dispatcher.ts` (`initialStateFor`, `handleMeta`), `terminal.ts` (none reading `state.theme` directly), `theme.ts` (reads/writes DOM only). - [ ] **Step 2: Remove the field from `GameState`** In `src/engine/types.ts`: ```ts // before transcript: TranscriptLine[] theme: Theme endedWith: 'true' | 'wrong' | 'bad' | null // after transcript: TranscriptLine[] endedWith: 'true' | 'wrong' | 'bad' | null ``` Also delete the `Theme` type alias if no other module imports it. Verify with: ```bash grep -rn "import.*Theme.*from.*engine/types" src/ ``` If only `theme.ts` imports it, change `theme.ts` to declare its own `type Theme = 'amber' | 'ansi'` locally and drop the import. If none, delete the type from `types.ts`. - [ ] **Step 3: Remove `theme` initialization from `initialStateFor`** In `src/engine/dispatcher.ts`, in `initialStateFor`: ```ts // before return { schemaVersion: SCHEMA_VERSION, location: world.startingRoom, inventory, roomState: { [world.startingRoom]: { visited: true } }, flags: {}, resolveLevel: 'steady', encounterState: {}, lastNoun: null, pendingDisambiguation: null, transcript: opening, theme: 'amber', endedWith: null, } // after return { schemaVersion: SCHEMA_VERSION, location: world.startingRoom, inventory, roomState: { [world.startingRoom]: { visited: true } }, flags: {}, resolveLevel: 'steady', encounterState: {}, lastNoun: null, pendingDisambiguation: null, transcript: opening, endedWith: null, } ``` - [ ] **Step 4: Remove the `theme` branch from `handleMeta`** In `dispatcher.ts`: ```ts // before function handleMeta(state: GameState, verb: 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'): DispatchResult { if (verb === 'save') return narrate(state, [{ kind: 'system', text: '(your progress is saved automatically)' }]) if (verb === 'theme') { const newTheme = state.theme === 'amber' ? 'ansi' : 'amber' return narrate({ ...state, theme: newTheme }, [{ kind: 'system', text: `Theme: ${newTheme}.` }]) } return narrate(state, [{ kind: 'system', text: `(${verb})` }]) } // after function handleMeta(state: GameState, verb: 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'): DispatchResult { if (verb === 'save') return narrate(state, [{ kind: 'system', text: '(your progress is saved automatically)' }]) // 'theme' is a UI preference: the terminal intercepts it before dispatch and // dispatches a 'halfstreet-toggle-theme' DOM event. The engine no-ops here so // the typing the verb still produces transcript output if the UI ever misses it. if (verb === 'theme') return narrate(state, [{ kind: 'system', text: '(theme)' }]) return narrate(state, [{ kind: 'system', text: `(${verb})` }]) } ``` - [ ] **Step 5: Add a save-format note** In `src/engine/save.ts`, append a comment near `loadState` so the next person reading it knows extra fields on disk are intentional: ```ts // Older saves may carry fields no longer in GameState (e.g. `theme` before // it became a UI-only preference). TypeScript ignores extra fields at runtime; // bump SCHEMA_VERSION only when the meaning of an existing field changes. return parsed as GameState ``` - [ ] **Step 6: Build and test** ```bash npm run build && npm test ``` Expected: PASS. If a test in `save.test.ts` asserts `theme === 'amber'` on a fresh state, delete that assertion. - [ ] **Step 7: Smoke-test the theme button manually (just visually skim)** ```bash grep -n "halfstreet-toggle-theme" src/ui/ ``` Expected: `terminal.ts` dispatches the event when the player types `theme`; `theme.ts` listens and toggles. No engine state touched. (Manual browser verification deferred to Task 14.) - [ ] **Step 8: Commit** ```bash git add src/engine/types.ts src/engine/dispatcher.ts src/engine/save.ts src/ui/theme.ts git commit -m "$(cat <<'EOF' refactor(engine): theme is a UI preference; remove it from GameState Previously, clicking the theme button updated localStorage and the DOM but not state.theme, so the engine's `theme` meta-verb toggled from stale state. Theme is now exclusively UI/storage concern. Old saves with the field still load; the field is silently ignored. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 4: Parser stop-word strip **Files:** - Modify: `src/engine/parser.ts` - Modify: `src/engine/parser.test.ts` `look at lamp` should resolve to `examine lamp`. Currently fails because the noun phrase `at lamp` doesn't match any alias. Strip leading `at`, `the`, `a`, `an` from the noun token list before noun matching. - [ ] **Step 1: Write failing tests** Append to `src/engine/parser.test.ts`: ```ts describe('stop-word stripping', () => { const ctx: ParserContext = { knownItems: ['lamp'], knownEncounters: [], visibleNouns: [{ id: 'lamp', aliases: ['lamp', 'oil lamp'] }], inventoryItemIds: [], lastNoun: null, awaitingDisambiguation: null, } it('strips a leading "at" from the noun phrase', () => { const cmd = parse('look at lamp', ctx) expect(cmd).toEqual({ kind: 'verb-target', verb: 'look', target: { canonical: 'lamp', raw: 'lamp' }, }) }) it('strips a leading "the"', () => { const cmd = parse('examine the lamp', ctx) expect(cmd.kind).toBe('verb-target') }) it('strips "a" and "an"', () => { expect(parse('take a lamp', ctx).kind).toBe('verb-target') expect(parse('take an oil lamp', ctx).kind).toBe('verb-target') }) it('does not strip stop-words mid-phrase', () => { // 'oil lamp' is the alias; 'oil at lamp' is not. Stop-words only strip from // the head of the noun phrase, not anywhere in the middle. const cmd = parse('take oil lamp', ctx) expect(cmd.kind).toBe('verb-target') }) }) ``` - [ ] **Step 2: Run tests to verify they fail** ```bash npm test -- src/engine/parser.test.ts ``` Expected: FAIL on the `at`/`the`/`a`/`an` tests (`unknown-noun`). - [ ] **Step 3: Implement the strip** In `src/engine/parser.ts`, after `rest = tokens.slice(1)` (or after the two-word verb branch sets `rest`) and before noun matching, add: ```ts const STOP_WORDS = new Set(['at', 'the', 'a', 'an']) while (rest.length > 0 && STOP_WORDS.has(rest[0]!)) { rest = rest.slice(1) } ``` If `rest` becomes empty after stripping and the verb requires a target, fall through to the existing `verb-only` / `unknown` branches (no special handling needed — the existing logic returns `unknown-noun` or `verb-only` correctly). - [ ] **Step 4: Run tests to verify they pass** ```bash npm test -- src/engine/parser.test.ts ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/engine/parser.ts src/engine/parser.test.ts git commit -m "$(cat <<'EOF' feat(parser): strip leading stop-words (at, the, a, an) from noun phrase Allows `look at lamp`, `examine the letter`, `take a key`, `take an oil lamp`. Stop-words are only removed from the head of the noun phrase, not from anywhere in the middle. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 5: Parser ambiguous-noun variant **Files:** - Modify: `src/engine/types.ts` - Modify: `src/engine/parser.ts` - Modify: `src/engine/parser.test.ts` Today, when a noun phrase matches ≥2 visible aliases (e.g. `take key` with both `iron-key` and `brass-key` present), the parser returns `unknown-noun`. Change it to return a dedicated `ambiguous` variant carrying the candidate ids, so the dispatcher can prompt for disambiguation. - [ ] **Step 1: Add the variant to ParsedCommand** In `src/engine/types.ts`: ```ts // before export type ParsedCommand = | { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' } | { kind: 'verb-target'; verb: Verb; target: NounRef } | { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef } | { kind: 'go'; direction: Direction } | { kind: 'meta'; verb: MetaVerb } | { kind: 'disambiguation'; chosen: string } | { kind: 'unknown'; raw: string; reason: 'unknown-verb' | 'unknown-noun' | 'malformed' } // after — add the 'ambiguous' variant export type ParsedCommand = | { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' } | { kind: 'verb-target'; verb: Verb; target: NounRef } | { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef } | { kind: 'ambiguous'; verb: Verb; rawNoun: string; candidates: string[] } | { kind: 'go'; direction: Direction } | { kind: 'meta'; verb: MetaVerb } | { kind: 'disambiguation'; chosen: string } | { kind: 'unknown'; raw: string; reason: 'unknown-verb' | 'unknown-noun' | 'malformed' } ``` - [ ] **Step 2: Write failing parser test** Append to `src/engine/parser.test.ts`: ```ts describe('ambiguous noun', () => { const ctx: ParserContext = { knownItems: ['iron-key', 'brass-key'], knownEncounters: [], visibleNouns: [ { id: 'iron-key', aliases: ['key', 'iron key'] }, { id: 'brass-key', aliases: ['key', 'brass key'] }, ], inventoryItemIds: [], lastNoun: null, awaitingDisambiguation: null, } it('returns ambiguous when two aliases match the same noun phrase', () => { const cmd = parse('take key', ctx) expect(cmd).toEqual({ kind: 'ambiguous', verb: 'take', rawNoun: 'key', candidates: ['iron-key', 'brass-key'], }) }) it('still returns verb-target when the phrase is unambiguous', () => { const cmd = parse('take iron key', ctx) expect(cmd.kind).toBe('verb-target') if (cmd.kind === 'verb-target') expect(cmd.target.canonical).toBe('iron-key') }) }) ``` - [ ] **Step 3: Run tests to verify they fail** ```bash npm test -- src/engine/parser.test.ts ``` Expected: FAIL — the `take key` test currently returns `unknown` reason `'unknown-noun'`. - [ ] **Step 4: Implement the variant** In `src/engine/parser.ts`, replace the multi-candidate branch: ```ts // before if (candidates.length > 1) { return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' } } // after if (candidates.length > 1) { const uniqueIds = [...new Set(candidates.map((c) => c.id))] if (uniqueIds.length === 1) { // Two aliases of the same item — not actually ambiguous. const id = uniqueIds[0]! return { kind: 'verb-target', verb, target: { canonical: id, raw: candidates[0]!.alias } } } return { kind: 'ambiguous', verb, rawNoun: targetRaw, candidates: uniqueIds } } ``` - [ ] **Step 5: Run tests to verify they pass** ```bash npm test -- src/engine/parser.test.ts ``` Expected: PASS. - [ ] **Step 6: Run full suite to ensure no callers broke** ```bash npm test ``` Expected: PASS. The dispatcher will currently treat `ambiguous` as an unhandled variant and fall through to `Nothing happens.` — that's fine; Task 7 wires it up properly. - [ ] **Step 7: Commit** ```bash git add src/engine/types.ts src/engine/parser.ts src/engine/parser.test.ts git commit -m "$(cat <<'EOF' feat(parser): return ambiguous variant when noun matches multiple aliases Replaces the previous behavior of returning unknown-noun. The dispatcher will use this in the next commit to prompt the player to disambiguate. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 6: Parser `with` separator → verb-target-prep **Files:** - Modify: `src/engine/parser.ts` - Modify: `src/engine/parser.test.ts` When the player types `light lamp with matches`, the parser should return: ```ts { kind: 'verb-target-prep', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' }, preposition: 'with', indirect: { canonical: 'matches', raw: 'matches' } } ``` The `verb-target-prep` variant already exists in types.ts; the parser just doesn't produce it yet. - [ ] **Step 1: Write failing tests** Append to `src/engine/parser.test.ts`: ```ts describe('verb-target-prep with "with"', () => { const ctx: ParserContext = { knownItems: ['lamp', 'matches'], knownEncounters: [], visibleNouns: [ { id: 'lamp', aliases: ['lamp'] }, { id: 'matches', aliases: ['matches', 'matchbook'] }, ], inventoryItemIds: ['matches'], lastNoun: null, awaitingDisambiguation: null, } it('parses "light lamp with matches" into verb-target-prep', () => { const cmd = parse('light lamp with matches', ctx) expect(cmd).toEqual({ kind: 'verb-target-prep', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' }, preposition: 'with', indirect: { canonical: 'matches', raw: 'matches' }, }) }) it('parses "use shears on vines" into verb-target-prep', () => { const localCtx: ParserContext = { knownItems: ['shears', 'ivy-figure'], knownEncounters: [], visibleNouns: [ { id: 'shears', aliases: ['shears'] }, { id: 'ivy-figure', aliases: ['vines', 'ivy'] }, ], inventoryItemIds: ['shears'], lastNoun: null, awaitingDisambiguation: null, } const cmd = parse('use shears on vines', localCtx) expect(cmd).toEqual({ kind: 'verb-target-prep', verb: 'use', target: { canonical: 'shears', raw: 'shears' }, preposition: 'on', indirect: { canonical: 'ivy-figure', raw: 'vines' }, }) }) it('still parses verb-target when no preposition is present', () => { const cmd = parse('take lamp', ctx) expect(cmd.kind).toBe('verb-target') }) it('falls back to unknown-noun when one side fails to resolve', () => { const cmd = parse('light lamp with feathers', ctx) expect(cmd).toEqual({ kind: 'unknown', raw: 'light lamp with feathers', reason: 'unknown-noun' }) }) }) ``` - [ ] **Step 2: Run tests to verify they fail** ```bash npm test -- src/engine/parser.test.ts ``` Expected: FAIL — `verb-target-prep` is never returned. - [ ] **Step 3: Add a noun-phrase resolver helper** Add this helper near the top of the file: ```ts const PREPOSITIONS = new Set(['with', 'on', 'in', 'to']) function resolveNoun(rawTokens: string[], ctx: ParserContext): { id: string; alias: string } | null { const phrase = rawTokens.join(' ') if (phrase === 'it' && ctx.lastNoun) { return { id: ctx.lastNoun.canonical, alias: 'it' } } for (const noun of ctx.visibleNouns) { for (const alias of noun.aliases) { if (alias === phrase) return { id: noun.id, alias } } } for (const itemId of ctx.inventoryItemIds) { if (itemId === phrase) return { id: itemId, alias: phrase } } return null } ``` - [ ] **Step 4: Detect prepositions and split the noun phrase** After the stop-word strip and before the existing single-noun resolution path, add: ```ts // Detect a preposition splitting target | indirect. const prepIdx = rest.findIndex((tok) => PREPOSITIONS.has(tok)) if (prepIdx > 0 && prepIdx < rest.length - 1) { const targetTokens = rest.slice(0, prepIdx) const prep = rest[prepIdx]! let indirectTokens = rest.slice(prepIdx + 1) // Strip stop-words at the head of the indirect phrase too ("on the table"). while (indirectTokens.length > 0 && STOP_WORDS.has(indirectTokens[0]!)) { indirectTokens = indirectTokens.slice(1) } if (indirectTokens.length > 0) { const target = resolveNoun(targetTokens, ctx) const indirect = resolveNoun(indirectTokens, ctx) if (target && indirect) { return { kind: 'verb-target-prep', verb, target: { canonical: target.id, raw: target.alias }, preposition: prep, indirect: { canonical: indirect.id, raw: indirect.alias }, } } // Either side failed to resolve → fall through to unknown-noun below. return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' } } } ``` (`STOP_WORDS` is the constant defined in Task 4. If you placed it inside `parse`, hoist it to module scope so the indirect-phrase strip can reuse it.) - [ ] **Step 5: Run tests to verify they pass** ```bash npm test -- src/engine/parser.test.ts ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add src/engine/parser.ts src/engine/parser.test.ts git commit -m "$(cat <<'EOF' feat(parser): emit verb-target-prep on 'with'/'on'/'in'/'to' separators Enables `light lamp with matches`, `use shears on vines`, and similar multi-noun forms. Both the target and indirect noun must resolve; otherwise the command falls back to unknown-noun. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 7: Dispatcher — handle ambiguous and disambiguation **Files:** - Modify: `src/engine/dispatcher.ts` - Modify: `src/engine/dispatcher.test.ts` When the parser returns `kind: 'ambiguous'`, the dispatcher records `pendingDisambiguation` on state and emits a "Which X — A, B, or C?" prompt. The existing `kind: 'disambiguation'` handler already re-issues the original verb. - [ ] **Step 1: Write failing tests** Append to `src/engine/dispatcher.test.ts` (look for the existing `describe` blocks; add a new one): ```ts describe('ambiguous → disambiguation flow', () => { it('sets pendingDisambiguation and prompts when the parser returns ambiguous', () => { const world: World = { startingRoom: 'r', startingInventory: [], rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: 'r', revisit: 'r', examined: 'r' }, exits: {}, items: ['iron-key', 'brass-key'], }, }, items: { 'iron-key': { id: 'iron-key', names: ['key', 'iron key'], short: 'an iron key', long: '.', initialState: {}, takeable: true }, 'brass-key': { id: 'brass-key', names: ['key', 'brass key'], short: 'a brass key', long: '.', initialState: {}, takeable: true }, }, encounters: {}, endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' }, }, } const state = initialStateFor(world) const cmd: ParsedCommand = { kind: 'ambiguous', verb: 'take', rawNoun: 'key', candidates: ['iron-key', 'brass-key'], } const result = dispatch(state, cmd, world) expect(result.state.pendingDisambiguation).toEqual({ verb: 'take', candidates: ['iron-key', 'brass-key'], prompt: 'Which key — an iron key, or a brass key?', }) expect(result.appended[0]?.text).toBe('Which key — an iron key, or a brass key?') }) it('handles a single-word disambiguation reply', () => { // (continuing from the same world) // After the 'ambiguous' turn, send a 'disambiguation' command and verify the // dispatcher re-issues take iron-key. // ... }) }) ``` (Fill in the second test inline; copy the world fixture into a top-level `const` if shared between tests.) - [ ] **Step 2: Run tests to verify they fail** ```bash npm test -- src/engine/dispatcher.test.ts ``` Expected: FAIL — dispatcher returns "Nothing happens." for the ambiguous branch. - [ ] **Step 3: Add the dispatcher branch** In `src/engine/dispatcher.ts`, in the `dispatch` function, add a new branch above `if (command.kind === 'verb-only')`: ```ts if (command.kind === 'ambiguous') { const candidateShorts = command.candidates.map((id) => world.items[id]?.short ?? id) const list = candidateShorts.length === 2 ? `${candidateShorts[0]}, or ${candidateShorts[1]}` : candidateShorts.slice(0, -1).join(', ') + ', or ' + candidateShorts[candidateShorts.length - 1] const prompt = `Which ${command.rawNoun} — ${list}?` const next: GameState = { ...state, pendingDisambiguation: { verb: command.verb, candidates: command.candidates, prompt }, } return narrate(next, [{ kind: 'narration', text: prompt }]) } ``` - [ ] **Step 4: Run tests** ```bash npm test -- src/engine/dispatcher.test.ts ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/engine/dispatcher.ts src/engine/dispatcher.test.ts git commit -m "$(cat <<'EOF' feat(engine): dispatcher handles ambiguous parses with a disambiguation prompt Sets pendingDisambiguation on state and emits "Which X — A, or B?" using each candidate item's short text. The existing disambiguation reply path then re-issues the original verb against the chosen target. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 8: Item schema — readable / lightable / lighter / lighterUses fields **Files:** - Modify: `src/world/types.ts` - Modify: `src/world/schema.ts` - Modify: `src/world/schema.test.ts` Add the four optional fields to the `Item` type and the Zod schema. No loader changes yet — those come in Task 9. - [ ] **Step 1: Extend `Item` in world types** In `src/world/types.ts`: ```ts export interface Item { id: ItemId names: string[] short: string long: string initialState: Record takeable: boolean /** True if `read X` should narrate the item's `## read` section. */ readable?: boolean /** True if `light X` / `extinguish X` apply; toggles state.lit. */ lightable?: boolean /** True if this item can light other items. */ lighter?: boolean /** Optional remaining-charges counter; absent means unlimited. */ lighterUses?: number /** Prose returned by `read X`. Required iff readable is true. */ readableText?: string /** Prose narrated when `light X` succeeds. Falls back to "It catches." */ litText?: string /** Prose narrated when `extinguish X` succeeds. Falls back to "The flame dies." */ extinguishedText?: string /** Prose narrated when this item's lighterUses reaches 0. Falls back to "It is spent." */ lighterEmptyText?: string } ``` (Note: also widened `initialState` value union to include `string[]` to match Task 1. Apply that to `Item.initialState` too.) - [ ] **Step 2: Extend `itemFrontmatterSchema`** In `src/world/schema.ts`: ```ts 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({}), readable: z.boolean().optional(), lightable: z.boolean().optional(), lighter: z.boolean().optional(), lighterUses: z.number().int().nonnegative().optional(), }) ``` - [ ] **Step 3: Add tests** In `src/world/schema.test.ts`, append: ```ts describe('itemFrontmatterSchema — bible additions', () => { it('accepts readable + lighter fields', () => { const data = { id: 'matches', names: ['matches', 'matchbook'], short: 'a matchbook', takeable: true, lighter: true, lighterUses: 4, } expect(() => itemFrontmatterSchema.parse(data)).not.toThrow() }) it('accepts lightable on its own', () => { const data = { id: 'lamp', names: ['lamp'], short: 'a lamp', takeable: true, lightable: true } expect(() => itemFrontmatterSchema.parse(data)).not.toThrow() }) it('rejects negative lighterUses', () => { const data = { id: 'matches', names: ['matches'], short: 'matches', takeable: true, lighter: true, lighterUses: -1 } expect(() => itemFrontmatterSchema.parse(data)).toThrow() }) }) ``` - [ ] **Step 4: Run schema tests** ```bash npm test -- src/world/schema.test.ts ``` Expected: PASS (3 new tests). - [ ] **Step 5: Commit** ```bash git add src/world/types.ts src/world/schema.ts src/world/schema.test.ts git commit -m "$(cat <<'EOF' feat(world): item schema — readable, lightable, lighter, lighterUses Optional fields used by the new read/light/extinguish dispatcher branches. Loader updates and dispatcher logic follow. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 9: Loader — extract optional `## read`, `## lit`, `## extinguished`, `## lighter-empty` sections **Files:** - Modify: `src/world/loader.ts` - Modify: `src/world/loader.test.ts` When an item's body contains `##` section headers, the long description is the prose *before* the first header, and the recognized section names are extracted into typed fields. When there are no headers, the body is the long description (current behavior). - [ ] **Step 1: Write failing tests** Append to `src/world/loader.test.ts`: ```ts describe('parseItem — body sections', () => { it('extracts ## read into readableText', () => { const md = `--- id: letter names: [letter, note] short: a folded letter takeable: true readable: true --- A folded letter, sealed with wax. ## read You loved Halfstreet, the letter says. I loved it too. ` const item = parseItem(md, 'items/letter.md') expect(item.long).toBe('A folded letter, sealed with wax.') expect(item.readable).toBe(true) expect(item.readableText).toBe('You loved Halfstreet, the letter says. I loved it too.') }) it('extracts ## lit and ## extinguished', () => { const md = `--- id: lamp names: [lamp] short: an oil lamp takeable: true lightable: true initialState: lit: false --- An iron oil lamp. ## lit The wick catches; warm yellow light fills the space. ## extinguished You smother the flame. The room darkens. ` const item = parseItem(md, 'items/lamp.md') expect(item.long).toBe('An iron oil lamp.') expect(item.litText).toBe('The wick catches; warm yellow light fills the space.') expect(item.extinguishedText).toBe('You smother the flame. The room darkens.') }) it('extracts ## lighter-empty', () => { const md = `--- id: matches names: [matches] short: a matchbook takeable: true lighter: true lighterUses: 4 --- A matchbook from the Halfstreet Hotel. ## lighter-empty The last match flares and dies. The book is empty. ` const item = parseItem(md, 'items/matches.md') expect(item.lighterEmptyText).toBe('The last match flares and dies. The book is empty.') }) it('throws when readable: true but ## read is missing', () => { const md = `--- id: x names: [x] short: x takeable: true readable: true --- A thing. ` expect(() => parseItem(md, 'items/x.md')).toThrow(/## read.*required when readable/i) }) it('still parses items with no body sections (back-compat)', () => { const md = `--- id: lamp names: [lamp] short: an oil lamp takeable: true --- An iron oil lamp with a glass chimney. ` const item = parseItem(md, 'items/lamp.md') expect(item.long).toBe('An iron oil lamp with a glass chimney.') expect(item.readable).toBeUndefined() expect(item.readableText).toBeUndefined() }) }) ``` - [ ] **Step 2: Run tests to verify they fail** ```bash npm test -- src/world/loader.test.ts ``` Expected: FAIL — current `parseItem` puts the entire body in `long`, including section headers. - [ ] **Step 3: Update `parseItem`** Replace the existing `parseItem` in `src/world/loader.ts`: ```ts const ITEM_SECTION_KEYS = ['read', 'lit', 'extinguished', 'lighter-empty'] as const type ItemSectionKey = typeof ITEM_SECTION_KEYS[number] export function parseItem(raw: string, sourcePath: string): Item { const parsed = matter(raw) const frontmatter = stripWikilink(parsed.data) as Record const fm = itemFrontmatterSchema.parse(frontmatter) // Split body into long-description prefix + sectioned remainder. // The first `## key` header (if any) marks the boundary. const body = parsed.content const firstHeader = body.match(/^##\s+[\w-]+\s*$/m) const longRaw = firstHeader ? body.slice(0, firstHeader.index!).trim() : body.trim() if (longRaw.length === 0) { throw new Error(`${sourcePath}: empty long description`) } const sections = firstHeader ? splitSections(body.slice(firstHeader.index!)) : {} // Validate that only known section keys appear. for (const key of Object.keys(sections)) { if (!ITEM_SECTION_KEYS.includes(key as ItemSectionKey)) { throw new Error(`${sourcePath}: unknown item section "## ${key}". Allowed: ${ITEM_SECTION_KEYS.join(', ')}`) } } if (fm.readable && !sections['read']) { throw new Error(`${sourcePath}: ## read section is required when readable: true`) } const item: Item = { id: fm.id, names: fm.names, short: fm.short, long: longRaw, initialState: fm.initialState, takeable: fm.takeable, } if (fm.readable !== undefined) item.readable = fm.readable if (fm.lightable !== undefined) item.lightable = fm.lightable if (fm.lighter !== undefined) item.lighter = fm.lighter if (fm.lighterUses !== undefined) item.lighterUses = fm.lighterUses if (sections['read']) item.readableText = sections['read'] if (sections['lit']) item.litText = sections['lit'] if (sections['extinguished']) item.extinguishedText = sections['extinguished'] if (sections['lighter-empty']) item.lighterEmptyText = sections['lighter-empty'] return item } ``` - [ ] **Step 4: Run tests** ```bash npm test -- src/world/loader.test.ts ``` Expected: PASS. - [ ] **Step 5: Run full test suite to ensure existing items still load** ```bash npm test ``` Expected: PASS. The three existing item files have no `##` headers, so the back-compat path applies. - [ ] **Step 6: Commit** ```bash git add src/world/loader.ts src/world/loader.test.ts git commit -m "$(cat <<'EOF' feat(world): parseItem extracts optional ## read / lit / extinguished / lighter-empty sections Existing items with no body sections continue to load unchanged. New items can author per-state prose in dedicated sections; the dispatcher will read these in subsequent commits. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 10: Item content — annotate lamp / matches / letter **Files:** - Modify: `src/world/items/lamp.md` - Modify: `src/world/items/matches.md` - Modify: `src/world/items/letter.md` Real content updates so the dispatcher work in Tasks 11–13 can be exercised against the actual world. - [ ] **Step 1: Update `lamp.md`** ```md --- id: lamp names: ["lamp", "oil lamp", "torch"] short: "an oil lamp" takeable: true lightable: true initialState: lit: false --- An iron oil lamp with a glass chimney. Currently unlit. ## lit The wick catches. Warm yellow light pushes the dark back. ## extinguished You smother the wick. The room closes around you again. ``` - [ ] **Step 2: Update `matches.md`** Read the current file first to preserve any existing prose: ```bash cat src/world/items/matches.md ``` Then replace with: ```md --- id: matches names: ["matches", "matchbook"] short: "a matchbook" takeable: true lighter: true lighterUses: 4 initialState: uses: 4 --- A matchbook. The cover bears the name of a hotel you don't remember staying in. ## lighter-empty The last match flares, burns down, and goes out. The book is empty. ``` (If the original `long` text differs, preserve it verbatim above the `## lighter-empty` line. The `lighterUses` schema field documents the initial charge count for authors; the runtime counter lives on the item instance under `state.uses`, seeded by `initialState.uses`. Task 12 reads and decrements the latter.) - [ ] **Step 3: Update `letter.md`** Read the current file: ```bash cat src/world/items/letter.md ``` Promote whatever it currently shows when read into a `## read` section. Example shape: ```md --- id: letter names: ["letter", "note"] short: "a folded letter" takeable: true readable: true --- A folded letter. The wax seal has been broken once already. ## read [the existing readable text from the original long description, or new bible-aligned prose if you prefer] ``` (The follow-on plan rewrites prose. For this task, just preserve whatever's currently there in the `## read` section; do not invent new content.) - [ ] **Step 4: Run all tests** ```bash npm test ``` Expected: PASS. Existing playthrough tests may assert on `examine letter` returning a specific string — if they do, they'll still pass because `item.long` (the unsectioned prefix) is what `examine` returns. Verify by skimming `playthrough.test.ts`. - [ ] **Step 5: Run build** ```bash npm run build ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add src/world/items/lamp.md src/world/items/matches.md src/world/items/letter.md git commit -m "$(cat <<'EOF' feat(world): annotate lamp/matches/letter for read/light/extinguish Adds the new schema flags and per-state body sections so the dispatcher's new verb handlers have content to narrate. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 11: Dispatcher — `read` verb **Files:** - Modify: `src/engine/dispatcher.ts` - Modify: `src/engine/dispatcher.test.ts` `read X` narrates the item's `## read` section if `readable: true`. Otherwise: "There's nothing to read on it." - [ ] **Step 1: Write failing tests** Append to `src/engine/dispatcher.test.ts`: ```ts describe('read verb', () => { it('narrates readableText for a readable item in inventory', () => { const world = readWorld() // small helper defined below let state = initialStateFor(world) state = { ...state, inventory: [{ id: 'letter', state: {} }] } const result = dispatch(state, { kind: 'verb-target', verb: 'read', target: { canonical: 'letter', raw: 'letter' }, }, world) expect(result.appended.at(-1)?.text).toBe('You loved Halfstreet, the letter says.') }) it('errors politely on non-readable items', () => { const world = readWorld() let state = initialStateFor(world) state = { ...state, inventory: [{ id: 'rock', state: {} }] } const result = dispatch(state, { kind: 'verb-target', verb: 'read', target: { canonical: 'rock', raw: 'rock' }, }, world) expect(result.appended.at(-1)?.text).toBe("There's nothing to read on it.") }) }) // Top-level helper near the other test fixtures: function readWorld(): World { return { startingRoom: 'r', startingInventory: [], rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } }, items: { letter: { id: 'letter', names: ['letter'], short: 'a letter', long: 'A letter.', initialState: {}, takeable: true, readable: true, readableText: 'You loved Halfstreet, the letter says.' }, rock: { id: 'rock', names: ['rock'], short: 'a rock', long: 'A rock.', initialState: {}, takeable: true }, }, encounters: {}, endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' }, }, } } ``` - [ ] **Step 2: Run tests to verify they fail** ```bash npm test -- src/engine/dispatcher.test.ts -t "read verb" ``` Expected: FAIL — current `verb-target` fallthrough emits `"You're not sure how to read that."` - [ ] **Step 3: Add the `read` handler** In `dispatch()`, in the `verb-target` block, before the trailing `return narrate(...)`: ```ts if (command.verb === 'read') return handleRead(stateWithNoun, command.target.canonical, world) ``` Add the handler: ```ts function handleRead(state: GameState, itemId: string, world: World): DispatchResult { const item = world.items[itemId] if (!item) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }]) const visible = state.inventory.find((i) => i.id === itemId) || getItemsInRoom(state, world, state.location).includes(itemId) if (!visible) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }]) if (!item.readable || !item.readableText) { return narrate(state, [{ kind: 'narration', text: "There's nothing to read on it." }]) } return narrate(state, [{ kind: 'narration', text: item.readableText }]) } ``` - [ ] **Step 4: Run tests** ```bash npm test -- src/engine/dispatcher.test.ts ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add src/engine/dispatcher.ts src/engine/dispatcher.test.ts git commit -m "$(cat <<'EOF' feat(engine): read verb narrates item.readableText Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 12: Dispatcher — `light` and `extinguish` (implicit lighter) **Files:** - Modify: `src/engine/dispatcher.ts` - Modify: `src/engine/dispatcher.test.ts` `light X` (no instrument): pick any inventory item with `lighter: true` and either no `lighterUses` or `lighterUses > 0`; consume one charge if applicable; set `target.state.lit = true`; narrate `litText`. `extinguish X`: requires `state.lit === true`; clears it; narrates `extinguishedText`. This task implements only the implicit form. Task 13 adds the explicit `light X with Y` form. - [ ] **Step 1: Write failing tests** Append to `dispatcher.test.ts`: ```ts describe('light/extinguish verbs (implicit lighter)', () => { function w(): World { return { startingRoom: 'r', startingInventory: [], rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } }, items: { lamp: { id: 'lamp', names: ['lamp'], short: 'an oil lamp', long: '.', initialState: { lit: false }, takeable: true, lightable: true, litText: 'The wick catches.', extinguishedText: 'The flame dies.' }, matches: { id: 'matches', names: ['matches'], short: 'a matchbook', long: '.', initialState: {}, takeable: true, lighter: true, lighterUses: 2, lighterEmptyText: 'The book is empty.' }, rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true }, }, encounters: {}, endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' } }, } } it('lights a lamp using the matchbook implicitly', () => { const world = w() let state = initialStateFor(world) state = { ...state, inventory: [ { id: 'lamp', state: { lit: false } }, { id: 'matches', state: { uses: 2 } }, ] } const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world) expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true) expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(1) expect(result.appended.at(-1)?.text).toBe('The wick catches.') }) it('refuses when the target is already lit', () => { const world = w() let state = initialStateFor(world) state = { ...state, inventory: [ { id: 'lamp', state: { lit: true } }, { id: 'matches', state: { uses: 2 } }, ] } const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world) expect(result.appended.at(-1)?.text).toBe("It's already lit.") }) it('refuses when no lighter is in inventory', () => { const world = w() let state = initialStateFor(world) state = { ...state, inventory: [{ id: 'lamp', state: { lit: false } }] } const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world) expect(result.appended.at(-1)?.text).toBe('You have nothing to light it with.') }) it('refuses when the target is not lightable', () => { const world = w() let state = initialStateFor(world) state = { ...state, inventory: [{ id: 'rock', state: {} }, { id: 'matches', state: { uses: 2 } }] } const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'rock', raw: 'rock' } }, world) expect(result.appended.at(-1)?.text).toBe("You can't light that.") }) it('emits the lighter-empty message when matches reach 0', () => { const world = w() let state = initialStateFor(world) state = { ...state, inventory: [ { id: 'lamp', state: { lit: false } }, { id: 'matches', state: { uses: 1 } }, ] } const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world) expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(0) const texts = result.appended.map((l) => l.text) expect(texts).toContain('The wick catches.') expect(texts).toContain('The book is empty.') }) it('extinguishes a lit lamp', () => { const world = w() let state = initialStateFor(world) state = { ...state, inventory: [{ id: 'lamp', state: { lit: true } }] } const result = dispatch(state, { kind: 'verb-target', verb: 'extinguish', target: { canonical: 'lamp', raw: 'lamp' } }, world) expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(false) expect(result.appended.at(-1)?.text).toBe('The flame dies.') }) it("refuses to extinguish what isn't lit", () => { const world = w() let state = initialStateFor(world) state = { ...state, inventory: [{ id: 'lamp', state: { lit: false } }] } const result = dispatch(state, { kind: 'verb-target', verb: 'extinguish', target: { canonical: 'lamp', raw: 'lamp' } }, world) expect(result.appended.at(-1)?.text).toBe("It isn't lit.") }) }) ``` - [ ] **Step 2: Run tests to verify they fail** ```bash npm test -- src/engine/dispatcher.test.ts -t "light/extinguish" ``` Expected: FAIL. - [ ] **Step 3: Implement the handlers** In `dispatch()`'s `verb-target` block: ```ts if (command.verb === 'light') return handleLight(stateWithNoun, command.target.canonical, null, world) if (command.verb === 'extinguish') return handleExtinguish(stateWithNoun, command.target.canonical, world) ``` Add: ```ts function handleLight(state: GameState, targetId: string, instrumentId: string | null, world: World): DispatchResult { const target = world.items[targetId] if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }]) if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't light that." }]) const targetInst = state.inventory.find((i) => i.id === targetId) ?? null const visibleInRoom = getItemsInRoom(state, world, state.location).includes(targetId) if (!targetInst && !visibleInRoom) { return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }]) } // The 'lit' state lives on the inventory instance for inventory items, or // (eventually) on roomState for items left in a room. For now we only // support lighting items the player is carrying. if (!targetInst) { return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }]) } if (targetInst.state['lit'] === true) { return narrate(state, [{ kind: 'narration', text: "It's already lit." }]) } // Pick an instrument. If explicit, validate it; if implicit, find any. let lighterInst = null as typeof state.inventory[number] | null if (instrumentId) { lighterInst = state.inventory.find((i) => i.id === instrumentId) ?? null if (!lighterInst) return narrate(state, [{ kind: 'narration', text: "You don't have that." }]) const lighterDef = world.items[instrumentId] if (!lighterDef?.lighter) return narrate(state, [{ kind: 'narration', text: "That isn't going to help." }]) if (typeof lighterInst.state['uses'] === 'number' && lighterInst.state['uses'] <= 0) { return narrate(state, [{ kind: 'narration', text: "It is spent." }]) } } else { for (const inst of state.inventory) { const def = world.items[inst.id] if (!def?.lighter) continue if (typeof inst.state['uses'] === 'number' && inst.state['uses'] <= 0) continue lighterInst = inst break } if (!lighterInst) { return narrate(state, [{ kind: 'narration', text: 'You have nothing to light it with.' }]) } } // Apply state changes immutably. const lighterDef = world.items[lighterInst.id]! const lighterUsesField = typeof lighterInst.state['uses'] === 'number' ? lighterInst.state['uses'] : null const newLighterUses = lighterUsesField === null ? null : lighterUsesField - 1 const newInventory = state.inventory.map((i) => { if (i.id === targetInst.id) return { ...i, state: { ...i.state, lit: true } } if (i.id === lighterInst!.id && newLighterUses !== null) return { ...i, state: { ...i.state, uses: newLighterUses } } return i }) const lines: TranscriptLine[] = [{ kind: 'narration', text: target.litText ?? 'It catches.' }] if (newLighterUses === 0) { lines.push({ kind: 'narration', text: lighterDef.lighterEmptyText ?? 'It is spent.' }) } return narrate({ ...state, inventory: newInventory }, lines) } function handleExtinguish(state: GameState, targetId: string, world: World): DispatchResult { const target = world.items[targetId] if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }]) if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't extinguish that." }]) const targetInst = state.inventory.find((i) => i.id === targetId) if (!targetInst) return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }]) if (targetInst.state['lit'] !== true) { return narrate(state, [{ kind: 'narration', text: "It isn't lit." }]) } const newInventory = state.inventory.map((i) => i.id === targetId ? { ...i, state: { ...i.state, lit: false } } : i, ) return narrate({ ...state, inventory: newInventory }, [{ kind: 'narration', text: target.extinguishedText ?? 'The flame dies.' }]) } ``` **Note on the `uses` state key:** the matches' `lighterUses` schema field is the *initial* charge count (set in Task 10's frontmatter); the runtime counter lives on the item instance under `state.uses`, seeded by `initialState.uses` in matches.md. Tests in this task use `state.uses` directly. - [ ] **Step 4: Run tests** ```bash npm test -- src/engine/dispatcher.test.ts ``` Expected: PASS for the new tests and all existing. - [ ] **Step 5: Commit** ```bash git add src/engine/dispatcher.ts src/engine/dispatcher.test.ts git commit -m "$(cat <<'EOF' feat(engine): light/extinguish verbs with implicit lighter selection `light X` finds a lighter (item with lighter:true and remaining state.uses) in inventory, decrements its charges, and toggles target.state.lit. The target's litText / extinguishedText / the lighter's lighterEmptyText provide narration. Refuses politely on each error path. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 13: Dispatcher — `light X with Y` (verb-target-prep) and `use` routing **Files:** - Modify: `src/engine/dispatcher.ts` - Modify: `src/engine/encounters.ts` - Modify: `src/engine/dispatcher.test.ts` Wire `verb-target-prep` through the dispatcher. For `light`, route to `handleLight` with the explicit instrument. For `use` and any other verb, route to the encounter dispatcher (with the prep info passed through), and fall back to "You can't think how to use that here." if no encounter consumes it. - [ ] **Step 1: Write failing tests** Append to `dispatcher.test.ts`: ```ts describe('light X with Y (explicit lighter)', () => { // reuse the world from Task 12's test block; if scoped, redeclare here. it('lights with the explicit instrument', () => { // ... using the same w() helper from Task 12 ... const world = w() let state = initialStateFor(world) state = { ...state, inventory: [ { id: 'lamp', state: { lit: false } }, { id: 'matches', state: { uses: 2 } }, ] } const result = dispatch(state, { kind: 'verb-target-prep', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' }, preposition: 'with', indirect: { canonical: 'matches', raw: 'matches' }, }, world) expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true) expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(1) }) it('refuses when the named instrument is not a lighter', () => { const world = w() let state = initialStateFor(world) state = { ...state, inventory: [ { id: 'lamp', state: { lit: false } }, { id: 'rock', state: {} }, ] } const result = dispatch(state, { kind: 'verb-target-prep', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' }, preposition: 'with', indirect: { canonical: 'rock', raw: 'rock' }, }, world) expect(result.appended.at(-1)?.text).toBe("That isn't going to help.") }) }) describe('use verb routing', () => { it('falls back when no encounter consumes use', () => { const world = w() let state = initialStateFor(world) state = { ...state, inventory: [{ id: 'rock', state: {} }] } const result = dispatch(state, { kind: 'verb-target', verb: 'use', target: { canonical: 'rock', raw: 'rock' }, }, world) expect(result.appended.at(-1)?.text).toBe("You can't think how to use that here.") }) }) ``` - [ ] **Step 2: Run tests to verify they fail** ```bash npm test -- src/engine/dispatcher.test.ts ``` Expected: FAIL on the prep + use cases. - [ ] **Step 3: Add the verb-target-prep dispatch branch** In `dispatch()`, before the closing `return narrate(state, [{ kind: 'narration', text: 'Nothing happens.' }])`: ```ts if (command.kind === 'verb-target-prep') { const stateWithNoun: GameState = { ...state, lastNoun: command.target } // Try the encounter first — it may consume verbs like 'cut vines with shears'. const encResult = applyVerbToEncounter(stateWithNoun, command, world) if (encResult?.consumed) { return { state: encResult.state, appended: encResult.lines } } if (command.verb === 'light' && command.preposition === 'with') { return handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world) } if (command.verb === 'use') { return narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]) } return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]) } ``` - [ ] **Step 4: Add `use` fallback in the existing `verb-target` branch** Currently `verb-target` falls through to `"You're not sure how to ${verb} that."` for `use`. Improve to the spec wording: ```ts if (command.verb === 'use') return narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]) ``` (Add this above the existing trailing `return` in the `verb-target` block.) - [ ] **Step 5: Extend `applyVerbToEncounter` to accept verb-target-prep** In `src/engine/encounters.ts`, in `applyVerbToEncounter`, add a third command-kind branch: ```ts // Only verb-target, verb-target-prep, and verb-only commands engage with encounters. let verb: string | null = null let targetId: string | null = null let instrumentId: string | null = null if (command.kind === 'verb-target') { verb = command.verb targetId = command.target.canonical } else if (command.kind === 'verb-target-prep') { verb = command.verb targetId = command.target.canonical instrumentId = command.indirect.canonical } else if (command.kind === 'verb-only' && command.verb !== 'inventory') { verb = command.verb } else { return null } ``` If a transition's `requires.item` is set and `instrumentId` is also set but doesn't match, treat as no transition (the player typed the wrong instrument). The existing matching loop already enforces `requires.item` via inventory presence; add an extra guard inside the `transitions.find` callback: ```ts if (t.requires && instrumentId && t.requires.item !== instrumentId) return false ``` (Place this after the existing `requires` block.) - [ ] **Step 6: Run all tests** ```bash npm test ``` Expected: PASS. - [ ] **Step 7: Commit** ```bash git add src/engine/dispatcher.ts src/engine/encounters.ts src/engine/dispatcher.test.ts git commit -m "$(cat <<'EOF' feat(engine): wire verb-target-prep — explicit `light X with Y` and `use` routing light X with Y validates the named instrument and reuses handleLight. use X / use X on Y route through the encounter dispatcher; if no encounter consumes it, the dispatcher narrates the fallback. The encounter matcher also rejects transitions whose required item doesn't match the typed instrument, so a mistyped instrument fails cleanly. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 14: Locked-exit synthetic-world dispatcher test **Files:** - Modify: `src/engine/dispatcher.test.ts` The existing locked-exit test stub asserts `expect(true).toBe(true)`. Replace it with a real fixture that exercises locked exits end-to-end without depending on the live world. - [ ] **Step 1: Locate and remove the stub** ```bash grep -n "locked exit" src/engine/dispatcher.test.ts ``` - [ ] **Step 2: Replace with a real test** Insert (replacing the stub): ```ts describe('locked exits', () => { function makeWorld(): World { return { startingRoom: 'antechamber', startingInventory: [], rooms: { antechamber: { id: 'antechamber', title: '[ Antechamber ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: { n: 'vault' }, lockedExits: { n: { requires: 'rusted-key', lockedNarration: 'The door is locked.' } }, items: ['rusted-key'], }, vault: { id: 'vault', title: '[ Vault ]', descriptions: { firstVisit: 'You are inside.', revisit: '.', examined: '.' }, exits: {}, items: [], }, }, items: { 'rusted-key': { id: 'rusted-key', names: ['rusted key', 'key'], short: 'a rusted key', long: '.', initialState: {}, takeable: true }, }, encounters: {}, endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' } }, } } it('blocks movement without the key', () => { const world = makeWorld() const state = initialStateFor(world) const result = dispatch(state, { kind: 'go', direction: 'n' }, world) expect(result.appended.at(-1)?.text).toBe('The door is locked.') expect(result.state.location).toBe('antechamber') }) it('permits movement once the key is in inventory', () => { const world = makeWorld() let state = initialStateFor(world) state = { ...state, inventory: [{ id: 'rusted-key', state: {} }] } const result = dispatch(state, { kind: 'go', direction: 'n' }, world) expect(result.state.location).toBe('vault') }) it('does not consume the key on passage', () => { const world = makeWorld() let state = initialStateFor(world) state = { ...state, inventory: [{ id: 'rusted-key', state: {} }] } const result = dispatch(state, { kind: 'go', direction: 'n' }, world) expect(result.state.inventory.find((i) => i.id === 'rusted-key')).toBeDefined() }) }) ``` - [ ] **Step 3: Run tests** ```bash npm test -- src/engine/dispatcher.test.ts ``` Expected: PASS (3 new tests). - [ ] **Step 4: Commit** ```bash git add src/engine/dispatcher.test.ts git commit -m "$(cat <<'EOF' test(engine): self-contained locked-exit fixture replaces the stub Verifies blocked movement, key-permitted passage, and that the key is not consumed by passing through. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 15: Dispatcher — ending detection and end-state lock **Files:** - Modify: `src/engine/dispatcher.ts` - Modify: `src/engine/dispatcher.test.ts` After every successful dispatch (verb-target, go, verb-only that mutates state — basically every branch except `unknown`/`meta`/disambiguation prompts), check whether any ending's `whenFlags` are now satisfied. First match (in declared order: `true`, `wrong`, `bad`) sets `state.endedWith` and appends an `'ending'` event with the prose. Once `endedWith !== null`, further dispatches return a narration "The story has ended. Type `restart` or `undo`." and do not mutate state otherwise. - [ ] **Step 1: Write failing tests** Append to `dispatcher.test.ts`: ```ts describe('ending detection', () => { function makeWorld(): World { return { startingRoom: 'r', startingInventory: [], rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: { n: 'r2' }, items: [], }, r2: { id: 'r2', title: '[ R2 ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [], }, }, items: {}, encounters: {}, endings: { true: { whenFlags: { reachedR2: true }, narration: 'You stand at the top of the stair.' }, wrong: { whenFlags: {}, narration: 'You disturb what should not be disturbed.' }, bad: { whenFlags: { tookPhoto: true }, narration: 'The child in it is you.' }, }, } } it('sets endedWith and emits an ending line when flags match', () => { const world = makeWorld() let state = initialStateFor(world) state = { ...state, flags: { reachedR2: true } } // Any successful dispatch should now trigger the ending check. const result = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world) expect(result.state.endedWith).toBe('true') const last = result.appended.at(-1)! expect(last.kind).toBe('ending') expect(last.text).toBe('You stand at the top of the stair.') }) it('honors priority order: true beats wrong beats bad', () => { const world = makeWorld() // wrong has empty whenFlags, so it matches every state — but true must win // when its flags also match. let state = initialStateFor(world) state = { ...state, flags: { reachedR2: true } } const result = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world) expect(result.state.endedWith).toBe('true') }) it('rejects further input once ended', () => { const world = makeWorld() let state = initialStateFor(world) state = { ...state, flags: { reachedR2: true } } const ended = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world).state const result = dispatch(ended, { kind: 'verb-only', verb: 'wait' }, world) expect(result.appended.at(-1)?.text).toBe('The story has ended. Type `restart` or `undo`.') expect(result.state.location).toBe(ended.location) }) it('does not fire on unknown/meta turns (no state mutation)', () => { const world = makeWorld() // wrong has empty whenFlags but unknown commands shouldn't re-trigger it. // (Bad would also match nothing here since tookPhoto is false.) // We verify that an 'unknown' command does not change endedWith. const state = initialStateFor(world) const result = dispatch(state, { kind: 'unknown', raw: 'fnord', reason: 'unknown-verb' }, world) expect(result.state.endedWith).toBeNull() }) }) ``` Note the third ending in the fixture: `wrong` has empty `whenFlags`. With "every key must match", an empty `whenFlags` matches everything. We rely on priority order to keep this from firing prematurely — `true` is checked first, and only when its conditions fail does `wrong` activate. The "doesn't fire on unknown" test depends on us only running ending detection on *successful* turns (not unknown). - [ ] **Step 2: Run tests to verify they fail** ```bash npm test -- src/engine/dispatcher.test.ts -t "ending detection" ``` Expected: FAIL. - [ ] **Step 3: Implement ending detection** Add helper in `dispatcher.ts`: ```ts const ENDING_PRIORITY: ('true' | 'wrong' | 'bad')[] = ['true', 'wrong', 'bad'] function evaluateEndings(state: GameState, world: World): GameState | null { if (state.endedWith) return null for (const id of ENDING_PRIORITY) { const ending = world.endings[id] const flags = ending.whenFlags let allMatch = true for (const [k, v] of Object.entries(flags)) { if (state.flags[k] !== v) { allMatch = false; break } } if (!allMatch) continue // Empty whenFlags would match any state; ensure 'true' / 'bad' must list at // least one flag in real worlds. The test fixture's 'wrong' with empty flags // is intentional: it's the catch-all replacement ending. return { ...state, endedWith: id, transcript: [...state.transcript, { kind: 'ending', text: ending.narration }], } } return null } ``` Wrap the relevant `dispatch` branches with an end-state check at the top: ```ts if (state.endedWith) { return narrate(state, [{ kind: 'narration', text: 'The story has ended. Type `restart` or `undo`.' }]) } ``` (Place near the very top of `dispatch`, after the disambiguation reply branch — that one already redirects, and it must remain functional even after ending so the player can clear stale state with restart/undo.) After every branch that produces a successful state mutation, evaluate endings. The simplest hook is to wrap the existing `narrate` helper *for game-mutating outcomes*. The cleanest path: just before returning from `dispatch` for `verb-only`, `verb-target`, `verb-target-prep`, and `go`, post-process: ```ts function withEndingCheck(result: DispatchResult, world: World): DispatchResult { const updated = evaluateEndings(result.state, world) if (!updated) return result const endingLine: TranscriptLine = updated.transcript[updated.transcript.length - 1]! return { state: updated, appended: [...result.appended, endingLine] } } ``` Then in `dispatch`, change the relevant returns: ```ts if (command.kind === 'go') return withEndingCheck(handleGo(state, command.direction, world), world) if (command.kind === 'verb-only') { if (command.verb === 'look') return withEndingCheck(handleLook(state, world), world) if (command.verb === 'inventory') return withEndingCheck(handleInventory(state, world), world) if (command.verb === 'wait') return withEndingCheck(narrate(state, [{ kind: 'narration', text: 'Time passes.' }]), world) } if (command.kind === 'verb-target') { // ... existing block ... return withEndingCheck(/* whichever branch result */, world) } if (command.kind === 'verb-target-prep') { // ... return withEndingCheck(/* result */, world) } ``` (Apply `withEndingCheck` to every successful state-mutating return inside `verb-target` and `verb-target-prep`. Don't apply it to `unknown`, `meta`, `ambiguous`, or `disambiguation` (the latter recurses).) - [ ] **Step 4: Run tests** ```bash npm test -- src/engine/dispatcher.test.ts ``` Expected: PASS for the new tests and existing. - [ ] **Step 5: Run full suite** ```bash npm test ``` Expected: PASS. Existing playthrough tests should still work — they only mutate flags via the rat encounter, and the existing `endings.true.whenFlags = { ratGone: true }` means killing the rat triggers the ending. The playthrough probably already tests this; verify the assertion shape still matches (transcript may now contain an `'ending'`-kind line). - [ ] **Step 6: If playthrough test breaks, update it** The expected ending narration is now appended with `kind: 'ending'`, not `'narration'`. If the test asserts `kind: 'narration'`, change it to `kind: 'ending'`. The text content should be the same. - [ ] **Step 7: Commit** ```bash git add src/engine/dispatcher.ts src/engine/dispatcher.test.ts src/engine/playthrough.test.ts git commit -m "$(cat <<'EOF' feat(engine): detect endings on every successful turn After each state-mutating dispatch, evaluate world.endings in priority order (true > wrong > bad). The first whose whenFlags are all satisfied sets state.endedWith and appends a kind:'ending' transcript line. Once ended, further dispatches return a "story has ended" narration. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 16: UI — render ending events and disable input on end **Files:** - Modify: `src/ui/terminal.ts` - Modify: `src/ui/crt.css` When `state.endedWith !== null`, the input field is disabled. Lines with `kind: 'ending'` render with a dedicated CSS class for visual distinction (separator above, larger gap, optional border). - [ ] **Step 1: Update `renderAll` to use the kind as a class** `renderAll` already does `el.className = line.kind`. The `'ending'` kind will arrive automatically — the renderer just needs CSS for it. Verify by reading the function: ```bash grep -n "el.className" src/ui/terminal.ts ``` Expected: a single line setting `el.className = line.kind`. No code change needed for class assignment. - [ ] **Step 2: Add an ended-state guard in the keydown handler** In `terminal.ts`, near the top of the `inputEl.addEventListener('keydown', …)` callback, before any dispatch logic: ```ts // Once the game has ended, only restart and undo are allowed. if (state.endedWith !== null) { const lower = raw.trim().toLowerCase() if (lower !== 'restart' && lower !== 'undo') { appendLines([{ kind: 'system', text: 'The story has ended. Type `restart` or `undo`.' }]) return } } ``` (The dispatcher also rejects, but doing it here saves a roundtrip and keeps the disabled-input UX consistent.) - [ ] **Step 3: Disable the input visually when ended** After every state mutation in the keydown handler (and once at startup after `loadState`), set: ```ts inputEl.disabled = state.endedWith !== null ``` Find the right hooks: - After `state = initialStateFor(world)` on startup - After every `state = result.state` in the dispatch path - After undo (`state = lastState`) - After restart (`state = initialStateFor(world)`) Add a small helper to keep this clean: ```ts const syncEndedUI = (): void => { inputEl!.disabled = state.endedWith !== null } ``` Call `syncEndedUI()` at the same places `refreshChips()` is called. - [ ] **Step 4: Add CSS for the ending block** Append to `src/ui/crt.css`: ```css .ending { margin-top: 2em; margin-bottom: 1em; padding-top: 1em; border-top: 1px solid currentColor; font-style: italic; white-space: pre-wrap; } [data-mystery-input]:disabled { opacity: 0.4; cursor: not-allowed; } ``` - [ ] **Step 5: Build** ```bash npm run build ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add src/ui/terminal.ts src/ui/crt.css git commit -m "$(cat <<'EOF' feat(ui): render ending lines distinctly and lock input on end-state Ending-kind lines get a separator and italic styling. Once endedWith is set, the terminal disables the input and rejects all commands except restart and undo. Co-Authored-By: Claude Opus 4.7 EOF )" ``` --- ## Task 17: Manual playthrough verification **Files:** none Type-checking and unit tests verify code correctness. The mystery is a UI-driven game and we have to actually play it. - [ ] **Step 1: Start dev server** ```bash npm run dev ``` Expected: Astro dev server prints a localhost URL. - [ ] **Step 2: Open `/mystery` in a browser** - [ ] **Step 3: Verify each new behavior** Run through these inputs and verify the response: - `look at lamp` → examine response (Task 4 stop-words) - `read letter` → narrates the readable text (Task 11) - `light lamp` → "The wick catches." (Task 12); inventory shows `(lit)` - `extinguish lamp` → "The flame dies." (Task 12); `(lit)` removed - `light lamp` × 4 (depleting matches) → after the 4th, narration includes "The book is empty." - `light lamp` after empty → "You have nothing to light it with." - `light lamp with matches` → behaves the same as implicit (Task 13) - `light lamp with rock` → "That isn't going to help." (Task 13) - `use rock` → "You can't think how to use that here." (Task 13) - `attack rat` (golden path) → reaches the ending → ending text rendered with separator → input disabled - Click restart in the chip footer → game resets, input re-enabled If any of the above fail, stop. Investigate and fix before moving on. - [ ] **Step 4: Verify disambiguation if there's an ambiguous noun in scope** The current 3-room world has no ambiguous nouns (matches/lamp/letter all distinct), so disambiguation can't be exercised live. Verify it's covered by unit tests instead. - [ ] **Step 5: Stop dev server (Ctrl-C). No commit needed.** --- ## Done Final state: - Engine supports `read`, `light`, `extinguish`, `use` verbs against bible-style item flags. - Parser handles stop-words, `with`/`on` prepositions, and ambiguous nouns. - Disambiguation prompts work end-to-end. - Endings fire on flag match; UI renders them and locks input. - `theme` is no longer in `GameState`. - All previously-passing tests still pass. Ready for **Phase 2**: full bible content draft, authored entirely in markdown for Obsidian editing. Polish items deferred (tracked in spec section 7): transcript scrolling, cursor blink rate, line fade, scanline accessibility toggle.