Files
halfstreet/docs/superpowers/specs/2026-05-09-mystery-engine-prereqs-design.md
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

10 KiB
Raw Permalink Blame History

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:

{ 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 Xverb-target with verb=use, target=X. If no encounter consumes it: "You can't think how to use that here."
  • use X on Yverb-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:

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