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, verb-target-prep, and verb-only commands engage with encounters. let verb: string | null = null let targetId: string | null = null let instrumentId: string | null = null if (command.kind === 'verb-target') { verb = command.verb targetId = command.target.canonical } else if (command.kind === 'verb-target-prep') { verb = command.verb targetId = command.target.canonical instrumentId = command.indirect.canonical } else if (command.kind === 'verb-only' && command.verb !== 'inventory') { 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 } } } if (t.requires && instrumentId && t.requires.item !== instrumentId) 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 } }