feat(engine): dispatcher handles ambiguous parses with a disambiguation prompt

Sets pendingDisambiguation on state and emits "Which X — A, or B?" using
each candidate item's short text. The existing disambiguation reply path
then re-issues the original verb against the chosen target.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 13:58:32 -05:00
parent b318747840
commit ab8c17fdd5
2 changed files with 70 additions and 1 deletions
+56 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { dispatch, initialStateFor } from './dispatcher' import { dispatch, initialStateFor } from './dispatcher'
import type { World } from '../world/types' import type { World } from '../world/types'
import type { GameState } from './types' import type { GameState, ParsedCommand } from './types'
import { SCHEMA_VERSION } from './types' import { SCHEMA_VERSION } from './types'
const world: World = { const world: World = {
@@ -157,3 +157,58 @@ describe('dispatcher — inventory', () => {
expect(r.appended.some((l) => /empty-handed|carrying nothing/i.test(l.text))).toBe(true) expect(r.appended.some((l) => /empty-handed|carrying nothing/i.test(l.text))).toBe(true)
}) })
}) })
describe('ambiguous → disambiguation flow', () => {
function makeAmbiguousWorld(): World {
return {
startingRoom: 'r',
startingInventory: [],
rooms: {
r: {
id: 'r',
title: '[ R ]',
descriptions: { firstVisit: 'r', revisit: 'r', examined: 'r' },
exits: {},
items: ['iron-key', 'brass-key'],
},
},
items: {
'iron-key': { id: 'iron-key', names: ['key', 'iron key'], short: 'an iron key', long: '.', initialState: {}, takeable: true },
'brass-key': { id: 'brass-key', names: ['key', 'brass key'], short: 'a brass key', long: '.', initialState: {}, takeable: true },
},
encounters: {},
endings: {
true: { whenFlags: {}, narration: '' },
wrong: { whenFlags: {}, narration: '' },
bad: { whenFlags: {}, narration: '' },
},
}
}
it('sets pendingDisambiguation and prompts when the parser returns ambiguous', () => {
const world = makeAmbiguousWorld()
const state = initialStateFor(world)
const cmd: ParsedCommand = {
kind: 'ambiguous', verb: 'take', rawNoun: 'key', candidates: ['iron-key', 'brass-key'],
}
const result = dispatch(state, cmd, world)
expect(result.state.pendingDisambiguation).toEqual({
verb: 'take',
candidates: ['iron-key', 'brass-key'],
prompt: 'Which key — an iron key, or a brass key?',
})
expect(result.appended[0]?.text).toBe('Which key — an iron key, or a brass key?')
})
it('handles a single-word disambiguation reply by re-issuing the verb', () => {
const world = makeAmbiguousWorld()
let state = initialStateFor(world)
state = {
...state,
pendingDisambiguation: { verb: 'take', candidates: ['iron-key', 'brass-key'], prompt: '...' },
}
const result = dispatch(state, { kind: 'disambiguation', chosen: 'iron-key' }, world)
expect(result.state.pendingDisambiguation).toBeNull()
expect(result.state.inventory.find((i) => i.id === 'iron-key')).toBeDefined()
})
})
+14
View File
@@ -86,6 +86,20 @@ export function dispatch(state: GameState, command: ParsedCommand, world: World)
return handleGo(state, command.direction, world) return handleGo(state, command.direction, world)
} }
if (command.kind === 'ambiguous') {
const candidateShorts = command.candidates.map((id) => world.items[id]?.short ?? id)
const list =
candidateShorts.length === 2
? `${candidateShorts[0]}, or ${candidateShorts[1]}`
: candidateShorts.slice(0, -1).join(', ') + ', or ' + candidateShorts[candidateShorts.length - 1]
const prompt = `Which ${command.rawNoun}${list}?`
const next: GameState = {
...state,
pendingDisambiguation: { verb: command.verb, candidates: command.candidates, prompt },
}
return narrate(next, [{ kind: 'narration', text: prompt }])
}
if (command.kind === 'verb-only') { if (command.kind === 'verb-only') {
if (command.verb === 'look') return handleLook(state, world) if (command.verb === 'look') return handleLook(state, world)
if (command.verb === 'inventory') return handleInventory(state, world) if (command.verb === 'inventory') return handleInventory(state, world)