From da82d9bf829a8f6c5bf4160e0227015c587a42cf Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:09:43 +0200 Subject: [PATCH] feat(01-02): implement prereq-evaluator (D-01..D-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3 GREEN phase. Three-layer parser + evaluator + German formatter. Evaluable patterns (D-01): - Skill rank: 'Trained in Athletics', 'Expert in Stealth', etc. - Disjunctive OR-list (Oxford comma): 'X, Y, or Z' - Conjunctive AND: 'X; Y' - Bare feat name (Title Case, ≤4 words, no function words) - Heritage: ' heritage' - Class ref + Ancestry ref (against known sets) - Level ref: 'level N' Non-evaluable (D-02 → {unknown, raw}): - Spellcasting tradition refs (spellcasting class feature, divine spells, etc.) - Deity / worship-of refs - Age / ethnicity refs - Vision/sense traits (low-light, darkvision, scent) - Free-text sentences (heuristic: contains 'you', 'a', 'the', 'of', 'and', 'to') UNKNOWN-aggressive: any unknown atom in OR or AND poisons the whole prereq. German failure reasons (D-15): 'Du benötigst...', 'Dir fehlt das Talent...', 'Voraussetzung nicht erfüllt: ...'. 19 tests passing. --- .../modules/leveling/lib/prereq-evaluator.ts | 358 ++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 server/src/modules/leveling/lib/prereq-evaluator.ts diff --git a/server/src/modules/leveling/lib/prereq-evaluator.ts b/server/src/modules/leveling/lib/prereq-evaluator.ts new file mode 100644 index 0000000..9fb21ad --- /dev/null +++ b/server/src/modules/leveling/lib/prereq-evaluator.ts @@ -0,0 +1,358 @@ +/** + * 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 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 ('unknown' in r && r.unknown) { + // UNKNOWN-aggressive: any unknown atom poisons the whole prereq. + return { unknown: true, raw }; + } + if (r.ok) return { ok: true }; + if (firstFailReason === null && !r.ok) { + 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 ('unknown' in r && r.unknown) { + return { unknown: true, raw }; + } + if (!r.ok) 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 };