Files
halfstreet/docs/superpowers/specs/2026-05-09-mystery-engine-prereqs-design.md
T
ejlewis bcff8a42f9 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 <noreply@anthropic.com>
2026-05-09 13:07:24 -05:00

201 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (811) 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<string, string | boolean | number>`, but `droppedItems` / `takenItems` store `string[]` via `as` casts.
Change: `Record<string, string | boolean | number | string[]>`. 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.