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>
10 KiB
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 byread X. Required iffreadable: true.## lit— narration whenlight Xsucceeds. Optional; falls back to"It catches."## extinguished— narration whenextinguish Xsucceeds. Optional; falls back to"The flame dies."## lighter-empty— narration when a lighter'slighterUsesreaches 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
## readtext.
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 nolighterUsesfield, orlighterUses > 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."
- if none and inventory contains a depleted lighter →
- else: decrement
lighterUseson the chosen lighter (if present); settarget.state.lit = true; narrate the target's## litsection. If decrement reached 0, additionally narrate the lighter's## lighter-emptysection.
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 X→verb-targetwith verb=use, target=X. If no encounter consumes it:"You can't think how to use that here."use X on Y→verb-target-instrumentwith verb=use, target=X, instrument=Y. The dispatcher routes the verb+target intoapplyVerbToEncounteras today; the encounter'srequires.itemmechanism (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'srequires.itemwhen 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?"whereXis therawNounand A/B/C are theshortstrings 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
pendingDisambiguationis 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
endingIdin a fixed priority order (true>wrong>bad— declared order inworld.endings), check that every key inwhenFlagsis present instate.flagsand has the matching value. First match wins. - On match: set
state.endedWith = endingId; append a narration event withkind: '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 (
disabledattribute 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
themefield fromGameStateand from save format thememeta-verb becomes a UI-layer action: dispatcher emits akind: 'ui-toggle-theme'event that the terminal handles- migration: existing saves drop the
themefield 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.