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 { World } from '../world/types'
|
||||||
import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine, NounRef } from './types'
|
import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine, NounRef } from './types'
|
||||||
import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
|
import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
|
||||||
|
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
|
||||||
|
|
||||||
export function initialStateFor(world: World): GameState {
|
export function initialStateFor(world: World): GameState {
|
||||||
const startingRoom = world.rooms[world.startingRoom]
|
const startingRoom = world.rooms[world.startingRoom]
|
||||||
@@ -93,12 +94,15 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (command.kind === 'verb-target') {
|
if (command.kind === 'verb-target') {
|
||||||
const next: NounRef = command.target
|
const stateWithNoun: GameState = { ...state, lastNoun: command.target }
|
||||||
const stateWithNoun: GameState = { ...state, lastNoun: next }
|
// 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 === 'take') return handleTake(stateWithNoun, command.target.canonical, world)
|
||||||
if (command.verb === 'drop') return handleDrop(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)
|
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.` }])
|
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 }
|
let next: GameState = { ...state, location: dest }
|
||||||
next = setRoomFlag(next, dest, 'visited', true)
|
next = setRoomFlag(next, dest, 'visited', true)
|
||||||
|
|
||||||
// Resolve regenerates one step on entering a safe room.
|
|
||||||
if (destRoom.safe) {
|
if (destRoom.safe) {
|
||||||
const ladder = ['steady', 'shaken', 'reeling', 'returning'] as const
|
const ladder = ['steady', 'shaken', 'reeling', 'returning'] as const
|
||||||
const idx = ladder.indexOf(state.resolveLevel)
|
const idx = ladder.indexOf(state.resolveLevel)
|
||||||
if (idx > 0) next = { ...next, resolveLevel: ladder[idx - 1]! }
|
if (idx > 0) next = { ...next, resolveLevel: ladder[idx - 1]! }
|
||||||
}
|
}
|
||||||
|
|
||||||
return narrate(next, [
|
const arrivalLines: TranscriptLine[] = [
|
||||||
{ kind: 'system', text: destRoom.title },
|
{ kind: 'system', text: destRoom.title },
|
||||||
{ kind: 'narration', text: description },
|
{ 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 {
|
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