feat(mystery): add opening and main-floor content

This commit is contained in:
2026-05-09 21:51:12 -05:00
parent e46b2359c0
commit 2a9b6155ef
65 changed files with 7555 additions and 72 deletions
@@ -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).
+13
View File
@@ -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)
+24
View File
@@ -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'],
+3
View File
@@ -32,6 +32,9 @@ const VERB_SYNONYMS: Record<string, Verb> = {
hold: 'hold', show: 'hold',
push: 'push', press: 'push',
pull: 'pull',
cut: 'cut', trim: 'cut',
play: 'play',
uncover: 'open',
wait: 'wait', z: 'wait',
}
+49 -4
View File
@@ -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',
]))
})
})
+1 -1
View File
@@ -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'
+7
View File
@@ -32,6 +32,13 @@ import '../ui/crt.css'
/>
</div>
</div>
<footer class="mystery-footer">
By <a href="https://ethanjlewis.com">Ethan J Lewis</a>
<span aria-hidden="true">|</span>
<a href="https://half.st/ejlewis/halfstreet">Source Code</a>
<span aria-hidden="true">|</span>
<a href="https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE">GNU General Public License v3.0</a>
</footer>
</div>
<script>
// Theme attribute is set on :root before any rendering to avoid a flash
+11 -5
View File
@@ -14,30 +14,36 @@ describe('computeChips — sample world', () => {
})
it('adds TAKE chips for visible takeable items', () => {
const s = initialStateFor(world)
let s = initialStateFor(world)
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
const chips = computeChips(s, world)
expect(chips.find((c) => c.kind === 'item' && c.command === 'take letter')).toBeTruthy()
expect(chips.find((c) => c.kind === 'item' && c.command === 'take lamp')).toBeTruthy()
})
it('removes TAKE chip after item is taken', () => {
let s = initialStateFor(world)
s = dispatch(s, { kind: 'verb-target', verb: 'take', target: { canonical: 'letter', raw: 'letter' } }, world).state
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
s = dispatch(s, { kind: 'verb-target', verb: 'take', target: { canonical: 'lamp', raw: 'lamp' } }, world).state
const chips = computeChips(s, world)
expect(chips.find((c) => c.command === 'take letter')).toBeUndefined()
expect(chips.find((c) => c.command === 'take lamp')).toBeUndefined()
})
it('adds an encounter verb chip when an encounter is active', () => {
let s = initialStateFor(world)
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
s = dispatch(s, { kind: 'go', direction: 'n' }, world).state
s = dispatch(s, { kind: 'go', direction: 'e' }, world).state
const chips = computeChips(s, world)
expect(chips.find((c) => c.kind === 'encounter' && c.command.includes('rat'))).toBeTruthy()
})
it('always includes LOOK and INV', () => {
it('always includes LOOK, INV, and HELP', () => {
const s = initialStateFor(world)
const chips = computeChips(s, world)
expect(chips.find((c) => c.command === 'look')).toBeTruthy()
expect(chips.find((c) => c.command === 'inventory')).toBeTruthy()
expect(chips.find((c) => c.command === 'help')).toBeTruthy()
})
})
+1
View File
@@ -68,6 +68,7 @@ export function computeChips(state: GameState, world: World): Chip[] {
// Persistent meta chips.
out.push({ kind: 'meta', label: 'LOOK', command: 'look', disabled: false })
out.push({ kind: 'meta', label: 'INV', command: 'inventory', disabled: false })
out.push({ kind: 'meta', label: 'HELP', command: 'help', disabled: false })
return out
}
+38 -6
View File
@@ -46,6 +46,25 @@
box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.55);
}
.mystery-footer {
position: relative;
z-index: 2;
padding: 8px 4px 0;
color: var(--m-dim);
font-size: 11px;
line-height: 1.35;
text-align: center;
}
.mystery-footer a {
color: var(--m-fg);
text-decoration: none;
}
.mystery-footer span {
margin: 0 0.5ch;
}
.mystery-bezel::before {
/* scanlines overlay */
content: '';
@@ -104,6 +123,14 @@
margin-top: 0.6em;
}
.mystery-transcript .help {
color: var(--m-fg);
font-weight: normal;
border: 1px var(--m-divider-style) var(--m-dim);
padding: 0.75em;
margin: 0.75em 0;
}
.mystery-transcript .player {
color: var(--m-accent-2);
}
@@ -140,13 +167,12 @@
}
.mystery-chips {
display: none;
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px 0 4px;
position: relative;
z-index: 2;
border-top: 1px var(--m-divider-style) var(--m-dim);
margin-top: 8px;
}
@@ -166,10 +192,6 @@
cursor: not-allowed;
}
@media (pointer: coarse) {
.mystery-chips { display: flex; }
}
.mystery-transcript .ending {
margin-top: 2em;
margin-bottom: 1em;
@@ -179,6 +201,16 @@
white-space: pre-wrap;
}
@media (max-width: 640px) {
.mystery-root {
padding: 8px;
}
.mystery-bezel {
padding: 18px 14px 12px;
}
}
[data-mystery-input].ended {
opacity: 0.55;
}
+47 -1
View File
@@ -11,12 +11,32 @@ import { renderChips } from './chip-render'
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
const HELP_TEXT = `You arrive at the address, but you do not remember what has happened. The road behind you is gone...
This is a text adventure. Type short commands to act in the house.
Common commands:
look describe the room again
n, s, e, w, u, d move by direction
take lamp pick something up
examine letter inspect something nearby or held
read letter read a readable object
inventory see what you carry
light lamp with matches use one thing with another
wait let the room continue
undo step back once
restart begin again
theme change the terminal colors
Most commands are verb first, then the thing: examine gate, take lamp, use key on door.`
if (!transcriptEl || !inputEl) {
console.error('[halfstreet] terminal mount points missing')
} else {
const restored = loadState()
let state: GameState = restored ?? initialStateFor(world)
let lastState: GameState | null = null // for one-step undo
let transientHelpEl: HTMLDivElement | null = null
if (!restored) {
// Fresh state already includes the opening narration in its transcript.
@@ -50,7 +70,11 @@ if (!transcriptEl || !inputEl) {
if (it) visibleNouns.push({ id, aliases: it.names })
}
if (room.encounter && s.encounterState[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 ?? [])],
})
}
}
for (const inst of s.inventory) {
@@ -78,6 +102,23 @@ if (!transcriptEl || !inputEl) {
transcriptEl.scrollTop = transcriptEl.scrollHeight
}
const clearTransientHelp = (): void => {
transientHelpEl?.remove()
transientHelpEl = null
}
const renderTransientHelp = (): void => {
if (!transcriptEl) return
clearTransientHelp()
const el = document.createElement('div')
el.className = 'system help'
el.dataset.transientHelp = 'true'
el.textContent = HELP_TEXT
transcriptEl.appendChild(el)
transientHelpEl = el
transcriptEl.scrollTop = transcriptEl.scrollHeight
}
// For UI-originated lines (player input, restart/undo/quit messages, error
// notices). Pushes into state.transcript so they survive reload, then renders.
// Engine-originated lines (from dispatch) are already in state.transcript;
@@ -98,6 +139,7 @@ if (!transcriptEl || !inputEl) {
const raw = inputEl.value
inputEl.value = ''
if (!raw.trim()) return
clearTransientHelp()
appendLines([{ kind: 'player', text: raw }])
// Once the game has ended, only restart and undo are allowed.
@@ -126,6 +168,10 @@ if (!transcriptEl || !inputEl) {
syncEndedUI()
return
}
if (trimmed === 'help') {
renderTransientHelp()
return
}
if (trimmed === 'undo') {
if (lastState) {
state = lastState
+2 -1
View File
@@ -2,5 +2,6 @@
"alwaysUpdateLinks": true,
"newLinkFormat": "shortest",
"useMarkdownLinks": false,
"attachmentFolderPath": "_attachments"
"attachmentFolderPath": "_attachments",
"propertiesInDocument": "hidden"
}
+5 -1
View File
@@ -1 +1,5 @@
{}
{
"theme": "obsidian",
"accentColor": "#50609f",
"cssTheme": "Primary"
}
+4
View File
@@ -0,0 +1,4 @@
[
"obsidian-minimal-settings",
"obsidian-style-settings"
]
+33 -4
View File
@@ -6,17 +6,46 @@
"hideUnresolved": false,
"showOrphans": true,
"collapse-color-groups": false,
"colorGroups": [],
"colorGroups": [
{
"query": "path:rooms ",
"color": {
"a": 1,
"rgb": 14701138
}
},
{
"query": "path:items ",
"color": {
"a": 1,
"rgb": 14725458
}
},
{
"query": "path:encounters ",
"color": {
"a": 1,
"rgb": 11657298
}
},
{
"query": "path:endings ",
"color": {
"a": 1,
"rgb": 5431378
}
}
],
"collapse-display": false,
"showArrow": false,
"textFadeMultiplier": 0,
"nodeSizeMultiplier": 1.2,
"lineSizeMultiplier": 1,
"nodeSizeMultiplier": 1.82265625,
"lineSizeMultiplier": 5,
"collapse-forces": false,
"centerStrength": 0.518713248970312,
"repelStrength": 10,
"linkStrength": 1,
"linkDistance": 250,
"scale": 2.25,
"scale": 0.8332185563593782,
"close": true
}
@@ -0,0 +1,33 @@
{
"lightStyle": "minimal-light",
"darkStyle": "minimal-dark",
"lightScheme": "minimal-default-light",
"darkScheme": "minimal-default-dark",
"editorFont": "",
"lineHeight": 1.5,
"lineWidth": 40,
"lineWidthWide": 50,
"maxWidth": 88,
"textNormal": 16,
"textSmall": 13,
"imgGrid": false,
"imgWidth": "img-default-width",
"tableWidth": "table-default-width",
"iframeWidth": "iframe-default-width",
"mapWidth": "map-default-width",
"chartWidth": "chart-default-width",
"colorfulHeadings": false,
"colorfulFrame": false,
"colorfulActiveStates": false,
"trimNames": true,
"labeledNav": false,
"fullWidthMedia": true,
"bordersToggle": true,
"minimalStatus": true,
"focusMode": false,
"underlineInternal": true,
"underlineExternal": true,
"folding": true,
"lineNumbers": false,
"readableLineLength": true
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,11 @@
{
"id": "obsidian-minimal-settings",
"name": "Minimal Theme Settings",
"version": "8.2.2",
"minAppVersion": "1.11.1",
"description": "Change the colors, fonts and features of Minimal Theme.",
"author": "@kepano",
"authorUrl": "https://www.twitter.com/kepano",
"fundingUrl": "https://www.buymeacoffee.com/kepano",
"isDesktopOnly": false
}
@@ -0,0 +1,3 @@
.setting-group > .setting-item-heading .setting-item-description {
margin-top: 4px;
}
@@ -0,0 +1,8 @@
{
"primary-theme@@alt-folder-icons": true,
"primary-theme@@colorful-folders_text": true,
"primary-theme@@colorful-folders_collapse-indicator": true,
"primary-theme@@colorful-folders_background": false,
"primary-theme@@colorful-folders_indentation-guide": true,
"primary-theme@@colorful-folders_inherit-color": true
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,10 @@
{
"id": "obsidian-style-settings",
"name": "Style Settings",
"version": "1.0.9",
"minAppVersion": "0.11.5",
"description": "Offers controls for adjusting theme, plugin, and snippet CSS variables.",
"author": "mgmeyers",
"authorUrl": "https://github.com/mgmeyers/obsidian-style-settings",
"isDesktopOnly": false
}
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
{
"name": "Minimal",
"version": "8.1.7",
"minAppVersion": "1.9.0",
"author": "@kepano",
"authorUrl": "https://twitter.com/kepano",
"fundingUrl": "https://www.buymeacoffee.com/kepano"
}
File diff suppressed because one or more lines are too long
+9
View File
@@ -0,0 +1,9 @@
{
"name": "Primary",
"version": "2.10.0",
"minAppVersion": "1.4.0",
"author": "Cecilia May",
"fundingUrl": {
"Ko-fi": "https://ko-fi.com/ceciliamay"
}
}
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
---
short:
readable:
---
+31
View File
@@ -0,0 +1,31 @@
formulas:
Room: |-
file.backlinks
.filter(value.asFile().inFolder("rooms"))
.map(value.asFile())
views:
- type: cards
name: Rooms - Exits
filters:
and:
- file.inFolder("rooms")
order:
- file.name
- exitN
- exitS
- exitE
- exitW
- exitU
- exitD
- type: cards
name: Items
filters:
and:
- file.inFolder("items")
order:
- file.name
- short
- readable
- takeable
- names
- formula.Room
+9
View File
@@ -0,0 +1,9 @@
- [x] Need to add help text when user types "help". This should give them some common commands and explain the concepts behind text adventure games. It should also include an exceprt at the beginning from the opening text. "You arrive at the address, but you do not remember what has happened. The road behind you is gone...". The help text should disappear after the user types a new prompt (i.e., it's not persistent).
- [x] Need to add the tiles from mobile to desktop view.
- [ ] Enhance tiles with contextual awareness, enabling tiles to appear in rooms when appropriate (e.g. "attack rat").
- [ ] Create a mechanic that asks "Are you sure?" before taking critical actions like attacking or other game-changing mechanics that might affect the final ending.
- [ ] Add a tile for USE
- [ ] Add contextual awareness and autocomplete. For example a popup that appears above the USE text or when a user types "use". The user is able to autofill the rest of the thing by using the keyboard to toggle through the inv list and tab-complete the option, (or tap on mobile) "e.g. use (matches, light, letter) *on* (lamp)" - the word "on" there being suggested. Suggestions for autocomplete are in italics. This is one modern design element we're going to add.
- [x] Add a footer with "By [Ethan J Lewis](https://ethanjlewis.com) | [Source Code](https://half.st/ejlewis/halfstreet) | [GNU General Public License v3.0](https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE)"
- [x] Add "Half Street" as ASCII Art to the intro text.
- [ ] Add logic to make the last sentence in the examined description conditional. This is where we'll list items in the room. (e.g., "The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. *The oil lamp is on the side table.*")
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

+30 -4
View File
@@ -2,12 +2,38 @@ import { describe, it, expect } from 'vitest'
import { world } from './index'
describe('assembled world', () => {
it('contains all three rooms', () => {
expect(Object.keys(world.rooms).sort()).toEqual(['cellar-stair', 'foyer', 'hallway'])
it('contains the authored opening and main-floor rooms', () => {
expect(Object.keys(world.rooms)).toEqual(expect.arrayContaining([
'outside-gate',
'foyer',
'hallway',
'cellar-stair',
'parlor',
'study',
'dining-room',
'conservatory',
'smoking-room',
'music-room',
'servants-passage',
'laundry',
]))
})
it('contains all three items', () => {
expect(Object.keys(world.items).sort()).toEqual(['lamp', 'letter', 'matches'])
it('contains the authored opening and main-floor items', () => {
expect(Object.keys(world.items)).toEqual(expect.arrayContaining([
'broken-cigarette',
'lamp',
'letter',
'matches',
'candlestick',
'pruning-shears',
'silver-lighter',
'music-box-key',
'damp-sheet',
'grandfather-clock',
'dinner-place-setting',
'covered-cage',
]))
})
it('all room exits resolve to known rooms', () => {
+138
View File
@@ -27,4 +27,142 @@ export const encounters: Record<string, EncounterDef> = {
onResolved: { setFlags: { ratGone: true } },
defaultWrongVerbNarration: 'The rat watches.',
},
'window-guest': {
id: 'window-guest',
aliases: ['guest', 'window guest', 'curtains', 'curtain', 'window'],
startsIn: 'dining-room',
initialPhase: 'standing-outside',
phases: {
'standing-outside': {
description: narration('window-guest', 'standing-outside'),
transitions: [
{
verb: 'close',
target: 'window-guest',
narration: narration('window-guest', 'close-window-guest-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { curtainsClosed: true } },
onFailed: { narration: narration('window-guest', 'failed'), retreatTo: 'hallway' },
defaultWrongVerbNarration: narration('window-guest', 'wrong-verb'),
},
'ivy-figure': {
id: 'ivy-figure',
aliases: ['ivy figure', 'figure', 'ivy', 'vines', 'vine'],
startsIn: 'conservatory',
initialPhase: 'hidden',
phases: {
hidden: {
description: narration('ivy-figure', 'hidden'),
transitions: [
{
verb: 'cut',
target: 'ivy-figure',
requires: { item: 'pruning-shears' },
narration: narration('ivy-figure', 'cut-ivy-figure-resolved'),
to: 'resolved',
},
{
verb: 'use',
target: 'ivy-figure',
requires: { item: 'pruning-shears' },
narration: narration('ivy-figure', 'cut-ivy-figure-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { conservatoryVinesCut: true } },
onFailed: { narration: narration('ivy-figure', 'failed'), retreatTo: 'dining-room' },
defaultWrongVerbNarration: narration('ivy-figure', 'wrong-verb'),
},
'covered-cage': {
id: 'covered-cage',
aliases: ['covered cage', 'cage', 'birdcage', 'cloth'],
startsIn: 'smoking-room',
initialPhase: 'rustling',
phases: {
rustling: {
description: narration('covered-cage', 'rustling'),
transitions: [
{
verb: 'open',
target: 'covered-cage',
narration: narration('covered-cage', 'open-covered-cage-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { cageUncovered: true } },
onFailed: { narration: narration('covered-cage', 'failed'), retreatTo: 'hallway' },
defaultWrongVerbNarration: narration('covered-cage', 'wrong-verb'),
},
'piano-echo': {
id: 'piano-echo',
aliases: ['piano echo', 'piano', 'note', 'key'],
startsIn: 'music-room',
initialPhase: 'listening',
phases: {
listening: {
description: narration('piano-echo', 'listening'),
transitions: [
{
verb: 'play',
target: 'piano-echo',
narration: narration('piano-echo', 'play-piano-echo-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { musicSolved: true } },
onFailed: { narration: narration('piano-echo', 'failed'), retreatTo: 'hallway' },
defaultWrongVerbNarration: narration('piano-echo', 'wrong-verb'),
},
'breathing-wall': {
id: 'breathing-wall',
aliases: ['breathing wall', 'wall', 'walls', 'breathing'],
startsIn: 'servants-passage',
initialPhase: 'audible',
phases: {
audible: {
description: narration('breathing-wall', 'audible'),
transitions: [
{
verb: 'wait',
narration: narration('breathing-wall', 'wait-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { breathingWallPassed: true } },
onFailed: { narration: narration('breathing-wall', 'failed'), retreatTo: 'music-room' },
defaultWrongVerbNarration: narration('breathing-wall', 'wrong-verb'),
},
'linen-shape': {
id: 'linen-shape',
aliases: ['linen shape', 'shape', 'sheet', 'sheets', 'linen'],
startsIn: 'laundry',
initialPhase: 'hanging',
phases: {
hanging: {
description: narration('linen-shape', 'hanging'),
transitions: [
{
verb: 'wait',
narration: narration('linen-shape', 'wait-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { linenShapeEmpty: true } },
onFailed: { narration: narration('linen-shape', 'failed'), retreatTo: 'servants-passage' },
defaultWrongVerbNarration: narration('linen-shape', 'wrong-verb'),
},
}
+19
View File
@@ -0,0 +1,19 @@
---
id: breathing-wall
startsIn: "[[servants-passage]]"
initialPhase: audible
---
## audible
The wall beside your shoulder breathes in. The opposite wall answers.
## wait-resolved
You stand still.
The passage narrows, then forgets to. The breathing passes on ahead of you.
## wrong-verb
The walls take a slower breath.
## failed
The boards lean close enough to touch your sleeves. You retreat into the music room.
+21
View File
@@ -0,0 +1,21 @@
---
id: covered-cage
startsIn: "[[smoking-room]]"
initialPhase: rustling
---
## rustling
The covered cage rustles once. Then again, softer, as if whatever is inside has learned restraint.
## open-covered-cage-resolved
You lift the cloth.
The cage is empty. A few pale feathers cling to the wire, though no bird could have passed through it.
Somewhere far above you, wings beat once inside a wall.
## wrong-verb
The cloth trembles.
## failed
The rustling grows too close to your ear. You leave the room before you decide to.
+19
View File
@@ -0,0 +1,19 @@
---
id: ivy-figure
startsIn: "[[conservatory]]"
initialPhase: hidden
---
## hidden
The ivy has gathered itself into the suggestion of a person. Leaves cling where eyes should be.
## cut-ivy-figure-resolved
The shears close with a sound like teeth.
The figure falls apart leaf by leaf. Behind it, the glass is only glass.
## wrong-verb
The vines tighten without moving.
## failed
The ivy catches at your wrists. When you pull free, you are back among the cold plates of the dining room.
+19
View File
@@ -0,0 +1,19 @@
---
id: linen-shape
startsIn: "[[laundry]]"
initialPhase: hanging
---
## hanging
One hanging sheet has the weight and outline of a person standing behind it.
## wait-resolved
You wait.
The sheet stirs. Nothing stands behind it. Nothing had stood behind it.
## wrong-verb
The shape seems to lean toward you.
## failed
You push through the hanging sheets and come out in the servants' passage, breathing hard.
+19
View File
@@ -0,0 +1,19 @@
---
id: piano-echo
startsIn: "[[music-room]]"
initialPhase: listening
---
## listening
The held piano key waits under your eye. A second note answers from a room you have not found.
## play-piano-echo-resolved
You play the waiting note.
The answer comes at once, nearer than before. A narrow part of the wall settles back into shadow.
## wrong-verb
The answering note repeats, patient and exact.
## failed
The wrong chord goes through the floorboards. You climb back to the hallway before the echo finishes.
+1 -1
View File
@@ -3,7 +3,7 @@ id: rat
startsIn: "[[cellar-stair]]"
initialPhase: lurking
---
![[Pasted image 20260509213136.png]]
## lurking
A heavy rat watches you from the third step. Its eyes catch the light.
+19
View File
@@ -0,0 +1,19 @@
---
id: window-guest
startsIn: "[[dining-room]]"
initialPhase: standing-outside
---
## standing-outside
Rain touches the dining-room window from the wrong side. Someone stands beyond the glass with their head bowed.
## close-window-guest-resolved
You draw the curtains together before you look closely.
For a moment, cloth and rain hold the same shape. Then there is only the table behind you.
## wrong-verb
The figure outside lifts its face a little.
## failed
The glass shows too much. You find yourself back in the hallway with the taste of rain in your mouth.
@@ -81,27 +81,27 @@ You are carrying: a folded letter, a matchbook, and a broken cigarette.
# Existing House Rooms
|id|title|summary|
|---|---|---|
|`outside-gate`|[ The Gate ]|The road behind you is gone.|
|`foyer`|[ Foyer ]|A foyer that doesn't feel welcoming. Beneath the dust is another scent: sweet at first, then medicinal, like crushed almond pits. A hallway runs impossibly far to the north.|
|`hallway`|[ Hallway ]|A hallway longer than the house should allow.|
|`parlor`|[ Parlor ]|Chairs arranged for absent company.|
|`study`|[ Study ]|Books left open at impossible pages.|
|`stair-up`|[ Upper Stair ]|A stair arriving at the wrong landing.|
|`bedroom`|[ Bedroom ]|Prepared for another sleeper.|
|`nursery`|[ Nursery ]|Toys arranged tonight.|
|`kitchen`|[ Kitchen ]|Something recently warm.|
|`back-door`|[ Back Door ]|Opens onto the grounds.|
|`garden`|[ Garden ]|Overgrown and listening.|
|`well`|[ The Well ]|Dry deeper than it should be.|
|`well-shaft`|[ Well Shaft ]|Descending below the water line.|
|`tunnel`|[ Tunnel ]|A tunnel aware of your presence.|
|`chamber`|[ Antechamber ]|Locked threshold before the vault.|
|`vault`|[ Vault ]|Holds what was buried at Halfstreet.|
|`chapel`|[ Chapel ]|Deconsecrated and occupied.|
|`attic`|[ Attic ]|Reached by a staircase that was not there before.|
|`cistern`|[ Cistern ]|Beneath the kitchen.|
| id | title | summary |
| -------------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `outside-gate` | [ The Gate ] | The road behind you is gone. |
| `foyer` | [ Foyer ] | A foyer that doesn't feel welcoming. Beneath the dust is another scent: sweet at first, then medicinal, like crushed almond pits. A hallway runs impossibly far to the north. |
| `hallway` | [ Hallway ] | A hallway longer than the house should allow. |
| `parlor` | [ Parlor ] | Chairs arranged for absent company. |
| `study` | [ Study ] | Books left open at impossible pages. |
| `stair-up` | [ Upper Stair ] | A stair arriving at the wrong landing. |
| `bedroom` | [ Bedroom ] | Prepared for another sleeper. |
| `nursery` | [ Nursery ] | Toys arranged tonight. |
| `kitchen` | [ Kitchen ] | Something recently warm. |
| `back-door` | [ Back Door ] | Opens onto the grounds. |
| `garden` | [ Garden ] | Overgrown and listening. |
| `well` | [ The Well ] | Dry deeper than it should be. |
| `well-shaft` | [ Well Shaft ] | Descending below the water line. |
| `tunnel` | [ Tunnel ] | A tunnel aware of your presence. |
| `chamber` | [ Antechamber ] | Locked threshold before the vault. |
| `vault` | [ Vault ] | Holds what was buried at Halfstreet. |
| `chapel` | [ Chapel ] | Deconsecrated and occupied. |
| `attic` | [ Attic ] | Reached by a staircase that was not there before. |
| `cistern` | [ Cistern ] | Beneath the kitchen. |
---
@@ -158,11 +158,11 @@ Themes reinforced by the new rooms:
These rooms appear only after certain flags are set.
|id|title|first-visit summary|exits|items|encounter|safe|
|---|---|---|---|---|---|---|
|`wrong-hallway`|[ Hallway ]|The hallway is longer now.|impossible-changing exits|—|distant-steps|—|
|`returned-nursery`|[ Nursery ]|The toys no longer appear arranged.|w: bedroom|—|—|yes|
|`rain-room`|[ Rain Room ]|Rain falls steadily inside the room and nowhere else.|unknown|rusted-key|rainwater-basin|—|
| id | title | first-visit summary | exits | items | encounter | safe |
| ------------------ | ------------- | ----------------------------------------------------- | ------------------------- | ---------- | --------------- | ---- |
| `wrong-hallway` | [ Hallway ] | The hallway is longer now. | impossible-changing exits | — | distant-steps | — |
| `returned-nursery` | [ Nursery ] | The toys no longer appear arranged. | w: bedroom | — | — | yes |
| `rain-room` | [ Rain Room ] | Rain falls steadily inside the room and nowhere else. | unknown | rusted-key | rainwater-basin | — |
---
+2 -2
View File
@@ -121,8 +121,8 @@ for (const enc of Object.values(encounters)) {
}
export const world: World = {
startingRoom: 'foyer',
startingInventory: ['matches'],
startingRoom: 'outside-gate',
startingInventory: ['letter', 'matches', 'broken-cigarette'],
rooms,
items,
encounters,
+17
View File
@@ -0,0 +1,17 @@
---
id: broken-cigarette
names: ["cigarette", "broken cigarette"]
short: "a broken cigarette"
takeable: true
lightable: true
initialState:
lit: false
---
A cigarette broken near the middle. The paper is creased where someone held it too tightly.
## lit
The end glows once, then steadies. The smoke is bitter.
## extinguished
You pinch out the ember. The smell remains.
+17
View File
@@ -0,0 +1,17 @@
---
id: candlestick
names: ["candlestick", "candle holder", "candle"]
short: "a brass candlestick"
takeable: true
lightable: true
initialState:
lit: false
---
A brass candlestick, heavy at the base. The candle inside it is burned low but not spent.
## lit
The wick takes. The flame bends toward the nearest doorway.
## extinguished
The candle gutters out. For a moment the smoke leans against your hand.
+9
View File
@@ -0,0 +1,9 @@
---
id: covered-cage
names: ["cage", "covered cage", "birdcage", "covered-cage"]
short: "a cloth-covered cage"
takeable: false
initialState: {}
---
A small cage beneath a dark cloth. Its wire frame is warm where your fingers touch it.
+9
View File
@@ -0,0 +1,9 @@
---
id: damp-sheet
names: ["sheet", "damp sheet", "damp-sheet"]
short: "a damp sheet"
takeable: true
initialState: {}
---
A sheet still wet from some old washing. It smells of rainwater and closed rooms.
+9
View File
@@ -0,0 +1,9 @@
---
id: dinner-place-setting
names: ["place setting", "plate", "setting", "dinner-place-setting"]
short: "an untouched place setting"
takeable: false
initialState: {}
---
The plate is untouched. The knife and fork have been set for someone left-handed.
+9
View File
@@ -0,0 +1,9 @@
---
id: grandfather-clock
names: ["clock", "grandfather clock", "grandfather-clock"]
short: "a stopped grandfather clock"
takeable: false
initialState: {}
---
The clock has stopped between minutes. Its pendulum hangs a little crooked, as if it stopped while fleeing.
+3 -3
View File
@@ -1,7 +1,7 @@
---
id: matches
names: ["matches", "safety matches", "box"]
short: "a box of safety matches"
names: ["matches", "safety matches", "matchbook", "box"]
short: "a matchbook"
takeable: true
lighter: true
lighterUses: 4
@@ -9,7 +9,7 @@ initialState:
uses: 4
---
A small cardboard box of safety matches. Half-full.
A damp matchbook with four matches left inside.
## lighter-empty
The last match flares, burns down, and goes out. The book is empty.
+9
View File
@@ -0,0 +1,9 @@
---
id: music-box-key
names: ["tiny key", "music box key", "music-box-key", "key"]
short: "a tiny key"
takeable: true
initialState: {}
---
A tiny key on a black ribbon. It is too small for any door in the house.
+9
View File
@@ -0,0 +1,9 @@
---
id: pruning-shears
names: ["shears", "pruning shears", "pruning-shears"]
short: "a pair of pruning shears"
takeable: true
initialState: {}
---
Iron pruning shears with dark soil in the hinge. The handles are cold through your palm.
+10
View File
@@ -0,0 +1,10 @@
---
id: silver-lighter
names: ["lighter", "silver lighter", "silver-lighter"]
short: "a silver lighter"
takeable: true
lighter: true
initialState: {}
---
A silver lighter with a worn crest on one side. It opens with a small reluctant click.
+24
View File
@@ -0,0 +1,24 @@
---
id: conservatory
title: "[ Conservatory ]"
exitN: null
exitS: "[[dining-room]]"
exitE: null
exitW: null
exitU: null
exitD: null
items:
- "[[pruning-shears]]"
encounter: "[[ivy-figure]]"
---
## first-visit
The conservatory roof has gone blind with moss and old rain. Vines press against the glass from both sides.
Something human-shaped hangs among the ivy. The dining room is south.
## revisit
The conservatory sweats in the dark.
## examined
The tiled floor is split by roots. The air smells of wet soil and cold iron. Pruning shears lie half-buried beneath a bench.
+25
View File
@@ -0,0 +1,25 @@
---
id: dining-room
title: "[ Dining Room ]"
exitN: "[[conservatory]]"
exitS: null
exitE: null
exitW: "[[hallway]]"
exitU: null
exitD: null
items:
- "[[candlestick]]"
- "[[dinner-place-setting]]"
encounter: "[[window-guest]]"
---
## first-visit
The dining room is laid for supper long after supper ended. Plates dull the long table. One place has not been touched.
The hallway is west. A greenish light presses through glass to the north.
## revisit
The dining room holds its place at the table.
## examined
The silver has tarnished black at the edges. The untouched plate is clean enough to show a dim shape behind you, though there is no one there. A candlestick rests beside it.
+4 -5
View File
@@ -2,22 +2,21 @@
id: foyer
title: "[ Foyer ]"
exitN: "[[hallway]]"
exitS: null
exitS: "[[outside-gate]]"
exitE: null
exitW: null
exitU: null
exitD: null
items:
- "[[letter]]"
items: []
encounter: null
safe: true
---
## first-visit
You stand in the foyer of a house you do not remember entering. The door behind you has closed without sound. A folded letter lies on a small table. A hallway leads north.
You stand in the foyer of a house you do not remember entering. The door behind you has closed without sound. A hallway leads north.
## revisit
The foyer. The door behind you is still closed.
## examined
A foyer with peeling paper. A small table holds nothing but the letter. The air smells of cold stone. A hallway leads north.
A foyer with peeling paper. The air smells of cold stone. A hallway leads north. The gate is south, though it feels less certain from this side.
+7 -7
View File
@@ -1,22 +1,22 @@
---
id: hallway
title: "[ Hallway ]"
exitN: null
exitN: "[[dining-room]]"
exitS: "[[foyer]]"
exitE: "[[cellar-stair]]"
exitW: null
exitU: null
exitD: null
exitW: "[[smoking-room]]"
exitU: "[[parlor]]"
exitD: "[[music-room]]"
items:
- "[[lamp]]"
encounter: null
---
## first-visit
A long hallway, lit by nothing. An iron oil lamp sits on a side table. The foyer is south. A stair descends east.
A long hallway, lit by nothing. It runs further than the house should allow. An iron oil lamp sits on a side table. The foyer is south. A stair descends east.
## revisit
The long hallway.
The long hallway. It has not shortened.
## examined
The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. The oil lamp is on the side table.
The hallway runs further than the house should be wide. The dust on the floor is undisturbed except where you have walked. The oil lamp is on the side table. Doors wait north and west. Two more thresholds sit where the wall should be solid.
+25
View File
@@ -0,0 +1,25 @@
---
id: laundry
title: "[ Laundry ]"
exitN: null
exitS: null
exitE: null
exitW: "[[servants-passage]]"
exitU: null
exitD: null
items:
- "[[damp-sheet]]"
encounter: "[[linen-shape]]"
safe: true
---
## first-visit
Sheets hang unmoving in the still air. They divide the room into narrow aisles. One of them has the shape of a person behind it.
The servants' passage is west.
## revisit
The laundry hangs white and silent.
## examined
Water darkens the floor beneath the hanging sheets, though none of them drip. A damp sheet has fallen into an empty basket.
+25
View File
@@ -0,0 +1,25 @@
---
id: music-room
title: "[ Music Room ]"
exitN: "[[servants-passage]]"
exitS: null
exitE: null
exitW: null
exitU: "[[hallway]]"
exitD: null
items:
- "[[music-box-key]]"
encounter: "[[piano-echo]]"
safe: true
---
## first-visit
A piano stands open with one key held silently down. The strings inside are furred with dust.
Something answers from elsewhere in the house, too soft to be music. A servants' passage opens north. The hallway is above.
## revisit
The music room waits on the same note.
## examined
The depressed piano key does not rise when touched. A tiny key rests on the music stand where sheet music should be.
+27
View File
@@ -0,0 +1,27 @@
---
id: outside-gate
title: "[ The Gate ]"
exitN: "[[foyer]]"
exitS: null
exitE: null
exitW: null
exitU: null
exitD: null
items: []
encounter: null
safe: true
---
## first-visit
You arrive at the address, but you do not remember what has happened.
There is no sign, no number on the gate. The fence is overgrown with grape vines. The road behind you is gone.
The air is cool and still around you, but a quiet rustling and a damp breeze seem to emanate from beneath the house.
You are carrying: a folded letter, a matchbook, and a broken cigarette.
## revisit
The gate waits behind you. The road does not return.
## examined
The ironwork has gone black with rain. Grape vines pull at the fence and knot themselves through the bars. Beyond the gate, the house stands without light.
+25
View File
@@ -0,0 +1,25 @@
---
id: parlor
title: "[ Parlor ]"
exitN: "[[study]]"
exitS: null
exitE: null
exitW: null
exitU: null
exitD: "[[hallway]]"
items:
- "[[grandfather-clock]]"
encounter: null
safe: true
---
## first-visit
The parlor has been arranged for company. No chair faces another. Each one waits at a slight angle, as if the guests stood suddenly and left the room in silence.
A stopped clock stands against the north wall. A narrow study opens beyond it. The hallway is below.
## revisit
The parlor waits with its empty chairs.
## examined
Dust gathers on the chair arms, but not on the seats. Someone has sat here recently, or the room remembers being occupied. The grandfather clock shows no hour you can name.
+23
View File
@@ -0,0 +1,23 @@
---
id: servants-passage
title: "[ Servants' Passage ]"
exitN: null
exitS: "[[music-room]]"
exitE: "[[laundry]]"
exitW: null
exitU: null
exitD: null
items: []
encounter: "[[breathing-wall]]"
---
## first-visit
The walls here are unfinished and smell of wet wood. The passage is too narrow for two people to pass.
The music room is south. A laundry lies east.
## revisit
The servants' passage breathes less loudly now, or you have learned when not to listen.
## examined
The boards show old finger marks in the grain. They run at shoulder height along both walls, as if someone felt their way through in the dark.
+25
View File
@@ -0,0 +1,25 @@
---
id: smoking-room
title: "[ Smoking Room ]"
exitN: null
exitS: null
exitE: "[[hallway]]"
exitW: null
exitU: null
exitD: null
items:
- "[[silver-lighter]]"
- "[[covered-cage]]"
encounter: "[[covered-cage]]"
---
## first-visit
The smoking room smells faintly of ash, velvet, and bitter almonds. The chairs are turned toward the cold hearth.
A cloth-covered cage hangs from a stand. The hallway is east.
## revisit
The smoking room keeps its breath.
## examined
Cigar ash lies in a clean saucer. A silver lighter has been placed beside it with care. The covered cage moves once, though the room is still.
+24
View File
@@ -0,0 +1,24 @@
---
id: study
title: "[ Study ]"
exitN: null
exitS: "[[parlor]]"
exitE: null
exitW: null
exitU: null
exitD: null
items: []
encounter: null
safe: true
---
## first-visit
Books lie open across the desk and floor. Their pages are swollen with damp. Several have been opened past their final leaves.
The parlor is south.
## revisit
The study remains open to the wrong pages.
## examined
The handwriting in the margins changes from book to book, then becomes the same hand. Dates repeat. Names are crossed out and written again beneath themselves.
+2
View File
@@ -80,6 +80,8 @@ export interface EncounterTransition {
export interface EncounterDef {
id: EncounterId
/** Optional parser aliases for the encounter target while it is active. */
aliases?: string[]
startsIn: RoomId
initialPhase: EncounterPhase
phases: Record<EncounterPhase, EncounterPhaseDef>