feat(mystery): parser — verb-only, direction, and meta commands
This commit is contained in:
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parse } from './parser'
|
||||||
|
import type { ParserContext } from './parser'
|
||||||
|
|
||||||
|
const emptyCtx: ParserContext = {
|
||||||
|
knownItems: [],
|
||||||
|
knownEncounters: [],
|
||||||
|
visibleNouns: [],
|
||||||
|
inventoryItemIds: [],
|
||||||
|
lastNoun: null,
|
||||||
|
awaitingDisambiguation: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parser — verb-only commands', () => {
|
||||||
|
it('recognizes bare "look"', () => {
|
||||||
|
expect(parse('look', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'look' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recognizes bare "inventory" and short forms', () => {
|
||||||
|
expect(parse('inventory', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
|
||||||
|
expect(parse('inv', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
|
||||||
|
expect(parse('i', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is case-insensitive', () => {
|
||||||
|
expect(parse('LOOK', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'look' })
|
||||||
|
expect(parse('Inv', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'inventory' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('trims whitespace', () => {
|
||||||
|
expect(parse(' look ', emptyCtx)).toEqual({ kind: 'verb-only', verb: 'look' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parser — direction shortcuts', () => {
|
||||||
|
it('maps single-letter directions', () => {
|
||||||
|
expect(parse('n', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
|
||||||
|
expect(parse('s', emptyCtx)).toEqual({ kind: 'go', direction: 's' })
|
||||||
|
expect(parse('e', emptyCtx)).toEqual({ kind: 'go', direction: 'e' })
|
||||||
|
expect(parse('w', emptyCtx)).toEqual({ kind: 'go', direction: 'w' })
|
||||||
|
expect(parse('u', emptyCtx)).toEqual({ kind: 'go', direction: 'u' })
|
||||||
|
expect(parse('d', emptyCtx)).toEqual({ kind: 'go', direction: 'd' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps full direction words', () => {
|
||||||
|
expect(parse('north', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
|
||||||
|
expect(parse('south', emptyCtx)).toEqual({ kind: 'go', direction: 's' })
|
||||||
|
expect(parse('go north', emptyCtx)).toEqual({ kind: 'go', direction: 'n' })
|
||||||
|
expect(parse('go up', emptyCtx)).toEqual({ kind: 'go', direction: 'u' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parser — meta-commands', () => {
|
||||||
|
it('recognizes restart, undo, hint, quit, save, theme', () => {
|
||||||
|
expect(parse('restart', emptyCtx)).toEqual({ kind: 'meta', verb: 'restart' })
|
||||||
|
expect(parse('undo', emptyCtx)).toEqual({ kind: 'meta', verb: 'undo' })
|
||||||
|
expect(parse('hint', emptyCtx)).toEqual({ kind: 'meta', verb: 'hint' })
|
||||||
|
expect(parse('quit', emptyCtx)).toEqual({ kind: 'meta', verb: 'quit' })
|
||||||
|
expect(parse('save', emptyCtx)).toEqual({ kind: 'meta', verb: 'save' })
|
||||||
|
expect(parse('theme', emptyCtx)).toEqual({ kind: 'meta', verb: 'theme' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parser — unknown input', () => {
|
||||||
|
it('returns unknown for empty input', () => {
|
||||||
|
expect(parse('', emptyCtx)).toEqual({ kind: 'unknown', raw: '', reason: 'malformed' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns unknown-verb for nonsense', () => {
|
||||||
|
expect(parse('flibbertigibbet', emptyCtx)).toEqual({
|
||||||
|
kind: 'unknown',
|
||||||
|
raw: 'flibbertigibbet',
|
||||||
|
reason: 'unknown-verb',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import type { ParsedCommand, NounRef, Verb, MetaVerb, Direction, PendingDisambiguation } from './types'
|
||||||
|
|
||||||
|
export interface ParserContext {
|
||||||
|
/** All item ids that exist in the world (for noun matching). */
|
||||||
|
knownItems: string[]
|
||||||
|
/** All encounter ids that exist in the world. */
|
||||||
|
knownEncounters: string[]
|
||||||
|
/** Nouns currently visible in this room (items + encounter targets). */
|
||||||
|
visibleNouns: { id: string; aliases: string[] }[]
|
||||||
|
/** Inventory item ids. */
|
||||||
|
inventoryItemIds: string[]
|
||||||
|
lastNoun: NounRef | null
|
||||||
|
awaitingDisambiguation: PendingDisambiguation | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verb synonym table: each entry maps an alias to the canonical Verb. */
|
||||||
|
const VERB_SYNONYMS: Record<string, Verb> = {
|
||||||
|
// movement
|
||||||
|
go: 'go', walk: 'go', move: 'go',
|
||||||
|
// perception
|
||||||
|
look: 'look', l: 'look',
|
||||||
|
examine: 'examine', x: 'examine', inspect: 'examine',
|
||||||
|
// inventory
|
||||||
|
inventory: 'inventory', inv: 'inventory', i: 'inventory',
|
||||||
|
// manipulation
|
||||||
|
take: 'take', get: 'take', grab: 'take', 'pick up': 'take',
|
||||||
|
drop: 'drop', put: 'drop', leave: 'drop',
|
||||||
|
use: 'use', combine: 'use',
|
||||||
|
open: 'open', close: 'close',
|
||||||
|
read: 'read', light: 'light', extinguish: 'extinguish', douse: 'extinguish',
|
||||||
|
attack: 'attack', kill: 'attack', fight: 'attack', strike: 'attack',
|
||||||
|
hold: 'hold', show: 'hold',
|
||||||
|
push: 'push', press: 'push',
|
||||||
|
pull: 'pull',
|
||||||
|
wait: 'wait', z: 'wait',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIRECTION_WORDS: Record<string, Direction> = {
|
||||||
|
n: 'n', north: 'n',
|
||||||
|
s: 's', south: 's',
|
||||||
|
e: 'e', east: 'e',
|
||||||
|
w: 'w', west: 'w',
|
||||||
|
u: 'u', up: 'u',
|
||||||
|
d: 'd', down: 'd',
|
||||||
|
}
|
||||||
|
|
||||||
|
const META_VERBS: Record<string, MetaVerb> = {
|
||||||
|
restart: 'restart',
|
||||||
|
undo: 'undo',
|
||||||
|
hint: 'hint',
|
||||||
|
save: 'save',
|
||||||
|
quit: 'quit',
|
||||||
|
theme: 'theme',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verbs that legally take no target. */
|
||||||
|
const VERB_ONLY_VERBS = new Set<string>(['look', 'inventory', 'wait'])
|
||||||
|
|
||||||
|
/** Two-word verb prefixes (e.g. "pick up X"). */
|
||||||
|
const TWO_WORD_VERBS = ['pick up']
|
||||||
|
|
||||||
|
function tokenize(input: string): string[] {
|
||||||
|
return input.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchTwoWordVerb(tokens: string[]): { verb: Verb; rest: string[] } | null {
|
||||||
|
if (tokens.length < 2) return null
|
||||||
|
const head = tokens.slice(0, 2).join(' ')
|
||||||
|
for (const phrase of TWO_WORD_VERBS) {
|
||||||
|
if (phrase === head) {
|
||||||
|
const verb = VERB_SYNONYMS[phrase]
|
||||||
|
if (verb) return { verb, rest: tokens.slice(2) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parse(rawInput: string, ctx: ParserContext): ParsedCommand {
|
||||||
|
const trimmed = rawInput.trim()
|
||||||
|
if (!trimmed) return { kind: 'unknown', raw: '', reason: 'malformed' }
|
||||||
|
|
||||||
|
const tokens = tokenize(trimmed)
|
||||||
|
const head = tokens[0]!
|
||||||
|
|
||||||
|
// Meta-commands take precedence (single-word).
|
||||||
|
if (META_VERBS[head] && tokens.length === 1) {
|
||||||
|
return { kind: 'meta', verb: META_VERBS[head]! }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direction shortcuts: "n", "north", "go n", "go north".
|
||||||
|
if (DIRECTION_WORDS[head] && tokens.length === 1) {
|
||||||
|
return { kind: 'go', direction: DIRECTION_WORDS[head]! }
|
||||||
|
}
|
||||||
|
if (head === 'go' && tokens.length === 2) {
|
||||||
|
const dir = DIRECTION_WORDS[tokens[1]!]
|
||||||
|
if (dir) return { kind: 'go', direction: dir }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two-word verb (e.g. "pick up X").
|
||||||
|
const twoWord = matchTwoWordVerb(tokens)
|
||||||
|
let verb: Verb | undefined
|
||||||
|
let rest: string[]
|
||||||
|
if (twoWord) {
|
||||||
|
verb = twoWord.verb
|
||||||
|
rest = twoWord.rest
|
||||||
|
} else {
|
||||||
|
verb = VERB_SYNONYMS[head]
|
||||||
|
rest = tokens.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verb) {
|
||||||
|
return { kind: 'unknown', raw: trimmed, reason: 'unknown-verb' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rest.length === 0) {
|
||||||
|
if (VERB_ONLY_VERBS.has(verb)) {
|
||||||
|
return { kind: 'verb-only', verb: verb as 'look' | 'inventory' | 'wait' }
|
||||||
|
}
|
||||||
|
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' }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user