This commit is contained in:
+1
-17
@@ -9,23 +9,7 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
- npm ci
|
- npm ci
|
||||||
- npm run test
|
- npm run test
|
||||||
- PUBLIC_GLITCHTIP_RELEASE=$CI_PIPELINE_NUMBER npm run build
|
- npm run build
|
||||||
|
|
||||||
- name: upload-sourcemaps
|
|
||||||
image: rust:1
|
|
||||||
environment:
|
|
||||||
SENTRY_URL:
|
|
||||||
from_secret: glitchtip_url
|
|
||||||
SENTRY_AUTH_TOKEN:
|
|
||||||
from_secret: glitchtip_auth_token
|
|
||||||
SENTRY_ORG:
|
|
||||||
from_secret: glitchtip_org
|
|
||||||
SENTRY_PROJECT:
|
|
||||||
from_secret: glitchtip_project
|
|
||||||
commands:
|
|
||||||
- cargo install --locked --git https://gitlab.com/glitchtip/glitchtip-cli.git
|
|
||||||
- glitchtip-cli sourcemaps inject ./dist
|
|
||||||
- glitchtip-cli sourcemaps upload ./dist --release "$CI_PIPELINE_NUMBER"
|
|
||||||
|
|
||||||
- name: deploy
|
- name: deploy
|
||||||
image: node:22
|
image: node:22
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ Played at [halfstreet.io](https://halfstreet.io).
|
|||||||
- TypeScript engine — pure (no DOM, `Date`, `Math.random`, or console)
|
- TypeScript engine — pure (no DOM, `Date`, `Math.random`, or console)
|
||||||
- World content authored in markdown (rooms, items, encounters, endings) under `src/world/`
|
- World content authored in markdown (rooms, items, encounters, endings) under `src/world/`
|
||||||
- [Vitest](https://vitest.dev) for tests
|
- [Vitest](https://vitest.dev) for tests
|
||||||
- Optional client error reporting via [GlitchTip](https://glitchtip.com) using `PUBLIC_GLITCHTIP_DSN`
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -23,9 +22,6 @@ npm run dev # local dev server
|
|||||||
npm run build # type-check + production build
|
npm run build # type-check + production build
|
||||||
```
|
```
|
||||||
|
|
||||||
To enable GlitchTip in the browser, set `PUBLIC_GLITCHTIP_DSN` in your environment before running or building the site.
|
|
||||||
If you want deploy-aware grouping and readable stack traces, also set `PUBLIC_GLITCHTIP_RELEASE` at build time and upload the generated source maps for that same release.
|
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `src/engine/` — parser, dispatcher, encounter logic
|
- `src/engine/` — parser, dispatcher, encounter logic
|
||||||
|
|||||||
@@ -6,9 +6,4 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
inlineStylesheets: 'auto',
|
inlineStylesheets: 'auto',
|
||||||
},
|
},
|
||||||
vite: {
|
|
||||||
build: {
|
|
||||||
sourcemap: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
Generated
-76
@@ -9,7 +9,6 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/browser": "^10.52.0",
|
|
||||||
"astro": "^6.1.9",
|
"astro": "^6.1.9",
|
||||||
"yaml": "^2.8.4",
|
"yaml": "^2.8.4",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -1767,81 +1766,6 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@sentry-internal/browser-utils": {
|
|
||||||
"version": "10.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.52.0.tgz",
|
|
||||||
"integrity": "sha512-x/yEPZdpH6NGQeoeQnV9tj8reAH8twNttiltGZl2o8Rk7sQeUfe7E8yuYP2XbJ2RqyZK5qRS3COrNyMPzf6KFA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/core": "10.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/feedback": {
|
|
||||||
"version": "10.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.52.0.tgz",
|
|
||||||
"integrity": "sha512-5kAn1W8ZvCuHtEHXpq6iRkUMdNCilwww+YxaN2yofVrCivAbB3Ha5JJUMqmWOPW0pC27zGYmoJMIDvG+PczUxA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry/core": "10.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/replay": {
|
|
||||||
"version": "10.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.52.0.tgz",
|
|
||||||
"integrity": "sha512-diywyuc/H7VTUR+W5ryVmLF+0X4UP1OskMqb6V8RSAvJHcj2JmIm7uP+Fc6ACTno+b6AUShwT/L4xVXzO6X9Cw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/browser-utils": "10.52.0",
|
|
||||||
"@sentry/core": "10.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry-internal/replay-canvas": {
|
|
||||||
"version": "10.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.52.0.tgz",
|
|
||||||
"integrity": "sha512-BI5ie4dxPuUJ344CXVSnAxY1xZCbghglPSCIlTOYODpR9so9yo5IZh+Mwspt0oWsUMaxWJiQSNYlbPWi7WDavg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/replay": "10.52.0",
|
|
||||||
"@sentry/core": "10.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/browser": {
|
|
||||||
"version": "10.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.52.0.tgz",
|
|
||||||
"integrity": "sha512-ijL9jN86oXwXQWbwhPlEb70ODJSEmjxQEQdnZkC4gDWbjswcwvRsVJPYk+1xl2ir2iZixRIHipVxDcLwian35g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@sentry-internal/browser-utils": "10.52.0",
|
|
||||||
"@sentry-internal/feedback": "10.52.0",
|
|
||||||
"@sentry-internal/replay": "10.52.0",
|
|
||||||
"@sentry-internal/replay-canvas": "10.52.0",
|
|
||||||
"@sentry/core": "10.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@sentry/core": {
|
|
||||||
"version": "10.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.52.0.tgz",
|
|
||||||
"integrity": "sha512-VA/kAqLhkMnRWY2RXdBLyTemR9D4m7MVRy/gyapoq9yvllVPx9WXbvKgnMP2LQp7mFgT/oLFvw58aQKaYTGn3A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@shikijs/core": {
|
"node_modules/@shikijs/core": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.0.2.tgz",
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
"test:watch": "vitest"
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sentry/browser": "^10.52.0",
|
|
||||||
"astro": "^6.1.9",
|
"astro": "^6.1.9",
|
||||||
"yaml": "^2.8.4",
|
"yaml": "^2.8.4",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
@@ -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 === 'look') return withEndingCheck(handleLook(state, world), world)
|
||||||
if (command.verb === 'inventory') return withEndingCheck(handleInventory(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 === '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') {
|
if (command.kind === 'verb-target') {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const world: World = {
|
|||||||
id: 'foyer',
|
id: 'foyer',
|
||||||
title: '[ Foyer ]',
|
title: '[ Foyer ]',
|
||||||
descriptions: { firstVisit: 'Foyer.', revisit: 'Foyer.', examined: 'Foyer.' },
|
descriptions: { firstVisit: 'Foyer.', revisit: 'Foyer.', examined: 'Foyer.' },
|
||||||
exits: { n: 'stair' },
|
exits: { n: 'stair', e: 'chapel' },
|
||||||
items: [],
|
items: [],
|
||||||
safe: true,
|
safe: true,
|
||||||
},
|
},
|
||||||
@@ -29,10 +29,19 @@ const world: World = {
|
|||||||
exits: { u: 'stair' },
|
exits: { u: 'stair' },
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
|
chapel: {
|
||||||
|
id: 'chapel',
|
||||||
|
title: '[ Chapel ]',
|
||||||
|
descriptions: { firstVisit: 'Chapel.', revisit: 'Chapel.', examined: 'Chapel.' },
|
||||||
|
exits: { s: 'foyer' },
|
||||||
|
items: ['vial'],
|
||||||
|
encounter: 'basilisk',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
items: {
|
items: {
|
||||||
mirror: { id: 'mirror', names: ['mirror', 'tarnished mirror'], short: 'a tarnished mirror', long: 'A small mirror, tarnished black.', initialState: {}, takeable: true },
|
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 },
|
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: {
|
encounters: {
|
||||||
revenant: {
|
revenant: {
|
||||||
@@ -59,6 +68,22 @@ const world: World = {
|
|||||||
onFailed: { narration: 'You stagger back.', retreatTo: 'foyer' },
|
onFailed: { narration: 'You stagger back.', retreatTo: 'foyer' },
|
||||||
defaultWrongVerbNarration: 'The revenant does not seem to notice.',
|
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: {
|
endings: {
|
||||||
true: { whenFlags: { _never: true }, narration: '' },
|
true: { whenFlags: { _never: true }, narration: '' },
|
||||||
@@ -116,4 +141,27 @@ describe('encounters — phase advancement', () => {
|
|||||||
s = dispatch(s, { kind: 'go', direction: 's' }, world).state
|
s = dispatch(s, { kind: 'go', direction: 's' }, world).state
|
||||||
expect(s.resolveLevel).toBe('steady')
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ describe('parser — verb-only commands', () => {
|
|||||||
expect(parse('look', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'look' })
|
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', () => {
|
it('recognizes bare "inventory" and short forms', () => {
|
||||||
expect(parse('inventory', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
|
expect(parse('inventory', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
|
||||||
expect(parse('inv', 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', () => {
|
it('resolves a single visible noun', () => {
|
||||||
const ctx: ParserContext = {
|
const ctx: ParserContext = {
|
||||||
knownItems: ['torch'],
|
knownItems: ['torch'],
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const VERB_SYNONYMS: Record<string, Verb> = {
|
|||||||
pull: 'pull',
|
pull: 'pull',
|
||||||
cut: 'cut', trim: 'cut',
|
cut: 'cut', trim: 'cut',
|
||||||
play: 'play',
|
play: 'play',
|
||||||
|
listen: 'listen',
|
||||||
|
pour: 'pour',
|
||||||
uncover: 'open',
|
uncover: 'open',
|
||||||
wait: 'wait', z: 'wait',
|
wait: 'wait', z: 'wait',
|
||||||
}
|
}
|
||||||
@@ -57,7 +59,7 @@ const META_VERBS: Record<string, MetaVerb> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Verbs that legally take no target. */
|
/** 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"). */
|
/** Two-word verb prefixes (e.g. "pick up X"). */
|
||||||
const TWO_WORD_VERBS = ['pick up']
|
const TWO_WORD_VERBS = ['pick up']
|
||||||
@@ -157,7 +159,7 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
|||||||
|
|
||||||
if (rest.length === 0) {
|
if (rest.length === 0) {
|
||||||
if (VERB_ONLY_VERBS.has(verb)) {
|
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' }
|
return { kind: 'unknown', raw: trimmed, reason: 'malformed' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,4 +128,98 @@ describe('playthrough — sample world', () => {
|
|||||||
expect(state.location).toBe('attic')
|
expect(state.location).toBe('attic')
|
||||||
expect(state.inventory.map((i) => i.id)).toContain('toy-dog')
|
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
@@ -9,7 +9,7 @@ export type Direction = 'n' | 's' | 'e' | 'w' | 'u' | 'd'
|
|||||||
export type Verb =
|
export type Verb =
|
||||||
| 'go' | 'look' | 'examine' | 'take' | 'drop' | 'use' | 'open' | 'close'
|
| 'go' | 'look' | 'examine' | 'take' | 'drop' | 'use' | 'open' | 'close'
|
||||||
| 'read' | 'light' | 'extinguish' | 'attack' | 'inventory' | 'wait'
|
| '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'
|
export type MetaVerb = 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export interface NounRef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ParsedCommand =
|
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'; verb: Verb; target: NounRef }
|
||||||
| { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef }
|
| { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef }
|
||||||
| { kind: 'ambiguous'; verb: Verb; rawNoun: string; candidates: string[] }
|
| { kind: 'ambiguous'; verb: Verb; rawNoun: string; candidates: string[] }
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ import '../ui/crt.css'
|
|||||||
</div>
|
</div>
|
||||||
<div class="mystery-options-group" aria-label="Game">
|
<div class="mystery-options-group" aria-label="Game">
|
||||||
<div class="mystery-options-label">Game</div>
|
<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>
|
<button type="button" data-restart-choice>Restart</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,11 +92,15 @@ import '../ui/crt.css'
|
|||||||
const storedCursor = (() => {
|
const storedCursor = (() => {
|
||||||
try { return localStorage.getItem('halfstreet:cursor:v1') } catch { return null }
|
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-theme', stored === 'ansi' ? 'ansi' : 'amber')
|
||||||
document.documentElement.setAttribute(
|
document.documentElement.setAttribute(
|
||||||
'data-mystery-cursor',
|
'data-mystery-cursor',
|
||||||
storedCursor === 'block' || storedCursor === 'underscore' ? storedCursor : 'bar',
|
storedCursor === 'block' || storedCursor === 'underscore' ? storedCursor : 'bar',
|
||||||
)
|
)
|
||||||
|
document.documentElement.setAttribute('data-mystery-chips-state', storedChips === 'off' ? 'off' : 'on')
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
import '../ui/terminal.ts'
|
import '../ui/terminal.ts'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Chip } from './chips'
|
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 {
|
export function renderChips(chips: Chip[], onSelect: (command: string) => void): void {
|
||||||
const container = document.querySelector<HTMLDivElement>(CHIP_CONTAINER)
|
const container = document.querySelector<HTMLDivElement>(CHIP_CONTAINER)
|
||||||
|
|||||||
@@ -359,6 +359,10 @@ body {
|
|||||||
animation: mystery-cursor-blink 1.05s steps(1, end) infinite;
|
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 {
|
@keyframes mystery-cursor-blink {
|
||||||
0%, 49% { opacity: 1; }
|
0%, 49% { opacity: 1; }
|
||||||
50%, 100% { opacity: 0; }
|
50%, 100% { opacity: 0; }
|
||||||
@@ -383,6 +387,10 @@ body {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-mystery-chips-state='off'] .mystery-chips {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.mystery-light-meter {
|
.mystery-light-meter {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 98px;
|
width: 98px;
|
||||||
|
|||||||
@@ -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
@@ -8,15 +8,12 @@ import { TRANSCRIPT_CAP } from '../engine/types'
|
|||||||
import { computeChips } from './chips'
|
import { computeChips } from './chips'
|
||||||
import { renderChips } from './chip-render'
|
import { renderChips } from './chip-render'
|
||||||
import LIGHT_ICON_SVG from '../assets/noun-candle-6409709.svg?raw'
|
import LIGHT_ICON_SVG from '../assets/noun-candle-6409709.svg?raw'
|
||||||
import { initGlitchTip } from './glitchtip'
|
|
||||||
|
|
||||||
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
|
const transcriptEl = document.querySelector<HTMLDivElement>('[data-mystery-transcript]')
|
||||||
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
|
const inputEl = document.querySelector<HTMLInputElement>('[data-mystery-input]')
|
||||||
const inputDisplayEl = document.querySelector<HTMLSpanElement>('[data-mystery-input-display]')
|
const inputDisplayEl = document.querySelector<HTMLSpanElement>('[data-mystery-input-display]')
|
||||||
const lightMeterEl = document.querySelector<HTMLDivElement>('[data-mystery-light-meter]')
|
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...
|
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.
|
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'
|
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 buildParserContext = (s: GameState): ParserContext => {
|
||||||
const room = world.rooms[s.location]
|
const room = world.rooms[s.location]
|
||||||
const visibleNouns: { id: string; aliases: string[] }[] = []
|
const visibleNouns: { id: string; aliases: string[] }[] = []
|
||||||
@@ -356,7 +357,13 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
inputEl.addEventListener('input', syncCommandLine)
|
inputEl.addEventListener('input', syncCommandLine)
|
||||||
inputEl.addEventListener('focus', clearIdleHint)
|
inputEl.addEventListener('focus', () => {
|
||||||
|
syncInputFocus(true)
|
||||||
|
clearIdleHint()
|
||||||
|
})
|
||||||
|
inputEl.addEventListener('blur', () => {
|
||||||
|
syncInputFocus(false)
|
||||||
|
})
|
||||||
inputEl.addEventListener('pointerdown', clearIdleHint)
|
inputEl.addEventListener('pointerdown', clearIdleHint)
|
||||||
|
|
||||||
inputEl.parentElement?.addEventListener('pointerdown', () => {
|
inputEl.parentElement?.addEventListener('pointerdown', () => {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
const STORAGE_KEY = 'halfstreet:theme:v1'
|
const STORAGE_KEY = 'halfstreet:theme:v1'
|
||||||
const CURSOR_STORAGE_KEY = 'halfstreet:cursor:v1'
|
const CURSOR_STORAGE_KEY = 'halfstreet:cursor:v1'
|
||||||
|
const CHIPS_STORAGE_KEY = 'halfstreet:chips:v1'
|
||||||
|
|
||||||
type Theme = 'amber' | 'ansi'
|
type Theme = 'amber' | 'ansi'
|
||||||
type Cursor = 'bar' | 'block' | 'underscore'
|
type Cursor = 'bar' | 'block' | 'underscore'
|
||||||
|
type Chips = 'on' | 'off'
|
||||||
|
|
||||||
function getStored(): Theme {
|
function getStored(): Theme {
|
||||||
try {
|
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 {
|
function setTheme(theme: Theme): void {
|
||||||
document.documentElement.setAttribute('data-mystery-theme', theme)
|
document.documentElement.setAttribute('data-mystery-theme', theme)
|
||||||
try {
|
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()
|
const initial = getStored()
|
||||||
setTheme(initial)
|
setTheme(initial)
|
||||||
setCursor(getStoredCursor())
|
setCursor(getStoredCursor())
|
||||||
|
setChips(getStoredChips())
|
||||||
|
|
||||||
const optionsRoot = document.querySelector<HTMLElement>('[data-mystery-options]')
|
const optionsRoot = document.querySelector<HTMLElement>('[data-mystery-options]')
|
||||||
const optionsToggle = document.querySelector<HTMLButtonElement>('[data-options-toggle]')
|
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', () => {
|
document.querySelector<HTMLButtonElement>('[data-restart-choice]')?.addEventListener('click', () => {
|
||||||
setOptionsOpen(false)
|
setOptionsOpen(false)
|
||||||
document.dispatchEvent(new CustomEvent('halfstreet-restart'))
|
document.dispatchEvent(new CustomEvent('halfstreet-restart'))
|
||||||
|
|||||||
+8
-6
@@ -25,7 +25,7 @@
|
|||||||
- [x] If the user says "use match with letter" they should burn the letter.
|
- [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.
|
- [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.
|
- [ ] 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 "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?
|
- [ ] 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
|
- [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 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.
|
- [ ] 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 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.
|
- [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.
|
- [x] FEATURE: Add an option to disable the chips in the options menu.
|
||||||
- [ ] BUG: The new cursor doesn't appear on mobile.
|
- [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 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: 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.
|
- [ ] 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.
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe('assembled world', () => {
|
|||||||
'bedroom',
|
'bedroom',
|
||||||
'nursery',
|
'nursery',
|
||||||
'attic',
|
'attic',
|
||||||
|
'chapel',
|
||||||
]))
|
]))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ describe('assembled world', () => {
|
|||||||
'childs-drawing',
|
'childs-drawing',
|
||||||
'music-box',
|
'music-box',
|
||||||
'toy-dog',
|
'toy-dog',
|
||||||
|
'silver-vial',
|
||||||
]))
|
]))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -202,4 +202,188 @@ export const encounters: Record<string, EncounterDef> = {
|
|||||||
onFailed: { narration: narration('stair-sleeper', 'failed'), retreatTo: 'parlor' },
|
onFailed: { narration: narration('stair-sleeper', 'failed'), retreatTo: 'parlor' },
|
||||||
defaultWrongVerbNarration: narration('stair-sleeper', 'wrong-verb'),
|
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'),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -3,7 +3,9 @@ id: conservatory
|
|||||||
title: "[ Conservatory ]"
|
title: "[ Conservatory ]"
|
||||||
exitN: null
|
exitN: null
|
||||||
exitS: "[[dining-room]]"
|
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
|
exitW: null
|
||||||
exitU: null
|
exitU: null
|
||||||
exitD: null
|
exitD: null
|
||||||
@@ -15,7 +17,7 @@ encounter: "[[ivy-figure]]"
|
|||||||
## first-visit
|
## 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.
|
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
|
## revisit
|
||||||
The conservatory sweats in the dark.
|
The conservatory sweats in the dark.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -3,7 +3,7 @@ id: kitchen
|
|||||||
title: "[ Kitchen ]"
|
title: "[ Kitchen ]"
|
||||||
exitN: null
|
exitN: null
|
||||||
exitS: null
|
exitS: null
|
||||||
exitE: null
|
exitE: "[[back-door]]"
|
||||||
exitW: "[[dining-room]]"
|
exitW: "[[dining-room]]"
|
||||||
exitU: null
|
exitU: null
|
||||||
exitD: null
|
exitD: null
|
||||||
@@ -16,7 +16,7 @@ safe: true
|
|||||||
## first-visit
|
## first-visit
|
||||||
Something recently warm. The room smells of fat, onions, and wood smoke that has not yet cleared.
|
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
|
## revisit
|
||||||
The kitchen keeps its heat a little too long.
|
The kitchen keeps its heat a little too long.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user