feat(world): add light timer and indicator
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-10 10:17:42 -05:00
parent d56c0c8363
commit 4d9077d586
9 changed files with 359 additions and 21 deletions
+57
View File
@@ -193,6 +193,21 @@ describe('dispatcher — take and drop', () => {
const r = dispatch(s, { kind: 'verb-target', verb: 'drop', target: { canonical: 'torch', raw: 'torch' } }, world)
expect(r.state.inventory.find((i) => i.id === 'torch')).toBeUndefined()
})
it('refuses to drop a lit lightable item', () => {
const lightWorld: World = {
...world,
items: {
...world.items,
lamp: { id: 'lamp', names: ['lamp'], short: 'an oil lamp', long: 'An oil lamp.', initialState: { lit: false }, takeable: true, lightable: true },
},
}
let s = initialStateFor(lightWorld)
s = { ...s, inventory: [{ id: 'lamp', state: { lit: true, burn: 6 } }] }
const r = dispatch(s, { kind: 'verb-target', verb: 'drop', target: { canonical: 'lamp', raw: 'lamp' } }, lightWorld)
expect(r.appended.at(-1)?.text).toBe('Extinguish it first.')
expect(r.state.inventory.find((i) => i.id === 'lamp')).toBeDefined()
})
})
describe('dispatcher — examine', () => {
@@ -414,6 +429,48 @@ describe('light/extinguish verbs (implicit lighter)', () => {
expect(texts).toContain('The book is empty.')
})
it('burns two segments on wait and extinguishes on the third wait', () => {
const world = w()
let state = initialStateFor(world)
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 6 } }] }
const first = dispatch(state, { kind: 'verb-only', verb: 'wait' }, world)
expect(first.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(4)
expect(first.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true)
const second = dispatch(first.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(second.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(2)
const third = dispatch(second.state, { kind: 'verb-only', verb: 'wait' }, world)
expect(third.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(0)
expect(third.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(false)
expect(third.appended.map((l) => l.text)).toContain('The flame dies.')
})
it('burns one segment on movement', () => {
const world = w()
const movingWorld: World = {
...world,
rooms: {
...world.rooms,
r: {
id: 'r',
title: '[ R ]',
descriptions: { firstVisit: '.', revisit: '.', examined: '.' },
exits: { n: 'r2' },
items: [],
},
r2: { id: 'r2', title: '[ R2 ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] },
},
}
let state = initialStateFor(movingWorld)
state = { ...state, inventory: [{ id: 'lamp', state: { lit: true, burn: 6 } }] }
const result = dispatch(state, { kind: 'go', direction: 'n' }, movingWorld)
expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['burn']).toBe(5)
expect(result.state.location).toBe('r2')
})
it('extinguishes a lit lamp', () => {
const world = w()
let state = initialStateFor(world)
+74 -3
View File
@@ -3,6 +3,15 @@ import type { GameState, ParsedCommand, DispatchResult, ItemInstance, Transcript
import { SCHEMA_VERSION, TRANSCRIPT_CAP } from './types'
import { applyVerbToEncounter, maybeTriggerEncounter } from './encounters'
export const LIGHT_TURNS_MAX = 6
export interface LightStatus {
itemId: string
lit: boolean
turnsLeft: number
maxTurns: number
}
const HALFSTREET_ASCII = String.raw`
_ _ _ __ ____ _ _
| | | | __ _| |/ _| / ___|| |_ _ __ ___ ___| |_
@@ -42,6 +51,22 @@ export function initialStateFor(world: World): GameState {
}
}
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) }
@@ -149,7 +174,7 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
}
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)
if (command.verb === 'wait') return withEndingCheck(handleWait(state, world), world)
}
if (command.kind === 'verb-target') {
@@ -247,9 +272,13 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
if (idx > 0) next = { ...next, resolveLevel: ladder[idx - 1]! }
}
const lightTick = advanceLightState(next, 1, world)
next = lightTick.state
const arrivalLines: TranscriptLine[] = [
{ kind: 'system', text: destRoom.title },
{ kind: 'narration', text: description },
...lightTick.lines,
]
const result = narrate(next, arrivalLines)
@@ -261,6 +290,14 @@ function handleGo(state: GameState, direction: 'n' | 's' | 'e' | 'w' | 'u' | 'd'
return result
}
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.' }])
@@ -337,6 +374,11 @@ function handleDrop(state: GameState, itemId: string, _world: World): DispatchRe
if (!state.inventory.find((i) => i.id === itemId)) {
return narrate(state, [{ kind: 'narration', text: 'You don\'t have that.' }])
}
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),
@@ -431,7 +473,7 @@ function handleLight(state: GameState, targetId: string, instrumentId: string |
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 === 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
})
@@ -506,7 +548,36 @@ function handleExtinguish(state: GameState, targetId: string, world: World): Dis
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,
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.' }])
}
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
}