2026-05-08 22:56:11 -05:00
|
|
|
import type { World } from '../world/types'
|
2026-05-10 05:53:32 -05:00
|
|
|
import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine } from './types'
|
2026-05-08 22:56:11 -05:00
|
|
|
import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
|
2026-05-08 23:18:22 -05:00
|
|
|
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
|
2026-05-08 22:56:11 -05:00
|
|
|
|
2026-05-09 21:51:12 -05:00
|
|
|
const HALFSTREET_ASCII = String.raw`
|
|
|
|
|
_ _ _ __ ____ _ _
|
|
|
|
|
| | | | __ _| |/ _| / ___|| |_ _ __ ___ ___| |_
|
|
|
|
|
| |_| |/ _\` | | |_ \___ \| __| '__/ _ \/ _ \ __|
|
|
|
|
|
| _ | (_| | | _| ___) | |_| | | __/ __/ |_
|
|
|
|
|
|_| |_|\__,_|_|_| |____/ \__|_| \___|\___|\__|
|
|
|
|
|
`.trim()
|
|
|
|
|
|
2026-05-08 22:56:11 -05:00
|
|
|
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[] = [
|
2026-05-09 21:51:12 -05:00
|
|
|
{ kind: 'system', text: HALFSTREET_ASCII },
|
2026-05-08 22:56:11 -05:00
|
|
|
{ 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) }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 00:37:45 -05:00
|
|
|
export function getItemsInRoom(state: GameState, world: World, roomId: string): string[] {
|
2026-05-08 22:56:11 -05:00
|
|
|
const baseItems = world.rooms[roomId]?.items ?? []
|
2026-05-09 13:30:35 -05:00
|
|
|
const dropped = (state.roomState[roomId]?.['droppedItems'] ?? []) as string[]
|
|
|
|
|
const taken = (state.roomState[roomId]?.['takenItems'] ?? []) as string[]
|
2026-05-08 22:56:11 -05:00
|
|
|
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,
|
2026-05-09 13:30:35 -05:00
|
|
|
[roomId]: { ...(state.roomState[roomId] ?? {}), [key]: value },
|
2026-05-08 22:56:11 -05:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 14:53:07 -05:00
|
|
|
const ENDING_PRIORITY: ('true' | 'wrong' | 'bad')[] = ['true', 'wrong', 'bad']
|
|
|
|
|
|
|
|
|
|
function evaluateEndings(state: GameState, world: World): GameState | null {
|
|
|
|
|
if (state.endedWith) return null
|
|
|
|
|
for (const id of ENDING_PRIORITY) {
|
|
|
|
|
const ending = world.endings[id]
|
|
|
|
|
const flags = ending.whenFlags
|
|
|
|
|
let allMatch = true
|
|
|
|
|
for (const [k, v] of Object.entries(flags)) {
|
|
|
|
|
if (state.flags[k] !== v) { allMatch = false; break }
|
|
|
|
|
}
|
|
|
|
|
if (!allMatch) continue
|
|
|
|
|
return {
|
|
|
|
|
...state,
|
|
|
|
|
endedWith: id,
|
|
|
|
|
transcript: [...state.transcript, { kind: 'ending', text: ending.narration }],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function withEndingCheck(result: DispatchResult, world: World): DispatchResult {
|
|
|
|
|
const updated = evaluateEndings(result.state, world)
|
|
|
|
|
if (!updated) return result
|
|
|
|
|
const endingLine: TranscriptLine = updated.transcript[updated.transcript.length - 1]!
|
|
|
|
|
return { state: updated, appended: [...result.appended, endingLine] }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 22:56:11 -05:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 14:53:07 -05:00
|
|
|
// Once the game has ended, only restart/undo (handled by the UI) can clear state.
|
|
|
|
|
if (state.endedWith) {
|
|
|
|
|
return narrate(state, [{ kind: 'narration', text: 'The story has ended. Type `restart` or `undo`.' }])
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 22:56:11 -05:00
|
|
|
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') {
|
2026-05-09 14:53:07 -05:00
|
|
|
return withEndingCheck(handleGo(state, command.direction, world), world)
|
2026-05-08 22:56:11 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 13:58:32 -05:00
|
|
|
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 }])
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 22:56:11 -05:00
|
|
|
if (command.kind === 'verb-only') {
|
2026-05-09 21:51:12 -05:00
|
|
|
const encResult = applyVerbToEncounter(state, command, world)
|
|
|
|
|
if (encResult?.consumed) {
|
|
|
|
|
return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world)
|
|
|
|
|
}
|
2026-05-09 14:53:07 -05:00
|
|
|
if (command.verb === 'look') return withEndingCheck(handleLook(state, world), world)
|
|
|
|
|
if (command.verb === 'inventory') return withEndingCheck(handleInventory(state, world), world)
|
|
|
|
|
if (command.verb === 'wait') return withEndingCheck(narrate(state, [{ kind: 'narration', text: 'Time passes.' }]), world)
|
2026-05-08 22:56:11 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (command.kind === 'verb-target') {
|
2026-05-08 23:18:22 -05:00
|
|
|
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) {
|
2026-05-09 14:53:07 -05:00
|
|
|
return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world)
|
2026-05-08 23:18:22 -05:00
|
|
|
}
|
2026-05-09 14:53:07 -05:00
|
|
|
if (command.verb === 'take') return withEndingCheck(handleTake(stateWithNoun, command.target.canonical, world), world)
|
|
|
|
|
if (command.verb === 'drop') return withEndingCheck(handleDrop(stateWithNoun, command.target.canonical, world), world)
|
|
|
|
|
if (command.verb === 'examine' || command.verb === 'look') return withEndingCheck(handleExamine(stateWithNoun, command.target.canonical, world), world)
|
|
|
|
|
if (command.verb === 'read') return withEndingCheck(handleRead(stateWithNoun, command.target.canonical, world), world)
|
|
|
|
|
if (command.verb === 'light') return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, null, world), world)
|
|
|
|
|
if (command.verb === 'extinguish') return withEndingCheck(handleExtinguish(stateWithNoun, command.target.canonical, world), world)
|
2026-05-10 07:56:31 -05:00
|
|
|
if (command.verb === 'use') {
|
|
|
|
|
const target = world.items[command.target.canonical]
|
|
|
|
|
if (target?.lighter && !target.lightable) {
|
|
|
|
|
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: 'Use match with what?' }]), world)
|
|
|
|
|
}
|
|
|
|
|
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
|
|
|
|
|
}
|
2026-05-09 14:53:07 -05:00
|
|
|
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
|
2026-05-09 14:22:03 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (command.kind === 'verb-target-prep') {
|
|
|
|
|
const stateWithNoun: GameState = { ...state, lastNoun: command.target }
|
|
|
|
|
// Try the encounter first — it may consume verbs like 'cut vines with shears'.
|
|
|
|
|
const encResult = applyVerbToEncounter(stateWithNoun, command, world)
|
|
|
|
|
if (encResult?.consumed) {
|
2026-05-09 14:53:07 -05:00
|
|
|
return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world)
|
2026-05-09 14:22:03 -05:00
|
|
|
}
|
|
|
|
|
if (command.verb === 'light' && command.preposition === 'with') {
|
2026-05-09 14:53:07 -05:00
|
|
|
return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world), world)
|
2026-05-09 14:22:03 -05:00
|
|
|
}
|
|
|
|
|
if (command.verb === 'use') {
|
2026-05-10 07:56:31 -05:00
|
|
|
const burnResult = handleBurnLetter(stateWithNoun, command.target.canonical, command.indirect.canonical, world)
|
|
|
|
|
if (burnResult) return withEndingCheck(burnResult, world)
|
|
|
|
|
const lightResult = handleUseAsLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world)
|
|
|
|
|
if (lightResult) return withEndingCheck(lightResult, world)
|
2026-05-09 14:53:07 -05:00
|
|
|
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
|
2026-05-09 14:22:03 -05:00
|
|
|
}
|
2026-05-09 14:53:07 -05:00
|
|
|
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
|
2026-05-08 22:56:11 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)' }])
|
2026-05-09 13:33:57 -05:00
|
|
|
// '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)' }])
|
2026-05-08 22:56:11 -05:00
|
|
|
// 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]! }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 23:18:22 -05:00
|
|
|
const arrivalLines: TranscriptLine[] = [
|
2026-05-08 22:56:11 -05:00
|
|
|
{ kind: 'system', text: destRoom.title },
|
|
|
|
|
{ kind: 'narration', text: description },
|
2026-05-08 23:18:22 -05:00
|
|
|
]
|
|
|
|
|
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
|
2026-05-08 22:56:11 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-05-10 05:53:32 -05:00
|
|
|
const itemNarration = describeRoomItems(items.map((id) => world.items[id]?.short ?? id))
|
2026-05-08 22:56:11 -05:00
|
|
|
return narrate(state, [
|
|
|
|
|
{ kind: 'system', text: room.title },
|
|
|
|
|
{ kind: 'narration', text: room.descriptions.examined },
|
|
|
|
|
...(itemNarration ? [{ kind: 'narration' as const, text: itemNarration }] : []),
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 05:53:32 -05:00
|
|
|
function describeRoomItems(shorts: string[]): string {
|
|
|
|
|
if (shorts.length === 0) return ''
|
|
|
|
|
const names = [sentenceCase(shorts[0]!), ...shorts.slice(1)]
|
|
|
|
|
const verb = names.length === 1 ? 'is' : 'are'
|
|
|
|
|
return `${joinList(names)} ${verb} here.`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sentenceCase(value: string): string {
|
|
|
|
|
return value.length === 0 ? value : value[0]!.toUpperCase() + value.slice(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function joinList(values: string[]): string {
|
|
|
|
|
if (values.length === 1) return values[0]!
|
|
|
|
|
if (values.length === 2) return `${values[0]} and ${values[1]}`
|
|
|
|
|
return `${values.slice(0, -1).join(', ')}, and ${values[values.length - 1]}`
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 22:56:11 -05:00
|
|
|
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) {
|
2026-05-09 13:30:35 -05:00
|
|
|
const taken = (next.roomState[state.location]?.['takenItems'] ?? []) as string[]
|
2026-05-08 22:56:11 -05:00
|
|
|
next = setRoomFlag(next, state.location, 'takenItems', [...taken, itemId])
|
|
|
|
|
} else {
|
2026-05-09 13:30:35 -05:00
|
|
|
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
|
2026-05-08 22:56:11 -05:00
|
|
|
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== itemId))
|
|
|
|
|
}
|
|
|
|
|
return narrate(next, [{ kind: 'narration', text: 'Taken.' }])
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 05:53:32 -05:00
|
|
|
function handleDrop(state: GameState, itemId: string, _world: World): DispatchResult {
|
2026-05-08 22:56:11 -05:00
|
|
|
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),
|
|
|
|
|
}
|
2026-05-09 13:30:35 -05:00
|
|
|
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
|
2026-05-08 22:56:11 -05:00
|
|
|
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.' }])
|
2026-05-10 07:56:31 -05:00
|
|
|
const inventoryInst = state.inventory.find((i) => i.id === itemId) ?? null
|
2026-05-08 22:56:11 -05:00
|
|
|
const visible =
|
2026-05-10 07:56:31 -05:00
|
|
|
inventoryInst ||
|
2026-05-08 22:56:11 -05:00
|
|
|
getItemsInRoom(state, world, state.location).includes(itemId)
|
|
|
|
|
if (!visible) return narrate(state, [{ kind: 'narration', text: 'You don\'t see anything like that.' }])
|
2026-05-10 07:56:31 -05:00
|
|
|
return narrate(state, [{ kind: 'narration', text: describeItem(itemId, item.long, inventoryInst) }])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function describeItem(itemId: string, longDescription: string, inst: ItemInstance | null): string {
|
|
|
|
|
if (itemId !== 'matches' || typeof inst?.state['uses'] !== 'number') return longDescription
|
|
|
|
|
const uses = inst.state['uses']
|
|
|
|
|
const noun = uses === 1 ? 'match' : 'matches'
|
|
|
|
|
const count = spellSmallCount(uses)
|
|
|
|
|
return longDescription.replace(/with \w+ matches? left inside\./i, `with ${count} ${noun} left inside.`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function spellSmallCount(value: number): string {
|
|
|
|
|
const words = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten']
|
|
|
|
|
return words[value] ?? String(value)
|
2026-05-08 22:56:11 -05:00
|
|
|
}
|
2026-05-09 14:15:35 -05:00
|
|
|
|
|
|
|
|
function handleRead(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." }])
|
|
|
|
|
if (!item.readable || !item.readableText) {
|
|
|
|
|
return narrate(state, [{ kind: 'narration', text: "There's nothing to read on it." }])
|
|
|
|
|
}
|
|
|
|
|
return narrate(state, [{ kind: 'narration', text: item.readableText }])
|
|
|
|
|
}
|
2026-05-09 14:18:54 -05:00
|
|
|
|
|
|
|
|
function handleLight(state: GameState, targetId: string, instrumentId: string | null, world: World): DispatchResult {
|
|
|
|
|
const target = world.items[targetId]
|
|
|
|
|
if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
|
2026-05-10 07:56:31 -05:00
|
|
|
if (target.lighter && !target.lightable) return narrate(state, [{ kind: 'narration', text: 'Use match with what?' }])
|
2026-05-09 14:18:54 -05:00
|
|
|
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't light that." }])
|
|
|
|
|
const targetInst = state.inventory.find((i) => i.id === targetId) ?? null
|
|
|
|
|
const visibleInRoom = getItemsInRoom(state, world, state.location).includes(targetId)
|
|
|
|
|
if (!targetInst && !visibleInRoom) {
|
|
|
|
|
return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
|
|
|
|
|
}
|
|
|
|
|
// The 'lit' state lives on the inventory instance for inventory items, or
|
|
|
|
|
// (eventually) on roomState for items left in a room. For now we only
|
|
|
|
|
// support lighting items the player is carrying.
|
|
|
|
|
if (!targetInst) {
|
|
|
|
|
return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }])
|
|
|
|
|
}
|
|
|
|
|
if (targetInst.state['lit'] === true) {
|
|
|
|
|
return narrate(state, [{ kind: 'narration', text: "It's already lit." }])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Pick an instrument. If explicit, validate it; if implicit, find any.
|
|
|
|
|
let lighterInst = null as typeof state.inventory[number] | null
|
|
|
|
|
if (instrumentId) {
|
|
|
|
|
lighterInst = state.inventory.find((i) => i.id === instrumentId) ?? null
|
|
|
|
|
if (!lighterInst) return narrate(state, [{ kind: 'narration', text: "You don't have that." }])
|
|
|
|
|
const lighterDef = world.items[instrumentId]
|
|
|
|
|
if (!lighterDef?.lighter) return narrate(state, [{ kind: 'narration', text: "That isn't going to help." }])
|
|
|
|
|
if (typeof lighterInst.state['uses'] === 'number' && lighterInst.state['uses'] <= 0) {
|
|
|
|
|
return narrate(state, [{ kind: 'narration', text: "It is spent." }])
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
for (const inst of state.inventory) {
|
|
|
|
|
const def = world.items[inst.id]
|
|
|
|
|
if (!def?.lighter) continue
|
|
|
|
|
if (typeof inst.state['uses'] === 'number' && inst.state['uses'] <= 0) continue
|
|
|
|
|
lighterInst = inst
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
if (!lighterInst) {
|
|
|
|
|
return narrate(state, [{ kind: 'narration', text: 'You have nothing to light it with.' }])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Apply state changes immutably.
|
|
|
|
|
const lighterDef = world.items[lighterInst.id]!
|
|
|
|
|
const lighterUsesField = typeof lighterInst.state['uses'] === 'number' ? lighterInst.state['uses'] : null
|
|
|
|
|
const newLighterUses = lighterUsesField === null ? null : lighterUsesField - 1
|
|
|
|
|
const newInventory = state.inventory.map((i) => {
|
|
|
|
|
if (i.id === targetInst.id) return { ...i, state: { ...i.state, lit: true } }
|
|
|
|
|
if (i.id === lighterInst!.id && newLighterUses !== null) return { ...i, state: { ...i.state, uses: newLighterUses } }
|
|
|
|
|
return i
|
|
|
|
|
})
|
|
|
|
|
const lines: TranscriptLine[] = [{ kind: 'narration', text: target.litText ?? 'It catches.' }]
|
|
|
|
|
if (newLighterUses === 0) {
|
|
|
|
|
lines.push({ kind: 'narration', text: lighterDef.lighterEmptyText ?? 'It is spent.' })
|
|
|
|
|
}
|
|
|
|
|
return narrate({ ...state, inventory: newInventory }, lines)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 07:56:31 -05:00
|
|
|
function handleBurnLetter(state: GameState, firstId: string, secondId: string, world: World): DispatchResult | null {
|
|
|
|
|
const ids = [firstId, secondId]
|
|
|
|
|
if (!ids.includes('letter') || !ids.includes('matches')) return null
|
|
|
|
|
|
|
|
|
|
const matches = state.inventory.find((i) => i.id === 'matches')
|
|
|
|
|
if (!matches) return narrate(state, [{ kind: 'narration', text: "You don't have a match." }])
|
|
|
|
|
if (typeof matches.state['uses'] === 'number' && matches.state['uses'] <= 0) {
|
|
|
|
|
return narrate(state, [{ kind: 'narration', text: 'The matchbook is empty.' }])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const letterHeld = state.inventory.some((i) => i.id === 'letter')
|
|
|
|
|
const letterInRoom = getItemsInRoom(state, world, state.location).includes('letter')
|
|
|
|
|
if (!letterHeld && !letterInRoom) {
|
|
|
|
|
return narrate(state, [{ kind: 'narration', text: "You don't see the letter here." }])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const newMatchesUses = typeof matches.state['uses'] === 'number' ? matches.state['uses'] - 1 : null
|
|
|
|
|
let next: GameState = {
|
|
|
|
|
...state,
|
|
|
|
|
inventory: state.inventory
|
|
|
|
|
.filter((i) => i.id !== 'letter')
|
|
|
|
|
.map((i) => i.id === 'matches' && newMatchesUses !== null ? { ...i, state: { ...i.state, uses: newMatchesUses } } : i),
|
|
|
|
|
flags: { ...state.flags, letterBurned: true },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (letterInRoom) {
|
|
|
|
|
const baseItems = world.rooms[state.location]?.items ?? []
|
|
|
|
|
const dropped = (next.roomState[state.location]?.['droppedItems'] ?? []) as string[]
|
|
|
|
|
if (baseItems.includes('letter')) {
|
|
|
|
|
const taken = (next.roomState[state.location]?.['takenItems'] ?? []) as string[]
|
|
|
|
|
next = setRoomFlag(next, state.location, 'takenItems', [...new Set([...taken, 'letter'])])
|
|
|
|
|
}
|
|
|
|
|
if (dropped.includes('letter')) {
|
|
|
|
|
next = setRoomFlag(next, state.location, 'droppedItems', dropped.filter((id) => id !== 'letter'))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lines: TranscriptLine[] = [
|
|
|
|
|
{ kind: 'narration', text: 'The letter catches at one corner. In a few breaths it is ash.' },
|
|
|
|
|
]
|
|
|
|
|
if (newMatchesUses === 0) {
|
|
|
|
|
lines.push({ kind: 'narration', text: world.items['matches']?.lighterEmptyText ?? 'The matchbook is empty.' })
|
|
|
|
|
}
|
|
|
|
|
return narrate(next, lines)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleUseAsLight(state: GameState, firstId: string, secondId: string, world: World): DispatchResult | null {
|
|
|
|
|
const first = world.items[firstId]
|
|
|
|
|
const second = world.items[secondId]
|
|
|
|
|
if (first?.lighter && second?.lightable) return handleLight(state, secondId, firstId, world)
|
|
|
|
|
if (second?.lighter && first?.lightable) return handleLight(state, firstId, secondId, world)
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 14:18:54 -05:00
|
|
|
function handleExtinguish(state: GameState, targetId: string, world: World): DispatchResult {
|
|
|
|
|
const target = world.items[targetId]
|
|
|
|
|
if (!target) return narrate(state, [{ kind: 'narration', text: "You don't see anything like that." }])
|
|
|
|
|
if (!target.lightable) return narrate(state, [{ kind: 'narration', text: "You can't extinguish that." }])
|
|
|
|
|
const targetInst = state.inventory.find((i) => i.id === targetId)
|
|
|
|
|
if (!targetInst) return narrate(state, [{ kind: 'narration', text: "You'd have to be carrying it." }])
|
|
|
|
|
if (targetInst.state['lit'] !== true) {
|
|
|
|
|
return narrate(state, [{ kind: 'narration', text: "It isn't lit." }])
|
|
|
|
|
}
|
|
|
|
|
const newInventory = state.inventory.map((i) =>
|
|
|
|
|
i.id === targetId ? { ...i, state: { ...i.state, lit: false } } : i,
|
|
|
|
|
)
|
|
|
|
|
return narrate({ ...state, inventory: newInventory }, [{ kind: 'narration', text: target.extinguishedText ?? 'The flame dies.' }])
|
|
|
|
|
}
|