feat(mystery): add altered rooms and drunk sequence
This commit is contained in:
@@ -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, [
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user