From b870d884ef00b60037b0a0eff9c6c5b855d77c41 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 14:22:03 -0500 Subject: [PATCH] =?UTF-8?q?feat(engine):=20wire=20verb-target-prep=20?= =?UTF-8?q?=E2=80=94=20explicit=20\`light=20X=20with=20Y\`=20and=20\`use\`?= =?UTF-8?q?=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit light X with Y validates the named instrument and reuses handleLight. use X / use X on Y route through the encounter dispatcher; if no encounter consumes it, the dispatcher narrates the fallback. The encounter matcher also rejects transitions whose required item doesn't match the typed instrument, so a mistyped instrument fails cleanly. Co-Authored-By: Claude Opus 4.7 --- src/engine/dispatcher.test.ts | 75 +++++++++++++++++++++++++++++++++++ src/engine/dispatcher.ts | 17 ++++++++ src/engine/encounters.ts | 8 +++- 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/engine/dispatcher.test.ts b/src/engine/dispatcher.test.ts index a8e069b..f9e1899 100644 --- a/src/engine/dispatcher.test.ts +++ b/src/engine/dispatcher.test.ts @@ -340,3 +340,78 @@ describe('light/extinguish verbs (implicit lighter)', () => { expect(result.appended.at(-1)?.text).toBe("It isn't lit.") }) }) + +describe('light X with Y (explicit 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 with the explicit instrument', () => { + 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-prep', verb: 'light', + target: { canonical: 'lamp', raw: 'lamp' }, + preposition: 'with', + indirect: { canonical: 'matches', raw: 'matches' }, + }, 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) + }) + + it('refuses when the named instrument is not a lighter', () => { + const world = w() + let state = initialStateFor(world) + state = { ...state, inventory: [ + { id: 'lamp', state: { lit: false } }, + { id: 'rock', state: {} }, + ] } + const result = dispatch(state, { + kind: 'verb-target-prep', verb: 'light', + target: { canonical: 'lamp', raw: 'lamp' }, + preposition: 'with', + indirect: { canonical: 'rock', raw: 'rock' }, + }, world) + expect(result.appended.at(-1)?.text).toBe("That isn't going to help.") + }) +}) + +describe('use verb routing', () => { + function w(): World { + return { + startingRoom: 'r', + startingInventory: [], + rooms: { r: { id: 'r', title: '[ R ]', descriptions: { firstVisit: '.', revisit: '.', examined: '.' }, exits: {}, items: [] } }, + items: { + rock: { id: 'rock', names: ['rock'], short: 'a rock', long: '.', initialState: {}, takeable: true }, + }, + encounters: {}, + endings: { true: { whenFlags: {}, narration: '' }, wrong: { whenFlags: {}, narration: '' }, bad: { whenFlags: {}, narration: '' } }, + } + } + + it('falls back when no encounter consumes use', () => { + const world = w() + let state = initialStateFor(world) + state = { ...state, inventory: [{ id: 'rock', state: {} }] } + const result = dispatch(state, { + kind: 'verb-target', verb: 'use', target: { canonical: 'rock', raw: 'rock' }, + }, world) + expect(result.appended.at(-1)?.text).toBe("You can't think how to use that here.") + }) +}) diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index cfe6033..6aefdd4 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -119,6 +119,23 @@ export function dispatch(state: GameState, command: ParsedCommand, world: 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) + if (command.verb === 'use') return narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]) + return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]) + } + + 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 { state: encResult.state, appended: encResult.lines } + } + if (command.verb === 'light' && command.preposition === 'with') { + return handleLight(stateWithNoun, command.target.canonical, command.indirect.canonical, world) + } + if (command.verb === 'use') { + return narrate(stateWithNoun, [{ kind: 'narration', text: "You can't think how to use that here." }]) + } return narrate(stateWithNoun, [{ kind: 'narration', text: `You're not sure how to ${command.verb} that.` }]) } diff --git a/src/engine/encounters.ts b/src/engine/encounters.ts index 8daa9b3..2f2a09f 100644 --- a/src/engine/encounters.ts +++ b/src/engine/encounters.ts @@ -66,12 +66,17 @@ export function applyVerbToEncounter( const phaseDef = def.phases[currentPhase] if (!phaseDef) return null - // Only verb-target and verb-only commands engage with encounters. + // Only verb-target, verb-target-prep, and verb-only commands engage with encounters. let verb: string | null = null let targetId: string | null = null + let instrumentId: string | null = null if (command.kind === 'verb-target') { verb = command.verb targetId = command.target.canonical + } else if (command.kind === 'verb-target-prep') { + verb = command.verb + targetId = command.target.canonical + instrumentId = command.indirect.canonical } else if (command.kind === 'verb-only' && command.verb !== 'inventory') { verb = command.verb } else { @@ -91,6 +96,7 @@ export function applyVerbToEncounter( } } } + if (t.requires && instrumentId && t.requires.item !== instrumentId) return false return true })