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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.' }])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user