feat(engine): wire verb-target-prep — explicit \light X with Y\ and \use\ routing
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.` }])
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user