From 49fc5a101517aac2a4f2d051cbb7925ad35e87ac Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Fri, 8 May 2026 23:18:22 -0500 Subject: [PATCH] feat(mystery): encounter phase machine wired into dispatcher --- src/engine/dispatcher.ts | 23 +++-- src/engine/encounters.test.ts | 119 ++++++++++++++++++++++++ src/engine/encounters.ts | 168 ++++++++++++++++++++++++++++++++++ 3 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 src/engine/encounters.test.ts create mode 100644 src/engine/encounters.ts diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index 0bdff5a..b3f593e 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -1,6 +1,7 @@ import type { World } from '../world/types' import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine, NounRef } from './types' import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types' +import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters' export function initialStateFor(world: World): GameState { const startingRoom = world.rooms[world.startingRoom] @@ -93,12 +94,15 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World) } if (command.kind === 'verb-target') { - const next: NounRef = command.target - const stateWithNoun: GameState = { ...state, lastNoun: next } + const stateWithNoun: GameState = { ...state, lastNoun: command.target } + // Try the active encounter first — it may consume verbs like 'attack', 'hold'. + const encResult = applyVerbToEncounter(stateWithNoun, command, world) + if (encResult?.consumed) { + return { state: encResult.state, appended: encResult.lines } + } if (command.verb === 'take') return handleTake(stateWithNoun, command.target.canonical, world) if (command.verb === 'drop') return handleDrop(stateWithNoun, command.target.canonical, world) if (command.verb === 'examine' || command.verb === 'look') return handleExamine(stateWithNoun, command.target.canonical, world) - // Other verbs (use, light, attack, hold, etc.) handled by encounters in Task 6. return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]) } @@ -148,17 +152,24 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd' let next: GameState = { ...state, location: dest } next = setRoomFlag(next, dest, 'visited', true) - // Resolve regenerates one step on entering a safe room. if (destRoom.safe) { const ladder = ['steady', 'shaken', 'reeling', 'returning'] as const const idx = ladder.indexOf(state.resolveLevel) if (idx > 0) next = { ...next, resolveLevel: ladder[idx - 1]! } } - return narrate(next, [ + const arrivalLines: TranscriptLine[] = [ { kind: 'system', text: destRoom.title }, { kind: 'narration', text: description }, - ]) + ] + const result = narrate(next, arrivalLines) + + // Trigger any encounter waiting in this room. + const triggered = maybeTriggerEncounter(result.state, world) + if (triggered) { + return { state: triggered.state, appended: [...arrivalLines, ...triggered.appended] } + } + return result } function handleLook(state: GameState, world: World): DispatchResult { diff --git a/src/engine/encounters.test.ts b/src/engine/encounters.test.ts new file mode 100644 index 0000000..31dbada --- /dev/null +++ b/src/engine/encounters.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest' +import { dispatch, initialStateFor } from './dispatcher' +import type { World } from '../world/types' + +const world: World = { + startingRoom: 'foyer', + startingInventory: ['mirror'], + rooms: { + foyer: { + id: 'foyer', + title: '[ Foyer ]', + descriptions: { firstVisit: 'Foyer.', revisit: 'Foyer.', examined: 'Foyer.' }, + exits: { n: 'stair' }, + items: [], + safe: true, + }, + stair: { + id: 'stair', + title: '[ Cellar Stair ]', + descriptions: { firstVisit: 'Stair.', revisit: 'Stair.', examined: 'Stair.' }, + exits: { s: 'foyer', d: 'cellar' }, + items: [], + encounter: 'revenant', + }, + cellar: { + id: 'cellar', + title: '[ Cellar ]', + descriptions: { firstVisit: 'Cellar.', revisit: 'Cellar.', examined: 'Cellar.' }, + exits: { u: 'stair' }, + items: [], + }, + }, + items: { + mirror: { id: 'mirror', names: ['mirror', 'tarnished mirror'], short: 'a tarnished mirror', long: 'A small mirror, tarnished black.', initialState: {}, takeable: true }, + sword: { id: 'sword', names: ['sword', 'cane sword'], short: 'a cane sword', long: 'A slim cane sword.', initialState: {}, takeable: true }, + }, + encounters: { + revenant: { + id: 'revenant', + startsIn: 'stair', + initialPhase: 'wary', + phases: { + wary: { + description: 'A revenant rises from the wet stone.', + transitions: [ + { verb: 'attack', target: 'revenant', narration: 'Your blade passes through.', to: 'shaken', resolveCost: 1 }, + { verb: 'examine', target: 'revenant', narration: 'There is a tarnish around its eyes.', to: 'wary' }, + { verb: 'hold', target: 'revenant', requires: { item: 'mirror' }, narration: 'It looks into the silver.', to: 'resolved' }, + ], + }, + shaken: { + description: 'The revenant comes closer.', + transitions: [ + { verb: 'hold', target: 'revenant', requires: { item: 'mirror' }, narration: 'It looks. It remembers.', to: 'resolved' }, + ], + }, + }, + onResolved: { setFlags: { revenantLaid: true } }, + onFailed: { narration: 'You stagger back.', retreatTo: 'foyer' }, + defaultWrongVerbNarration: 'The revenant does not seem to notice.', + }, + }, + endings: { + true: { whenFlags: {}, narration: '' }, + wrong: { whenFlags: {}, narration: '' }, + bad: { whenFlags: {}, narration: '' }, + }, +} + +describe('encounters — phase advancement', () => { + it('triggers an encounter on entering its room', () => { + let s = initialStateFor(world) + const r = dispatch(s, { kind: 'go', direction: 'n' }, world) + expect(r.state.encounterState['revenant']).toBe('wary') + expect(r.appended.some((l) => l.text.includes('revenant rises'))).toBe(true) + }) + + it('right verb resolves the encounter', () => { + let s = initialStateFor(world) + s = dispatch(s, { kind: 'go', direction: 'n' }, world).state + const r = dispatch(s, { kind: 'verb-target', verb: 'hold', target: { canonical: 'revenant', raw: 'revenant' } }, world) + expect(r.state.encounterState['revenant']).toBeUndefined() + expect(r.state.flags['revenantLaid']).toBe(true) + expect(r.appended.some((l) => l.text.includes('looks into the silver'))).toBe(true) + }) + + it('wrong verb costs resolve and surfaces a clue', () => { + let s = initialStateFor(world) + s = dispatch(s, { kind: 'go', direction: 'n' }, world).state + const r = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world) + expect(r.state.resolveLevel).toBe('shaken') + expect(r.state.encounterState['revenant']).toBe('shaken') + }) + + it('falls back to defaultWrongVerbNarration for unrecognized verbs', () => { + let s = initialStateFor(world) + s = dispatch(s, { kind: 'go', direction: 'n' }, world).state + const r = dispatch(s, { kind: 'verb-target', verb: 'push', target: { canonical: 'revenant', raw: 'revenant' } }, world) + expect(r.appended.some((l) => l.text.includes('does not seem to notice'))).toBe(true) + }) + + it('retreats to safe room when resolve runs out', () => { + let s = initialStateFor(world) + s = dispatch(s, { kind: 'go', direction: 'n' }, world).state + // Force resolve to 'returning' so the next failure retreats. + s = { ...s, resolveLevel: 'returning' } + const r = dispatch(s, { kind: 'verb-target', verb: 'attack', target: { canonical: 'revenant', raw: 'revenant' } }, world) + expect(r.state.location).toBe('foyer') + expect(r.appended.some((l) => l.text.includes('stagger back'))).toBe(true) + }) + + it('safe room entry regenerates resolve', () => { + let s = initialStateFor(world) + s = { ...s, resolveLevel: 'shaken' } + s = dispatch(s, { kind: 'go', direction: 'n' }, world).state + s = dispatch(s, { kind: 'go', direction: 's' }, world).state + expect(s.resolveLevel).toBe('steady') + }) +}) diff --git a/src/engine/encounters.ts b/src/engine/encounters.ts new file mode 100644 index 0000000..8a7278e --- /dev/null +++ b/src/engine/encounters.ts @@ -0,0 +1,168 @@ +import type { World } from '../world/types' +import type { GameState, ParsedCommand, DispatchResult, TranscriptLine, ResolveLevel } from './types' +import { TRANSCRIPT_CAP, RESOLVE_LEVELS } from './types' + +function append(state: GameState, lines: TranscriptLine[]): GameState { + const transcript = [...state.transcript, ...lines] + return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) } +} + +function narrate(state: GameState, lines: TranscriptLine[]): DispatchResult { + return { state: append(state, lines), appended: lines } +} + +/** Returns the encounter id active in the current room, or null. */ +export function activeEncounterId(state: GameState, world: World): string | null { + const roomEncounter = world.rooms[state.location]?.encounter + if (!roomEncounter) return null + const phase = state.encounterState[roomEncounter] + if (!phase) return null + return roomEncounter +} + +/** Triggers a fresh encounter when the player enters its starting room. */ +export function maybeTriggerEncounter(state: GameState, world: World): DispatchResult | null { + const roomEncounter = world.rooms[state.location]?.encounter + if (!roomEncounter) return null + const def = world.encounters[roomEncounter] + if (!def) return null + if (state.encounterState[roomEncounter]) return null // already active or resolved + if (state.flags[`${roomEncounter}.resolved`]) return null // already done + + const next: GameState = { + ...state, + encounterState: { ...state.encounterState, [roomEncounter]: def.initialPhase }, + } + const phase = def.phases[def.initialPhase] + if (!phase) return null + return narrate(next, [{ kind: 'narration', text: phase.description }]) +} + +function bumpResolve(level: ResolveLevel, cost: 0 | 1 | 2 | undefined): ResolveLevel { + if (!cost) return level + const idx = RESOLVE_LEVELS.indexOf(level) + const newIdx = Math.min(RESOLVE_LEVELS.length - 1, idx + cost) + return RESOLVE_LEVELS[newIdx]! +} + +export interface EncounterResolution { + state: GameState + lines: TranscriptLine[] + /** True if the encounter consumed the verb and the dispatcher should not handle it further. */ + consumed: boolean +} + +/** Try to apply a verb against the active encounter. Returns null if no encounter is active. */ +export function applyVerbToEncounter( + state: GameState, + command: ParsedCommand, + world: World, +): EncounterResolution | null { + const encId = activeEncounterId(state, world) + if (!encId) return null + const def = world.encounters[encId] + if (!def) return null + const currentPhase = state.encounterState[encId]! + const phaseDef = def.phases[currentPhase] + if (!phaseDef) return null + + // Only verb-target and verb-only commands engage with encounters. + let verb: string | null = null + let targetId: string | null = null + if (command.kind === 'verb-target') { + verb = command.verb + targetId = command.target.canonical + } else if (command.kind === 'verb-only' && command.verb !== 'inventory' && command.verb !== 'wait') { + verb = command.verb + } else { + return null + } + + // Find a matching transition. + const transition = phaseDef.transitions.find((t) => { + if (t.verb !== verb) return false + if (t.target && t.target !== '*' && t.target !== targetId) return false + if (t.requires) { + const inst = state.inventory.find((i) => i.id === t.requires!.item) + if (!inst) return false + if (t.requires.state) { + for (const [k, v] of Object.entries(t.requires.state)) { + if (inst.state[k] !== v) return false + } + } + } + return true + }) + + if (!transition) { + // Wrong verb — apply default narration and resolve cost. + if (!verb || (targetId !== null && targetId !== encId)) return null // verb is unrelated to this encounter + const newResolve = bumpResolve(state.resolveLevel, 1) + if (state.resolveLevel === 'returning') { + // Retreat. + const retreat = def.onFailed + if (retreat) { + const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: 'shaken' } + const dest = world.rooms[retreat.retreatTo] + const lines: TranscriptLine[] = [ + { kind: 'narration', text: retreat.narration }, + ...(dest ? [{ kind: 'system' as const, text: dest.title }, { kind: 'narration' as const, text: dest.descriptions.revisit }] : []), + ] + return { state: append(next, lines), lines, consumed: true } + } + } + const next: GameState = { ...state, resolveLevel: newResolve } + const lines: TranscriptLine[] = [ + { kind: 'narration', text: def.defaultWrongVerbNarration ?? 'That has no effect.' }, + ] + return { state: append(next, lines), lines, consumed: true } + } + + // Right verb — but if it has a resolve cost and player is already at 'returning', retreat. + if (transition.resolveCost && transition.resolveCost > 0 && state.resolveLevel === 'returning') { + const retreat = def.onFailed + if (retreat) { + const next: GameState = { ...state, location: retreat.retreatTo, resolveLevel: 'shaken' } + const dest = world.rooms[retreat.retreatTo] + const lines: TranscriptLine[] = [ + { kind: 'narration', text: transition.narration }, + { kind: 'narration', text: retreat.narration }, + ...(dest ? [{ kind: 'system' as const, text: dest.title }, { kind: 'narration' as const, text: dest.descriptions.revisit }] : []), + ] + return { state: append(next, lines), lines, consumed: true } + } + } + + // Right verb — narrate and transition. + let next: GameState = { ...state } + if (transition.resolveCost) { + next = { ...next, resolveLevel: bumpResolve(next.resolveLevel, transition.resolveCost) } + } + + if (transition.to === 'resolved') { + const newEncState = { ...next.encounterState } + delete newEncState[encId] + let resolvedFlags = { ...next.flags, [`${encId}.resolved`]: true } + if (def.onResolved?.setFlags) resolvedFlags = { ...resolvedFlags, ...def.onResolved.setFlags } + next = { ...next, encounterState: newEncState, flags: resolvedFlags } + } else if (transition.to === 'failed') { + const retreat = def.onFailed + if (retreat) { + const dest = world.rooms[retreat.retreatTo] + const newEncState = { ...next.encounterState } + delete newEncState[encId] + next = { ...next, location: retreat.retreatTo, encounterState: newEncState, resolveLevel: 'shaken' } + const lines: TranscriptLine[] = [ + { kind: 'narration', text: transition.narration }, + { kind: 'narration', text: retreat.narration }, + ...(dest ? [{ kind: 'system' as const, text: dest.title }, { kind: 'narration' as const, text: dest.descriptions.revisit }] : []), + ] + return { state: append(next, lines), lines, consumed: true } + } + } else { + next = { ...next, encounterState: { ...next.encounterState, [encId]: transition.to } } + } + + const lines: TranscriptLine[] = [{ kind: 'narration', text: transition.narration }] + return { state: append(next, lines), lines, consumed: true } +}