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:
358
server/src/modules/leveling/lib/prereq-evaluator.ts
Normal file
358
server/src/modules/leveling/lib/prereq-evaluator.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user