From 46f851bc3a7283b1a2140d1d77b28fc45d26e4ef Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 13:53:38 -0500 Subject: [PATCH] feat(parser): return ambiguous variant when noun matches multiple aliases Replaces the previous behavior of returning unknown-noun. The dispatcher will use this in the next commit to prompt the player to disambiguate. Co-Authored-By: Claude Opus 4.7 --- src/engine/parser.test.ts | 43 ++++++++++++++++++++++++++++++++------- src/engine/parser.ts | 13 ++++++++---- src/engine/types.ts | 1 + 3 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/engine/parser.test.ts b/src/engine/parser.test.ts index 754a64c..f08e342 100644 --- a/src/engine/parser.test.ts +++ b/src/engine/parser.test.ts @@ -158,7 +158,7 @@ describe('parser — verb + target', () => { }) describe('parser — disambiguation', () => { - it('returns disambiguation request when two candidates match', () => { + it('returns ambiguous when two candidates match', () => { const ctx: ParserContext = { knownItems: ['brass-key', 'iron-key'], knownEncounters: [], @@ -171,12 +171,11 @@ describe('parser — disambiguation', () => { awaitingDisambiguation: null, } const result = parse('take key', ctx) - expect(result.kind).toBe('unknown') - if (result.kind === 'unknown') { - // Parser flags ambiguity by returning unknown-noun; the dispatcher - // turns this into a PendingDisambiguation. (Keeping parser pure: it - // signals; the dispatcher decides UI flow.) - expect(result.reason).toBe('unknown-noun') + expect(result.kind).toBe('ambiguous') + if (result.kind === 'ambiguous') { + expect(result.verb).toBe('take') + expect(result.rawNoun).toBe('key') + expect(result.candidates).toEqual(['brass-key', 'iron-key']) } }) @@ -268,3 +267,33 @@ describe('stop-word stripping', () => { expect(cmd.kind).toBe('verb-target') }) }) + +describe('ambiguous noun', () => { + const ctx: ParserContext = { + knownItems: ['iron-key', 'brass-key'], + knownEncounters: [], + visibleNouns: [ + { id: 'iron-key', aliases: ['key', 'iron key'] }, + { id: 'brass-key', aliases: ['key', 'brass key'] }, + ], + inventoryItemIds: [], + lastNoun: null, + awaitingDisambiguation: null, + } + + it('returns ambiguous when two aliases match the same noun phrase', () => { + const cmd = parse('take key', ctx) + expect(cmd).toEqual({ + kind: 'ambiguous', + verb: 'take', + rawNoun: 'key', + candidates: ['iron-key', 'brass-key'], + }) + }) + + it('still returns verb-target when the phrase is unambiguous', () => { + const cmd = parse('take iron key', ctx) + expect(cmd.kind).toBe('verb-target') + if (cmd.kind === 'verb-target') expect(cmd.target.canonical).toBe('iron-key') + }) +}) diff --git a/src/engine/parser.ts b/src/engine/parser.ts index 364e9ba..ee220bd 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -173,11 +173,16 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand { return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' } } - // Multiple candidates → ambiguous. Parser signals; the dispatcher records the - // PendingDisambiguation in state so the next turn's input is interpreted as - // a disambiguation reply. + // Multiple candidates → ambiguous. Dedupe by id; if only one distinct id + // remains, two aliases of the same item matched — not truly ambiguous. if (candidates.length > 1) { - return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' } + const uniqueIds = [...new Set(candidates.map((c) => c.id))] + if (uniqueIds.length === 1) { + // Two aliases of the same item — not actually ambiguous. + const id = uniqueIds[0]! + return { kind: 'verb-target', verb, target: { canonical: id, raw: candidates[0]!.alias } } + } + return { kind: 'ambiguous', verb, rawNoun: targetRaw, candidates: uniqueIds } } const target = candidates[0]! diff --git a/src/engine/types.ts b/src/engine/types.ts index e68419d..cb4a7db 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -24,6 +24,7 @@ export type ParsedCommand = | { kind: 'verb-only'; verb: Verb | 'look' | 'inventory' | 'wait' } | { kind: 'verb-target'; verb: Verb; target: NounRef } | { kind: 'verb-target-prep'; verb: Verb; target: NounRef; preposition: string; indirect: NounRef } + | { kind: 'ambiguous'; verb: Verb; rawNoun: string; candidates: string[] } | { kind: 'go'; direction: Direction } | { kind: 'meta'; verb: MetaVerb } | { kind: 'disambiguation'; chosen: string }