feat(mystery): parser — noun resolution, disambiguation, pronouns

This commit is contained in:
2026-05-08 22:44:43 -05:00
parent b59644270e
commit bf9e210b88
2 changed files with 216 additions and 3 deletions
+157
View File
@@ -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')
})
})
+59 -3
View File
@@ -96,6 +96,20 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
if (dir) return { kind: 'go', direction: dir } 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"). // Two-word verb (e.g. "pick up X").
const twoWord = matchTwoWordVerb(tokens) const twoWord = matchTwoWordVerb(tokens)
let verb: Verb | undefined let verb: Verb | undefined
@@ -119,7 +133,49 @@ export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
return { kind: 'unknown', raw: trimmed, reason: 'malformed' } return { kind: 'unknown', raw: trimmed, reason: 'malformed' }
} }
// Verb + target — noun resolution comes in Task 3. For now, return unknown. // Pronoun resolution: "it" maps to lastNoun.
// This will be replaced when noun resolution lands. if (rest.length === 1 && rest[0] === 'it') {
return { kind: 'unknown', raw: trimmed, reason: 'unknown-noun' } 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 },
}
} }