From b318747840757133d1bef80d948f56520085eb69 Mon Sep 17 00:00:00 2001 From: Ethan J Lewis Date: Sat, 9 May 2026 13:56:19 -0500 Subject: [PATCH] feat(parser): emit verb-target-prep on 'with'/'on'/'in'/'to' separators Enables `light lamp with matches`, `use shears on vines`, and similar multi-noun forms. Both the target and indirect noun must resolve; otherwise the command falls back to unknown-noun. Co-Authored-By: Claude Opus 4.7 --- src/engine/parser.test.ts | 57 +++++++++++++++++++++++++++++++++++++++ src/engine/parser.ts | 45 +++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/src/engine/parser.test.ts b/src/engine/parser.test.ts index f08e342..1046d8f 100644 --- a/src/engine/parser.test.ts +++ b/src/engine/parser.test.ts @@ -297,3 +297,60 @@ describe('ambiguous noun', () => { if (cmd.kind === 'verb-target') expect(cmd.target.canonical).toBe('iron-key') }) }) + +describe('verb-target-prep with "with"', () => { + const ctx: ParserContext = { + knownItems: ['lamp', 'matches'], + knownEncounters: [], + visibleNouns: [ + { id: 'lamp', aliases: ['lamp'] }, + { id: 'matches', aliases: ['matches', 'matchbook'] }, + ], + inventoryItemIds: ['matches'], + lastNoun: null, + awaitingDisambiguation: null, + } + + it('parses "light lamp with matches" into verb-target-prep', () => { + const cmd = parse('light lamp with matches', ctx) + expect(cmd).toEqual({ + kind: 'verb-target-prep', + verb: 'light', + target: { canonical: 'lamp', raw: 'lamp' }, + preposition: 'with', + indirect: { canonical: 'matches', raw: 'matches' }, + }) + }) + + it('parses "use shears on vines" into verb-target-prep', () => { + const localCtx: ParserContext = { + knownItems: ['shears', 'ivy-figure'], + knownEncounters: [], + visibleNouns: [ + { id: 'shears', aliases: ['shears'] }, + { id: 'ivy-figure', aliases: ['vines', 'ivy'] }, + ], + inventoryItemIds: ['shears'], + lastNoun: null, + awaitingDisambiguation: null, + } + const cmd = parse('use shears on vines', localCtx) + expect(cmd).toEqual({ + kind: 'verb-target-prep', + verb: 'use', + target: { canonical: 'shears', raw: 'shears' }, + preposition: 'on', + indirect: { canonical: 'ivy-figure', raw: 'vines' }, + }) + }) + + it('still parses verb-target when no preposition is present', () => { + const cmd = parse('take lamp', ctx) + expect(cmd.kind).toBe('verb-target') + }) + + it('falls back to unknown-noun when one side fails to resolve', () => { + const cmd = parse('light lamp with feathers', ctx) + expect(cmd).toEqual({ kind: 'unknown', raw: 'light lamp with feathers', reason: 'unknown-noun' }) + }) +}) diff --git a/src/engine/parser.ts b/src/engine/parser.ts index ee220bd..ade392c 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -62,6 +62,24 @@ const TWO_WORD_VERBS = ['pick up'] /** Leading stop-words stripped from the noun phrase before matching. */ const STOP_WORDS = new Set(['at', 'the', 'a', 'an']) +const PREPOSITIONS = new Set(['with', 'on', 'in', 'to']) + +function resolveNoun(rawTokens: string[], ctx: ParserContext): { id: string; alias: string } | null { + const phrase = rawTokens.join(' ') + if (phrase === 'it' && ctx.lastNoun) { + return { id: ctx.lastNoun.canonical, alias: 'it' } + } + for (const noun of ctx.visibleNouns) { + for (const alias of noun.aliases) { + if (alias === phrase) return { id: noun.id, alias } + } + } + for (const itemId of ctx.inventoryItemIds) { + if (itemId === phrase) return { id: itemId, alias: phrase } + } + return null +} + function tokenize(input: string): string[] { return input.trim().toLowerCase().split(/\s+/).filter(Boolean) } @@ -141,6 +159,33 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand { return { kind: 'unknown', raw: trimmed, reason: 'malformed' } } + // Detect a preposition splitting target | indirect. + const prepIdx = rest.findIndex((tok) => PREPOSITIONS.has(tok)) + if (prepIdx > 0 && prepIdx < rest.length - 1) { + const targetTokens = rest.slice(0, prepIdx) + const prep = rest[prepIdx]! + let indirectTokens = rest.slice(prepIdx + 1) + // Strip stop-words at the head of the indirect phrase too ("on the table"). + while (indirectTokens.length > 0 && STOP_WORDS.has(indirectTokens[0]!)) { + indirectTokens = indirectTokens.slice(1) + } + if (indirectTokens.length > 0) { + const target = resolveNoun(targetTokens, ctx) + const indirect = resolveNoun(indirectTokens, ctx) + if (target && indirect) { + return { + kind: 'verb-target-prep', + verb, + target: { canonical: target.id, raw: target.alias }, + preposition: prep, + indirect: { canonical: indirect.id, raw: indirect.alias }, + } + } + // Either side failed to resolve → fall through to unknown-noun below. + return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' } + } + } + // Pronoun resolution: "it" maps to lastNoun. if (rest.length === 1 && rest[0] === 'it') { if (!ctx.lastNoun) {