From 8401e7d2811d16dd5ed62c5cfff8ed043f8f6aee Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 14:18:54 -0500 Subject: [PATCH] feat(engine): light/extinguish verbs with implicit lighter selection \`light X\` finds a lighter (item with lighter:true and remaining state.uses) in inventory, decrements its charges, and toggles target.state.lit. The target's litText / extinguishedText / the lighter's lighterEmptyText provide narration. Refuses politely on each error path. Co-Authored-By: Claude Opus 4.7 --- src/engine/dispatcher.test.ts | 88 +++++++++++++++++++++++++++++++++++ src/engine/dispatcher.ts | 75 +++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/src/engine/dispatcher.test.ts b/src/engine/dispatcher.test.ts index 460386a..a8e069b 100644 --- a/src/engine/dispatcher.test.ts +++ b/src/engine/dispatcher.test.ts @@ -252,3 +252,91 @@ describe('read verb', () => { expect(result.appended.at(-1)?.text).toBe("There's nothing to read on it.") }) }) + +describe('light/extinguish verbs (implicit lighter)', () => { + function w(): World { + return { + startingRoom: 'r', + startingInventory: [], + rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } }, + items: { + lamp: { id: 'lamp', names: ['lamp'], short: 'an oil lamp', long: '.', initialState: { lit: false }, takeable: true, lightable: true, litText: 'The wick catches.', extinguishedText: 'The flame dies.' }, + matches: { id: 'matches', names: ['matches'], short: 'a matchbook', long: '.', initialState: {}, takeable: true, lighter: true, lighterUses: 2, lighterEmptyText: 'The book is empty.' }, + rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true }, + }, + encounters: {}, + endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' } }, + } + } + + it('lights a lamp using the matchbook implicitly', () => { + const world = w() + let state = initialStateFor(world) + state = { ...state, inventory: [ + { id: 'lamp', state: { lit: false } }, + { id: 'matches', state: { uses: 2 } }, + ] } + const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world) + expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(true) + expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(1) + expect(result.appended.at(-1)?.text).toBe('The wick catches.') + }) + + it('refuses when the target is already lit', () => { + const world = w() + let state = initialStateFor(world) + state = { ...state, inventory: [ + { id: 'lamp', state: { lit: true } }, + { id: 'matches', state: { uses: 2 } }, + ] } + const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world) + expect(result.appended.at(-1)?.text).toBe("It's already lit.") + }) + + it('refuses when no lighter is in inventory', () => { + const world = w() + let state = initialStateFor(world) + state = { ...state, inventory: [{ id: 'lamp', state: { lit: false } }] } + const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world) + expect(result.appended.at(-1)?.text).toBe('You have nothing to light it with.') + }) + + it('refuses when the target is not lightable', () => { + const world = w() + let state = initialStateFor(world) + state = { ...state, inventory: [{ id: 'rock', state: {} }, { id: 'matches', state: { uses: 2 } }] } + const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'rock', raw: 'rock' } }, world) + expect(result.appended.at(-1)?.text).toBe("You can't light that.") + }) + + it('emits the lighter-empty message when matches reach 0', () => { + const world = w() + let state = initialStateFor(world) + state = { ...state, inventory: [ + { id: 'lamp', state: { lit: false } }, + { id: 'matches', state: { uses: 1 } }, + ] } + const result = dispatch(state, { kind: 'verb-target', verb: 'light', target: { canonical: 'lamp', raw: 'lamp' } }, world) + expect(result.state.inventory.find((i) => i.id === 'matches')?.state['uses']).toBe(0) + const texts = result.appended.map((l) => l.text) + expect(texts).toContain('The wick catches.') + expect(texts).toContain('The book is empty.') + }) + + it('extinguishes a lit lamp', () => { + const world = w() + let state = initialStateFor(world) + state = { ...state, inventory: [{ id: 'lamp', state: { lit: true } }] } + const result = dispatch(state, { kind: 'verb-target', verb: 'extinguish', target: { canonical: 'lamp', raw: 'lamp' } }, world) + expect(result.state.inventory.find((i) => i.id === 'lamp')?.state['lit']).toBe(false) + expect(result.appended.at(-1)?.text).toBe('The flame dies.') + }) + + it("refuses to extinguish what isn't lit", () => { + const world = w() + let state = initialStateFor(world) + state = { ...state, inventory: [{ id: 'lamp', state: { lit: false } }] } + const result = dispatch(state, { kind: 'verb-target', verb: 'extinguish', target: { canonical: 'lamp', raw: 'lamp' } }, world) + expect(result.appended.at(-1)?.text).toBe("It isn't lit.") + }) +}) diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index 36b0183..cfe6033 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -117,6 +117,8 @@ export function dispatch(state: GameState, command: ParsedCommand, world: 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) if (command.verb === 'read') return handleRead(stateWithNoun, command.target.canonical, world) + if (command.verb === 'light') return handleLight(stateWithNoun, command.target.canonical, null, world) + if (command.verb === 'extinguish') return handleExtinguish(stateWithNoun, command.target.canonical, world) return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]) } @@ -276,3 +278,76 @@ function handleRead(state: GameState, itemId: string, world: World): DispatchRes } 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." }]) + 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) +} + +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.' }]) +}