From bf9e210b88e627d481d36175637cf0dd8e2cce74 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Fri, 8 May 2026 22:44:43 -0500 Subject: [PATCH] =?UTF-8?q?feat(mystery):=20parser=20=E2=80=94=20noun=20re?= =?UTF-8?q?solution,=20disambiguation,=20pronouns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/parser.test.ts | 157 ++++++++++++++++++++++++++++++++++++++ src/engine/parser.ts | 62 ++++++++++++++- 2 files changed, 216 insertions(+), 3 deletions(-) diff --git a/src/engine/parser.test.ts b/src/engine/parser.test.ts index 5cba6e8..1ea48ff 100644 --- a/src/engine/parser.test.ts +++ b/src/engine/parser.test.ts @@ -74,3 +74,160 @@ describe('parser — unknown input', () => { }) }) }) + +describe('parser — verb + target', () => { + it('resolves a single visible noun', () => { + const ctx: ParserContext = { + knownItems: ['torch'], + knownEncounters: [], + visibleNouns: [{ id: 'torch', aliases: ['torch', 'lamp'] }], + inventoryItemIds: [], + lastNoun: null, + awaitingDisambiguation: null, + } + expect(parse('take torch', ctx)).toEqual({ + kind: 'verb-target', + verb: 'take', + target: { canonical: 'torch', raw: 'torch' }, + }) + }) + + it('matches multi-word object names', () => { + const ctx: ParserContext = { + knownItems: ['brass-key'], + knownEncounters: [], + visibleNouns: [{ id: 'brass-key', aliases: ['brass key', 'key'] }], + inventoryItemIds: [], + lastNoun: null, + awaitingDisambiguation: null, + } + expect(parse('take brass key', ctx)).toEqual({ + kind: 'verb-target', + verb: 'take', + target: { canonical: 'brass-key', raw: 'brass key' }, + }) + }) + + it('matches by alias', () => { + const ctx: ParserContext = { + knownItems: ['torch'], + knownEncounters: [], + visibleNouns: [{ id: 'torch', aliases: ['torch', 'lamp'] }], + inventoryItemIds: [], + lastNoun: null, + awaitingDisambiguation: null, + } + expect(parse('take lamp', ctx)).toEqual({ + kind: 'verb-target', + verb: 'take', + target: { canonical: 'torch', raw: 'lamp' }, + }) + }) + + it('returns unknown-noun for noun not in scope', () => { + const ctx: ParserContext = { + knownItems: ['torch'], + knownEncounters: [], + visibleNouns: [], + inventoryItemIds: [], + lastNoun: null, + awaitingDisambiguation: null, + } + expect(parse('take torch', ctx)).toEqual({ + kind: 'unknown', + raw: 'take torch', + reason: 'unknown-noun', + }) + }) + + it('checks inventory for noun resolution', () => { + const ctx: ParserContext = { + knownItems: ['torch'], + knownEncounters: [], + visibleNouns: [], + inventoryItemIds: ['torch'], + lastNoun: null, + awaitingDisambiguation: null, + } + expect(parse('drop torch', ctx)).toEqual({ + kind: 'verb-target', + verb: 'drop', + target: { canonical: 'torch', raw: 'torch' }, + }) + }) +}) + +describe('parser — disambiguation', () => { + it('returns disambiguation request when two candidates match', () => { + const ctx: ParserContext = { + knownItems: ['brass-key', 'iron-key'], + knownEncounters: [], + visibleNouns: [ + { id: 'brass-key', aliases: ['brass key', 'key'] }, + { id: 'iron-key', aliases: ['iron key', 'key'] }, + ], + inventoryItemIds: [], + lastNoun: null, + 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') + } + }) + + it('disambiguation reply resolves the pending choice', () => { + const ctx: ParserContext = { + knownItems: ['brass-key', 'iron-key'], + knownEncounters: [], + visibleNouns: [ + { id: 'brass-key', aliases: ['brass key', 'key'] }, + { id: 'iron-key', aliases: ['iron key', 'key'] }, + ], + inventoryItemIds: [], + lastNoun: null, + awaitingDisambiguation: { + verb: 'take', + candidates: ['brass-key', 'iron-key'], + prompt: 'Which key — the brass key or the iron key?', + }, + } + expect(parse('brass', ctx)).toEqual({ kind: 'disambiguation', chosen: 'brass-key' }) + expect(parse('iron', ctx)).toEqual({ kind: 'disambiguation', chosen: 'iron-key' }) + }) +}) + +describe('parser — pronouns', () => { + it('resolves "it" to lastNoun', () => { + const ctx: ParserContext = { + knownItems: ['torch'], + knownEncounters: [], + visibleNouns: [{ id: 'torch', aliases: ['torch'] }], + inventoryItemIds: [], + lastNoun: { canonical: 'torch', raw: 'torch' }, + awaitingDisambiguation: null, + } + expect(parse('examine it', ctx)).toEqual({ + kind: 'verb-target', + verb: 'examine', + target: { canonical: 'torch', raw: 'it' }, + }) + }) + + it('returns unknown-noun for "it" with no lastNoun', () => { + const ctx: ParserContext = { + knownItems: ['torch'], + knownEncounters: [], + visibleNouns: [{ id: 'torch', aliases: ['torch'] }], + inventoryItemIds: [], + lastNoun: null, + awaitingDisambiguation: null, + } + const result = parse('examine it', ctx) + expect(result.kind).toBe('unknown') + }) +}) diff --git a/src/engine/parser.ts b/src/engine/parser.ts index 9a96fd7..bdab883 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -96,6 +96,20 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand { if (dir) return { kind: 'go', direction: dir } } + // Disambiguation reply: a single-word answer matching one of the candidates. + // Must be checked before verb resolution so "brass" / "iron" etc. are caught. + if (ctx.awaitingDisambiguation && tokens.length === 1) { + const choice = tokens[0]! + for (const candidateId of ctx.awaitingDisambiguation.candidates) { + // Match if the choice is a substring of any alias or the id itself. + const candidate = ctx.visibleNouns.find((n) => n.id === candidateId) + const aliases = candidate?.aliases ?? [candidateId] + if (aliases.some((a) => a.toLowerCase().includes(choice))) { + return { kind: 'disambiguation', chosen: candidateId } + } + } + } + // Two-word verb (e.g. "pick up X"). const twoWord = matchTwoWordVerb(tokens) let verb: Verb | undefined @@ -119,7 +133,49 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand { return { kind: 'unknown', raw: trimmed, reason: 'malformed' } } - // Verb + target — noun resolution comes in Task 3. For now, return unknown. - // This will be replaced when noun resolution lands. - return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' } + // Pronoun resolution: "it" maps to lastNoun. + if (rest.length === 1 && rest[0] === 'it') { + if (!ctx.lastNoun) { + return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' } + } + return { + kind: 'verb-target', + verb, + target: { canonical: ctx.lastNoun.canonical, raw: 'it' }, + } + } + + // Multi-word noun matching: try the longest possible suffix first. + const targetRaw = rest.join(' ') + const candidates: { id: string; alias: string }[] = [] + for (const noun of ctx.visibleNouns) { + for (const alias of noun.aliases) { + if (alias === targetRaw) candidates.push({ id: noun.id, alias }) + } + } + + // Also check inventory items (id used directly as alias). + if (candidates.length === 0) { + for (const itemId of ctx.inventoryItemIds) { + if (itemId === targetRaw) candidates.push({ id: itemId, alias: targetRaw }) + } + } + + if (candidates.length === 0) { + 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. + if (candidates.length > 1) { + return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' } + } + + const target = candidates[0]! + return { + kind: 'verb-target', + verb, + target: { canonical: target.id, raw: target.alias }, + } }