feat(mystery): encounter phase machine wired into dispatcher
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user