diff --git a/src/engine/parser.test.ts b/src/engine/parser.test.ts new file mode 100644 index 0000000..5cba6e8 --- /dev/null +++ b/src/engine/parser.test.ts @@ -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', + }) + }) +}) diff --git a/src/engine/parser.ts b/src/engine/parser.ts new file mode 100644 index 0000000..9a96fd7 --- /dev/null +++ b/src/engine/parser.ts @@ -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 = { + // 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 = { + 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 = { + restart: 'restart', + undo: 'undo', + hint: 'hint', + save: 'save', + quit: 'quit', + theme: 'theme', +} + +/** Verbs that legally take no target. */ +const VERB_ONLY_VERBS = new Set(['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' } +}