Files
halfstreet/src/engine/dispatcher.ts
T

584 lines
26 KiB
TypeScript
Raw Normal View History

import type { World } from '../world/types'
import type { GameState, ParsedCommand, DispatchResult, ItemInstance, TranscriptLine } from './types'
import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
2026-05-10 10:17:42 -05:00
export const LIGHT_TURNS_MAX = 6
export interface LightStatus {
itemId: string
lit: boolean
turnsLeft: number
maxTurns: number
}
const HALFSTREET_ASCII = String.raw`
_ _ _ __ ____ _ _
| | | | __ _| |/ _| / ___|| |_ _ __ ___ ___| |_
| |_| |/ _\` | | |_ \___ \| __| '__/ _ \/ _ \ __|
| _ | (_| | | _| ___) | |_| | | __/ __/ |_
|_| |_|\__,_|_|_| |____/ \__|_| \___|\___|\__|
`.trim()
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: HALFSTREET_ASCII },
{ 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,
}
}
2026-05-10 10:17:42 -05:00
export function getLightStatus(state: GameState, world: World): LightStatus | null {
for (const inst of state.inventory) {
const def = world.items[inst.id]
if (!def?.lightable) continue
if (inst.state['lit'] !== true) continue
const turnsLeft = getLightTurnsLeft(inst)
return {
itemId: inst.id,
lit: true,
turnsLeft,
maxTurns: LIGHT_TURNS_MAX,
}
}
return 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 },
},
}
}
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] }
}
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,
)
}
// 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`.' }])
}
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 withEndingCheck(handleGo(state, command.direction, world), 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') {
const encResult = applyVerbToEncounter(state, command, world)
if (encResult?.consumed) {
return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world)
}
if (command.verb === 'look') return withEndingCheck(handleLook(state, world), world)
if (command.verb === 'inventory') return withEndingCheck(handleInventory(state, world), world)
2026-05-10 10:17:42 -05:00
if (command.verb === 'wait') return withEndingCheck(handleWait(state, world), world)
}
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 withEndingCheck({ state: encResult.state, appended: encResult.lines }, world)
}
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)
}
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
}
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) {
return withEndingCheck({ state: encResult.state, appended: encResult.lines }, world)
}
if (command.verb === 'light' && command.preposition === 'with') {
return withEndingCheck(handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world), world)
}
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)
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]), world)
}
return withEndingCheck(narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]), world)
}
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]! }
}
2026-05-10 10:17:42 -05:00
const lightTick = advanceLightState(next, 1, world)
next = lightTick.state
const arrivalLines: TranscriptLine[] = [
{ kind: 'system', text: destRoom.title },
{ kind: 'narration', text: description },
2026-05-10 10:17:42 -05:00
...lightTick.lines,
]
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-10 10:17:42 -05:00
function handleWait(state: GameState, world: World): DispatchResult {
const lightTick = advanceLightState(state, 2, world)
return narrate(lightTick.state, [
{ kind: 'narration', text: 'Time passes.' },
...lightTick.lines,
])
}
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 = describeRoomItems(items.map((id) => world.items[id]?.short ?? id))
return narrate(state, [
{ kind: 'system', text: room.title },
{ kind: 'narration', text: room.descriptions.examined },
...(itemNarration ? [{ kind: 'narration' as const, text: itemNarration }] : []),
])
}
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]}`
}
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.' }])
}
2026-05-10 10:17:42 -05:00
const itemDef = _world.items[itemId]
const itemInst = state.inventory.find((i) => i.id === itemId) ?? null
if (itemDef?.lightable && itemInst?.state['lit'] === true) {
return narrate(state, [{ kind: 'narration', text: "Extinguish it first." }])
}
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.' }])
2026-05-10 07:56:31 -05:00
const inventoryInst = state.inventory.find((i) => i.id === itemId) ?? null
const visible =
2026-05-10 07:56:31 -05:00
inventoryInst ||
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)
}
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 }])
}
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?' }])
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) => {
2026-05-10 10:17:42 -05:00
if (i.id === targetInst.id) return { ...i, state: { ...i.state, lit: true, burn: LIGHT_TURNS_MAX } }
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
}
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) =>
2026-05-10 10:17:42 -05:00
i.id === targetId ? { ...i, state: { ...i.state, lit: false, burn: 0 } } : i,
)
return narrate({ ...state, inventory: newInventory }, [{ kind: 'narration', text: target.extinguishedText ?? 'The flame dies.' }])
}
2026-05-10 10:17:42 -05:00
function advanceLightState(state: GameState, cost: number, world: World): { state: GameState; lines: TranscriptLine[] } {
if (cost <= 0) return { state, lines: [] }
let changed = false
const lines: TranscriptLine[] = []
const inventory = state.inventory.map((inst) => {
const def = world.items[inst.id]
if (!def?.lightable || inst.state['lit'] !== true) return inst
const turnsLeft = getLightTurnsLeft(inst)
const nextTurns = Math.max(0, turnsLeft - cost)
changed = true
if (nextTurns === 0) {
lines.push({ kind: 'narration', text: def.extinguishedText ?? 'The flame dies.' })
return { ...inst, state: { ...inst.state, lit: false, burn: 0 } }
}
return { ...inst, state: { ...inst.state, burn: nextTurns } }
})
return changed ? { state: { ...state, inventory }, lines } : { state, lines }
}
function getLightTurnsLeft(inst: ItemInstance): number {
const turns = inst.state['burn']
if (typeof turns === 'number') return Math.max(0, turns)
return inst.state['lit'] === true ? LIGHT_TURNS_MAX : 0
}