feat(01-02): implement prereq-evaluator (D-01..D-04)

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: '<name> 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.
This commit is contained in:
2026-04-27 14:09:43 +02:00
parent 66d9d5cc0a
commit da82d9bf82

View File

@@ -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<string> = new Set([
'Alchemist',
'Barbarian',
'Bard',
'Champion',
'Cleric',
'Druid',
'Fighter',
'Investigator',
'Monk',
'Oracle',
'Ranger',
'Rogue',
'Sorcerer',
'Swashbuckler',
'Witch',
'Wizard',
]);
const KNOWN_ANCESTRY_NAMES: ReadonlySet<string> = 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<Node>((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<Node>((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<Proficiency, string> = {
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 };