/** * Prereq DSL parser + evaluator + formatter (D-01..D-04). * * Three-layer module: * 1. parse() — turns a prereq string into an AST of Atoms combined with AND/OR. * 2. evaluate() — walks the AST against a CharacterContext. * 3. formatReason() — German user-facing reason for failures. * * UNKNOWN-aggressive: when ANY atom is non-classifiable (deity, spellcasting-tradition, * age, ethnicity, vision/sense, free-text), the whole prereq returns { unknown: true, raw }. * Per RESEARCH.md line 550 and D-03 — when in doubt, ask the user (no hard-block). * * No NestJS, no Prisma, no I/O — pure function module. */ import type { CharacterContext, EvalResult, Proficiency } from './types'; const PROFICIENCY_RANK_ORDER: readonly Proficiency[] = [ 'UNTRAINED', 'TRAINED', 'EXPERT', 'MASTER', 'LEGENDARY', ]; const KNOWN_CLASS_NAMES: ReadonlySet = new Set([ 'Alchemist', 'Barbarian', 'Bard', 'Champion', 'Cleric', 'Druid', 'Fighter', 'Investigator', 'Monk', 'Oracle', 'Ranger', 'Rogue', 'Sorcerer', 'Swashbuckler', 'Witch', 'Wizard', ]); const KNOWN_ANCESTRY_NAMES: ReadonlySet = new Set([ 'Human', 'Elf', 'Dwarf', 'Halfling', 'Gnome', 'Goblin', 'Hobgoblin', 'Leshy', 'Lizardfolk', 'Catfolk', 'Kobold', 'Orc', 'Ratfolk', 'Tengu', 'Tiefling', 'Aasimar', ]); /** Patterns that mark the entire prereq as non-evaluable (D-02). */ const NON_EVALUABLE_PATTERNS: readonly RegExp[] = [ /spellcasting\s+class\s+feature/i, /\bspellcaster\b/i, /(divine|arcane|primal|occult)\s+spells?/i, /\bcantrip\b/i, /worship(?:per|s|ing)?\s+of/i, /follower\s+of/i, /\bdeity\b/i, /at\s+least\s+\d+\s+years?\s+old/i, /\bethnicity\b/i, /low-light\s+vision/i, /\bdarkvision\b/i, /\bscent\b/i, ]; // --------------------------------------------------------------------------- // AST types // --------------------------------------------------------------------------- type Atom = | { kind: 'skill'; skill: string; required: Proficiency } | { kind: 'heritage'; name: string } | { kind: 'class'; name: string } | { kind: 'ancestry'; name: string } | { kind: 'level'; min: number } | { kind: 'feat'; name: string } | { kind: 'unknown'; raw: string }; type Node = | { kind: 'atom'; atom: Atom } | { kind: 'and'; children: Node[] } | { kind: 'or'; children: Node[] }; // --------------------------------------------------------------------------- // Parser // --------------------------------------------------------------------------- const SKILL_RANK_RE = /^(trained|expert|master|legendary)\s+in\s+(.+)$/i; const HERITAGE_RE = /^(.+?)\s+heritage$/i; const LEVEL_RE = /^level\s+(\d+)$/i; function classifyAtom(raw: string): Atom { const trimmed = raw.trim(); if (trimmed === '') { return { kind: 'unknown', raw: trimmed }; } // Skill rank: "Trained in Athletics" const skillMatch = SKILL_RANK_RE.exec(trimmed); if (skillMatch) { const required = skillMatch[1].toUpperCase() as Proficiency; const skillName = skillMatch[2].trim(); return { kind: 'skill', skill: skillName, required }; } // Heritage: "Unbreakable Goblin heritage" const heritageMatch = HERITAGE_RE.exec(trimmed); if (heritageMatch) { return { kind: 'heritage', name: heritageMatch[1].trim() }; } // Level: "level 5" const levelMatch = LEVEL_RE.exec(trimmed); if (levelMatch) { return { kind: 'level', min: Number.parseInt(levelMatch[1], 10) }; } // Class ref (exact match against known class names) if (KNOWN_CLASS_NAMES.has(trimmed)) { return { kind: 'class', name: trimmed }; } // Ancestry ref (exact match against known ancestry names) if (KNOWN_ANCESTRY_NAMES.has(trimmed)) { return { kind: 'ancestry', name: trimmed }; } // Bare capitalized phrase → feat lookup last-resort. // Real PF2e feat names are short (≤4 words), Title Case, and never contain // lowercase function-words like "you", "a", "the", "of", "and", "to" mid-sentence. // This guards against classifying free-text sentences as feat lookups. if (/^[A-Z][A-Za-z' \-]*$/.test(trimmed)) { const words = trimmed.split(/\s+/); const hasFreeTextMarker = /(?:^| )(you|a|the|of|and|to|with|from|by)(?: |$)/i.test( trimmed, ); if (words.length <= 4 && !hasFreeTextMarker) { return { kind: 'feat', name: trimmed }; } } return { kind: 'unknown', raw: trimmed }; } /** * Splits a clause on " or " and "," into OR-disjuncts when the clause looks like * a comma-separated list of skill rank atoms (the common PF2e shape). * Otherwise treats commas as soft AND (rare in real corpus). */ function parseClause(clause: string): Node { const trimmed = clause.trim(); if (trimmed === '') { return { kind: 'atom', atom: { kind: 'unknown', raw: trimmed } }; } // Detect OR-list: presence of ' or ' (case-insensitive, with surrounding spaces). // Real PF2e corpus uses "X, Y, or Z" — split on commas AND on ' or '. const hasOrConnector = / or /i.test(trimmed); if (hasOrConnector) { // Normalize the Oxford-comma form "X, Y, or Z" by collapsing ", or " → " or " // before splitting. Without this, splitting on /,\s*/ first would leave // a stray leading "or " on the last segment. const normalized = trimmed.replace(/,\s*or\s+/gi, ' or '); const parts = normalized .split(/,\s*|\s+or\s+/i) .map((p) => p.trim()) .filter((p) => p.length > 0); if (parts.length === 1) { return { kind: 'atom', atom: classifyAtom(parts[0]) }; } const children = parts.map((p) => ({ kind: 'atom', atom: classifyAtom(p) })); return { kind: 'or', children }; } // Single atom (no or-connector, no comma — or comma but treat single) if (trimmed.includes(',')) { // Comma without "or" — treat as AND of atoms (soft conjunction). const parts = trimmed .split(/,\s*/) .map((p) => p.trim()) .filter((p) => p.length > 0); const children = parts.map((p) => ({ kind: 'atom', atom: classifyAtom(p) })); return { kind: 'and', children }; } return { kind: 'atom', atom: classifyAtom(trimmed) }; } function parsePrereq(input: string): Node { // Top-level split on ';' for AND clauses const clauses = input .split(';') .map((c) => c.trim()) .filter((c) => c.length > 0); if (clauses.length === 0) { return { kind: 'atom', atom: { kind: 'unknown', raw: input } }; } if (clauses.length === 1) { return parseClause(clauses[0]); } return { kind: 'and', children: clauses.map((c) => parseClause(c)) }; } // --------------------------------------------------------------------------- // Evaluator // --------------------------------------------------------------------------- function rankAtLeast(have: Proficiency, need: Proficiency): boolean { return PROFICIENCY_RANK_ORDER.indexOf(have) >= PROFICIENCY_RANK_ORDER.indexOf(need); } function rankToGerman(rank: Proficiency): string { // German proficiency labels — keep canonical English ranks but quote them as identifiers. const label: Record = { UNTRAINED: 'Untrained', TRAINED: 'Trained', EXPERT: 'Expert', MASTER: 'Master', LEGENDARY: 'Legendary', }; return label[rank]; } type AtomResult = | { ok: true } | { ok: false; reason: string } | { unknown: true; raw: string }; function evaluateAtom(atom: Atom, ctx: CharacterContext): AtomResult { switch (atom.kind) { case 'unknown': return { unknown: true, raw: atom.raw }; case 'skill': { const have = ctx.skills[atom.skill] ?? 'UNTRAINED'; if (rankAtLeast(have, atom.required)) return { ok: true }; return { ok: false, reason: `Du benötigst mindestens '${rankToGerman(atom.required)}' in ${atom.skill}`, }; } case 'heritage': if (ctx.heritageName === atom.name) return { ok: true }; return { ok: false, reason: `Voraussetzung nicht erfüllt: Abstammungsmerkmal '${atom.name}'`, }; case 'class': if (ctx.className === atom.name) return { ok: true }; return { ok: false, reason: `Voraussetzung nicht erfüllt: Klasse '${atom.name}'`, }; case 'ancestry': if (ctx.ancestryName === atom.name) return { ok: true }; return { ok: false, reason: `Voraussetzung nicht erfüllt: Volk '${atom.name}'`, }; case 'level': if (ctx.level >= atom.min) return { ok: true }; return { ok: false, reason: `Du benötigst mindestens Stufe ${atom.min}`, }; case 'feat': if (ctx.feats.has(atom.name)) return { ok: true }; return { ok: false, reason: `Dir fehlt das Talent: ${atom.name}`, }; } } type NodeResult = | { ok: true } | { ok: false; reason: string } | { unknown: true; raw: string }; function isUnknown(r: NodeResult): r is { unknown: true; raw: string } { return 'unknown' in r; } function isOk(r: NodeResult): r is { ok: true } { return 'ok' in r && r.ok === true; } function isFail(r: NodeResult): r is { ok: false; reason: string } { return 'ok' in r && r.ok === false; } function evaluateNode(node: Node, ctx: CharacterContext, raw: string): NodeResult { if (node.kind === 'atom') { return evaluateAtom(node.atom, ctx); } if (node.kind === 'or') { let firstFailReason: string | null = null; for (const child of node.children) { const r = evaluateNode(child, ctx, raw); if (isUnknown(r)) { // UNKNOWN-aggressive: any unknown atom poisons the whole prereq. return { unknown: true, raw }; } if (isOk(r)) return { ok: true }; if (firstFailReason === null && isFail(r)) { firstFailReason = r.reason; } } return { ok: false, reason: firstFailReason ?? `Voraussetzung nicht erfüllt: ${raw}`, }; } // AND for (const child of node.children) { const r = evaluateNode(child, ctx, raw); if (isUnknown(r)) { return { unknown: true, raw }; } if (isFail(r)) return r; } return { ok: true }; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Evaluate a PF2e prerequisite string against a character context. * Returns one of: * - { ok: true } — prereq is met (or empty/null) * - { ok: false, reason } — evaluable AND failed (German reason) * - { unknown: true, raw } — non-evaluable (deity, spellcasting, etc.) */ export function evaluatePrereq( prereqString: string | null, ctx: CharacterContext, ): EvalResult { if (prereqString === null) return { ok: true }; const trimmed = prereqString.trim(); if (trimmed === '') return { ok: true }; // Quick reject — if any non-evaluable pattern matches anywhere, return unknown. if (NON_EVALUABLE_PATTERNS.some((rx) => rx.test(trimmed))) { return { unknown: true, raw: trimmed }; } const tree = parsePrereq(trimmed); return evaluateNode(tree, ctx, trimmed); } /** Internal parser exposed for testing/debugging. */ export { parsePrereq };