From bcff8a42f91023c89618e54f3db3970e4180ed82 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 13:07:24 -0500 Subject: [PATCH 01/19] docs(mystery): spec for engine prereqs (verbs, disambiguation, ending UI) Hard prereqs from halfstreet-followon-notes plus should-fix items. Polish items deferred. Phase 2 (full bible content draft) follows after this lands. Co-Authored-By: Claude Opus 4.7 --- ...026-05-09-mystery-engine-prereqs-design.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-09-mystery-engine-prereqs-design.md diff --git a/docs/superpowers/specs/2026-05-09-mystery-engine-prereqs-design.md b/docs/superpowers/specs/2026-05-09-mystery-engine-prereqs-design.md new file mode 100644 index 0000000..7c284cf --- /dev/null +++ b/docs/superpowers/specs/2026-05-09-mystery-engine-prereqs-design.md @@ -0,0 +1,200 @@ +# Mystery Engine Prereqs — Design + +**Status:** approved 2026-05-09 +**Goal:** land the engine work that the Halfstreet content bible depends on, so that Phase 2 (full bible content draft into markdown) can author every room/item/encounter/ending the bible specifies without engine gaps. + +**Scope:** the hard prerequisites and "should-fix while you're in there" items from `docs/superpowers/specs/halfstreet-followon-notes.md`. Polish items (8–11) are explicitly deferred. + +**Out of scope:** any new prose authoring. This round is engine-only. Phase 2 gets its own spec. + +--- + +## 1. New verbs: `read`, `light`, `extinguish` + +The parser already aliases these to canonical `Verb` values; the dispatcher does not handle them. + +### Schema additions (`Item`) + +All optional. Items that don't set them behave as today. + +| field | type | meaning | +|---|---|---| +| `readable` | `boolean` | item supports `read X` | +| `lightable` | `boolean` | item supports `light X` / `extinguish X`; toggles `state.lit` | +| `lighter` | `boolean` | item can act as the light source for another item | +| `lighterUses` | `number` | optional remaining-charges counter (matches: 4 by convention; absence = unlimited) | + +Body sections (markdown): + +- `## read` — prose returned by `read X`. Required iff `readable: true`. +- `## lit` — narration when `light X` succeeds. Optional; falls back to `"It catches."` +- `## extinguished` — narration when `extinguish X` succeeds. Optional; falls back to `"The flame dies."` +- `## lighter-empty` — narration when a lighter's `lighterUses` reaches 0. Optional; falls back to `"It is spent."` + +### Dispatcher behavior + +`read X`: +- if `!item.readable` → `"There's nothing to read on it."` +- else → narrate the item's `## read` text. + +`light X` (implicit lighter): +- if `!item.lightable` → `"You can't light that."` +- if `state.lit === true` → `"It's already lit."` +- find an inventory item with `lighter: true` (and either no `lighterUses` field, or `lighterUses > 0`): + - if none and inventory contains a depleted lighter → `"You have nothing to light it with."` (with a hint that the spent lighter is depleted) + - if none at all → `"You have nothing to light it with."` +- else: decrement `lighterUses` on the chosen lighter (if present); set `target.state.lit = true`; narrate the target's `## lit` section. If decrement reached 0, additionally narrate the lighter's `## lighter-empty` section. + +`light X with Y`: +- as above but Y must be the inventory item used. Errors: Y not in inventory, Y not a lighter, Y depleted. +- on success, same state mutations. + +`extinguish X`: +- if `!item.lightable` → `"You can't extinguish that."` +- if `state.lit !== true` → `"It isn't lit."` +- else: set `state.lit = false`; narrate `## extinguished`. + +### Parser additions + +New command kind: + +```ts +{ kind: 'verb-target-instrument', verb, target: NounRef, instrument: NounRef } +``` + +Parsing rule: when the noun-phrase tokens (after the verb and any pronoun handling) contain the literal word `with` between two recognized nouns, split into target (left of `with`) and instrument (right of `with`). Both must resolve via the same noun-resolution path used today (visible nouns + inventory). If either fails to resolve, fall back to the current `unknown-noun` behavior. + +`with` is also legal as a no-op separator for verbs that don't accept instruments (e.g. `examine letter with care`) — for safety, only `light` consumes the instrument arm in this round; other verbs treat the instrument tail as part of the target phrase and re-resolve as today (no behavior change). + +--- + +## 2. `use` verb + +`use X` and `use X on Y` route through the existing encounter dispatcher (`applyVerbToEncounter`). Encounters declare resolutions in their phase transitions; `use` is intentionally thin. + +- `use X` → `verb-target` with verb=`use`, target=X. If no encounter consumes it: `"You can't think how to use that here."` +- `use X on Y` → `verb-target-instrument` with verb=`use`, target=X, instrument=Y. The dispatcher routes the verb+target into `applyVerbToEncounter` as today; the encounter's `requires.item` mechanism (already implemented) gates the transition on the instrument being in inventory. The parser-level instrument is therefore a UX nicety: it lets the player type the form the bible uses, and we can validate the typed instrument matches the transition's `requires.item` when present (mismatch → fallback narration "That isn't going to help."). + +No special-case dispatcher handler beyond the fallback narration. No new fields on `EncounterDef`. + +--- + +## 3. Disambiguation + +Today the parser returns `unknown-noun` when multiple visible aliases match a noun phrase. Replace with an explicit ambiguous variant. + +### Parser + +New `ParsedCommand` variant: + +```ts +{ kind: 'ambiguous', verb: Verb, rawNoun: string, candidates: string[] } +``` + +Returned when ≥2 entries in `visibleNouns ∪ inventory` match the same alias. `candidates` is the list of canonical ids. + +The existing `disambiguation` reply variant stays as-is; the parser already handles single-word reply matching. + +### Dispatcher + +On `kind: 'ambiguous'`: +- set `state.pendingDisambiguation = { verb, candidates }` +- emit a single narration: `"Which X — A, B, or C?"` where `X` is the `rawNoun` and A/B/C are the `short` strings of each candidate item. + +On `kind: 'disambiguation'` reply (already wired): +- read `state.pendingDisambiguation`, clear it, re-issue the original verb against the chosen canonical id. + +### Edge cases + +- If the player issues a fresh command (not a single-word disambiguation reply) while `pendingDisambiguation` is set, clear it and proceed normally. +- If candidates resolve to identical canonical ids (shouldn't happen given current schema), prefer the first; no error. + +--- + +## 4. Ending UI + flag matching + +### Dispatcher + +After every dispatched turn (and only on turns that succeeded — meta-commands and parse failures don't trigger ending evaluation), iterate `world.endings`: + +- For each `endingId` in a fixed priority order (`true` > `wrong` > `bad` — declared order in `world.endings`), check that **every** key in `whenFlags` is present in `state.flags` and has the matching value. First match wins. +- On match: set `state.endedWith = endingId`; append a narration event with `kind: 'ending'` and the body prose (already loaded from the ending markdown). Mark the run as ended (subsequent turns are rejected with a "the game has ended" narration until restart). + +Ending evaluation is pure — same inputs, same outputs. + +### UI (terminal renderer) + +- `kind: 'ending'` events render with a separator line above, the prose centered or left-aligned with extra vertical gap, no `>` prompt afterward. +- Input field disables (`disabled` attribute or readonly + faded styling). +- Footer chips replace the regular set with `[R] Restart`, `[U] Undo`. +- `restart` (typed or button) resets state and re-enables input. +- `undo` (typed or button) pops the last turn (existing behavior) and re-enables input if the popped state had no ending. + +--- + +## 5. Should-fix items (in scope) + +### 5a. Parser stop-word strip + +Before noun resolution, strip leading occurrences of `at`, `the`, `a`, `an` from the noun-phrase tokens. So `look at the lamp` → tokens `[lamp]` for matching. The verb itself is not touched. + +Affects only the noun phrase. Stop-words *between* meaningful tokens are left alone (e.g. `light lamp with the matches` strips the leading-of-instrument `the`). + +### 5b. Remove `theme` from `GameState` + +`theme` is a UI preference, not game state. Today, clicking `[B]/[C]` updates DOM + localStorage but not `state.theme`, then the next `theme` meta-verb toggles from a stale value. + +Changes: +- delete `theme` field from `GameState` and from save format +- `theme` meta-verb becomes a UI-layer action: dispatcher emits a `kind: 'ui-toggle-theme'` event that the terminal handles +- migration: existing saves drop the `theme` field on load (forward-compatible) + +### 5c. Widen `RoomState` type + +Today: `Record`, but `droppedItems` / `takenItems` store `string[]` via `as` casts. + +Change: `Record`. Remove all `as` casts in dispatcher and engine paths that touch `roomState`. No runtime change. + +### 5d. Self-contained locked-exit dispatcher test + +The current `dispatcher.test.ts` "opens a locked exit" test is a stub. Add a 15-line synthetic world fixture (two rooms, one key item, one locked exit) and exercise: +- locked exit blocks movement with the `lockedNarration` +- carrying the required item permits movement +- the key is not consumed by passage (unless flagged so in future; current behavior: not consumed) + +--- + +## 6. Test plan + +TDD throughout, per existing project conventions. + +| area | test types | +|---|---| +| schema additions | Zod parse tests (valid + each invalid shape) | +| `read` verb | dispatcher unit (readable, non-readable, missing item) | +| `light` / `extinguish` | dispatcher unit (lightable + lighter, lighter-with-charges decrement, lighter-empty fallthrough, explicit `with`, error paths) | +| `use` verb | dispatcher unit (no encounter consumes → fallback; encounter consumes → routed) | +| disambiguation | parser unit (ambiguous candidates), dispatcher round-trip (prompt → reply → re-issue) | +| ending detection | dispatcher unit per ending; priority order; ended-state turn rejection | +| stop-word strip | parser unit (`look at lamp`, `read the letter`, etc.) | +| theme removal | save round-trip drops field; `theme` meta-verb emits UI event, no state mutation | +| roomState widen | type-level (compile passes); existing tests unchanged | +| locked-exit | new synthetic-world dispatcher test | +| manual playthrough | golden path on current 3-room world after all changes | + +--- + +## 7. Out of scope — follow-up tracker + +These are tracked but not part of this round: + +- Transcript scrolling (PageUp/PageDown) +- Cursor blink at 1.05 Hz +- Old-line opacity fade at top of transcript +- Scanline accessibility toggle (`[?]` settings dropdown — important for photosensitivity) + +--- + +## 8. After this lands + +Phase 2 brainstorming → spec → plan: full bible content draft. All 25+ rooms, all bible items, all encounters, all 5 endings authored to markdown for Obsidian editing. Engine will support every interaction the bible specifies by then. From e21a308e9d292b751d0f9b12037b003f830f5268 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 13:21:13 -0500 Subject: [PATCH 02/19] docs(mystery): implementation plan for engine prereqs 17 tasks covering type widening, theme removal, parser stop-words + ambiguous + verb-target-prep variants, dispatcher handlers for read/light/extinguish/use, ending detection, and UI ending screen. Includes self-contained locked-exit fixture and manual playthrough. Co-Authored-By: Claude Opus 4.7 --- .../2026-05-09-mystery-engine-prereqs.md | 2294 +++++++++++++++++ 1 file changed, 2294 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-09-mystery-engine-prereqs.md diff --git a/docs/superpowers/plans/2026-05-09-mystery-engine-prereqs.md b/docs/superpowers/plans/2026-05-09-mystery-engine-prereqs.md new file mode 100644 index 0000000..a09bbab --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-mystery-engine-prereqs.md @@ -0,0 +1,2294 @@ +# 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. From 6cffb87a6361e67b21b96c175d9b6247c5aa5468 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 13:28:14 -0500 Subject: [PATCH 03/19] 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 --- src/engine/types.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/engine/types.ts b/src/engine/types.ts index ad20157..b28349f 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -35,13 +35,13 @@ export type Theme = 'amber' | 'ansi' export interface ItemInstance { id: ItemId /** Per-instance state: lit/unlit, broken/whole, etc. */ - state: Record + state: Record } export type EncounterPhase = string // phase names are encounter-specific export interface TranscriptLine { - kind: 'narration' | 'player' | 'system' + kind: 'narration' | 'player' | 'system' | 'ending' text: string } @@ -56,9 +56,9 @@ export interface GameState { location: RoomId inventory: ItemInstance[] /** Per-room state: visited, items dropped, descriptive flags. */ - roomState: Record> + roomState: Record> /** Story-wide flags (e.g. 'gateOpened', 'mirrorTarnished'). */ - flags: Record + flags: Record resolveLevel: ResolveLevel /** Active encounter phase by encounter id, or null if no encounter is mid-flight. */ encounterState: Record From 657ed22b480db83d7c193ac38e083e1e061cad9d Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 13:30:35 -0500 Subject: [PATCH 04/19] refactor(engine): drop redundant string[] casts now that RoomState includes arrays Co-Authored-By: Claude Opus 4.7 --- src/engine/dispatcher.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index 640421f..91e047c 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -41,8 +41,8 @@ function append(state: GameState, lines: TranscriptLine[]): GameState { export function getItemsInRoom(state: GameState, world: World, roomId: string): string[] { const baseItems = world.rooms[roomId]?.items ?? [] - const dropped = (state.roomState[roomId]?.['droppedItems'] as string[] | undefined) ?? [] - const taken = (state.roomState[roomId]?.['takenItems'] as string[] | undefined) ?? [] + const dropped = (state.roomState[roomId]?.['droppedItems'] ?? []) as string[] + const taken = (state.roomState[roomId]?.['takenItems'] ?? []) as string[] return [...baseItems.filter((i) => !taken.includes(i)), ...dropped] } @@ -51,7 +51,7 @@ function setRoomFlag(state: GameState, roomId: string, key: string, value: strin ...state, roomState: { ...state.roomState, - [roomId]: { ...(state.roomState[roomId] ?? {}), [key]: value as string | boolean | number }, + [roomId]: { ...(state.roomState[roomId] ?? {}), [key]: value }, }, } } @@ -218,10 +218,10 @@ function handleTake(state: GameState, itemId: string, world: World): DispatchRes inventory: [...state.inventory, { id: itemId, state: { ...item.initialState } }], } if (wasInRoomBase) { - const taken = (next.roomState[state.location]?.['takenItems'] as string[] | undefined) ?? [] + const taken = (next.roomState[state.location]?.['takenItems'] ?? []) as string[] next = setRoomFlag(next, state.location, 'takenItems', [...taken, itemId]) } else { - const dropped = (next.roomState[state.location]?.['droppedItems'] as string[] | undefined) ?? [] + const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[] next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId)) } return narrate(next, [{ kind: 'narration', text: 'Taken.' }]) @@ -235,7 +235,7 @@ function handleDrop(state: GameState, itemId: string, world: World): DispatchRes ...state, inventory: state.inventory.filter((i) => i.id !== itemId), } - const dropped = (next.roomState[state.location]?.['droppedItems'] as string[] | undefined) ?? [] + const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[] next = setRoomFlag(next, state.location, 'droppedItems', [...dropped, itemId]) return narrate(next, [{ kind: 'narration', text: 'Dropped.' }]) } From 14a58481b12d7dbbc62399d50d794aa5668d6aa6 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 13:33:57 -0500 Subject: [PATCH 05/19] 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 --- src/engine/dispatcher.ts | 9 ++++----- src/engine/save.test.ts | 1 - src/engine/save.ts | 3 +++ src/engine/types.ts | 2 -- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index 91e047c..20cef42 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -29,7 +29,6 @@ export function initialStateFor(world: World): GameState { lastNoun: null, pendingDisambiguation: null, transcript: opening, - theme: 'amber', endedWith: null, } } @@ -115,10 +114,10 @@ function narrate(state: GameState, lines: TranscriptLine[]): DispatchResult { 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}.` }]) - } + // '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 + // typing the verb still produces transcript output if the UI ever misses it. + if (verb === 'theme') return narrate(state, [{ kind: 'system', text: '(theme)' }]) // restart / undo / hint / quit are handled by the UI layer (state mutations // require coordination with the save layer and route navigation). The // engine acknowledges them with a no-op narration; the UI intercepts before diff --git a/src/engine/save.test.ts b/src/engine/save.test.ts index 43a8aae..7ac5886 100644 --- a/src/engine/save.test.ts +++ b/src/engine/save.test.ts @@ -14,7 +14,6 @@ const baseState = (overrides: Partial = {}): GameState => ({ lastNoun: null, pendingDisambiguation: null, transcript: [], - theme: 'amber', endedWith: null, ...overrides, }) diff --git a/src/engine/save.ts b/src/engine/save.ts index 3c48523..bbfede1 100644 --- a/src/engine/save.ts +++ b/src/engine/save.ts @@ -48,6 +48,9 @@ export function loadState(): GameState | null { return null } + // 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 } diff --git a/src/engine/types.ts b/src/engine/types.ts index b28349f..e68419d 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -30,7 +30,6 @@ export type ParsedCommand = | { kind: 'unknown'; raw: string; reason: 'unknown-verb' | 'unknown-noun' | 'malformed' } export type ResolveLevel = 'steady' | 'shaken' | 'reeling' | 'returning' -export type Theme = 'amber' | 'ansi' export interface ItemInstance { id: ItemId @@ -68,7 +67,6 @@ export interface GameState { pendingDisambiguation: PendingDisambiguation | null /** Capped at 200 entries; older entries are dropped on append. */ transcript: TranscriptLine[] - theme: Theme /** Set true when the player has reached an ending. UI shows ending screen. */ endedWith: 'true' | 'wrong' | 'bad' | null } From b325f04b02df4a7e42d1d4dfb691b1307e97ba96 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 13:36:34 -0500 Subject: [PATCH 06/19] 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 --- src/engine/parser.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/engine/parser.ts | 8 ++++++++ 2 files changed, 45 insertions(+) diff --git a/src/engine/parser.test.ts b/src/engine/parser.test.ts index 1ea48ff..754a64c 100644 --- a/src/engine/parser.test.ts +++ b/src/engine/parser.test.ts @@ -231,3 +231,40 @@ describe('parser — pronouns', () => { expect(result.kind).toBe('unknown') }) }) + +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') + }) +}) diff --git a/src/engine/parser.ts b/src/engine/parser.ts index bdab883..364e9ba 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -59,6 +59,9 @@ const VERB_ONLY_VERBS = new Set(['look', 'inventory', 'wait']) /** Two-word verb prefixes (e.g. "pick up X"). */ const TWO_WORD_VERBS = ['pick up'] +/** Leading stop-words stripped from the noun phrase before matching. */ +const STOP_WORDS = new Set(['at', 'the', 'a', 'an']) + function tokenize(input: string): string[] { return input.trim().toLowerCase().split(/\s+/).filter(Boolean) } @@ -126,6 +129,11 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand { return { kind: 'unknown', raw: trimmed, reason: 'unknown-verb' } } + // Strip leading stop-words from the noun phrase (e.g. "at", "the", "a", "an"). + while (rest.length > 0 && STOP_WORDS.has(rest[0]!)) { + rest = rest.slice(1) + } + if (rest.length === 0) { if (VERB_ONLY_VERBS.has(verb)) { return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' } From 46f851bc3a7283b1a2140d1d77b28fc45d26e4ef Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 13:53:38 -0500 Subject: [PATCH 07/19] 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 --- src/engine/parser.test.ts | 43 ++++++++++++++++++++++++++++++++------- src/engine/parser.ts | 13 ++++++++---- src/engine/types.ts | 1 + 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/engine/parser.test.ts b/src/engine/parser.test.ts index 754a64c..f08e342 100644 --- a/src/engine/parser.test.ts +++ b/src/engine/parser.test.ts @@ -158,7 +158,7 @@ describe('parser — verb + target', () => { }) describe('parser — disambiguation', () => { - it('returns disambiguation request when two candidates match', () => { + it('returns ambiguous when two candidates match', () => { const ctx: ParserContext = { knownItems: ['brass-key', 'iron-key'], knownEncounters: [], @@ -171,12 +171,11 @@ describe('parser — disambiguation', () => { awaitingDisambiguation: null, } const result = parse('take key', ctx) - expect(result.kind).toBe('unknown') - if (result.kind === 'unknown') { - // Parser flags ambiguity by returning unknown-noun; the dispatcher - // turns this into a PendingDisambiguation. (Keeping parser pure: it - // signals; the dispatcher decides UI flow.) - expect(result.reason).toBe('unknown-noun') + expect(result.kind).toBe('ambiguous') + if (result.kind === 'ambiguous') { + expect(result.verb).toBe('take') + expect(result.rawNoun).toBe('key') + expect(result.candidates).toEqual(['brass-key', 'iron-key']) } }) @@ -268,3 +267,33 @@ describe('stop-word stripping', () => { expect(cmd.kind).toBe('verb-target') }) }) + +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') + }) +}) diff --git a/src/engine/parser.ts b/src/engine/parser.ts index 364e9ba..ee220bd 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -173,11 +173,16 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand { return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' } } - // Multiple candidates → ambiguous. Parser signals; the dispatcher records the - // PendingDisambiguation in state so the next turn's input is interpreted as - // a disambiguation reply. + // Multiple candidates → ambiguous. Dedupe by id; if only one distinct id + // remains, two aliases of the same item matched — not truly ambiguous. if (candidates.length > 1) { - return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' } + 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 } } const target = candidates[0]! diff --git a/src/engine/types.ts b/src/engine/types.ts index e68419d..cb4a7db 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -24,6 +24,7 @@ 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 } From b318747840757133d1bef80d948f56520085eb69 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 13:56:19 -0500 Subject: [PATCH 08/19] 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 --- src/engine/parser.test.ts | 57 +++++++++++++++++++++++++++++++++++++++ src/engine/parser.ts | 45 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/src/engine/parser.test.ts b/src/engine/parser.test.ts index f08e342..1046d8f 100644 --- a/src/engine/parser.test.ts +++ b/src/engine/parser.test.ts @@ -297,3 +297,60 @@ describe('ambiguous noun', () => { if (cmd.kind === 'verb-target') expect(cmd.target.canonical).toBe('iron-key') }) }) + +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' }) + }) +}) diff --git a/src/engine/parser.ts b/src/engine/parser.ts index ee220bd..ade392c 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -62,6 +62,24 @@ const TWO_WORD_VERBS = ['pick up'] /** Leading stop-words stripped from the noun phrase before matching. */ const STOP_WORDS = new Set(['at', 'the', 'a', 'an']) +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 +} + function tokenize(input: string): string[] { return input.trim().toLowerCase().split(/\s+/).filter(Boolean) } @@ -141,6 +159,33 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand { return { kind: 'unknown', raw: trimmed, reason: 'malformed' } } + // 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' } + } + } + // Pronoun resolution: "it" maps to lastNoun. if (rest.length === 1 && rest[0] === 'it') { if (!ctx.lastNoun) { From ab8c17fdd5b728c56ec9bcdfaece3394229bf53a Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 13:58:32 -0500 Subject: [PATCH 09/19] feat(engine): dispatcher handles ambiguous parses with a disambiguation prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/engine/dispatcher.test.ts | 57 ++++++++++++++++++++++++++++++++++- src/engine/dispatcher.ts | 14 +++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/engine/dispatcher.test.ts b/src/engine/dispatcher.test.ts index 440e565..35f11a3 100644 --- a/src/engine/dispatcher.test.ts +++ b/src/engine/dispatcher.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { dispatch, initialStateFor } from './dispatcher' import type { World } from '../world/types' -import type { GameState } from './types' +import type { GameState, ParsedCommand } from './types' import { SCHEMA_VERSION } from './types' const world: World = { @@ -157,3 +157,58 @@ describe('dispatcher — inventory', () => { expect(r.appended.some((l) => /empty-handed|carrying nothing/i.test(l.text))).toBe(true) }) }) + +describe('ambiguous → disambiguation flow', () => { + function makeAmbiguousWorld(): World { + return { + 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: '' }, + }, + } + } + + it('sets pendingDisambiguation and prompts when the parser returns ambiguous', () => { + const world = makeAmbiguousWorld() + 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 by re-issuing the verb', () => { + const world = makeAmbiguousWorld() + let state = initialStateFor(world) + state = { + ...state, + pendingDisambiguation: { verb: 'take', candidates: ['iron-key', 'brass-key'], prompt: '...' }, + } + const result = dispatch(state, { kind: 'disambiguation', chosen: 'iron-key' }, world) + expect(result.state.pendingDisambiguation).toBeNull() + expect(result.state.inventory.find((i) => i.id === 'iron-key')).toBeDefined() + }) +}) diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index 20cef42..de9a49b 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -86,6 +86,20 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World) return handleGo(state, command.direction, world) } + 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 }]) + } + if (command.kind === 'verb-only') { if (command.verb === 'look') return handleLook(state, world) if (command.verb === 'inventory') return handleInventory(state, world) From df50afa479cdfd62bb1492bfe63fa5529572257c Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 14:01:31 -0500 Subject: [PATCH 10/19] =?UTF-8?q?feat(world):=20item=20schema=20=E2=80=94?= =?UTF-8?q?=20readable,=20lightable,=20lighter,=20lighterUses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Optional fields used by the new read/light/extinguish dispatcher branches. Loader updates and dispatcher logic follow. Co-Authored-By: Claude Opus 4.7 --- src/world/loader.ts | 2 +- src/world/schema.test.ts | 24 ++++++++++++++++++++++++ src/world/schema.ts | 6 +++++- src/world/types.ts | 24 ++++++++++++++++++++---- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/world/loader.ts b/src/world/loader.ts index 75880bb..d3fc545 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -150,7 +150,7 @@ export function parseItem(raw: string, sourcePath: string): Item { export interface ParsedEnding { id: 'true' | 'wrong' | 'bad' - ending: { whenFlags: Record; narration: string } + ending: { whenFlags: Record; narration: string } } export function parseEnding(raw: string, _sourcePath: string): ParsedEnding { diff --git a/src/world/schema.test.ts b/src/world/schema.test.ts index f3f581a..632b9f4 100644 --- a/src/world/schema.test.ts +++ b/src/world/schema.test.ts @@ -79,3 +79,27 @@ describe('encounterFrontmatterSchema', () => { expect(() => encounterFrontmatterSchema.parse(data)).not.toThrow() }) }) + +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() + }) +}) diff --git a/src/world/schema.ts b/src/world/schema.ts index f93f343..2d19aeb 100644 --- a/src/world/schema.ts +++ b/src/world/schema.ts @@ -1,6 +1,6 @@ import { z } from 'zod' -const stateValueSchema = z.union([z.string(), z.boolean(), z.number()]) +const stateValueSchema = z.union([z.string(), z.boolean(), z.number(), z.array(z.string())]) const stateRecordSchema = z.record(z.string(), stateValueSchema) export const roomFrontmatterSchema = z.object({ @@ -37,6 +37,10 @@ export const itemFrontmatterSchema = z.object({ 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(), }) export type ItemFrontmatter = z.infer diff --git a/src/world/types.ts b/src/world/types.ts index a1449b5..e35c301 100644 --- a/src/world/types.ts +++ b/src/world/types.ts @@ -37,9 +37,25 @@ export interface Item { /** Long description shown when examined. */ long: string /** Initial per-instance state (e.g. `{ lit: false }`). */ - initialState: Record + initialState: Record /** True if the player can pick it up. */ 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 } export interface EncounterPhaseDef { @@ -83,8 +99,8 @@ export interface World { encounters: Record /** Story flag definitions and the endings they unlock. */ endings: { - true: { whenFlags: Record; narration: string } - wrong: { whenFlags: Record; narration: string } - bad: { whenFlags: Record; narration: string } + true: { whenFlags: Record; narration: string } + wrong: { whenFlags: Record; narration: string } + bad: { whenFlags: Record; narration: string } } } From ee3cfcc00de900e243fa66a481222ddcaaaf109f Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 14:06:29 -0500 Subject: [PATCH 11/19] 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 --- src/world/loader.test.ts | 112 +++++++++++++++++++++++++++++++++++++++ src/world/loader.ts | 38 +++++++++++-- 2 files changed, 146 insertions(+), 4 deletions(-) diff --git a/src/world/loader.test.ts b/src/world/loader.test.ts index 90e39f2..a6b923a 100644 --- a/src/world/loader.test.ts +++ b/src/world/loader.test.ts @@ -311,6 +311,118 @@ describe('narration registry', () => { }) }) +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() + }) + + it('throws for unknown section keys', () => { + const md = `--- +id: x +names: [x] +short: x +takeable: true +--- + +A thing. + +## badkey +Content. +` + expect(() => parseItem(md, 'items/x.md')).toThrow(/unknown item section "## badkey".*Allowed:.*read.*lit.*extinguished.*lighter-empty/i) + }) +}) + describe('parseRoom invalid headers', () => { it('throws a clear error when a header has spaces', () => { const md = `--- diff --git a/src/world/loader.ts b/src/world/loader.ts index d3fc545..48acec4 100644 --- a/src/world/loader.ts +++ b/src/world/loader.ts @@ -130,22 +130,52 @@ export function parseRoom(raw: string, sourcePath: string): Room { return room } +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) - const long = parsed.content.trim() - if (long.length === 0) { + + // 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`) } - return { + 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, + 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 } export interface ParsedEnding { From 2fecc7878de6fa73ffcf10b2fe8320f247d30ca2 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 14:12:10 -0500 Subject: [PATCH 12/19] 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 --- src/world/items/lamp.md | 7 +++++++ src/world/items/letter.md | 4 ++++ src/world/items/matches.md | 7 +++++++ 3 files changed, 18 insertions(+) diff --git a/src/world/items/lamp.md b/src/world/items/lamp.md index 201fa6c..a7e5abe 100644 --- a/src/world/items/lamp.md +++ b/src/world/items/lamp.md @@ -3,8 +3,15 @@ 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. diff --git a/src/world/items/letter.md b/src/world/items/letter.md index 2823332..070f4d4 100644 --- a/src/world/items/letter.md +++ b/src/world/items/letter.md @@ -3,6 +3,10 @@ id: letter names: ["letter", "folded letter", "paper"] short: "a folded letter" takeable: true +readable: true --- +A folded letter. The wax seal has been broken once already. + +## read A folded letter on yellowed paper. The hand is unfamiliar. It reads: "Come at once. The thing in the cellar is waking." diff --git a/src/world/items/matches.md b/src/world/items/matches.md index 808ddac..74fafb9 100644 --- a/src/world/items/matches.md +++ b/src/world/items/matches.md @@ -3,6 +3,13 @@ id: matches names: ["matches", "safety matches", "box"] short: "a box of safety matches" takeable: true +lighter: true +lighterUses: 4 +initialState: + uses: 4 --- A small cardboard box of safety matches. Half-full. + +## lighter-empty +The last match flares, burns down, and goes out. The book is empty. From dac8487dbebe256b4778337e329e02f462e6a689 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 14:15:35 -0500 Subject: [PATCH 13/19] feat(engine): read verb narrates item.readableText Co-Authored-By: Claude Opus 4.7 --- src/engine/dispatcher.test.ts | 40 +++++++++++++++++++++++++++++++++++ src/engine/dispatcher.ts | 14 ++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/engine/dispatcher.test.ts b/src/engine/dispatcher.test.ts index 35f11a3..460386a 100644 --- a/src/engine/dispatcher.test.ts +++ b/src/engine/dispatcher.test.ts @@ -212,3 +212,43 @@ describe('ambiguous → disambiguation flow', () => { expect(result.state.inventory.find((i) => i.id === 'iron-key')).toBeDefined() }) }) + +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: '' }, + }, + } +} + +describe('read verb', () => { + it('narrates readableText for a readable item in inventory', () => { + const world = readWorld() + 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.") + }) +}) diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index de9a49b..36b0183 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -116,6 +116,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World) if (command.verb === 'take') return handleTake(stateWithNoun, command.target.canonical, world) if (command.verb === 'drop') return handleDrop(stateWithNoun, command.target.canonical, world) if (command.verb === 'examine' || command.verb === 'look') return handleExamine(stateWithNoun, command.target.canonical, world) + if (command.verb === 'read') return handleRead(stateWithNoun, command.target.canonical, world) return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]) } @@ -262,3 +263,16 @@ function handleExamine(state: GameState, itemId: string, world: World): Dispatch if (!visible) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }]) return narrate(state, [{ kind: 'narration', text: item.long }]) } + +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 }]) +} From 8401e7d2811d16dd5ed62c5cfff8ed043f8f6aee Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 14:18:54 -0500 Subject: [PATCH 14/19] 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 --- src/engine/dispatcher.test.ts | 88 +++++++++++++++++++++++++++++++++++ src/engine/dispatcher.ts | 75 +++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/src/engine/dispatcher.test.ts b/src/engine/dispatcher.test.ts index 460386a..a8e069b 100644 --- a/src/engine/dispatcher.test.ts +++ b/src/engine/dispatcher.test.ts @@ -252,3 +252,91 @@ describe('read verb', () => { expect(result.appended.at(-1)?.text).toBe("There's nothing to read on it.") }) }) + +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.") + }) +}) diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index 36b0183..cfe6033 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -117,6 +117,8 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World) if (command.verb === 'drop') return handleDrop(stateWithNoun, command.target.canonical, world) if (command.verb === 'examine' || command.verb === 'look') return handleExamine(stateWithNoun, command.target.canonical, world) if (command.verb === 'read') return handleRead(stateWithNoun, command.target.canonical, world) + if (command.verb === 'light') return handleLight(stateWithNoun, command.target.canonical, null, world) + if (command.verb === 'extinguish') return handleExtinguish(stateWithNoun, command.target.canonical, world) return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]) } @@ -276,3 +278,76 @@ function handleRead(state: GameState, itemId: string, world: World): DispatchRes } return narrate(state, [{ kind: 'narration', text: item.readableText }]) } + +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.' }]) +} From b870d884ef00b60037b0a0eff9c6c5b855d77c41 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 14:22:03 -0500 Subject: [PATCH 15/19] =?UTF-8?q?feat(engine):=20wire=20verb-target-prep?= =?UTF-8?q?=20=E2=80=94=20explicit=20\`light=20X=20with=20Y\`=20and=20\`us?= =?UTF-8?q?e\`=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/engine/dispatcher.test.ts | 75 +++++++++++++++++++++++++++++++++++ src/engine/dispatcher.ts | 17 ++++++++ src/engine/encounters.ts | 8 +++- 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/engine/dispatcher.test.ts b/src/engine/dispatcher.test.ts index a8e069b..f9e1899 100644 --- a/src/engine/dispatcher.test.ts +++ b/src/engine/dispatcher.test.ts @@ -340,3 +340,78 @@ describe('light/extinguish verbs (implicit lighter)', () => { expect(result.appended.at(-1)?.text).toBe("It isn't lit.") }) }) + +describe('light X with Y (explicit 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 with the explicit instrument', () => { + 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', () => { + function w(): World { + return { + startingRoom: 'r', + startingInventory: [], + rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } }, + items: { + rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true }, + }, + encounters: {}, + endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' } }, + } + } + + 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.") + }) +}) diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index cfe6033..6aefdd4 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -119,6 +119,23 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World) if (command.verb === 'read') return handleRead(stateWithNoun, command.target.canonical, world) if (command.verb === 'light') return handleLight(stateWithNoun, command.target.canonical, null, world) if (command.verb === 'extinguish') return handleExtinguish(stateWithNoun, command.target.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.` }]) + } + + 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.` }]) } diff --git a/src/engine/encounters.ts b/src/engine/encounters.ts index 8daa9b3..2f2a09f 100644 --- a/src/engine/encounters.ts +++ b/src/engine/encounters.ts @@ -66,12 +66,17 @@ export function applyVerbToEncounter( const phaseDef = def.phases[currentPhase] if (!phaseDef) return null - // Only verb-target and verb-only commands engage with encounters. + // 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 { @@ -91,6 +96,7 @@ export function applyVerbToEncounter( } } } + if (t.requires && instrumentId && t.requires.item !== instrumentId) return false return true }) From 0d9db9bb555b9a301e4b6b765eb5f4bf46f48c03 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 14:42:22 -0500 Subject: [PATCH 16/19] 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 --- src/engine/dispatcher.test.ts | 59 +++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/src/engine/dispatcher.test.ts b/src/engine/dispatcher.test.ts index f9e1899..1931815 100644 --- a/src/engine/dispatcher.test.ts +++ b/src/engine/dispatcher.test.ts @@ -94,13 +94,60 @@ describe('dispatcher — go', () => { expect(r.state.location).toBe('hallway') expect(r.appended.some((l) => l.text.includes('locked'))).toBe(true) }) +}) - it('opens a locked exit when required item is in inventory', () => { - // Locked-exit-with-key happy path is covered by the playthrough integration - // test in Task 8. The sample world above doesn't have an unlocked path to - // pick up the brass key without first traversing the locked door, so this - // test is intentionally a placeholder. - expect(true).toBe(true) +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() }) }) From 19d1efc5864aa774c1d8f0846aa39af253c55643 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 14:53:07 -0500 Subject: [PATCH 17/19] 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. Also update test-world fixtures and placeholder ending markdown files to use whenFlags: { _never: true } instead of {} so that vacuously-true empty flags don't accidentally fire on every successful turn. Co-Authored-By: Claude Opus 4.7 --- src/engine/dispatcher.test.ts | 88 +++++++++++++++++++++++++++++++---- src/engine/dispatcher.ts | 67 +++++++++++++++++++------- src/engine/encounters.test.ts | 6 +-- src/world/endings/bad.md | 3 +- src/world/endings/wrong.md | 3 +- 5 files changed, 135 insertions(+), 32 deletions(-) diff --git a/src/engine/dispatcher.test.ts b/src/engine/dispatcher.test.ts index 1931815..0f9d0d1 100644 --- a/src/engine/dispatcher.test.ts +++ b/src/engine/dispatcher.test.ts @@ -122,7 +122,7 @@ describe('locked exits', () => { '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: '' } }, + endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } }, } } @@ -225,9 +225,9 @@ describe('ambiguous → disambiguation flow', () => { }, encounters: {}, endings: { - true: { whenFlags: {}, narration: '' }, - wrong: { whenFlags: {}, narration: '' }, - bad: { whenFlags: {}, narration: '' }, + true: { whenFlags: { _never: true }, narration: '' }, + wrong: { whenFlags: { _never: true }, narration: '' }, + bad: { whenFlags: { _never: true }, narration: '' }, }, } } @@ -271,9 +271,9 @@ function readWorld(): World { }, encounters: {}, endings: { - true: { whenFlags: {}, narration: '' }, - wrong: { whenFlags: {}, narration: '' }, - bad: { whenFlags: {}, narration: '' }, + true: { whenFlags: { _never: true }, narration: '' }, + wrong: { whenFlags: { _never: true }, narration: '' }, + bad: { whenFlags: { _never: true }, narration: '' }, }, } } @@ -312,7 +312,7 @@ describe('light/extinguish verbs (implicit lighter)', () => { rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true }, }, encounters: {}, - endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' } }, + endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } }, } } @@ -400,7 +400,7 @@ describe('light X with Y (explicit lighter)', () => { rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true }, }, encounters: {}, - endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' } }, + endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } }, } } @@ -448,7 +448,7 @@ describe('use verb routing', () => { rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true }, }, encounters: {}, - endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' } }, + endings: { true: { whenFlags: { _never: true }, narration: '' }, wrong: { whenFlags: { _never: true }, narration: '' }, bad: { whenFlags: { _never: true }, narration: '' } }, } } @@ -462,3 +462,71 @@ describe('use verb routing', () => { expect(result.appended.at(-1)?.text).toBe("You can't think how to use that here.") }) }) + +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 } } + 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() + 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 turns (no state mutation)', () => { + const world = makeWorld() + const state = initialStateFor(world) + const result = dispatch(state, { kind: 'unknown', raw: 'fnord', reason: 'unknown-verb' }, world) + expect(result.state.endedWith).toBeNull() + }) +}) diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index 6aefdd4..70cf4d6 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -55,6 +55,34 @@ function setRoomFlag(state: GameState, roomId: string, key: string, value: strin } } +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 + return { + ...state, + endedWith: id, + transcript: [...state.transcript, { kind: 'ending', text: ending.narration }], + } + } + return null +} + +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] } +} + export function dispatch(state: GameState, command: ParsedCommand, world: World): DispatchResult { // Disambiguation reply: re-issue the original verb with the chosen target. if (command.kind === 'disambiguation') { @@ -70,6 +98,11 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World) ) } + // Once the game has ended, only restart/undo (handled by the UI) can clear state. + if (state.endedWith) { + return narrate(state, [{ kind: 'narration', text: 'The story has ended. Type `restart` or `undo`.' }]) + } + if (command.kind === 'unknown') { const text = command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.' @@ -83,7 +116,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World) } if (command.kind === 'go') { - return handleGo(state, command.direction, world) + return withEndingCheck(handleGo(state, command.direction, world), world) } if (command.kind === 'ambiguous') { @@ -101,9 +134,9 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World) } if (command.kind === 'verb-only') { - if (command.verb === 'look') return handleLook(state, world) - if (command.verb === 'inventory') return handleInventory(state, world) - if (command.verb === 'wait') return narrate(state, [{ kind: 'narration', text: 'Time passes.' }]) + 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') { @@ -111,16 +144,16 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World) // Try the active encounter first — it may consume verbs like 'attack', 'hold'. const encResult = applyVerbToEncounter(stateWithNoun, command, world) if (encResult?.consumed) { - return { state: encResult.state, appended: encResult.lines } + return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world) } - if (command.verb === 'take') return handleTake(stateWithNoun, command.target.canonical, world) - if (command.verb === 'drop') return handleDrop(stateWithNoun, command.target.canonical, world) - if (command.verb === 'examine' || command.verb === 'look') return handleExamine(stateWithNoun, command.target.canonical, world) - if (command.verb === 'read') return handleRead(stateWithNoun, command.target.canonical, world) - if (command.verb === 'light') return handleLight(stateWithNoun, command.target.canonical, null, world) - if (command.verb === 'extinguish') return handleExtinguish(stateWithNoun, command.target.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.` }]) + if (command.verb === 'take') return withEndingCheck(handleTake(stateWithNoun, command.target.canonical, world), world) + if (command.verb === 'drop') return withEndingCheck(handleDrop(stateWithNoun, command.target.canonical, world), world) + if (command.verb === 'examine' || command.verb === 'look') return withEndingCheck(handleExamine(stateWithNoun, command.target.canonical, world), world) + if (command.verb === 'read') return withEndingCheck(handleRead(stateWithNoun, command.target.canonical, world), world) + if (command.verb === 'light') return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, null, world), world) + if (command.verb === 'extinguish') return withEndingCheck(handleExtinguish(stateWithNoun, command.target.canonical, world), world) + if (command.verb === 'use') return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world) + return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world) } if (command.kind === 'verb-target-prep') { @@ -128,15 +161,15 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World) // 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 } + return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world) } if (command.verb === 'light' && command.preposition === 'with') { - return handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world) + return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world), world) } if (command.verb === 'use') { - return narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]) + return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world) } - return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]) + return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world) } return narrate(state, [{ kind: 'narration', text: 'Nothing happens.' }]) diff --git a/src/engine/encounters.test.ts b/src/engine/encounters.test.ts index 31dbada..7839864 100644 --- a/src/engine/encounters.test.ts +++ b/src/engine/encounters.test.ts @@ -61,9 +61,9 @@ const world: World = { }, }, endings: { - true: { whenFlags: {}, narration: '' }, - wrong: { whenFlags: {}, narration: '' }, - bad: { whenFlags: {}, narration: '' }, + true: { whenFlags: { _never: true }, narration: '' }, + wrong: { whenFlags: { _never: true }, narration: '' }, + bad: { whenFlags: { _never: true }, narration: '' }, }, } diff --git a/src/world/endings/bad.md b/src/world/endings/bad.md index 2ecd732..59e5a3f 100644 --- a/src/world/endings/bad.md +++ b/src/world/endings/bad.md @@ -1,5 +1,6 @@ --- id: bad -whenFlags: {} +whenFlags: + _never: true --- diff --git a/src/world/endings/wrong.md b/src/world/endings/wrong.md index e78c958..d87eb0e 100644 --- a/src/world/endings/wrong.md +++ b/src/world/endings/wrong.md @@ -1,5 +1,6 @@ --- id: wrong -whenFlags: {} +whenFlags: + _never: true --- From e167979fa71e6321ba34921806aee339551fd1ab Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 14:57:52 -0500 Subject: [PATCH 18/19] 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 --- src/ui/crt.css | 14 ++++++++++++++ src/ui/terminal.ts | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/ui/crt.css b/src/ui/crt.css index d6c5159..4c06e91 100644 --- a/src/ui/crt.css +++ b/src/ui/crt.css @@ -169,3 +169,17 @@ @media (pointer: coarse) { .mystery-chips { display: flex; } } + +.mystery-transcript .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; +} diff --git a/src/ui/terminal.ts b/src/ui/terminal.ts index 0716c1c..349828a 100644 --- a/src/ui/terminal.ts +++ b/src/ui/terminal.ts @@ -33,6 +33,10 @@ if (!transcriptEl || !inputEl) { }) } + const syncEndedUI = (): void => { + inputEl!.disabled = state.endedWith !== null + } + const buildParserContext = (s: GameState): ParserContext => { const room = world.rooms[s.location] const visibleNouns: { id: string; aliases: string[] }[] = [] @@ -81,6 +85,7 @@ if (!transcriptEl || !inputEl) { renderAll(state.transcript) refreshChips() + syncEndedUI() inputEl.focus() inputEl.addEventListener('keydown', (e) => { @@ -91,6 +96,15 @@ if (!transcriptEl || !inputEl) { if (!raw.trim()) return appendLines([{ kind: 'player', text: raw }]) + // 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 + } + } + // Engine-level meta-commands handled here so the engine stays pure. const trimmed = raw.trim().toLowerCase() if (trimmed === 'restart') { @@ -105,6 +119,7 @@ if (!transcriptEl || !inputEl) { renderAll(state.transcript) saveState(state) refreshChips() + syncEndedUI() return } if (trimmed === 'undo') { @@ -114,6 +129,7 @@ if (!transcriptEl || !inputEl) { appendLines([{ kind: 'system', text: '(undone)' }]) saveState(state) refreshChips() + syncEndedUI() } else { appendLines([{ kind: 'system', text: 'There is no further back.' }]) } @@ -139,6 +155,7 @@ if (!transcriptEl || !inputEl) { document.dispatchEvent(new CustomEvent('halfstreet-toggle-theme')) } refreshChips() + syncEndedUI() } catch (err) { console.error('[halfstreet] dispatch error', err) appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }]) From 5f8e3b1a34a5a00729c3f2990e83fb23f59e021c Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 15:06:37 -0500 Subject: [PATCH 19/19] fix(ui): keep input typable post-end so player can type restart/undo Disabling the input via the disabled attribute blocks keydown events entirely, so players couldn't type 'restart' or 'undo' after reaching an ending. Switch to a CSS class for the faded visual state; the keydown handler already restricts post-end input to those two commands. Co-Authored-By: Claude Opus 4.7 --- src/ui/crt.css | 5 ++--- src/ui/terminal.ts | 6 +++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/ui/crt.css b/src/ui/crt.css index 4c06e91..9cd9b28 100644 --- a/src/ui/crt.css +++ b/src/ui/crt.css @@ -179,7 +179,6 @@ white-space: pre-wrap; } -[data-mystery-input]:disabled { - opacity: 0.4; - cursor: not-allowed; +[data-mystery-input].ended { + opacity: 0.55; } diff --git a/src/ui/terminal.ts b/src/ui/terminal.ts index 349828a..6ea55ee 100644 --- a/src/ui/terminal.ts +++ b/src/ui/terminal.ts @@ -34,7 +34,11 @@ if (!transcriptEl || !inputEl) { } const syncEndedUI = (): void => { - inputEl!.disabled = state.endedWith !== null + // Don't disable the input — the player still needs to type `restart` or + // `undo`. A `disabled` input rejects keydown events entirely. Use a class + // for visual styling instead; the keydown handler enforces the input + // restriction. + inputEl!.classList.toggle('ended', state.endedWith !== null) } const buildParserContext = (s: GameState): ParserContext => {