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