From ab8c17fdd5b728c56ec9bcdfaece3394229bf53a Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 13:58:32 -0500 Subject: [PATCH] feat(engine): dispatcher handles ambiguous parses with a disambiguation prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/engine/dispatcher.test.ts | 57 ++++++++++++++++++++++++++++++++++- src/engine/dispatcher.ts | 14 +++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/engine/dispatcher.test.ts b/src/engine/dispatcher.test.ts index 440e565..35f11a3 100644 --- a/src/engine/dispatcher.test.ts +++ b/src/engine/dispatcher.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { dispatch, initialStateFor } from './dispatcher' import type { World } from '../world/types' -import type { GameState } from './types' +import type { GameState, ParsedCommand } from './types' import { SCHEMA_VERSION } from './types' 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) }) }) + +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() + }) +}) diff --git a/src/engine/dispatcher.ts b/src/engine/dispatcher.ts index 20cef42..de9a49b 100644 --- a/src/engine/dispatcher.ts +++ b/src/engine/dispatcher.ts @@ -86,6 +86,20 @@ export function dispatch(state: GameState, command: ParsedCommand, world: 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.verb === 'look') return handleLook(state, world) if (command.verb === 'inventory') return handleInventory(state, world)