Files
halfstreet/src/engine/dispatcher.ts
T

265 lines
11 KiB
TypeScript
Raw Normal View History

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]
if (!startingRoom) throw new Error(`World has invalid startingRoom: ${world.startingRoom}`)
const inventory: ItemInstance[] = world.startingInventory.map((id) => {
const item = world.items[id]
if (!item) throw new Error(`Starting inventory references unknown item: ${id}`)
return { id, state: { ...item.initialState } }
})
const opening: TranscriptLine[] = [
{ kind: 'system', text: startingRoom.title },
{ kind: 'narration', text: startingRoom.descriptions.firstVisit },
]
return {
schemaVersion: SCHEMA_VERSION,
location: world.startingRoom,
inventory,
roomState: { [world.startingRoom]: { visited: true } },
flags: {},
resolveLevel: 'steady',
encounterState: {},
lastNoun: null,
pendingDisambiguation: null,
transcript: opening,
endedWith: null,
}
}
function append(state: GameState, lines: TranscriptLine[]): GameState {
const transcript = [...state.transcript, ...lines]
return { ...state, transcript: transcript.slice(-TRANSCRIPT_CAP) }
}
export function getItemsInRoom(state: GameState, world: World, roomId: string): string[] {
const baseItems = world.rooms[roomId]?.items ?? []
const dropped = (state.roomState[roomId]?.['droppedItems'] ?? []) as string[]
const taken = (state.roomState[roomId]?.['takenItems'] ?? []) as string[]
return [...baseItems.filter((i) => !taken.includes(i)), ...dropped]
}
function setRoomFlag(state: GameState, roomId: string, key: string, value: string | boolean | number | string[]): GameState {
return {
...state,
roomState: {
...state.roomState,
[roomId]: { ...(state.roomState[roomId] ?? {}), [key]: value },
},
}
}
export function dispatch(state: GameState, command: ParsedCommand, world: World): DispatchResult {
// Disambiguation reply: re-issue the original verb with the chosen target.
if (command.kind === 'disambiguation') {
const pending = state.pendingDisambiguation
if (!pending) {
return narrate(state, [{ kind: 'narration', text: 'Nothing to choose between.' }])
}
const cleared: GameState = { ...state, pendingDisambiguation: null }
return dispatch(
cleared,
{ kind: 'verb-target', verb: pending.verb, target: { canonical: command.chosen, raw: command.chosen } },
world,
)
}
if (command.kind === 'unknown') {
const text =
command.reason === 'unknown-verb' ? 'You consider the words, but they don\'t fit this place.'
: command.reason === 'unknown-noun' ? 'You don\'t see anything like that here.'
: 'You hesitate.'
return narrate(state, [{ kind: 'narration', text }])
}
if (command.kind === 'meta') {
return handleMeta(state, command.verb)
}
if (command.kind === 'go') {
return handleGo(state, command.direction, world)
}
if (command.kind === 'ambiguous') {
const candidateShorts = command.candidates.map((id) => world.items[id]?.short ?? id)
const list =
candidateShorts.length === 2
? `${candidateShorts[0]}, or ${candidateShorts[1]}`
: candidateShorts.slice(0, -1).join(', ') + ', or ' + candidateShorts[candidateShorts.length - 1]
const prompt = `Which ${command.rawNoun}${list}?`
const next: GameState = {
...state,
pendingDisambiguation: { verb: command.verb, candidates: command.candidates, prompt },
}
return narrate(next, [{ kind: 'narration', text: prompt }])
}
if (command.kind === 'verb-only') {
if (command.verb === 'look') return handleLook(state, world)
if (command.verb === 'inventory') return handleInventory(state, world)
if (command.verb === 'wait') return narrate(state, [{ kind: 'narration', text: 'Time passes.' }])
}
if (command.kind === 'verb-target') {
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)
return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }])
}
return narrate(state, [{ kind: 'narration', text: 'Nothing happens.' }])
}
function narrate(state: GameState, lines: TranscriptLine[]): DispatchResult {
return { state: append(state, lines), appended: lines }
}
function handleMeta(state: GameState, verb: 'restart' | 'undo' | 'hint' | 'save' | 'quit' | 'theme'): DispatchResult {
if (verb === 'save') return narrate(state, [{ kind: 'system', text: '(your progress is saved automatically)' }])
// 'theme' is a UI preference: the terminal intercepts it before dispatch and
// dispatches a 'halfstreet-toggle-theme' DOM event. The engine no-ops here so
// typing the verb still produces transcript output if the UI ever misses it.
if (verb === 'theme') return narrate(state, [{ kind: 'system', text: '(theme)' }])
// restart / undo / hint / quit are handled by the UI layer (state mutations
// require coordination with the save layer and route navigation). The
// engine acknowledges them with a no-op narration; the UI intercepts before
// calling dispatch for these.
return narrate(state, [{ kind: 'system', text: `(${verb})` }])
}
function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd', world: World): DispatchResult {
const room = world.rooms[state.location]
if (!room) return narrate(state, [{ kind: 'narration', text: 'You are nowhere.' }])
const dest = room.exits[direction]
if (!dest) {
return narrate(state, [{ kind: 'narration', text: 'You can\'t go that way.' }])
}
const lock = room.lockedExits?.[direction]
if (lock) {
const hasKey = state.inventory.some((i) => i.id === lock.requires) || !!state.flags[lock.requires]
if (!hasKey) {
return narrate(state, [{ kind: 'narration', text: lock.lockedNarration }])
}
}
const destRoom = world.rooms[dest]
if (!destRoom) return narrate(state, [{ kind: 'narration', text: 'The way ahead is unfinished.' }])
const visited = !!state.roomState[dest]?.['visited']
const description = visited ? destRoom.descriptions.revisit : destRoom.descriptions.firstVisit
let next: GameState = { ...state, location: dest }
next = setRoomFlag(next, dest, 'visited', true)
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]! }
}
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 {
const room = world.rooms[state.location]
if (!room) return narrate(state, [{ kind: 'narration', text: 'You see nothing.' }])
const items = getItemsInRoom(state, world, state.location)
const itemNarration = items.length > 0 ? `You see here: ${items.map((id) => world.items[id]?.short ?? id).join(', ')}.` : ''
return narrate(state, [
{ kind: 'system', text: room.title },
{ kind: 'narration', text: room.descriptions.examined },
...(itemNarration ? [{ kind: 'narration' as const, text: itemNarration }] : []),
])
}
function handleInventory(state: GameState, world: World): DispatchResult {
if (state.inventory.length === 0) {
return narrate(state, [{ kind: 'narration', text: 'You are empty-handed.' }])
}
const lines = state.inventory.map((inst) => {
const item = world.items[inst.id]
const litSuffix = inst.state['lit'] === true ? ' (lit)' : ''
return ` ${item?.short ?? inst.id}${litSuffix}`
})
return narrate(state, [
{ kind: 'narration', text: 'You are carrying:' },
{ kind: 'narration', text: lines.join('\n') },
])
}
function handleTake(state: GameState, itemId: string, world: World): DispatchResult {
const item = world.items[itemId]
if (!item) return narrate(state, [{ kind: 'narration', text: 'You don\'t see that here.' }])
if (!item.takeable) return narrate(state, [{ kind: 'narration', text: 'You can\'t take that.' }])
const itemsHere = getItemsInRoom(state, world, state.location)
if (!itemsHere.includes(itemId)) {
return narrate(state, [{ kind: 'narration', text: 'You don\'t see that here.' }])
}
if (state.inventory.find((i) => i.id === itemId)) {
return narrate(state, [{ kind: 'narration', text: 'You already have it.' }])
}
const wasInRoomBase = (world.rooms[state.location]?.items ?? []).includes(itemId)
let next: GameState = {
...state,
inventory: [...state.inventory, { id: itemId, state: { ...item.initialState } }],
}
if (wasInRoomBase) {
const taken = (next.roomState[state.location]?.['takenItems'] ?? []) as string[]
next = setRoomFlag(next, state.location, 'takenItems', [...taken, itemId])
} else {
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId))
}
return narrate(next, [{ kind: 'narration', text: 'Taken.' }])
}
function handleDrop(state: GameState, itemId: string, world: World): DispatchResult {
if (!state.inventory.find((i) => i.id === itemId)) {
return narrate(state, [{ kind: 'narration', text: 'You don\'t have that.' }])
}
let next: GameState = {
...state,
inventory: state.inventory.filter((i) => i.id !== itemId),
}
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
next = setRoomFlag(next, state.location, 'droppedItems', [...dropped, itemId])
return narrate(next, [{ kind: 'narration', text: 'Dropped.' }])
}
function handleExamine(state: GameState, itemId: string, world: World): DispatchResult {
const item = world.items[itemId]
if (!item) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }])
const visible =
state.inventory.find((i) => i.id === itemId) ||
getItemsInRoom(state, world, state.location).includes(itemId)
if (!visible) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }])
return narrate(state, [{ kind: 'narration', text: item.long }])
}