Compare commits

...

3 Commits

Author SHA1 Message Date
ejlewis 7b1b5d0f6c chore: add build number and release scripts
ci/woodpecker/push/woodpecker Pipeline was successful
2026-05-12 20:52:35 -05:00
ejlewis e7b74c827a fix(engine): burn lamp one segment per wait 2026-05-12 20:31:50 -05:00
ejlewis 52fb869976 feat(mystery): add altered rooms and drunk sequence 2026-05-12 20:22:20 -05:00
46 changed files with 921 additions and 26 deletions
+16
View File
@@ -22,6 +22,22 @@ npm run dev # local dev server
npm run build # type-check + production build npm run build # type-check + production build
``` ```
## Releases
The footer build number comes from Woodpecker's pipeline number and increments on each CI build.
The package version is an intentional release label.
Use one of these from a clean worktree when you are ready to cut a release:
```sh
npm run release:patch # fixes, typo corrections, small polish
npm run release:minor # meaningful playable additions or mechanics
npm run release:major # disruptive changes after 1.0.0
git push --follow-tags
```
Each release script updates `package.json` and `package-lock.json`, creates a release commit, and tags it.
## Layout ## Layout
- `src/engine/` — parser, dispatcher, encounter logic - `src/engine/` — parser, dispatcher, encounter logic
+3
View File
@@ -11,6 +11,9 @@
"build": "astro check && astro build", "build": "astro check && astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
"release:patch": "npm version patch -m \"chore(release): %s\"",
"release:minor": "npm version minor -m \"chore(release): %s\"",
"release:major": "npm version major -m \"chore(release): %s\"",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest"
}, },
+16 -6
View File
@@ -501,22 +501,32 @@ describe('light/extinguish verbs (implicit lighter)', () => {
expect(texts).toContain('The book is empty.') expect(texts).toContain('The book is empty.')
}) })
it('burns two segments on wait and extinguishes on the third wait', () => { it('burns one segment on each wait and extinguishes when the last segment expires', () => {
const world = w() const world = w()
let state = initialStateFor(world) let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 6 } }] } state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 6 } }] }
const first = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world) const first = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world)
expect(first.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(4) expect(first.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(5)
expect(first.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true) expect(first.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true)
const second = dispatch(first.state, { kind: 'verb-only', verb: 'wait' }, world) const second = dispatch(first.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(second.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(2) expect(second.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(4)
const third = dispatch(second.state, { kind: 'verb-only', verb: 'wait' }, world) const third = dispatch(second.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(third.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(0) expect(third.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(3)
expect(third.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(false)
expect(third.appended.map((l) => l.text)).toContain('The flame dies.') const fourth = dispatch(third.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(fourth.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(2)
const fifth = dispatch(fourth.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(fifth.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(1)
expect(fifth.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true)
const sixth = dispatch(fifth.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(sixth.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(0)
expect(sixth.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(false)
expect(sixth.appended.map((l) => l.text)).toContain('The flame dies.')
}) })
it('burns one segment on movement', () => { it('burns one segment on movement', () => {
+88 -3
View File
@@ -4,6 +4,7 @@ import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters' import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
export const LIGHT_TURNS_MAX = 6 export const LIGHT_TURNS_MAX = 6
const DRUNK_TURNS_MAX = 20
export interface LightStatus { export interface LightStatus {
itemId: string itemId: string
@@ -93,12 +94,13 @@ function setRoomFlag(state: GameState, roomId: string, key: string, value: strin
} }
} }
const ENDING_PRIORITY: ('true' | 'wrong' | 'bad')[] = ['true', 'wrong', 'bad'] const ENDING_PRIORITY = ['mercy', 'true', 'replacement', 'bad', 'wrong'] as const
function evaluateEndings(state: GameState, world: World): GameState | null { function evaluateEndings(state: GameState, world: World): GameState | null {
if (state.endedWith) return null if (state.endedWith) return null
for (const id of ENDING_PRIORITY) { for (const id of ENDING_PRIORITY) {
const ending = world.endings[id] const ending = world.endings[id]
if (!ending) continue
const flags = ending.whenFlags const flags = ending.whenFlags
let allMatch = true let allMatch = true
for (const [k, v] of Object.entries(flags)) { for (const [k, v] of Object.entries(flags)) {
@@ -115,6 +117,7 @@ function evaluateEndings(state: GameState, world: World): GameState | null {
} }
function withEndingCheck(result: DispatchResult, world: World): DispatchResult { function withEndingCheck(result: DispatchResult, world: World): DispatchResult {
result = maybeResolveDrunkState(result, world)
const updated = evaluateEndings(result.state, world) const updated = evaluateEndings(result.state, world)
if (!updated) return result if (!updated) return result
const endingLine: TranscriptLine = updated.transcript[updated.transcript.length - 1]! const endingLine: TranscriptLine = updated.transcript[updated.transcript.length - 1]!
@@ -233,6 +236,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World,
if (command.verb === 'drop') return withEndingCheck(handleDrop(stateWithNoun, command.target.canonical, world), world) if (command.verb === 'drop') return withEndingCheck(handleDrop(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'examine' || command.verb === 'look') return withEndingCheck(handleExamine(stateWithNoun, command.target.canonical, world), world) if (command.verb === 'examine' || command.verb === 'look') return withEndingCheck(handleExamine(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'read') return withEndingCheck(handleRead(stateWithNoun, command.target.canonical, world), world) if (command.verb === 'read') return withEndingCheck(handleRead(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'drink') return withEndingCheck(handleDrink(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'light') return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, null, world), world) if (command.verb === 'light') return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, null, world), world)
if (command.verb === 'extinguish') return withEndingCheck(handleExtinguish(stateWithNoun, command.target.canonical, world), world) if (command.verb === 'extinguish') return withEndingCheck(handleExtinguish(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'use') { if (command.verb === 'use') {
@@ -325,7 +329,13 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
{ kind: 'narration', text: description }, { kind: 'narration', text: description },
...lightTick.lines, ...lightTick.lines,
] ]
const result = narrate(next, arrivalLines) let result = narrate(next, arrivalLines)
if (state.flags['drunk'] === true && dest.startsWith('drunk-')) {
const moved = advanceDrunkTurns(result.state, world)
if (moved.appended.length > 0) return { state: moved.state, appended: [...arrivalLines, ...moved.appended] }
result = { state: moved.state, appended: arrivalLines }
}
// Trigger any encounter waiting in this room. // Trigger any encounter waiting in this room.
const triggered = maybeTriggerEncounter(result.state, world) const triggered = maybeTriggerEncounter(result.state, world)
@@ -335,8 +345,83 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
return result return result
} }
function handleDrink(state: GameState, itemId: string, world: World): DispatchResult {
if (itemId !== 'whiskey') {
return narrate(state, [{ kind: 'narration', text: "You can't drink that." }])
}
const held = state.inventory.some((i) => i.id === 'whiskey')
if (!held) {
return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }])
}
const dest = world.rooms['drunk-hall']
const next: GameState = {
...state,
location: 'drunk-hall',
inventory: state.inventory.filter((i) => i.id !== 'whiskey'),
flags: { ...state.flags, drunk: true, drunkMoves: 0, drunkSecretFound: false },
}
const visited = !!next.roomState['drunk-hall']?.['visited']
const withVisit = setRoomFlag(next, 'drunk-hall', 'visited', true)
const lines: TranscriptLine[] = [
{ kind: 'narration', text: 'You drink from the bottle. It tastes of smoke, sugar, and rainwater left too long in a pipe.' },
]
if (dest) {
lines.push(
{ kind: 'system', text: dest.title },
{ kind: 'narration', text: visited ? dest.descriptions.revisit : dest.descriptions.firstVisit },
)
}
return narrate(withVisit, lines)
}
function maybeResolveDrunkState(result: DispatchResult, world: World): DispatchResult {
if (result.state.flags['drunk'] !== true) return result
if (result.state.flags['drunkSecretFound'] === true) {
const passed = passOutFromDrunk(result.state, world, 'The faceless man steps backward into the dark. The floor rises under you, or you fall toward it.')
return { state: passed.state, appended: [...result.appended, ...passed.appended] }
}
return result
}
function advanceDrunkTurns(state: GameState, world: World): DispatchResult {
const current = typeof state.flags['drunkMoves'] === 'number' ? state.flags['drunkMoves'] : 0
const moves = current + 1
const next = { ...state, flags: { ...state.flags, drunkMoves: moves } }
if (moves < DRUNK_TURNS_MAX) return { state: next, appended: [] }
return passOutFromDrunk(next, world, 'The rooms keep turning until they become one room. Then even that room is gone.')
}
function passOutFromDrunk(state: GameState, world: World, preface: string): DispatchResult {
const foyer = world.rooms['foyer']
const kitchenState = state.roomState['kitchen'] ?? {}
const kitchenTaken = ((kitchenState['takenItems'] ?? []) as string[]).filter((id) => id !== 'whiskey')
const kitchenDropped = ((kitchenState['droppedItems'] ?? []) as string[]).filter((id) => id !== 'whiskey')
const next: GameState = {
...state,
location: 'foyer',
inventory: state.inventory.filter((i) => i.id !== 'whiskey'),
flags: { ...state.flags, drunk: false, drunkMoves: 0, drunkSecretFound: false },
roomState: {
...state.roomState,
kitchen: {
...kitchenState,
takenItems: kitchenTaken,
droppedItems: kitchenDropped,
},
foyer: { ...(state.roomState['foyer'] ?? {}), visited: true },
},
}
const lines: TranscriptLine[] = [
{ kind: 'narration', text: preface },
{ kind: 'system', text: foyer?.title ?? '[ Foyer ]' },
{ kind: 'narration', text: foyer?.descriptions.revisit ?? 'You wake in the foyer.' },
{ kind: 'narration', text: 'The bottle is not with you. Somewhere in the kitchen, it is half full again.' },
]
return narrate(next, lines)
}
function handleWait(state: GameState, world: World): DispatchResult { function handleWait(state: GameState, world: World): DispatchResult {
const lightTick = advanceLightState(state, 2, world) const lightTick = advanceLightState(state, 1, world)
return narrate(lightTick.state, [ return narrate(lightTick.state, [
{ kind: 'narration', text: 'Time passes.' }, { kind: 'narration', text: 'Time passes.' },
...lightTick.lines, ...lightTick.lines,
+1
View File
@@ -149,6 +149,7 @@ export function applyVerbToEncounter(
const newEncState = { ...next.encounterState } const newEncState = { ...next.encounterState }
delete newEncState[encId] delete newEncState[encId]
let resolvedFlags = { ...next.flags, [`${encId}.resolved`]: true } let resolvedFlags = { ...next.flags, [`${encId}.resolved`]: true }
if (transition.setFlags) resolvedFlags = { ...resolvedFlags, ...transition.setFlags }
if (def.onResolved?.setFlags) resolvedFlags = { ...resolvedFlags, ...def.onResolved.setFlags } if (def.onResolved?.setFlags) resolvedFlags = { ...resolvedFlags, ...def.onResolved.setFlags }
next = { ...next, encounterState: newEncState, flags: resolvedFlags } next = { ...next, encounterState: newEncState, flags: resolvedFlags }
} else if (transition.to === 'failed') { } else if (transition.to === 'failed') {
+1
View File
@@ -27,6 +27,7 @@ const VERB_SYNONYMS: Record<string, Verb> = {
drop: 'drop', put: 'drop', leave: 'drop', drop: 'drop', put: 'drop', leave: 'drop',
use: 'use', combine: 'use', use: 'use', combine: 'use',
open: 'open', close: 'close', open: 'open', close: 'close',
drink: 'drink', sip: 'drink',
read: 'read', light: 'light', extinguish: 'extinguish', douse: 'extinguish', read: 'read', light: 'light', extinguish: 'extinguish', douse: 'extinguish',
attack: 'attack', kill: 'attack', fight: 'attack', strike: 'attack', attack: 'attack', kill: 'attack', fight: 'attack', strike: 'attack',
hold: 'hold', show: 'hold', hold: 'hold', show: 'hold',
+113 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { parse } from './parser' import { parse } from './parser'
import type { ParserContext } from './parser' import type { ParserContext } from './parser'
import { dispatch, initialStateFor } from './dispatcher' import { dispatch, getItemsInRoom, initialStateFor } from './dispatcher'
import { world } from '../world' import { world } from '../world'
import type { GameState } from './types' import type { GameState } from './types'
@@ -223,4 +223,116 @@ describe('playthrough — sample world', () => {
'family-register', 'family-register',
])) ]))
}) })
it('plays through the conditional rain-room branch', () => {
const state = play([
'n', // gate → foyer
'n', // foyer → hallway
'u', // hallway → parlor
'u', // parlor → upper stair
'wait',
'd', // upper stair → parlor
's', // parlor → wrong hallway
'wait',
'n', // wrong hallway → rain room
'look basin',
])
expect(state.flags['distant-steps.resolved']).toBe(true)
expect(state.flags['rainwater-basin.resolved']).toBe(true)
expect(state.flags['rainRoomEntered']).toBe(true)
expect(state.endedWith).toBe('replacement')
})
it('reaches the expanded true ending through the vault choice', () => {
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',
'n', // flooded-passage → root-chamber
'listen',
'e', // root-chamber → burial-gallery
'examine portraits',
'take register',
'e', // burial-gallery → antechamber
'e', // antechamber → vault
'n', // vault → chapel
'take vial',
'pour vial on basilisk',
's', // chapel → vault
'read register',
])
expect(state.flags['basiliskSpared']).toBe(true)
expect(state.flags['nameSpoken']).toBe(true)
expect(state.endedWith).toBe('true')
})
it('passes out after wandering the drunk rooms too long', () => {
const state = play([
'n', // gate → foyer
'n', // foyer → hallway
'n', // hallway → dining-room
'e', // dining-room → kitchen
'take whiskey',
'drink whiskey',
'e', 'w', 'e', 'w', 'e',
'w', 'e', 'w', 'e', 'w',
'e', 'w', 'e', 'w', 'e',
'w', 'e', 'w', 'e', 'w',
])
expect(state.location).toBe('foyer')
expect(state.flags['drunk']).toBe(false)
expect(state.flags['drunkMoves']).toBe(0)
expect(getItemsInRoom(state, world, 'kitchen')).toContain('whiskey')
})
it('finds the faceless man in the drunk rooms and wakes in the foyer', () => {
const state = play([
'n', // gate → foyer
'n', // foyer → hallway
'n', // hallway → dining-room
'e', // dining-room → kitchen
'take whiskey',
'drink whiskey',
'u', // drunk hall → drunk landing
'listen',
])
expect(state.location).toBe('foyer')
expect(state.flags['facelessManMet']).toBe(true)
expect(state.flags['houseDebtNamed']).toBe(true)
expect(state.flags['drunk']).toBe(false)
})
}) })
+3 -2
View File
@@ -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' | 'listen' | 'pour' | 'hold' | 'push' | 'pull' | 'cut' | 'play' | 'listen' | 'pour' | 'drink'
export type MetaVerb = 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme' export type MetaVerb = 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'
@@ -40,6 +40,7 @@ export interface ItemInstance {
} }
export type EncounterPhase = string // phase names are encounter-specific export type EncounterPhase = string // phase names are encounter-specific
export type EndingId = 'true' | 'wrong' | 'bad' | 'replacement' | 'mercy'
export interface TranscriptLine { export interface TranscriptLine {
kind: 'narration' | 'player' | 'system' | 'ending' kind: 'narration' | 'player' | 'system' | 'ending'
@@ -77,7 +78,7 @@ export interface GameState {
/** Capped at 200 entries; older entries are dropped on append. */ /** Capped at 200 entries; older entries are dropped on append. */
transcript: TranscriptLine[] transcript: TranscriptLine[]
/** Set true when the player has reached an ending. UI shows ending screen. */ /** Set true when the player has reached an ending. UI shows ending screen. */
endedWith: 'true' | 'wrong' | 'bad' | null endedWith: EndingId | null
} }
export interface DispatchResult { export interface DispatchResult {
+4
View File
@@ -1,5 +1,7 @@
--- ---
import '../ui/crt.css' import '../ui/crt.css'
const buildNumber = process.env.CI_PIPELINE_NUMBER ?? 'local'
--- ---
<html lang="en"> <html lang="en">
@@ -80,6 +82,8 @@ import '../ui/crt.css'
<span aria-hidden="true">|</span> <span aria-hidden="true">|</span>
<a href="https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE">GNU 3.0</a> <a href="https://half.st/ejlewis/halfstreet/src/branch/main/LICENSE">GNU 3.0</a>
<span aria-hidden="true">|</span> <span aria-hidden="true">|</span>
<span>Build #{buildNumber}</span>
<span aria-hidden="true">|</span>
<a href="https://half.st/ejlewis/halfstreet">Source Code</a> <a href="https://half.st/ejlewis/halfstreet">Source Code</a>
</footer> </footer>
</div> </div>
+109
View File
@@ -115,6 +115,35 @@ body {
z-index: 1; z-index: 1;
} }
.mystery-bezel::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
opacity: 0;
}
:root[data-mystery-drunk] .mystery-bezel::after {
background:
linear-gradient(
93deg,
transparent 0 48%,
color-mix(in srgb, var(--m-fg) 12%, transparent) 49% 50%,
transparent 51% 100%
),
repeating-linear-gradient(
to bottom,
transparent 0 9px,
color-mix(in srgb, var(--m-fg) 8%, transparent) 9px 10px,
transparent 10px 23px
);
mix-blend-mode: screen;
opacity: 0.32;
transform: translateX(0.32ch) skewX(-1.4deg);
animation: mystery-drunk-screen 5.8s ease-in-out infinite;
}
.mystery-options { .mystery-options {
position: absolute; position: absolute;
top: 8px; top: 8px;
@@ -308,6 +337,30 @@ body {
margin: 0.25em 0; margin: 0.25em 0;
} }
:root[data-mystery-drunk] .mystery-transcript {
filter: blur(0.35px) contrast(1.05);
text-shadow:
0.055em 0.02em currentColor,
-0.075em -0.015em color-mix(in srgb, var(--m-fg) 50%, transparent),
0.14em 0 color-mix(in srgb, var(--m-accent-2) 26%, transparent),
0 0 4px currentColor;
animation: mystery-drunk-copy 6.4s ease-in-out infinite;
}
:root[data-mystery-drunk] .mystery-transcript .player,
:root[data-mystery-drunk] .mystery-transcript .system,
:root[data-mystery-drunk] .mystery-transcript .narration {
transform: translateX(0.12ch) skewX(-1.2deg);
}
:root[data-mystery-drunk] .mystery-transcript > div:nth-child(2n) {
transform: translateX(-0.18ch) skewX(0.8deg);
}
:root[data-mystery-drunk] .mystery-transcript > div:nth-child(3n) {
transform: translateX(0.28ch) skewX(-1.4deg);
}
.mystery-input-row { .mystery-input-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -370,6 +423,26 @@ body {
animation: mystery-cursor-blink 1.05s steps(1, end) infinite; animation: mystery-cursor-blink 1.05s steps(1, end) infinite;
} }
:root[data-mystery-drunk] .mystery-input-row {
filter: blur(0.2px);
transform: translateX(0.2ch);
text-shadow:
0.12em 0 currentColor,
-0.16em 0 color-mix(in srgb, var(--m-accent-2) 42%, transparent),
0 0 5px currentColor;
}
:root[data-mystery-drunk] .mystery-input-row::before {
content: '>>';
letter-spacing: -0.4ch;
}
:root[data-mystery-drunk] .mystery-input-display::after {
text-shadow:
0.22em 0 currentColor,
-0.18em 0 color-mix(in srgb, var(--m-fg) 50%, transparent);
}
:root[data-mystery-input-focused] .mystery-input-display::after { :root[data-mystery-input-focused] .mystery-input-display::after {
animation: mystery-cursor-blink 1.05s steps(1, end) infinite; animation: mystery-cursor-blink 1.05s steps(1, end) infinite;
} }
@@ -379,6 +452,31 @@ body {
50%, 100% { opacity: 0; } 50%, 100% { opacity: 0; }
} }
@keyframes mystery-drunk-copy {
0%, 18%, 100% {
filter: blur(0.2px) contrast(1.03);
transform: translate(0, 0) skewX(0);
}
36% {
filter: blur(0.8px) contrast(1.08);
transform: translate(0.12ch, -0.04em) skewX(-0.25deg);
}
58% {
filter: blur(0.3px) contrast(1.04);
transform: translate(-0.06ch, 0.02em) skewX(0.08deg);
}
76% {
filter: blur(0.65px) contrast(1.07);
transform: translate(-0.18ch, 0.05em) skewX(0.32deg);
}
}
@keyframes mystery-drunk-screen {
0%, 100% { transform: translateX(0.32ch) skewX(-1.4deg); }
45% { transform: translateX(-0.22ch) skewX(1.1deg); }
72% { transform: translateX(0.5ch) skewX(-0.6deg); }
}
.mystery-controls { .mystery-controls {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -479,6 +577,17 @@ body {
cursor: not-allowed; cursor: not-allowed;
} }
:root[data-mystery-drunk] .mystery-chip {
transform: rotate(-0.6deg);
text-shadow:
0.1em 0 currentColor,
-0.1em 0 color-mix(in srgb, var(--m-fg) 35%, transparent);
}
:root[data-mystery-drunk] .mystery-chip:nth-child(2n) {
transform: translateY(1px) rotate(0.8deg);
}
.mystery-transcript .ending { .mystery-transcript .ending {
margin-top: 2em; margin-top: 2em;
margin-bottom: 1em; margin-bottom: 1em;
+8
View File
@@ -119,6 +119,10 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
inputEl!.classList.toggle('ended', state.endedWith !== null) inputEl!.classList.toggle('ended', state.endedWith !== null)
} }
const syncDrunkEffect = (): void => {
document.documentElement.toggleAttribute('data-mystery-drunk', state.flags['drunk'] === true)
}
const syncCommandLine = (): void => { const syncCommandLine = (): void => {
const visibleText = inputEl.value || inputEl.placeholder const visibleText = inputEl.value || inputEl.placeholder
inputDisplayEl.textContent = visibleText inputDisplayEl.textContent = visibleText
@@ -369,12 +373,14 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
refreshChips() refreshChips()
syncLightMeter() syncLightMeter()
syncEndedUI() syncEndedUI()
syncDrunkEffect()
} }
renderAll(state.transcript, { animate: false }) renderAll(state.transcript, { animate: false })
refreshChips() refreshChips()
syncLightMeter() syncLightMeter()
syncEndedUI() syncEndedUI()
syncDrunkEffect()
syncCommandLine() syncCommandLine()
scheduleIdleHint() scheduleIdleHint()
@@ -437,6 +443,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
refreshChips() refreshChips()
syncLightMeter() syncLightMeter()
syncEndedUI() syncEndedUI()
syncDrunkEffect()
} else { } else {
appendLines([{ kind: 'system', text: 'There is no further back.' }], { scroll: false }) appendLines([{ kind: 'system', text: 'There is no further back.' }], { scroll: false })
} }
@@ -465,6 +472,7 @@ if (!transcriptEl || !inputEl || !inputDisplayEl) {
refreshChips() refreshChips()
syncLightMeter() syncLightMeter()
syncEndedUI() syncEndedUI()
syncDrunkEffect()
} catch (err) { } catch (err) {
console.error('[halfstreet] dispatch error', err) console.error('[halfstreet] dispatch error', err)
appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }], { scroll: false }) appendLines([{ kind: 'system', text: '[ The terminal hums and resets. ]' }], { scroll: false })
+1 -1
View File
@@ -41,7 +41,7 @@
- [ ] 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 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. - [ ] 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. - [x] 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. When the player is "drunk" the text on the terminal should appear blurry, like the user has double vision.
- [ ] 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. - [ ] 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. - [ ] BUG: It says the door closes behind you when you enter the lobby, but you can still exit S to the gate.
- [x] FEATURE: Add a short "typed" effect to the text. Make it look like it's being typed out, if that makes sense, one character at a time. The effect should be brief. - [x] FEATURE: Add a short "typed" effect to the text. Make it look like it's being typed out, if that makes sense, one character at a time. The effect should be brief.
+128
View File
@@ -240,6 +240,7 @@ export const encounters: Record<string, EncounterDef> = {
chipCommand: 'hold dog', chipCommand: 'hold dog',
requires: { item: 'toy-dog' }, requires: { item: 'toy-dog' },
narration: narration('child-beneath-well', 'hold-toy-dog-resolved'), narration: narration('child-beneath-well', 'hold-toy-dog-resolved'),
setFlags: { woofReturned: true },
to: 'resolved', to: 'resolved',
}, },
{ {
@@ -386,4 +387,131 @@ export const encounters: Record<string, EncounterDef> = {
onFailed: { narration: narration('basilisk', 'failed'), retreatTo: 'vault' }, onFailed: { narration: narration('basilisk', 'failed'), retreatTo: 'vault' },
defaultWrongVerbNarration: narration('basilisk', 'wrong-verb'), defaultWrongVerbNarration: narration('basilisk', 'wrong-verb'),
}, },
'vault-memory': {
id: 'vault-memory',
aliases: ['vault memory', 'memory', 'bed', 'photograph', 'photo', 'thing', 'buried thing'],
startsIn: 'vault',
initialPhase: 'buried',
phases: {
buried: {
description: narration('vault-memory', 'buried'),
transitions: [
{
verb: 'read',
target: 'family-register',
chipLabel: 'READ REGISTER',
chipCommand: 'read register',
requires: { item: 'family-register' },
narration: narration('vault-memory', 'read-register-resolved'),
setFlags: { nameSpoken: true },
to: 'resolved',
},
{
verb: 'take',
target: 'vault-memory',
chipLabel: 'TAKE PHOTO',
chipCommand: 'take photograph',
narration: narration('vault-memory', 'take-photograph-resolved'),
setFlags: { tookPhotograph: true },
to: 'resolved',
},
{
verb: 'attack',
target: 'vault-memory',
chipLabel: 'ATTACK BED',
chipCommand: 'attack bed',
narration: narration('vault-memory', 'attack-bed-resolved'),
setFlags: { disturbedVault: true },
to: 'resolved',
},
],
},
},
defaultWrongVerbNarration: narration('vault-memory', 'wrong-verb'),
},
'creaking-floorboard': {
id: 'creaking-floorboard',
aliases: ['creaking floorboard', 'floorboard', 'board', 'creak', 'secret door', 'faceless man', 'man', 'voice'],
startsIn: 'drunk-landing',
initialPhase: 'creaking',
phases: {
creaking: {
description: narration('creaking-floorboard', 'creaking'),
transitions: [
{
verb: 'listen',
chipLabel: 'LISTEN',
chipCommand: 'listen',
narration: narration('creaking-floorboard', 'listen-resolved'),
setFlags: { drunkSecretFound: true, facelessManMet: true, houseDebtNamed: true },
to: 'resolved',
},
{
verb: 'open',
target: 'creaking-floorboard',
chipLabel: 'OPEN BOARD',
chipCommand: 'open floorboard',
narration: narration('creaking-floorboard', 'listen-resolved'),
setFlags: { drunkSecretFound: true, facelessManMet: true, houseDebtNamed: true },
to: 'resolved',
},
],
},
},
defaultWrongVerbNarration: narration('creaking-floorboard', 'wrong-verb'),
},
'distant-steps': {
id: 'distant-steps',
aliases: ['distant steps', 'steps', 'footsteps', 'hallway'],
startsIn: 'wrong-hallway',
initialPhase: 'approaching',
phases: {
approaching: {
description: narration('distant-steps', 'approaching'),
transitions: [
{
verb: 'wait',
chipLabel: 'WAIT',
narration: narration('distant-steps', 'wait-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { distantStepsPassed: true } },
onFailed: { narration: narration('distant-steps', 'failed'), retreatTo: 'parlor' },
defaultWrongVerbNarration: narration('distant-steps', 'wrong-verb'),
},
'rainwater-basin': {
id: 'rainwater-basin',
aliases: ['rainwater basin', 'basin', 'water', 'rainwater', 'reflection'],
startsIn: 'rain-room',
initialPhase: 'reflecting',
phases: {
reflecting: {
description: narration('rainwater-basin', 'reflecting'),
transitions: [
{
verb: 'look',
target: 'rainwater-basin',
chipLabel: 'LOOK BASIN',
chipCommand: 'look basin',
narration: narration('rainwater-basin', 'look-resolved'),
to: 'resolved',
},
{
verb: 'examine',
target: 'rainwater-basin',
chipLabel: 'EXAMINE BASIN',
chipCommand: 'examine basin',
narration: narration('rainwater-basin', 'look-resolved'),
to: 'resolved',
},
],
},
},
onResolved: { setFlags: { rainRoomEntered: true, houseAcceptedYou: true } },
onFailed: { narration: narration('rainwater-basin', 'failed'), retreatTo: 'wrong-hallway' },
defaultWrongVerbNarration: narration('rainwater-basin', 'wrong-verb'),
},
} }
@@ -0,0 +1,20 @@
---
id: creaking-floorboard
startsIn: "[[drunk-landing]]"
initialPhase: creaking
---
## creaking
One floorboard creaks after you have stopped moving.
It creaks again, softer, from under the wrong foot.
## listen-resolved
You listen.
The board lifts by itself. Beneath it is a narrow door, and behind the door a man without a face sits with his hands folded.
"Halfstreet keeps what is owed," he whispers. "It does not know the difference between a debt and a child."
## wrong-verb
The floorboard waits until you breathe, then creaks beneath that too.
+21
View File
@@ -0,0 +1,21 @@
---
id: distant-steps
startsIn: "[[wrong-hallway]]"
initialPhase: approaching
---
## approaching
The footsteps come closer without growing louder.
They stop whenever you move.
## wait-resolved
You stand still.
The steps pass through you with the cold, careful pressure of someone carrying a tray through a dark room. When they are gone, the hallway is shorter by one door.
## wrong-verb
The hallway lengthens. The footsteps begin again from farther away.
## failed
You turn back too quickly and find the parlor waiting with all its chairs facing you.
+21
View File
@@ -0,0 +1,21 @@
---
id: rainwater-basin
startsIn: "[[rain-room]]"
initialPhase: reflecting
---
## reflecting
The basin shows no ceiling. It shows a hallway instead, and in that hallway a lamp going out.
## look-resolved
You look into the rainwater.
Rooms gather there one beneath another: nursery, chapel, vault, gate. For a moment they fit together correctly. Then your reflection enters them from the wrong side, and every door in the image opens inward.
When you look up, the room has learned you.
## wrong-verb
Rain touches your hands, though you are not beneath it.
## failed
The rain rises without filling the basin. You step back into the wrong hallway before it reaches your mouth.
-1
View File
@@ -3,7 +3,6 @@ id: rat
startsIn: "[[cellar-stair]]" startsIn: "[[cellar-stair]]"
initialPhase: lurking initialPhase: lurking
--- ---
![[Pasted image 20260509213136.png]]
## lurking ## lurking
A heavy rat watches you from the third step. Its eyes catch the light. A heavy rat watches you from the third step. Its eyes catch the light.
+28
View File
@@ -0,0 +1,28 @@
---
id: vault-memory
startsIn: "[[vault]]"
initialPhase: buried
---
## buried
The bed waits in the center of the vault. The photograph lies face down on the coverlet.
The family register feels heavier here, as if all its missing pages have returned at once.
## read-register-resolved
You read the name from the register.
The letters are difficult only until you say them. Then they seem to have been waiting in your mouth since the gate.
## take-photograph-resolved
You take the photograph.
It is dry and light and terribly easy to lift.
## attack-bed-resolved
You strike the little bed.
The sound is small. The house hears it anyway.
## wrong-verb
The photograph remains face down. The bedclothes do not stir.
+8 -1
View File
@@ -1,6 +1,13 @@
--- ---
id: bad id: bad
whenFlags: whenFlags:
_never: true tookPhotograph: true
--- ---
You take the photograph.
The child in it is you.
Behind you, on the stairs, someone has come up to meet you.
You do not go down again.
+17
View File
@@ -0,0 +1,17 @@
---
id: mercy
whenFlags:
nameSpoken: true
woofReturned: true
basiliskSpared: true
burialRingPlaced: true
familyResemblanceSeen: true
---
You remain in the vault until the lamp goes out.
The house is quiet.
Somewhere above, floorboards settle one final time.
By morning, Halfstreet contains one fewer restless thing.
+15
View File
@@ -0,0 +1,15 @@
---
id: replacement
whenFlags:
houseAcceptedYou: true
rainRoomEntered: true
hallwayShifted: true
---
You open the front door.
Someone is standing at the gate outside.
They look tired. Rain-darkened. Lost.
After a moment, you understand they have arrived for you.
+13 -2
View File
@@ -1,7 +1,18 @@
--- ---
id: true id: true
whenFlags: whenFlags:
ratGone: true nameSpoken: true
basiliskSpared: true
burialRingPlaced: true
familyResemblanceSeen: true
--- ---
You stand at the top of the stair. The thing below has settled. The door behind you opens, and outside, finally, is morning. You stand in the vault. What is buried at Halfstreet is buried because it was loved, and grieved, and finally let go.
You set the lamp beside it.
You speak the name aloud.
The house settles around you like a long exhalation.
Outside, the road exists again.
+10 -1
View File
@@ -1,6 +1,15 @@
--- ---
id: wrong id: wrong
whenFlags: whenFlags:
_never: true disturbedVault: true
--- ---
You disturb what should have remained untouched.
The house rearranges itself around your leaving.
Corridors repeat.
Doors return you to themselves.
The hallway grows longer.
+3 -1
View File
@@ -50,6 +50,8 @@ const endings: World['endings'] = {
true: { whenFlags: {}, narration: '' }, true: { whenFlags: {}, narration: '' },
wrong: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' },
bad: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' },
replacement: { whenFlags: {}, narration: '' },
mercy: { whenFlags: {}, narration: '' },
} }
const seenEndings = new Set<string>() const seenEndings = new Set<string>()
for (const [path, raw] of Object.entries(endingFiles)) { for (const [path, raw] of Object.entries(endingFiles)) {
@@ -57,7 +59,7 @@ for (const [path, raw] of Object.entries(endingFiles)) {
endings[id] = ending endings[id] = ending
seenEndings.add(id) seenEndings.add(id)
} }
const requiredEndings = ['true', 'wrong', 'bad'] as const const requiredEndings = ['true', 'wrong', 'bad', 'replacement', 'mercy'] as const
for (const id of requiredEndings) { for (const id of requiredEndings) {
if (!seenEndings.has(id)) { if (!seenEndings.has(id)) {
throw new Error(`endings/${id}.md is missing — every ending id must have a markdown file.`) throw new Error(`endings/${id}.md is missing — every ending id must have a markdown file.`)
+9
View File
@@ -0,0 +1,9 @@
---
id: altar-stone
names: ["altar", "altar stone", "cracked altar", "altar-stone"]
short: "a cracked altar stone"
takeable: false
initialState: {}
---
The altar has split across the center. The crack is narrow enough to hide a thread and deep enough to swallow light.
+9
View File
@@ -0,0 +1,9 @@
---
id: portrait-frame
names: ["frame", "portrait frame", "shattered frame", "portrait-frame"]
short: "a shattered portrait frame"
takeable: false
initialState: {}
---
The backing has warped away from the frame. Someone wrote beneath it, then scratched the writing into harmless lines.
+11
View File
@@ -0,0 +1,11 @@
---
id: rainwater-basin
names: ["basin", "rainwater basin", "rainwater", "water", "rainwater-basin"]
short: "a rainwater basin"
takeable: false
initialState: {}
---
The basin is cut directly into the floor. Rain strikes its surface without making rings.
If you lean near it, the water shows rooms you have already left.
+11
View File
@@ -0,0 +1,11 @@
---
id: rusted-key
names: ["key", "rusted key", "rusted-key"]
short: "a rusted key"
takeable: true
initialState: {}
---
The key is furred with rust except where fingers have worried the bow clean. Its teeth are thin, almost eaten through.
It smells faintly of rain.
+1 -1
View File
@@ -179,7 +179,7 @@ export function parseItem(raw: string, sourcePath: string): Item {
} }
export interface ParsedEnding { export interface ParsedEnding {
id: 'true' | 'wrong' | 'bad' id: 'true' | 'wrong' | 'bad' | 'replacement' | 'mercy'
ending: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string } ending: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
} }
+3 -1
View File
@@ -2,7 +2,9 @@
id: bedroom id: bedroom
title: "[ Bedroom ]" title: "[ Bedroom ]"
exitN: null exitN: null
exitS: null exitS: "[[returned-nursery]]"
exitSRequires: woofReturned
exitSLockedText: "The south wall holds only wallpaper, faded to the color of old linen."
exitE: "[[nursery]]" exitE: "[[nursery]]"
exitW: null exitW: null
exitU: "[[attic]]" exitU: "[[attic]]"
+1
View File
@@ -9,6 +9,7 @@ exitU: null
exitD: null exitD: null
items: items:
- "[[family-register]]" - "[[family-register]]"
- "[[portrait-frame]]"
encounter: "[[portrait-woman]]" encounter: "[[portrait-woman]]"
--- ---
+1 -1
View File
@@ -7,7 +7,7 @@ exitE: null
exitW: null exitW: null
exitU: null exitU: null
exitD: null exitD: null
items: ["silver-vial"] items: ["silver-vial", "altar-stone"]
encounter: "[[basilisk]]" encounter: "[[basilisk]]"
safe: false safe: false
--- ---
+23
View File
@@ -0,0 +1,23 @@
---
id: drunk-cellar
title: "[ Cellar ]"
exitN: "[[drunk-stairs]]"
exitS: "[[drunk-hall]]"
exitE: "[[drunk-door-loop]]"
exitW: null
exitU: "[[drunk-kitchen]]"
exitD: "[[drunk-cellar]]"
items: []
encounter: null
---
## first-visit
The cellar smells of earth, bottles, and hair singed by a candle.
The ceiling is too low until you stoop, then too high. Shelves line the walls with jars that hold only dark.
## revisit
The cellar keeps its dark in jars.
## examined
The floor slopes toward a drain that is not at the lowest point. The jars have labels, but the ink runs away from your eyes.
+23
View File
@@ -0,0 +1,23 @@
---
id: drunk-door-loop
title: "[ Door ]"
exitN: "[[drunk-hall]]"
exitS: "[[drunk-kitchen]]"
exitE: "[[drunk-stairs]]"
exitW: "[[drunk-landing]]"
exitU: null
exitD: null
items: []
encounter: null
---
## first-visit
A door stands alone in a room too narrow to contain it.
It is open. It is closed. Both facts are true enough to make your stomach turn.
## revisit
The door returns you to itself by another route.
## examined
The hinges are on both sides. The keyhole shows a dim hall, then the back of your own head, then nothing.
+23
View File
@@ -0,0 +1,23 @@
---
id: drunk-hall
title: "[ Hallway ]"
exitN: "[[drunk-door-loop]]"
exitS: "[[drunk-stairs]]"
exitE: "[[drunk-kitchen]]"
exitW: "[[drunk-hall]]"
exitU: "[[drunk-landing]]"
exitD: "[[drunk-cellar]]"
items: []
encounter: null
---
## first-visit
The hallway receives you sideways.
There are too many doors, and each one has the familiar brass knob from somewhere else in the house. The carpet rises and falls as if the boards beneath it are breathing through cloth.
## revisit
The hallway is still here, or here again.
## examined
The walls lean without falling. North is ahead until you look at it. South is behind you unless it is upstairs. Every direction seems possible in the way a bad idea seems possible.
+23
View File
@@ -0,0 +1,23 @@
---
id: drunk-kitchen
title: "[ Kitchen ]"
exitN: "[[drunk-landing]]"
exitS: "[[drunk-door-loop]]"
exitE: "[[drunk-hall]]"
exitW: "[[drunk-cellar]]"
exitU: null
exitD: "[[drunk-stairs]]"
items: []
encounter: null
---
## first-visit
The kitchen has no stove. It has three sinks, all running.
Steam gathers near the ceiling and lowers itself in slow sheets. Something knocks once from inside a cupboard, politely.
## revisit
The kitchen pours water into itself.
## examined
Pots hang where windows should be. The counter is wet. A corkscrew turns in a small circle by itself and leaves no mark on the wood.
+23
View File
@@ -0,0 +1,23 @@
---
id: drunk-landing
title: "[ Landing ]"
exitN: "[[drunk-cellar]]"
exitS: "[[drunk-hall]]"
exitE: "[[drunk-kitchen]]"
exitW: "[[drunk-stairs]]"
exitU: "[[drunk-door-loop]]"
exitD: "[[drunk-hall]]"
items: []
encounter: "[[creaking-floorboard]]"
---
## first-visit
The landing hangs in the dark without a staircase touching it.
A floorboard complains under your weight, then answers from several feet away.
## revisit
The landing waits for the sound of your next step.
## examined
There is a narrow seam between two boards. Cold air moves through it in small spoken breaths.
+23
View File
@@ -0,0 +1,23 @@
---
id: drunk-stairs
title: "[ Stairs ]"
exitN: "[[drunk-hall]]"
exitS: "[[drunk-cellar]]"
exitE: null
exitW: "[[drunk-door-loop]]"
exitU: "[[drunk-landing]]"
exitD: "[[drunk-hall]]"
items: []
encounter: null
---
## first-visit
The stairs climb and descend with the same steps.
The rail is cold on one side and warm on the other. Halfway along, a landing passes you without stopping.
## revisit
The stairs remember your feet badly.
## examined
Each tread is worn in the center. Some are worn on the underside too. The banister has been gripped hard enough to smooth the varnish away.
+3 -1
View File
@@ -2,7 +2,9 @@
id: parlor id: parlor
title: "[ Parlor ]" title: "[ Parlor ]"
exitN: "[[study]]" exitN: "[[study]]"
exitS: null exitS: "[[wrong-hallway]]"
exitSRequires: hallwayShifted
exitSLockedText: "The chairs face that wall with great expectation, but the room has not yet made a door there."
exitE: null exitE: null
exitW: null exitW: null
exitU: "[[stair-up]]" exitU: "[[stair-up]]"
+27
View File
@@ -0,0 +1,27 @@
---
id: rain-room
title: "[ Rain Room ]"
exitN: null
exitS: "[[wrong-hallway]]"
exitE: null
exitW: null
exitU: null
exitD: null
items:
- "[[rusted-key]]"
- "[[rainwater-basin]]"
encounter: "[[rainwater-basin]]"
---
## first-visit
Rain falls steadily inside the room and nowhere else.
It drops from a ceiling without clouds and gathers in a basin set into the floor. The walls are papered like a bedroom, tiled like a kitchen, then papered again.
Something small and iron glints near the basin's rim.
## revisit
The rain room keeps raining.
## examined
Water threads down the walls without staining them. The basin catches every drop and never fills. The south door opens back into the hallway that should not be here.
+26
View File
@@ -0,0 +1,26 @@
---
id: returned-nursery
title: "[ Nursery ]"
exitN: null
exitS: null
exitE: null
exitW: "[[bedroom]]"
exitU: null
exitD: null
items: []
encounter: null
safe: true
---
## first-visit
The toys are no longer arranged.
They lie where a child might have dropped them: horse on its side, blocks scattered under the bed, a wooden soldier face down near the door.
The room is less careful now. Less awake.
## revisit
The nursery remains untidied.
## examined
The circle on the floor has opened. Dust fills the places where the toys once sat, pale as old chalk. On the shelf, the bolted music box is closed, and for once it does not seem to be listening.
+1 -1
View File
@@ -8,7 +8,7 @@ exitW: "[[chamber]]"
exitU: null exitU: null
exitD: null exitD: null
items: [] items: []
encounter: null encounter: "[[vault-memory]]"
safe: true safe: true
--- ---
+27
View File
@@ -0,0 +1,27 @@
---
id: wrong-hallway
title: "[ Hallway ]"
exitN: "[[rain-room]]"
exitNRequires: distantStepsPassed
exitNLockedText: "The hallway lengthens ahead of you. Something is still walking there."
exitS: "[[parlor]]"
exitE: null
exitW: null
exitU: null
exitD: null
items: []
encounter: "[[distant-steps]]"
---
## first-visit
The hallway is longer now.
The wallpaper repeats in small mistakes: vine, gate, hand, vine, gate, hand. Doors stand where no doors stood before, but each knob turns into the same cold brass.
Footsteps approach from the far end.
## revisit
The wrong hallway has kept its length for you.
## examined
The boards bend slightly under your weight, though the footsteps ahead do not touch them. A rain-smell waits north, thin and steady.
+1 -1
View File
@@ -68,7 +68,7 @@ describe('endingFrontmatterSchema', () => {
}) })
it('rejects unknown ending id', () => { it('rejects unknown ending id', () => {
const data = { id: 'mercy', whenFlags: {} } const data = { id: 'secret', whenFlags: {} }
expect(() => endingFrontmatterSchema.parse(data)).toThrow() expect(() => endingFrontmatterSchema.parse(data)).toThrow()
}) })
}) })
+1 -1
View File
@@ -46,7 +46,7 @@ export const itemFrontmatterSchema = z.object({
export type ItemFrontmatter = z.infer<typeof itemFrontmatterSchema> export type ItemFrontmatter = z.infer<typeof itemFrontmatterSchema>
export const endingFrontmatterSchema = z.object({ export const endingFrontmatterSchema = z.object({
id: z.enum(['true', 'wrong', 'bad']), id: z.enum(['true', 'wrong', 'bad', 'replacement', 'mercy']),
whenFlags: stateRecordSchema.default({}), whenFlags: stateRecordSchema.default({}),
}) })
+4
View File
@@ -80,6 +80,8 @@ export interface EncounterTransition {
narration: string narration: string
/** Resolve cost for the player on this transition (02). */ /** Resolve cost for the player on this transition (02). */
resolveCost?: 0 | 1 | 2 resolveCost?: 0 | 1 | 2
/** Optional transition-specific story flags. */
setFlags?: Record<string, string | boolean | number>
} }
export interface EncounterDef { export interface EncounterDef {
@@ -108,5 +110,7 @@ export interface World {
true: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string } true: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
wrong: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string } wrong: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
bad: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string } bad: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
replacement?: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
mercy?: { whenFlags: Record<string, string | boolean | number | string[]>; narration: string }
} }
} }