This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user