feat(world): expand Halfstreet content slices
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-12 14:48:19 -05:00
parent 26dd91947f
commit cc98aa180b
48 changed files with 951 additions and 139 deletions
+1
View File
@@ -178,6 +178,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: 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(handleWait(state, world), world)
if (command.verb === 'listen') return withEndingCheck(narrate(state, [{ kind: 'narration', text: 'You listen. The house listens back.' }]), world)
}
if (command.kind === 'verb-target') {
+49 -1
View File
@@ -10,7 +10,7 @@ const world: World = {
id: 'foyer',
title: '[ Foyer ]',
descriptions: { firstVisit: 'Foyer.', revisit: 'Foyer.', examined: 'Foyer.' },
exits: { n: 'stair' },
exits: { n: 'stair', e: 'chapel' },
items: [],
safe: true,
},
@@ -29,10 +29,19 @@ const world: World = {
exits: { u: 'stair' },
items: [],
},
chapel: {
id: 'chapel',
title: '[ Chapel ]',
descriptions: { firstVisit: 'Chapel.', revisit: 'Chapel.', examined: 'Chapel.' },
exits: { s: 'foyer' },
items: ['vial'],
encounter: 'basilisk',
},
},
items: {
mirror: { id: 'mirror', names: ['mirror', 'tarnished mirror'], short: 'a tarnished mirror', long: 'A small mirror, tarnished black.', initialState: {}, takeable: true },
sword: { id: 'sword', names: ['sword', 'cane sword'], short: 'a cane sword', long: 'A slim cane sword.', initialState: {}, takeable: true },
vial: { id: 'vial', names: ['vial'], short: 'a vial', long: 'A small vial.', initialState: {}, takeable: true },
},
encounters: {
revenant: {
@@ -59,6 +68,22 @@ const world: World = {
onFailed: { narration: 'You stagger back.', retreatTo: 'foyer' },
defaultWrongVerbNarration: 'The revenant does not seem to notice.',
},
basilisk: {
id: 'basilisk',
aliases: ['basilisk'],
startsIn: 'chapel',
initialPhase: 'sleeping',
phases: {
sleeping: {
description: 'An eye opens beneath the altar.',
transitions: [
{ verb: 'pour', target: 'vial', requires: { item: 'vial' }, narration: 'The eye closes.', to: 'resolved' },
],
},
},
onResolved: { setFlags: { basiliskSpared: true } },
defaultWrongVerbNarration: 'The eye watches.',
},
},
endings: {
true: { whenFlags: { _never: true }, narration: '' },
@@ -116,4 +141,27 @@ describe('encounters — phase advancement', () => {
s = dispatch(s, { kind: 'go', direction: 's' }, world).state
expect(s.resolveLevel).toBe('steady')
})
it('allows a required item to be the direct target in a target-preposition encounter command', () => {
let s = initialStateFor(world)
s = {
...s,
inventory: [...s.inventory, { id: 'vial', state: {} }],
roomState: { ...s.roomState, chapel: { takenItems: ['vial'] } },
}
s = dispatch(s, { kind: 'go', direction: 'e' }, world).state
const r = dispatch(
s,
{
kind: 'verb-target-prep',
verb: 'pour',
target: { canonical: 'vial', raw: 'vial' },
preposition: 'on',
indirect: { canonical: 'basilisk', raw: 'basilisk' },
},
world,
)
expect(r.state.flags['basiliskSpared']).toBe(true)
expect(r.appended.some((l) => l.text.includes('eye closes'))).toBe(true)
})
})
+1 -1
View File
@@ -96,7 +96,7 @@ export function applyVerbToEncounter(
}
}
}
if (t.requires && instrumentId && t.requires.item !== instrumentId) return false
if (t.requires && instrumentId && t.requires.item !== instrumentId && t.requires.item !== targetId) return false
return true
})
+22
View File
@@ -16,6 +16,10 @@ describe('parser — verb-only commands', () => {
expect(parse('look', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'look' })
})
it('recognizes bare "listen"', () => {
expect(parse('listen', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'listen' })
})
it('recognizes bare "inventory" and short forms', () => {
expect(parse('inventory', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
expect(parse('inv', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
@@ -100,6 +104,24 @@ describe('parser — verb + target', () => {
})
})
it('recognizes pour commands with an indirect target', () => {
const ctx: ParserContext = {
knownItems: ['silver-vial'],
knownEncounters: ['basilisk'],
visibleNouns: [{ id: 'basilisk', aliases: ['basilisk'] }],
inventoryItemIds: ['silver-vial'],
lastNoun: null,
awaitingDisambiguation: null,
}
expect(parse('pour silver-vial on basilisk', ctx)).toEqual({
kind: 'verb-target-prep',
verb: 'pour',
target: { canonical: 'silver-vial', raw: 'silver-vial' },
preposition: 'on',
indirect: { canonical: 'basilisk', raw: 'basilisk' },
})
})
it('resolves a single visible noun', () => {
const ctx: ParserContext = {
knownItems: ['torch'],
+4 -2
View File
@@ -34,6 +34,8 @@ const VERB_SYNONYMS: Record<string, Verb> = {
pull: 'pull',
cut: 'cut', trim: 'cut',
play: 'play',
listen: 'listen',
pour: 'pour',
uncover: 'open',
wait: 'wait', z: 'wait',
}
@@ -57,7 +59,7 @@ const META_VERBS: Record<string, MetaVerb> = {
}
/** Verbs that legally take no target. */
const VERB_ONLY_VERBS = new Set<string>(['look', 'inventory', 'wait'])
const VERB_ONLY_VERBS = new Set<string>(['look', 'inventory', 'wait', 'listen'])
/** Two-word verb prefixes (e.g. "pick up X"). */
const TWO_WORD_VERBS = ['pick up']
@@ -157,7 +159,7 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
if (rest.length === 0) {
if (VERB_ONLY_VERBS.has(verb)) {
return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' }
return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' | 'listen' }
}
return { kind: 'unknown', raw: trimmed, reason: 'malformed' }
}
+94
View File
@@ -128,4 +128,98 @@ describe('playthrough — sample world', () => {
expect(state.location).toBe('attic')
expect(state.inventory.map((i) => i.id)).toContain('toy-dog')
})
it('plays through the garden and grounds slice', () => {
const state = play([
'n', // gate → foyer
'n', // foyer → hallway
'u', // hallway → parlor
'u', // parlor → upper stair
'wait',
'u', // upper stair → bedroom
'e', // bedroom → nursery
'take dog',
'w',
'd', // bedroom → upper stair
'd', // upper stair → parlor
'd', // parlor → hallway
'n', // hallway → dining-room
'close curtains',
'e', // dining-room → kitchen
'e', // kitchen → back-door
'e', // back-door → garden
'wait',
'n', // garden → well
'd', // well → well-shaft
'hold dog',
])
expect(state.flags['garden-procession.resolved']).toBe(true)
expect(state.flags['child-beneath-well.resolved']).toBe(true)
expect(state.flags['gardenQuiet']).toBe(true)
expect(state.flags['childPassedWell']).toBe(true)
expect(state.location).toBe('well-shaft')
})
it('plays through the lower-passages slice', () => {
const state = play([
'n', // gate → foyer
'n', // foyer → hallway
'n', // hallway → dining-room
'close curtains',
'n', // dining-room → conservatory
'take shears',
'cut vines with shears',
's', // conservatory → dining-room
'w', // dining-room → hallway
'd', // hallway → music-room
'play note',
'n', // music-room → servants-passage
'wait',
'e', // servants-passage → laundry
'wait',
'take damp sheet',
'w', // laundry → servants-passage
's', // servants-passage → music-room
'u', // music-room → hallway
'n', // hallway → dining-room
'e', // dining-room → kitchen
'e', // kitchen → back-door
'e', // back-door → garden
'wait',
'n', // garden → well
'd', // well → well-shaft
'wait',
'd', // well-shaft → tunnel
'n', // tunnel → ossuary
'take ring',
'leave ring',
'e', // ossuary → flooded-passage
'use water with sheet',
'take boat',
'n', // flooded-passage → root-chamber
'listen',
'e', // root-chamber → burial-gallery
'examine portraits',
'take register',
'read register',
'e', // burial-gallery → antechamber
'e', // antechamber → vault
])
expect(state.flags['bone-keeper.resolved']).toBe(true)
expect(state.flags['reflection.resolved']).toBe(true)
expect(state.flags['root-movement.resolved']).toBe(true)
expect(state.flags['portrait-woman.resolved']).toBe(true)
expect(state.flags['burialRingPlaced']).toBe(true)
expect(state.flags['reflectionObscured']).toBe(true)
expect(state.flags['rootsListenedTo']).toBe(true)
expect(state.flags['familyResemblanceSeen']).toBe(true)
expect(state.location).toBe('vault')
expect(state.inventory.map((i) => i.id)).toEqual(expect.arrayContaining([
'damp-sheet',
'toy-boat',
'family-register',
]))
})
})
+2 -2
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' | 'cut' | 'play'
| 'hold' | 'push' | 'pull' | 'cut' | 'play' | 'listen' | 'pour'
export type MetaVerb = 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'
@@ -21,7 +21,7 @@ export interface NounRef {
}
export type ParsedCommand =
| { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' }
| { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' | 'listen' }
| { kind: 'verb-target'; verb: Verb; target: NounRef }
| { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef }
| { kind: 'ambiguous'; verb: Verb; rawNoun: string; candidates: string[] }
+6
View File
@@ -50,6 +50,8 @@ import '../ui/crt.css'
</div>
<div class="mystery-options-group" aria-label="Game">
<div class="mystery-options-label">Game</div>
<button type="button" data-chips-choice="on" aria-pressed="true">Chips On</button>
<button type="button" data-chips-choice="off" aria-pressed="false">Chips Off</button>
<button type="button" data-restart-choice>Restart</button>
</div>
</div>
@@ -90,11 +92,15 @@ import '../ui/crt.css'
const storedCursor = (() => {
try { return localStorage.getItem('halfstreet:cursor:v1') } catch { return null }
})()
const storedChips = (() => {
try { return localStorage.getItem('halfstreet:chips:v1') } catch { return null }
})()
document.documentElement.setAttribute('data-mystery-theme', stored === 'ansi' ? 'ansi' : 'amber')
document.documentElement.setAttribute(
'data-mystery-cursor',
storedCursor === 'block' || storedCursor === 'underscore' ? storedCursor : 'bar',
)
document.documentElement.setAttribute('data-mystery-chips-state', storedChips === 'off' ? 'off' : 'on')
</script>
<script>
import '../ui/terminal.ts'
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Chip } from './chips'
const CHIP_CONTAINER = '[data-mystery-chips]'
const CHIP_CONTAINER = '.mystery-chips[data-mystery-chips]'
export function renderChips(chips: Chip[], onSelect: (command: string) => void): void {
const container = document.querySelector<HTMLDivElement>(CHIP_CONTAINER)
+8
View File
@@ -359,6 +359,10 @@ body {
animation: mystery-cursor-blink 1.05s steps(1, end) infinite;
}
:root[data-mystery-input-focused] .mystery-input-display::after {
animation: mystery-cursor-blink 1.05s steps(1, end) infinite;
}
@keyframes mystery-cursor-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
@@ -383,6 +387,10 @@ body {
flex: 1 1 auto;
}
:root[data-mystery-chips-state='off'] .mystery-chips {
display: none;
}
.mystery-light-meter {
flex: 0 0 auto;
width: 98px;
-15
View File
@@ -1,15 +0,0 @@
import * as Sentry from '@sentry/browser'
const dsn = import.meta.env.PUBLIC_GLITCHTIP_DSN as string | undefined
const release = import.meta.env.PUBLIC_GLITCHTIP_RELEASE as string | undefined
export function initGlitchTip(): void {
if (!dsn) return
Sentry.init({
dsn,
release,
environment: import.meta.env.MODE,
tracesSampleRate: 0.01,
})
}
+11 -4
View File
@@ -8,15 +8,12 @@ import { TRANSCRIPT_CAP } from '../engine/types'
import { computeChips } from './chips'
import { renderChips } from './chip-render'
import LIGHT_ICON_SVG from '../assets/noun-candle-6409709.svg?raw'
import { initGlitchTip } from './glitchtip'
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
const inputDisplayEl = document.querySelector<HTMLSpanElement>('[data-mystery-input-display]')
const lightMeterEl = document.querySelector<HTMLDivElement>('[data-mystery-light-meter]')
initGlitchTip()
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.
@@ -121,6 +118,10 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
inputDisplayEl.dataset['placeholder'] = inputEl.value ? 'false' : inputEl.placeholder ? 'true' : 'false'
}
const syncInputFocus = (focused: boolean): void => {
document.documentElement.toggleAttribute('data-mystery-input-focused', focused)
}
const buildParserContext = (s: GameState): ParserContext => {
const room = world.rooms[s.location]
const visibleNouns: { id: string; aliases: string[] }[] = []
@@ -356,7 +357,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
})
inputEl.addEventListener('input', syncCommandLine)
inputEl.addEventListener('focus', clearIdleHint)
inputEl.addEventListener('focus', () => {
syncInputFocus(true)
clearIdleHint()
})
inputEl.addEventListener('blur', () => {
syncInputFocus(false)
})
inputEl.addEventListener('pointerdown', clearIdleHint)
inputEl.parentElement?.addEventListener('pointerdown', () => {
+30
View File
@@ -1,8 +1,10 @@
const STORAGE_KEY = 'halfstreet:theme:v1'
const CURSOR_STORAGE_KEY = 'halfstreet:cursor:v1'
const CHIPS_STORAGE_KEY = 'halfstreet:chips:v1'
type Theme = 'amber' | 'ansi'
type Cursor = 'bar' | 'block' | 'underscore'
type Chips = 'on' | 'off'
function getStored(): Theme {
try {
@@ -21,6 +23,14 @@ function getStoredCursor(): Cursor {
}
}
function getStoredChips(): Chips {
try {
return localStorage.getItem(CHIPS_STORAGE_KEY) === 'off' ? 'off' : 'on'
} catch {
return 'on'
}
}
function setTheme(theme: Theme): void {
document.documentElement.setAttribute('data-mystery-theme', theme)
try {
@@ -45,9 +55,22 @@ function setCursor(cursor: Cursor): void {
}
}
function setChips(chips: Chips): void {
document.documentElement.setAttribute('data-mystery-chips-state', chips)
try {
localStorage.setItem(CHIPS_STORAGE_KEY, chips)
} catch {
// ignore
}
for (const btn of document.querySelectorAll<HTMLButtonElement>('[data-chips-choice]')) {
btn.setAttribute('aria-pressed', btn.dataset['chipsChoice'] === chips ? 'true' : 'false')
}
}
const initial = getStored()
setTheme(initial)
setCursor(getStoredCursor())
setChips(getStoredChips())
const optionsRoot = document.querySelector<HTMLElement>('[data-mystery-options]')
const optionsToggle = document.querySelector<HTMLButtonElement>('[data-options-toggle]')
@@ -88,6 +111,13 @@ document.querySelectorAll<HTMLButtonElement>('[data-cursor-choice]').forEach((bt
})
})
document.querySelectorAll<HTMLButtonElement>('[data-chips-choice]').forEach((btn) => {
btn.addEventListener('click', () => {
const next = (btn.dataset['chipsChoice'] as Chips | undefined) ?? 'on'
setChips(next)
})
})
document.querySelector<HTMLButtonElement>('[data-restart-choice]')?.addEventListener('click', () => {
setOptionsOpen(false)
document.dispatchEvent(new CustomEvent('halfstreet-restart'))
+8 -6
View File
@@ -25,7 +25,7 @@
- [x] If the user says "use match with letter" they should burn the letter.
- [x] There should be a lighter in the smoking room that allows unlimited lighting.
- [ ] 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 lightened descriptions to darkened rooms. About half the rooms should be too dark to see anything (affects ability to move forward, can't see exits or entounters, except for maybe hints at the encounters, like sounds or shapes in the dark) Add frontmatter property to all rooms: (dark: true/false). Make text in darkened rooms a grey color.
- [ ] Add lightened descriptions to darkened rooms. About half the rooms should be too dark to see anything (affects ability to move forward, can't see exits or encounters, except for maybe hints at the encounters, like sounds or shapes in the dark) Add frontmatter property to all rooms: (dark: true/false). Make text in darkened rooms a grey color.
- [ ] Implement a simple "stealth mechanic", where sometimes it's advantageous to have the light out.
- [ ] Implement a simple (optional?) minimap in the UI? - Maybe tied to an item? Once you get the map the minimap appears? Can we POC it?
- [x] Add a mechanic where after the player waits 3 times or moves six times the light goes out and needs to be relit. Or something along those lines. We need a sense of time. Maybe some situations blow out the light
@@ -34,12 +34,14 @@
- [ ] 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.
- [ ] Add a Notebook function. Automatically make notes as the game progresses.
- [ ] Implement a carry mechanic. Decide whether we should have a limited carry ability (only able to carry a few things?) or we night need a full inventory system, where items are assigned to pockets or hand carry and we can only hand carry a couple of items?
- [ ] Implement a "drop" mechanic
- [x] Implement a "drop" mechanic in order to drop items. Dropped items remain in the room in which they were dropped and can be picked up again.
- [x] We need a light indicator that shows when the light is lit and how much time is left on the light. Use the svg file I dropped in the src/assets folder for the indicator. The indicator should be a 6-segment led that runs in a dotted line underneath the light indicator and burns out right to left. The color of the indicator should be bright when it's lit and dim when it's not. The indicator should be to the right of the tiles and sized appropriately.
- [ ] FEATURE: Add an option to disable the chips in the options menu.
- [ ] BUG: The new cursor doesn't appear on mobile.
- [x] FEATURE: Add an option to disable the chips in the options menu.
- [x] BUG: The new cursor doesn't appear on mobile.
- [ ] FEATURE: Add a Safe to somewhere that it makes sense (the bedroom?) We can add a safe-cracking mini-game. The safe contains a single bullet, which can be used with the revolver.
- [ ] FEATURE: Add item Revolver, which can be used to kill the thing at the end. It comes with no bullets, so players need to defeat the safe-cracking minjgame. Place the revolver somewhere it takes the player some effort to find.
- [ ] Feature: A faceless voice that speaks in a whisper in the chapel, which demands whiskey and dispenses riddles if the player finds a bottle of whiskey (but hasn't drunk it yet). The riddles are randomly chosen from a list of 25 difficult riddles (source them). If the riddle is answered correctly there needs to be some reward and maybe a major plot point is revealed.
- [ ] Feature: A faceless voice that speaks in a whisper in the smoking room, which demands whiskey and dispenses riddles if the player finds a bottle of whiskey (but hasn't drunk it yet). The riddles are randomly chosen from a list of 25 difficult riddles (source them). If the riddle is answered correctly there needs to be some reward and maybe a major plot point is revealed.
- [ ] Add item Whiskey bottle, half full of something smoky. In the kitchen. Drinking it gets the player drunk, which causes them to unlock the drunk rooms, which are a series of rooms where they can go many directions, but somehow end up in seemingly back in the same spot. They are essentially a maze of doors and hallways, ladders and levels. There should be boundaries established though, it shouldn't be endless. After 20 or so moves the player passes out and wakes up back in the foyer, with the whiskey bottle returned to the kitchen, somehow still half full. Add a random encounter in the dark rooms, a creaking floorboard helps you find a secret door that opens with a faceless man inside who gives you major plot info. After this encounter you pass out and wake up in the lobby as before.
- [ ]
- [ ] Set up BugPin as a self-hosted visual bug reporter for the site, then have incoming reports create markdown files under `src/world/bugs/` via a webhook or API bridge so bugs can be tracked in git alongside the game content. Include screenshot/annotation metadata in the markdown and decide whether these bug docs stay outside the world loader or get their own loader later.
- [ ] BUG: It says the door closes behind you when you enter the lobby, but you can still exit S to the gate.
+2
View File
@@ -20,6 +20,7 @@ describe('assembled world', () => {
'bedroom',
'nursery',
'attic',
'chapel',
]))
})
@@ -40,6 +41,7 @@ describe('assembled world', () => {
'childs-drawing',
'music-box',
'toy-dog',
'silver-vial',
]))
})
+184
View File
@@ -202,4 +202,188 @@ export const encounters: Record<string, EncounterDef> = {
onFailed: { narration: narration('stair-sleeper', 'failed'), retreatTo: 'parlor' },
defaultWrongVerbNarration: narration('stair-sleeper', 'wrong-verb'),
},
'garden-procession': {
id: 'garden-procession',
aliases: ['garden procession', 'procession', 'lanterns', 'lantern', 'lights', 'hedge'],
startsIn: 'garden',
initialPhase: 'passing',
phases: {
passing: {
description: narration('garden-procession', 'passing'),
transitions: [
{
verb: 'wait',
chipLabel: 'WAIT',
narration: narration('garden-procession', 'wait-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { gardenQuiet: true } },
onFailed: { narration: narration('garden-procession', 'failed'), retreatTo: 'back-door' },
defaultWrongVerbNarration: narration('garden-procession', 'wrong-verb'),
},
'child-beneath-well': {
id: 'child-beneath-well',
aliases: ['child', 'well child', 'child beneath well', 'barefoot child'],
startsIn: 'well-shaft',
initialPhase: 'climbing',
phases: {
climbing: {
description: narration('child-beneath-well', 'climbing'),
transitions: [
{
verb: 'hold',
target: 'toy-dog',
chipLabel: 'SHOW DOG',
chipCommand: 'hold dog',
requires: { item: 'toy-dog' },
narration: narration('child-beneath-well', 'hold-toy-dog-resolved'),
to: 'resolved',
},
{
verb: 'wait',
chipLabel: 'WAIT',
narration: narration('child-beneath-well', 'wait-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { childPassedWell: true } },
onFailed: { narration: narration('child-beneath-well', 'failed'), retreatTo: 'well' },
defaultWrongVerbNarration: narration('child-beneath-well', 'wrong-verb'),
},
'bone-keeper': {
id: 'bone-keeper',
aliases: ['bone keeper', 'keeper', 'hands', 'bones', 'ribs'],
startsIn: 'ossuary',
initialPhase: 'arranging',
phases: {
arranging: {
description: narration('bone-keeper', 'arranging'),
transitions: [
{
verb: 'drop',
target: 'burial-ring',
chipLabel: 'LEAVE RING',
chipCommand: 'leave ring',
requires: { item: 'burial-ring' },
narration: narration('bone-keeper', 'leave-burial-ring-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { burialRingPlaced: true } },
onFailed: { narration: narration('bone-keeper', 'failed'), retreatTo: 'tunnel' },
defaultWrongVerbNarration: narration('bone-keeper', 'wrong-verb'),
},
reflection: {
id: 'reflection',
aliases: ['reflection', 'water', 'black water', 'face', 'reflected figure'],
startsIn: 'flooded-passage',
initialPhase: 'following',
phases: {
following: {
description: narration('reflection', 'following'),
transitions: [
{
verb: 'use',
target: 'reflection',
chipLabel: 'USE SHEET',
chipCommand: 'use water with sheet',
requires: { item: 'damp-sheet' },
narration: narration('reflection', 'obscure-water-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { reflectionObscured: true } },
onFailed: { narration: narration('reflection', 'failed'), retreatTo: 'ossuary' },
defaultWrongVerbNarration: narration('reflection', 'wrong-verb'),
},
'root-movement': {
id: 'root-movement',
aliases: ['root movement', 'roots', 'root', 'opening'],
startsIn: 'root-chamber',
initialPhase: 'shifting',
phases: {
shifting: {
description: narration('root-movement', 'shifting'),
transitions: [
{
verb: 'listen',
chipLabel: 'LISTEN',
chipCommand: 'listen',
narration: narration('root-movement', 'listen-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { rootsListenedTo: true } },
onFailed: { narration: narration('root-movement', 'failed'), retreatTo: 'flooded-passage' },
defaultWrongVerbNarration: narration('root-movement', 'wrong-verb'),
},
'portrait-woman': {
id: 'portrait-woman',
aliases: ['portrait woman', 'woman', 'portrait', 'portraits', 'veil', 'funeral veil'],
startsIn: 'burial-gallery',
initialPhase: 'watching',
phases: {
watching: {
description: narration('portrait-woman', 'watching'),
transitions: [
{
verb: 'examine',
target: 'portrait-woman',
chipLabel: 'EXAMINE PORTRAITS',
chipCommand: 'examine portraits',
narration: narration('portrait-woman', 'examine-portraits-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { familyResemblanceSeen: true } },
onFailed: { narration: narration('portrait-woman', 'failed'), retreatTo: 'root-chamber' },
defaultWrongVerbNarration: narration('portrait-woman', 'wrong-verb'),
},
basilisk: {
id: 'basilisk',
aliases: ['basilisk', 'creature', 'eye', 'altar', 'coil'],
startsIn: 'chapel',
initialPhase: 'sleeping',
phases: {
sleeping: {
description: narration('basilisk', 'sleeping'),
transitions: [
{
verb: 'pour',
target: 'silver-vial',
chipLabel: 'POUR VIAL',
chipCommand: 'pour vial on basilisk',
requires: { item: 'silver-vial' },
narration: narration('basilisk', 'pour-vial-resolved'),
to: 'resolved',
},
{
verb: 'use',
target: 'basilisk',
chipLabel: 'USE VIAL',
chipCommand: 'use basilisk with vial',
requires: { item: 'silver-vial' },
narration: narration('basilisk', 'pour-vial-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { basiliskSpared: true } },
onFailed: { narration: narration('basilisk', 'failed'), retreatTo: 'vault' },
defaultWrongVerbNarration: narration('basilisk', 'wrong-verb'),
},
}
+27
View File
@@ -0,0 +1,27 @@
---
id: basilisk
startsIn: "[[chapel]]"
initialPhase: sleeping
---
## sleeping
Something large is coiled beneath the altar.
You become aware of the eye first.
Not glowing. Merely open.
## pour-vial-resolved
The wax breaks beneath your thumb.
You pour the vial over the cracked altar stone. The clear liquid disappears into it without running. Beneath the altar, the coil withdraws slowly, scale against stone, until there is only the smell of rain on dust.
The eye closes last.
## wrong-verb
The open eye receives the motion without interest.
Your own eyes begin to water. You look away too late.
## failed
The chapel floor tilts under you. When you find the wall, you are back in the vault with the taste of stone in your mouth.
+19
View File
@@ -0,0 +1,19 @@
---
id: bone-keeper
startsIn: "[[ossuary]]"
initialPhase: arranging
---
## arranging
Something kneels before the shelves of bone. It has no face you can make out, only hands, and the hands are placing ribs in order of size.
## leave-burial-ring-resolved
You set the ring among the bones.
The hands stop their work. One finger touches the crest, gently, and the shelves settle as if relieved of a small but unbearable error.
## wrong-verb
The arranging hands pause. A rib turns slowly in their grip.
## failed
The bones clatter all at once. You retreat to the tunnel with the sound following you in pieces.
@@ -0,0 +1,34 @@
---
id: child-beneath-well
startsIn: "[[well-shaft]]"
initialPhase: climbing
---
## climbing
Something moves below before you do.
A child emerges from the tunnel beneath the well, barefoot and breathless, one hand against the stone wall as it climbs past you. It does not stop. A draft of cold air follows after it.
Then it is gone upward toward the garden.
## hold-toy-dog-resolved
The child pauses at the garden gate.
"You found Woof."
Or perhaps: "Wolf."
The child takes the toy carefully and disappears into the overgrowth.
The garden grows quieter afterward.
## wait-resolved
You let the child climb past.
Small bare feet find the rungs without looking. The cold draft follows upward, and after a while the shaft is only stone again.
## wrong-verb
The child does not look at you. It climbs as if something below still has its name.
## failed
The cold rises too quickly. You climb back to the well with your hands numb around the rungs.
+19
View File
@@ -0,0 +1,19 @@
---
id: garden-procession
startsIn: "[[garden]]"
initialPhase: passing
---
## passing
Lanterns pass behind the hedge in a slow line. Each flame is held at the height of a face, but the leaves show no faces through them.
## wait-resolved
You remain silent.
The lanterns go by one after another, counting themselves in light. When the last has passed, the hedge exhales and the garden belongs to the rain again.
## wrong-verb
The nearest lantern stops.
## failed
The procession turns with one motion. You are back at the kitchen door before you remember retreating, and something in the hedge has learned your footstep.
+19
View File
@@ -0,0 +1,19 @@
---
id: portrait-woman
startsIn: "[[burial-gallery]]"
initialPhase: watching
---
## watching
One ruined portrait has not lost its eyes. The woman in it watches from behind a funeral veil, though the paint around her face has split to canvas.
## examine-portraits-resolved
You examine the portraits one by one.
Damage has made a family resemblance where blood may not have. Then the veiled woman's mouth, still painted, shows you the part you did not want to see: your own expression, waiting inside hers.
## wrong-verb
The veiled portrait watches you with patient, damaged eyes.
## failed
The veil lifts though nothing touches it. You step back into the root chamber before the face beneath can finish becoming familiar.
+19
View File
@@ -0,0 +1,19 @@
---
id: reflection
startsIn: "[[flooded-passage]]"
initialPhase: following
---
## following
Your reflection in the black water is a half-second late. When you stop, it finishes the motion after you.
## obscure-water-resolved
You spread the damp sheet across the water.
The cloth drinks the reflection into itself. For a moment a face presses up beneath the linen, then slackens into ordinary wet cloth.
## wrong-verb
The reflected figure takes a step you have not taken.
## failed
The water rises without rising. You stumble back into the ossuary before the face below reaches the surface.
+19
View File
@@ -0,0 +1,19 @@
---
id: root-movement
startsIn: "[[root-chamber]]"
initialPhase: shifting
---
## shifting
The roots stir whenever your light touches them. Their movement is slow, but every opening in the room is narrower than it was a breath ago.
## listen-resolved
You put out the light and listen.
In darkness, the roots stop pretending to be wood. They creak like a house settling around a sleeping child, then draw back from the eastern opening.
## wrong-verb
The roots tighten against one another, dry fibers whispering overhead.
## failed
The roots drop like ropes. You force your way back to the flooded passage with bark dust in your mouth.
+9
View File
@@ -0,0 +1,9 @@
---
id: burial-ring
names: ["ring", "burial ring", "crest ring", "burial-ring"]
short: "a burial ring"
takeable: true
initialState: {}
---
The ring is too small for your hand. A worn crest remains on its face: a gate, a vine, and something below them that may be a well or an eye.
+15
View File
@@ -0,0 +1,15 @@
---
id: family-register
names: ["register", "ledger", "family register", "family-register"]
short: "a family register"
takeable: true
readable: true
initialState: {}
---
The register is bound in black cloth, swollen at the corners from damp. Several pages have been cut out so neatly that their absence feels official.
## read
Names repeat across three generations. Births answer deaths. Deaths answer births.
One name appears in a different hand each time, always beside the same date, always corrected and written again beneath itself.
+11
View File
@@ -0,0 +1,11 @@
---
id: silver-vial
names: ["vial", "silver vial", "silver-vial"]
short: "a silver vial"
takeable: true
initialState: {}
---
The vial is cold enough to sting your fingers. Its stopper has been sealed with black wax and pressed with the same worn crest: gate, vine, and the shape beneath them.
Something clear moves inside, though the vial is still.
+9
View File
@@ -0,0 +1,9 @@
---
id: toy-boat
names: ["toy boat", "wooden boat", "boat", "toy-boat"]
short: "a wooden toy boat"
takeable: true
initialState: {}
---
The boat has been carved from a single piece of soft wood. Its mast is broken. Along one side, a child's thumbnail has pressed a row of moons into the paint.
+9
View File
@@ -0,0 +1,9 @@
---
id: well-rope-wheel
names: ["wheel", "rope wheel", "well wheel", "iron wheel", "well-rope-wheel"]
short: "an iron rope wheel"
takeable: false
initialState: {}
---
The wheel is bolted to a post beside the well. Rust has gathered around the handle in small dark flowers. When you touch it, the rope below answers with a slow movement too deep to hear clearly.
+24
View File
@@ -0,0 +1,24 @@
---
id: back-door
title: "[ Back Door ]"
exitN: null
exitS: null
exitE: "[[garden]]"
exitW: "[[kitchen]]"
exitU: null
exitD: null
items: []
encounter: null
safe: true
---
## first-visit
The back door stands at the end of a narrow mud room. Coats hang from iron hooks, each one collapsed into the shape of someone who has just stepped out of it.
The kitchen is west. The grounds breathe through the door to the east.
## revisit
The back door waits with its latch lifted.
## examined
Mud has dried in overlapping prints across the threshold. Some enter the house. Some leave it. None are recent enough to explain the damp line shining under the door.
+24
View File
@@ -0,0 +1,24 @@
---
id: burial-gallery
title: "[ Burial Gallery ]"
exitN: null
exitS: null
exitE: "[[chamber]]"
exitW: "[[root-chamber]]"
exitU: null
exitD: null
items:
- "[[family-register]]"
encounter: "[[portrait-woman]]"
---
## first-visit
Portraits line the walls, though none survive intact. Faces have been scratched away, smoked over, or softened by damp until they resemble each other by damage alone.
The room feels nearer to the dining room than stone should allow.
## revisit
The ruined portraits keep their places.
## examined
Frames crowd both walls in uneven rows. A funeral veil hangs from one torn canvas. On a narrow lectern below it, a register lies open under a weight of dust.
+24
View File
@@ -0,0 +1,24 @@
---
id: chamber
title: "[ Antechamber ]"
exitN: null
exitS: null
exitE: "[[vault]]"
exitERequires: familyResemblanceSeen
exitELockedText: "The vault door has no handle on this side. The scratched portraits behind you seem to know why."
exitW: "[[burial-gallery]]"
exitU: null
exitD: null
items: []
encounter: null
safe: true
---
## first-visit
The antechamber is bare except for the vault door. No name is carved above it. No warning. Only a smooth place in the stone where a hand has rested often.
## revisit
The antechamber waits before the vault.
## examined
The vault door is stone faced with iron. Its lock has been filled with wax, then scratched clean by fingernails. The air smells faintly of extinguished lamps.
+26
View File
@@ -0,0 +1,26 @@
---
id: chapel
title: "[ Chapel ]"
exitN: null
exitS: "[[vault]]"
exitE: null
exitW: null
exitU: null
exitD: null
items: ["silver-vial"]
encounter: "[[basilisk]]"
safe: false
---
## first-visit
The chapel is too small for its altar. The benches have been dragged aside and stacked against the walls.
Something large is coiled beneath the altar. You become aware of the eye first. Not glowing. Merely open.
The vault is south.
## revisit
The chapel keeps its eye under the altar.
## examined
No candle has been lit here in years, but the stone smells of wax. A small silver vial rests near the altar's cracked edge.
+24
View File
@@ -0,0 +1,24 @@
---
id: cistern
title: "[ Cistern ]"
exitN: null
exitS: null
exitE: "[[tunnel]]"
exitW: null
exitU: null
exitD: null
items: []
encounter: null
safe: true
---
## first-visit
The cistern lies beneath the house like a second, colder kitchen. Black water fills a brick basin from wall to wall. Drops fall from above at intervals too regular to be weather.
The tunnel is east.
## revisit
The cistern counts its drops in the dark.
## examined
An iron pipe descends from the ceiling and vanishes into the water. Grease, ash, and old rain have made a skin on the surface. For one moment it reflects a ceiling hung with pans.
+4 -2
View File
@@ -3,7 +3,9 @@ id: conservatory
title: "[ Conservatory ]"
exitN: null
exitS: "[[dining-room]]"
exitE: null
exitE: "[[garden]]"
exitERequires: conservatoryVinesCut
exitELockedText: "The ivy knots itself across the eastern glass. You cannot pass through it yet."
exitW: null
exitU: null
exitD: null
@@ -15,7 +17,7 @@ encounter: "[[ivy-figure]]"
## first-visit
The conservatory roof has gone blind with moss and old rain. Vines press against the glass from both sides, as if the room were trying to keep something in.
Something human-shaped hangs among the ivy. The dining room is south.
Something human-shaped hangs among the ivy. The dining room is south; beyond the eastern glass, the garden presses close.
## revisit
The conservatory sweats in the dark.
+24
View File
@@ -0,0 +1,24 @@
---
id: flooded-passage
title: "[ Flooded Passage ]"
exitN: "[[root-chamber]]"
exitS: null
exitE: null
exitW: "[[ossuary]]"
exitU: null
exitD: null
items:
- "[[toy-boat]]"
encounter: "[[reflection]]"
---
## first-visit
Black water moves softly across the stone floor. It is not deep, but it hides the floor completely, and every step makes a second step answer underneath.
The ossuary is west. A root-choked chamber waits north.
## revisit
The flooded passage stirs around your ankles.
## examined
The water carries no debris. It reflects the low ceiling too clearly, except where something below has learned to match your posture.
+23
View File
@@ -0,0 +1,23 @@
---
id: garden
title: "[ Garden ]"
exitN: "[[well]]"
exitS: "[[back-door]]"
exitE: null
exitW: "[[conservatory]]"
exitU: null
exitD: null
items: []
encounter: "[[garden-procession]]"
---
## first-visit
The garden has grown into the paths and then past them. Box hedges lean together in private corridors. The flowerbeds are black with rain and crowded with stems that hold no flowers.
Lantern light moves beyond the hedge, one small flame after another, though no footstep follows it.
## revisit
The garden listens from behind its leaves.
## examined
The path runs south to the back door and north toward the well. West, a gap in the conservatory glass shows where the ivy has been cut back. The hedges hold still as if they have been warned.
+2 -2
View File
@@ -3,7 +3,7 @@ id: kitchen
title: "[ Kitchen ]"
exitN: null
exitS: null
exitE: null
exitE: "[[back-door]]"
exitW: "[[dining-room]]"
exitU: null
exitD: null
@@ -16,7 +16,7 @@ safe: true
## first-visit
Something recently warm. The room smells of fat, onions, and wood smoke that has not yet cleared.
The dining room is west.
The dining room is west. A back door waits east.
## revisit
The kitchen keeps its heat a little too long.
+24
View File
@@ -0,0 +1,24 @@
---
id: ossuary
title: "[ Ossuary ]"
exitN: null
exitS: "[[tunnel]]"
exitE: "[[flooded-passage]]"
exitW: null
exitU: null
exitD: null
items:
- "[[burial-ring]]"
encounter: "[[bone-keeper]]"
---
## first-visit
The bones have been arranged with more care than devotion. Femurs make rails along the wall. Small bones fill the gaps like pale kindling.
Someone is still improving the order of them.
## revisit
The ossuary keeps its careful shelves.
## examined
Skulls face inward, away from you. A ring rests on a flat stone beneath one jaw, placed there as if the dead had only just taken it off.
+23
View File
@@ -0,0 +1,23 @@
---
id: root-chamber
title: "[ Root Chamber ]"
exitN: null
exitS: "[[flooded-passage]]"
exitE: "[[burial-gallery]]"
exitW: null
exitU: null
exitD: null
items: []
encounter: "[[root-movement]]"
---
## first-visit
Roots have entered through the ceiling and never stopped growing. They hang in ropes, in curtains, in thick knuckled cords that vanish into cracks in the floor.
Something moves through them when the light touches them.
## revisit
The root chamber creaks softly overhead.
## examined
The roots are dry despite the flooded passage behind you. They smell of turned earth and old linen. East, they draw aside around a low opening into a gallery.
+23
View File
@@ -0,0 +1,23 @@
---
id: tunnel
title: "[ Tunnel ]"
exitN: "[[ossuary]]"
exitS: null
exitE: null
exitW: "[[cistern]]"
exitU: "[[well-shaft]]"
exitD: null
items: []
encounter: null
---
## first-visit
The tunnel runs under the grounds with the patience of something dug by hand and widened by water. Its ceiling is low enough to make you bow.
The well shaft rises behind you. North, the passage whitens into bone. West, water echoes in a larger dark.
## revisit
The tunnel remembers the shape you made passing through it.
## examined
Pick marks cross the stone in old diagonals. In places the wall has been patched with brick scavenged from somewhere more domestic: hearth brick, kitchen brick, nursery chimney brick.
+26
View File
@@ -0,0 +1,26 @@
---
id: vault
title: "[ Vault ]"
exitN: "[[chapel]]"
exitS: null
exitE: null
exitW: "[[chamber]]"
exitU: null
exitD: null
items: []
encounter: null
safe: true
---
## first-visit
The vault holds what was buried at Halfstreet.
It is smaller than dread made it. A child's brass bed stands in the center of the room. Beside it, a photograph has been turned face down on the coverlet.
North, the stonework narrows toward a chapel.
## revisit
The vault remains small.
## examined
The bedclothes have yellowed but have not rotted. The turned photograph is dry and clean. Around the bed, the stone floor is marked by a circle of old lamp soot. A low chapel lies north.
+23
View File
@@ -0,0 +1,23 @@
---
id: well-shaft
title: "[ Well Shaft ]"
exitN: null
exitS: null
exitE: null
exitW: null
exitU: "[[well]]"
exitD: "[[tunnel]]"
items: []
encounter: "[[child-beneath-well]]"
---
## first-visit
You climb down inside the well, palm against cold stone, foot searching for the next iron rung.
Below you, the shaft opens into a darker passage.
## revisit
The well shaft is colder than the garden above.
## examined
The stones are furred with mineral bloom. Far below, the dark narrows into a tunnel where water moves without showing itself.
+24
View File
@@ -0,0 +1,24 @@
---
id: well
title: "[ The Well ]"
exitN: null
exitS: "[[garden]]"
exitE: null
exitW: null
exitU: null
exitD: "[[well-shaft]]"
items:
- "[[well-rope-wheel]]"
encounter: null
---
## first-visit
The well is dry. Its stones descend past the depth where water should have begun, past the depth where sound should return.
An iron wheel stands beside it, fixed to a rope dark with age.
## revisit
The well keeps its depth to itself.
## examined
The coping stones are worn smooth where hands have gripped them. The rope vanishes into the throat of the well without tension, as if the bucket below has forgotten weight.