feat(mystery): add altered rooms and drunk sequence

This commit is contained in:
2026-05-12 20:22:20 -05:00
parent 0755213d6a
commit 52fb869976
42 changed files with 881 additions and 19 deletions
+87 -2
View File
@@ -4,6 +4,7 @@ import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
export const LIGHT_TURNS_MAX = 6
const DRUNK_TURNS_MAX = 20
export interface LightStatus {
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 {
if (state.endedWith) return null
for (const id of ENDING_PRIORITY) {
const ending = world.endings[id]
if (!ending) continue
const flags = ending.whenFlags
let allMatch = true
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 {
result = maybeResolveDrunkState(result, world)
const updated = evaluateEndings(result.state, world)
if (!updated) return result
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 === '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 === '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 === 'extinguish') return withEndingCheck(handleExtinguish(stateWithNoun, command.target.canonical, world), world)
if (command.verb === 'use') {
@@ -325,7 +329,13 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
{ kind: 'narration', text: description },
...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.
const triggered = maybeTriggerEncounter(result.state, world)
@@ -335,6 +345,81 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
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 {
const lightTick = advanceLightState(state, 2, world)
return narrate(lightTick.state, [
+1
View File
@@ -149,6 +149,7 @@ export function applyVerbToEncounter(
const newEncState = { ...next.encounterState }
delete newEncState[encId]
let resolvedFlags = { ...next.flags, [`${encId}.resolved`]: true }
if (transition.setFlags) resolvedFlags = { ...resolvedFlags, ...transition.setFlags }
if (def.onResolved?.setFlags) resolvedFlags = { ...resolvedFlags, ...def.onResolved.setFlags }
next = { ...next, encounterState: newEncState, flags: resolvedFlags }
} else if (transition.to === 'failed') {
+1
View File
@@ -27,6 +27,7 @@ const VERB_SYNONYMS: Record<string, Verb> = {
drop: 'drop', put: 'drop', leave: 'drop',
use: 'use', combine: 'use',
open: 'open', close: 'close',
drink: 'drink', sip: 'drink',
read: 'read', light: 'light', extinguish: 'extinguish', douse: 'extinguish',
attack: 'attack', kill: 'attack', fight: 'attack', strike: 'attack',
hold: 'hold', show: 'hold',
+113 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest'
import { parse } from './parser'
import type { ParserContext } from './parser'
import { dispatch, initialStateFor } from './dispatcher'
import { dispatch, getItemsInRoom, initialStateFor } from './dispatcher'
import { world } from '../world'
import type { GameState } from './types'
@@ -223,4 +223,116 @@ describe('playthrough — sample world', () => {
'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 =
| 'go' | 'look' | 'examine' | 'take' | 'drop' | 'use' | 'open' | 'close'
| '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'
@@ -40,6 +40,7 @@ export interface ItemInstance {
}
export type EncounterPhase = string // phase names are encounter-specific
export type EndingId = 'true' | 'wrong' | 'bad' | 'replacement' | 'mercy'
export interface TranscriptLine {
kind: 'narration' | 'player' | 'system' | 'ending'
@@ -77,7 +78,7 @@ export interface GameState {
/** Capped at 200 entries; older entries are dropped on append. */
transcript: TranscriptLine[]
/** Set true when the player has reached an ending. UI shows ending screen. */
endedWith: 'true' | 'wrong' | 'bad' | null
endedWith: EndingId | null
}
export interface DispatchResult {