diff --git a/docs/superpowers/specs/2026-05-09-mystery-content-rewrite-roadmap.md b/docs/superpowers/specs/2026-05-09-mystery-content-rewrite-roadmap.md index b4392df..b7d1d70 100644 --- a/docs/superpowers/specs/2026-05-09-mystery-content-rewrite-roadmap.md +++ b/docs/superpowers/specs/2026-05-09-mystery-content-rewrite-roadmap.md @@ -59,7 +59,7 @@ Phase 2 is large enough that I recommend slicing by *region*, not by *kind*. A f Suggested slices: -1. **Rewrite the existing 3 rooms** in the bible's voice. Foyer, Hallway, Cellar Stair — already wired up, fastest route to "the new tone is on screen." This is the smallest possible PR and de-risks the voice direction before scaling up. +1. **Rewrite the opening slice** in the bible's voice. The Gate, Foyer, Hallway, Cellar Stair — The Gate is the opening room, and the player should begin there carrying the folded letter, matchbook, and broken cigarette. This is the smallest possible PR and de-risks the voice direction before scaling up. 2. **Main-floor expansion** — Parlor, Study, Dining Room, Conservatory, Smoking Room, Music Room, Servants' Passage, Laundry. These connect to the existing Hallway. Add the items each room references (candlestick, pruning-shears, silver-lighter, music-box-key, damp-sheet) and their encounters (window-guest, ivy-figure, covered-cage, piano-echo, breathing-wall, linen-shape). 3. **Upper floor** — Stair, Bedroom, Nursery, Attic. Items: child's drawing, music-box (non-key), toy dog. Encounters: stair-sleeper. 4. **Garden + grounds** — Back Door, Garden, Well, Well Shaft. Encounter: garden-procession, child-beneath-the-well (verbatim prose in bible). diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index 70cf4d6..814a205 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -3,6 +3,14 @@ import type { GameState, ParsedCommand, DispatchResult, ItemInstance, Transcript import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types' import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters' +const HALFSTREET_ASCII = String.raw` + _ _ _ __ ____ _ _ +| | | | __ _| |/ _| / ___|| |_ _ __ ___ ___| |_ +| |_| |/ _\` | | |_ \___ \| __| '__/ _ \/ _ \ __| +| _ | (_| | | _| ___) | |_| | | __/ __/ |_ +|_| |_|\__,_|_|_| |____/ \__|_| \___|\___|\__| +`.trim() + export function initialStateFor(world: World): GameState { const startingRoom = world.rooms[world.startingRoom] if (!startingRoom) throw new Error(`World has invalid startingRoom: ${world.startingRoom}`) @@ -14,6 +22,7 @@ export function initialStateFor(world: World): GameState { }) const opening: TranscriptLine[] = [ + { kind: 'system', text: HALFSTREET_ASCII }, { kind: 'system', text: startingRoom.title }, { kind: 'narration', text: startingRoom.descriptions.firstVisit }, ] @@ -134,6 +143,10 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World) } if (command.kind === 'verb-only') { + const encResult = applyVerbToEncounter(state, command, world) + if (encResult?.consumed) { + return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world) + } 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) diff --git a/src/engine/parser.test.ts b/src/engine/parser.test.ts index 1046d8f..eb767c2 100644 --- a/src/engine/parser.test.ts +++ b/src/engine/parser.test.ts @@ -76,6 +76,30 @@ describe('parser — unknown input', () => { }) describe('parser — verb + target', () => { + it('recognizes slice-two encounter verbs', () => { + const ctx: ParserContext = { + knownItems: [], + knownEncounters: ['piano-echo', 'covered-cage'], + visibleNouns: [ + { id: 'piano-echo', aliases: ['piano', 'note'] }, + { id: 'covered-cage', aliases: ['cage'] }, + ], + inventoryItemIds: [], + lastNoun: null, + awaitingDisambiguation: null, + } + expect(parse('play note', ctx)).toEqual({ + kind: 'verb-target', + verb: 'play', + target: { canonical: 'piano-echo', raw: 'note' }, + }) + expect(parse('uncover cage', ctx)).toEqual({ + kind: 'verb-target', + verb: 'open', + target: { canonical: 'covered-cage', raw: 'cage' }, + }) + }) + it('resolves a single visible noun', () => { const ctx: ParserContext = { knownItems: ['torch'], diff --git a/src/engine/parser.ts b/src/engine/parser.ts index ade392c..de2cde0 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -32,6 +32,9 @@ const VERB_SYNONYMS: Record = { hold: 'hold', show: 'hold', push: 'push', press: 'push', pull: 'pull', + cut: 'cut', trim: 'cut', + play: 'play', + uncover: 'open', wait: 'wait', z: 'wait', } diff --git a/src/engine/playthrough.test.ts b/src/engine/playthrough.test.ts index 1e86a2d..567cb64 100644 --- a/src/engine/playthrough.test.ts +++ b/src/engine/playthrough.test.ts @@ -17,7 +17,11 @@ function ctxFor(state: GameState): ParserContext { if (item) visibleNouns.push({ id: inst.id, aliases: item.names }) } if (room?.encounter) { - visibleNouns.push({ id: room.encounter, aliases: [room.encounter] }) + const encounter = world.encounters[room.encounter] + visibleNouns.push({ + id: room.encounter, + aliases: [room.encounter, room.encounter.replace(/-/g, ' '), ...(encounter?.aliases ?? [])], + }) } return { knownItems: Object.keys(world.items), @@ -41,8 +45,8 @@ function play(commands: string[]): GameState { describe('playthrough — sample world', () => { it('reaches the rat-gone flag via the canonical command sequence', () => { const state = play([ - 'take letter', 'read letter', // verb is recognized but encounter takes priority elsewhere; here it's a no-op + 'n', // gate → foyer 'n', // foyer → hallway 'take lamp', 'e', // hallway → cellar-stair (triggers rat encounter) @@ -55,11 +59,52 @@ describe('playthrough — sample world', () => { it('handles invalid moves gracefully', () => { const state = play([ - 'go up', // foyer has no up exit + 'go up', // gate has no up exit 'n', 's', 'flibbertigibbet', // unknown verb ]) - expect(state.location).toBe('foyer') + expect(state.location).toBe('outside-gate') + }) + + it('plays through the main-floor slice encounters', () => { + const state = play([ + 'n', // gate → foyer + 'n', // foyer → hallway + 'n', // hallway → dining-room + 'close curtains', + 'take candlestick', + 'n', // dining-room → conservatory + 'take shears', + 'cut vines with shears', + 's', + 'w', // dining-room → hallway + 'w', // hallway → smoking-room + 'take lighter', + 'uncover cage', + 'e', + 'd', // hallway → music-room + 'play note', + 'take tiny key', + 'n', // music-room → servants-passage + 'wait', + 'e', // servants-passage → laundry + 'wait', + 'take damp sheet', + ]) + + expect(state.flags['window-guest.resolved']).toBe(true) + expect(state.flags['ivy-figure.resolved']).toBe(true) + expect(state.flags['covered-cage.resolved']).toBe(true) + expect(state.flags['piano-echo.resolved']).toBe(true) + expect(state.flags['breathing-wall.resolved']).toBe(true) + expect(state.flags['linen-shape.resolved']).toBe(true) + expect(state.inventory.map((i) => i.id)).toEqual(expect.arrayContaining([ + 'candlestick', + 'pruning-shears', + 'silver-lighter', + 'music-box-key', + 'damp-sheet', + ])) }) }) diff --git a/src/engine/types.ts b/src/engine/types.ts index cb4a7db..e9447bf 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -9,7 +9,7 @@ export type Direction = 'n' | 's' | 'e' | 'w' | 'u' | 'd' export type Verb = | 'go' | 'look' | 'examine' | 'take' | 'drop' | 'use' | 'open' | 'close' | 'read' | 'light' | 'extinguish' | 'attack' | 'inventory' | 'wait' - | 'hold' | 'push' | 'pull' + | 'hold' | 'push' | 'pull' | 'cut' | 'play' export type MetaVerb = 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme' diff --git a/src/pages/index.astro b/src/pages/index.astro index 13132e8..f78e512 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -32,6 +32,13 @@ import '../ui/crt.css' /> +