feat(mystery): parser — noun resolution, disambiguation, pronouns
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
+58
-2
@@ -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.
|
||||
// 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 },
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user