From 2a9b6155efa2b393f33123e44ee2c3c91c7842ff Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 21:51:12 -0500 Subject: [PATCH] feat(mystery): add opening and main-floor content --- ...6-05-09-mystery-content-rewrite-roadmap.md | 2 +- src/engine/dispatcher.ts | 13 + src/engine/parser.test.ts | 24 + src/engine/parser.ts | 3 + src/engine/playthrough.test.ts | 53 +- src/engine/types.ts | 2 +- src/pages/index.astro | 7 + src/ui/chips.test.ts | 16 +- src/ui/chips.ts | 1 + src/ui/crt.css | 44 +- src/ui/terminal.ts | 48 +- src/world/.obsidian/app.json | 3 +- src/world/.obsidian/appearance.json | 6 +- src/world/.obsidian/community-plugins.json | 4 + src/world/.obsidian/graph.json | 37 +- .../obsidian-minimal-settings/data.json | 33 + .../plugins/obsidian-minimal-settings/main.js | 8 + .../obsidian-minimal-settings/manifest.json | 11 + .../obsidian-minimal-settings/styles.css | 3 + .../plugins/obsidian-style-settings/data.json | 8 + .../plugins/obsidian-style-settings/main.js | 165 + .../obsidian-style-settings/manifest.json | 10 + .../obsidian-style-settings/styles.css | 243 ++ .../.obsidian/themes/Minimal/manifest.json | 8 + src/world/.obsidian/themes/Minimal/theme.css | 2246 ++++++++++ .../.obsidian/themes/Primary/manifest.json | 9 + src/world/.obsidian/themes/Primary/theme.css | 3878 +++++++++++++++++ src/world/.trash/Untitled.md | 4 + src/world/Objects.base | 31 + src/world/TODOs.md | 9 + .../Pasted image 20260509213136.png | Bin 0 -> 117186 bytes src/world/buildWorld.test.ts | 34 +- src/world/encounters.ts | 138 + src/world/encounters/breathing-wall.md | 19 + src/world/encounters/covered-cage.md | 21 + src/world/encounters/ivy-figure.md | 19 + src/world/encounters/linen-shape.md | 19 + src/world/encounters/piano-echo.md | 19 + src/world/encounters/rat.md | 2 +- src/world/encounters/window-guest.md | 19 + .../specs => src/world}/halfstreet-bible.md | 52 +- src/world/halfstreet-followon-notes.md | 0 src/world/index.ts | 4 +- src/world/items/broken-cigarette.md | 17 + src/world/items/candlestick.md | 17 + src/world/items/covered-cage.md | 9 + src/world/items/damp-sheet.md | 9 + src/world/items/dinner-place-setting.md | 9 + src/world/items/grandfather-clock.md | 9 + src/world/items/matches.md | 6 +- src/world/items/music-box-key.md | 9 + src/world/items/pruning-shears.md | 9 + src/world/items/silver-lighter.md | 10 + src/world/rooms/conservatory.md | 24 + src/world/rooms/dining-room.md | 25 + src/world/rooms/foyer.md | 9 +- src/world/rooms/hallway.md | 14 +- src/world/rooms/laundry.md | 25 + src/world/rooms/music-room.md | 25 + src/world/rooms/outside-gate.md | 27 + src/world/rooms/parlor.md | 25 + src/world/rooms/servants-passage.md | 23 + src/world/rooms/smoking-room.md | 25 + src/world/rooms/study.md | 24 + src/world/types.ts | 2 + 65 files changed, 7555 insertions(+), 72 deletions(-) create mode 100644 src/world/.obsidian/community-plugins.json create mode 100644 src/world/.obsidian/plugins/obsidian-minimal-settings/data.json create mode 100644 src/world/.obsidian/plugins/obsidian-minimal-settings/main.js create mode 100644 src/world/.obsidian/plugins/obsidian-minimal-settings/manifest.json create mode 100644 src/world/.obsidian/plugins/obsidian-minimal-settings/styles.css create mode 100644 src/world/.obsidian/plugins/obsidian-style-settings/data.json create mode 100644 src/world/.obsidian/plugins/obsidian-style-settings/main.js create mode 100644 src/world/.obsidian/plugins/obsidian-style-settings/manifest.json create mode 100644 src/world/.obsidian/plugins/obsidian-style-settings/styles.css create mode 100644 src/world/.obsidian/themes/Minimal/manifest.json create mode 100644 src/world/.obsidian/themes/Minimal/theme.css create mode 100644 src/world/.obsidian/themes/Primary/manifest.json create mode 100644 src/world/.obsidian/themes/Primary/theme.css create mode 100644 src/world/.trash/Untitled.md create mode 100644 src/world/Objects.base create mode 100644 src/world/TODOs.md create mode 100644 src/world/_attachments/Pasted image 20260509213136.png create mode 100644 src/world/encounters/breathing-wall.md create mode 100644 src/world/encounters/covered-cage.md create mode 100644 src/world/encounters/ivy-figure.md create mode 100644 src/world/encounters/linen-shape.md create mode 100644 src/world/encounters/piano-echo.md create mode 100644 src/world/encounters/window-guest.md rename {docs/superpowers/specs => src/world}/halfstreet-bible.md (70%) create mode 100644 src/world/halfstreet-followon-notes.md create mode 100644 src/world/items/broken-cigarette.md create mode 100644 src/world/items/candlestick.md create mode 100644 src/world/items/covered-cage.md create mode 100644 src/world/items/damp-sheet.md create mode 100644 src/world/items/dinner-place-setting.md create mode 100644 src/world/items/grandfather-clock.md create mode 100644 src/world/items/music-box-key.md create mode 100644 src/world/items/pruning-shears.md create mode 100644 src/world/items/silver-lighter.md create mode 100644 src/world/rooms/conservatory.md create mode 100644 src/world/rooms/dining-room.md create mode 100644 src/world/rooms/laundry.md create mode 100644 src/world/rooms/music-room.md create mode 100644 src/world/rooms/outside-gate.md create mode 100644 src/world/rooms/parlor.md create mode 100644 src/world/rooms/servants-passage.md create mode 100644 src/world/rooms/smoking-room.md create mode 100644 src/world/rooms/study.md 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' /> +