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 <noreply@anthropic.com>
This commit is contained in:
@@ -297,3 +297,60 @@ describe('ambiguous noun', () => {
|
|||||||
if (cmd.kind === 'verb-target') expect(cmd.target.canonical).toBe('iron-key')
|
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' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ const TWO_WORD_VERBS = ['pick up']
|
|||||||
/** Leading stop-words stripped from the noun phrase before matching. */
|
/** Leading stop-words stripped from the noun phrase before matching. */
|
||||||
const STOP_WORDS = new Set(['at', 'the', 'a', 'an'])
|
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[] {
|
function tokenize(input: string): string[] {
|
||||||
return input.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
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' }
|
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.
|
// Pronoun resolution: "it" maps to lastNoun.
|
||||||
if (rest.length === 1 && rest[0] === 'it') {
|
if (rest.length === 1 && rest[0] === 'it') {
|
||||||
if (!ctx.lastNoun) {
|
if (!ctx.lastNoun) {
|
||||||
|
|||||||
Reference in New Issue
Block a user