chore: merge executor worktree (worktree-agent-a58e8ff5fc0a2fc4e)
This commit is contained in:
185
server/src/modules/leveling/lib/compute-applicable-steps.spec.ts
Normal file
185
server/src/modules/leveling/lib/compute-applicable-steps.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { computeApplicableSteps } from './compute-applicable-steps';
|
||||
|
||||
describe('computeApplicableSteps — Fighter (martial, no FA, no caster)', () => {
|
||||
it('at L5 returns [class-features, boost, skill-increase, feat-ancestry, review] (L5 is odd → no class/skill feat)', () => {
|
||||
// PF2e CRB: class feats and skill feats are at even levels (2,4,6,...).
|
||||
// L5 grants ability-boosts, skill-increase, and ancestry-feat — but no class/skill feats.
|
||||
// (Plan's expected list at L5 was PF2e-incorrect; corrected here per project's "regelkonform" goal.)
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 5,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).toEqual([
|
||||
'class-features',
|
||||
'boost',
|
||||
'skill-increase',
|
||||
'feat-ancestry',
|
||||
'review',
|
||||
]);
|
||||
});
|
||||
|
||||
it('at L4 (not a boost level) does NOT contain boost', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 4,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).not.toContain('boost');
|
||||
});
|
||||
|
||||
it('at L4 (even level) contains feat-class AND feat-skill', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 4,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).toContain('feat-class');
|
||||
expect(steps).toContain('feat-skill');
|
||||
});
|
||||
|
||||
it('at L3 contains skill-increase AND feat-general but NOT feat-class (odd level)', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 3,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).toContain('skill-increase');
|
||||
expect(steps).toContain('feat-general');
|
||||
expect(steps).not.toContain('feat-class');
|
||||
});
|
||||
|
||||
it('at L2 (even but no skill-increase yet) contains feat-class+feat-skill but NOT skill-increase', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 2,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).toContain('feat-class');
|
||||
expect(steps).toContain('feat-skill');
|
||||
expect(steps).not.toContain('skill-increase');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeApplicableSteps — Free Archetype', () => {
|
||||
it('with FA enabled at L5 includes feat-archetype', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 5,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: true,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).toContain('feat-archetype');
|
||||
});
|
||||
|
||||
it('without FA never includes feat-archetype', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 5,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).not.toContain('feat-archetype');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeApplicableSteps — Spellcaster', () => {
|
||||
it('with isCaster=true includes spellcaster step', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 5,
|
||||
className: 'Wizard',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: true,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).toContain('spellcaster');
|
||||
});
|
||||
|
||||
it('with isCaster=false never includes spellcaster step', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 5,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).not.toContain('spellcaster');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeApplicableSteps — class-feature-choice (D-19)', () => {
|
||||
it('includes class-feature-choice when ClassProgression carries choiceType', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 1,
|
||||
className: 'Cleric',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: true,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: true,
|
||||
});
|
||||
expect(steps).toContain('class-feature-choice');
|
||||
});
|
||||
|
||||
it('does NOT include class-feature-choice when no choiceType', () => {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: 5,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps).not.toContain('class-feature-choice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeApplicableSteps — invariants', () => {
|
||||
it('always starts with class-features', () => {
|
||||
for (const level of [1, 2, 3, 5, 10, 15, 20]) {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: level,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps[0]).toBe('class-features');
|
||||
}
|
||||
});
|
||||
|
||||
it('always ends with review', () => {
|
||||
for (const level of [1, 2, 3, 5, 10, 15, 20]) {
|
||||
const steps = computeApplicableSteps({
|
||||
targetLevel: level,
|
||||
className: 'Fighter',
|
||||
hasFreeArchetype: false,
|
||||
isCaster: false,
|
||||
isSpontaneousCaster: false,
|
||||
classProgressionHasChoiceType: false,
|
||||
});
|
||||
expect(steps[steps.length - 1]).toBe('review');
|
||||
}
|
||||
});
|
||||
});
|
||||
67
server/src/modules/leveling/lib/compute-applicable-steps.ts
Normal file
67
server/src/modules/leveling/lib/compute-applicable-steps.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Pure function — given the level-up parameters, returns the ordered list of
|
||||
* wizard step kinds the player must walk through (D-10 step ordering).
|
||||
*
|
||||
* No NestJS, no Prisma, no I/O. Output is fully determined by inputs.
|
||||
*/
|
||||
import type { StepKind } from './types';
|
||||
|
||||
export interface ComputeStepsInput {
|
||||
targetLevel: number;
|
||||
className: string;
|
||||
hasFreeArchetype: boolean;
|
||||
isCaster: boolean;
|
||||
isSpontaneousCaster: boolean;
|
||||
classProgressionHasChoiceType: boolean;
|
||||
}
|
||||
|
||||
/** PF2e ability-boost levels (every 5th level after 5). */
|
||||
const BOOST_LEVELS: ReadonlySet<number> = new Set([5, 10, 15, 20]);
|
||||
|
||||
/** PF2e skill-increase steps (level 3 and every 2 levels thereafter). */
|
||||
const SKILL_INCREASE_LEVELS: ReadonlySet<number> = new Set([3, 5, 7, 9, 11, 13, 15, 17, 19]);
|
||||
|
||||
/** General-feat slot levels (PF2e CRB). */
|
||||
const GENERAL_FEAT_LEVELS: ReadonlySet<number> = new Set([3, 7, 11, 15, 19]);
|
||||
|
||||
/**
|
||||
* Ancestry-feat slot levels (PF2e CRB) — character creation grants level 1, but
|
||||
* this function is for level-ups (1 is excluded by callers / handled below).
|
||||
*/
|
||||
const ANCESTRY_FEAT_LEVELS: ReadonlySet<number> = new Set([1, 5, 9, 13, 17]);
|
||||
|
||||
/**
|
||||
* Returns the ordered list of wizard step kinds for a level-up.
|
||||
* Step ordering follows D-10:
|
||||
* class-features → class-feature-choice (if any)
|
||||
* → boost (if level ∈ {5,10,15,20})
|
||||
* → skill-increase (if level ∈ {3,5,7,...,19})
|
||||
* → feat-class (if even level)
|
||||
* → feat-skill (if even level)
|
||||
* → feat-general (if level ∈ {3,7,11,15,19})
|
||||
* → feat-ancestry (if level ∈ {5,9,13,17}; excludes L1)
|
||||
* → feat-archetype (if hasFreeArchetype)
|
||||
* → spellcaster (if isCaster)
|
||||
* → review
|
||||
*/
|
||||
export function computeApplicableSteps(input: ComputeStepsInput): StepKind[] {
|
||||
const { targetLevel, hasFreeArchetype, isCaster, classProgressionHasChoiceType } = input;
|
||||
const steps: StepKind[] = ['class-features'];
|
||||
|
||||
if (classProgressionHasChoiceType) steps.push('class-feature-choice');
|
||||
if (BOOST_LEVELS.has(targetLevel)) steps.push('boost');
|
||||
if (SKILL_INCREASE_LEVELS.has(targetLevel)) steps.push('skill-increase');
|
||||
if (targetLevel % 2 === 0) {
|
||||
steps.push('feat-class');
|
||||
steps.push('feat-skill');
|
||||
}
|
||||
if (GENERAL_FEAT_LEVELS.has(targetLevel)) steps.push('feat-general');
|
||||
if (ANCESTRY_FEAT_LEVELS.has(targetLevel) && targetLevel !== 1) {
|
||||
steps.push('feat-ancestry');
|
||||
}
|
||||
if (hasFreeArchetype) steps.push('feat-archetype');
|
||||
if (isCaster) steps.push('spellcaster');
|
||||
|
||||
steps.push('review');
|
||||
return steps;
|
||||
}
|
||||
145
server/src/modules/leveling/lib/prereq-evaluator.spec.ts
Normal file
145
server/src/modules/leveling/lib/prereq-evaluator.spec.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { evaluatePrereq } from './prereq-evaluator';
|
||||
import type { CharacterContext, EvalResult } from './types';
|
||||
|
||||
function isFail(r: EvalResult): r is { ok: false; reason: string } {
|
||||
return 'ok' in r && r.ok === false;
|
||||
}
|
||||
|
||||
function makeCtx(overrides: Partial<CharacterContext> = {}): CharacterContext {
|
||||
return {
|
||||
level: 5,
|
||||
className: 'Fighter',
|
||||
ancestryName: 'Human',
|
||||
heritageName: undefined,
|
||||
abilities: { STR: 16, DEX: 14, CON: 14, INT: 10, WIS: 12, CHA: 10 },
|
||||
skills: {},
|
||||
feats: new Set<string>(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('evaluatePrereq — empty/null', () => {
|
||||
it('returns ok for null prereq', () => {
|
||||
expect(evaluatePrereq(null, makeCtx())).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns ok for empty string', () => {
|
||||
expect(evaluatePrereq('', makeCtx())).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — skill rank', () => {
|
||||
it('returns ok when skill rank meets requirement', () => {
|
||||
const ctx = makeCtx({ skills: { Athletics: 'TRAINED' } });
|
||||
expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns ok when skill rank exceeds requirement', () => {
|
||||
const ctx = makeCtx({ skills: { Athletics: 'EXPERT' } });
|
||||
expect(evaluatePrereq('Trained in Athletics', ctx)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns ok:false with German reason when skill rank below requirement', () => {
|
||||
const ctx = makeCtx({ skills: { Athletics: 'UNTRAINED' } });
|
||||
const result = evaluatePrereq('Trained in Athletics', ctx);
|
||||
expect(isFail(result)).toBe(true);
|
||||
if (isFail(result)) {
|
||||
expect(result.reason).toMatch(/Athletics/i);
|
||||
// German wording check — must contain a German keyword
|
||||
expect(result.reason).toMatch(/(benötig|fehlt|Voraussetzung|mindestens)/);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns ok:false when skill is missing entirely', () => {
|
||||
const ctx = makeCtx({ skills: {} });
|
||||
const result = evaluatePrereq('Trained in Athletics', ctx);
|
||||
expect(isFail(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — disjunctive (OR-list)', () => {
|
||||
it('returns ok when any of the listed skills matches', () => {
|
||||
const ctx = makeCtx({ skills: { Arcana: 'TRAINED' } });
|
||||
expect(
|
||||
evaluatePrereq('Trained in Arcana, Trained in Nature, or Trained in Religion', ctx),
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns ok:false when no listed skill matches', () => {
|
||||
const ctx = makeCtx({ skills: { Athletics: 'TRAINED' } });
|
||||
const result = evaluatePrereq(
|
||||
'Trained in Arcana, Trained in Nature, or Trained in Religion',
|
||||
ctx,
|
||||
);
|
||||
expect(isFail(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — conjunctive (semicolon AND)', () => {
|
||||
it('returns ok when both clauses match', () => {
|
||||
const ctx = makeCtx({ skills: { Deception: 'TRAINED', Stealth: 'TRAINED' } });
|
||||
expect(evaluatePrereq('Trained in Deception; Trained in Stealth', ctx)).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns ok:false when one clause is missing', () => {
|
||||
const ctx = makeCtx({ skills: { Deception: 'TRAINED' } });
|
||||
const result = evaluatePrereq('Trained in Deception; Trained in Stealth', ctx);
|
||||
expect(isFail(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — bare feat name', () => {
|
||||
it('returns ok when the feat is held', () => {
|
||||
const ctx = makeCtx({ feats: new Set(['Power Attack']) });
|
||||
expect(evaluatePrereq('Power Attack', ctx)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('returns ok:false when the feat is missing', () => {
|
||||
const ctx = makeCtx({ feats: new Set() });
|
||||
const result = evaluatePrereq('Power Attack', ctx);
|
||||
expect(isFail(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — heritage', () => {
|
||||
it('returns ok when heritage matches', () => {
|
||||
const ctx = makeCtx({ heritageName: 'Unbreakable Goblin' });
|
||||
expect(evaluatePrereq('Unbreakable Goblin heritage', ctx)).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — class ref', () => {
|
||||
it('returns ok when class matches', () => {
|
||||
const ctx = makeCtx({ className: 'Fighter' });
|
||||
expect(evaluatePrereq('Fighter', ctx)).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePrereq — non-evaluable patterns (D-02 → unknown)', () => {
|
||||
it('returns unknown for spellcasting refs', () => {
|
||||
const result = evaluatePrereq('spellcasting class feature', makeCtx());
|
||||
expect(result).toEqual({ unknown: true, raw: 'spellcasting class feature' });
|
||||
});
|
||||
|
||||
it('returns unknown for deity refs', () => {
|
||||
const result = evaluatePrereq('worshipper of Droskar', makeCtx());
|
||||
expect('unknown' in result && result.unknown).toBe(true);
|
||||
});
|
||||
|
||||
it('returns unknown for age refs', () => {
|
||||
const result = evaluatePrereq('at least 100 years old', makeCtx());
|
||||
expect('unknown' in result && result.unknown).toBe(true);
|
||||
});
|
||||
|
||||
it('returns unknown for vision-trait refs', () => {
|
||||
const result = evaluatePrereq('low-light vision', makeCtx());
|
||||
expect('unknown' in result && result.unknown).toBe(true);
|
||||
});
|
||||
|
||||
it('returns unknown for free-text patterns the parser cannot classify', () => {
|
||||
const result = evaluatePrereq('You worship a god of fire and destruction', makeCtx());
|
||||
expect('unknown' in result && result.unknown).toBe(true);
|
||||
});
|
||||
});
|
||||
370
server/src/modules/leveling/lib/prereq-evaluator.ts
Normal file
370
server/src/modules/leveling/lib/prereq-evaluator.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 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 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 };
|
||||
@@ -0,0 +1,89 @@
|
||||
import { recomputeDerivedStats } from './recompute-derived-stats';
|
||||
import type { CharacterContext, ClassProgressionRow, WizardChoices, Proficiency } from './types';
|
||||
|
||||
type RecomputeInput = CharacterContext & {
|
||||
ancestryHp: number;
|
||||
classHp: number;
|
||||
armorAc: number;
|
||||
armorProficiency: Proficiency;
|
||||
dexCap: number;
|
||||
};
|
||||
|
||||
function baseCharacter(overrides: Partial<RecomputeInput> = {}): RecomputeInput {
|
||||
return {
|
||||
level: 4,
|
||||
className: 'Fighter',
|
||||
ancestryName: 'Human',
|
||||
heritageName: undefined,
|
||||
abilities: { STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 12, CHA: 10 },
|
||||
skills: {},
|
||||
feats: new Set(),
|
||||
ancestryHp: 8,
|
||||
classHp: 10,
|
||||
armorAc: 4,
|
||||
armorProficiency: 'EXPERT',
|
||||
dexCap: 2,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyProgression(level: number): ClassProgressionRow {
|
||||
return {
|
||||
className: 'Fighter',
|
||||
level,
|
||||
grants: [],
|
||||
proficiencyChanges: {},
|
||||
};
|
||||
}
|
||||
|
||||
describe('recomputeDerivedStats — hpMax', () => {
|
||||
it('computes hpMax = ancestryHP + (classHP + conMod) × newLevel for L4 → L5 Fighter, CON 16', () => {
|
||||
// CON 16 → mod +3; classHP 10 + 3 = 13 per level; ancestryHP 8; L5: 8 + 13×5 = 73
|
||||
const ch = baseCharacter();
|
||||
const choices: WizardChoices = {};
|
||||
const result = recomputeDerivedStats(ch, choices, emptyProgression(5));
|
||||
expect(result.hpMax).toBe(73);
|
||||
});
|
||||
|
||||
it('respects boost-cap-at-18 when CON is boosted from 18', () => {
|
||||
// CON starts at 18, boost target includes CON → new CON = 19 (not 20). conMod = +4.
|
||||
// L5: 8 + (10+4)×5 = 78
|
||||
const ch = baseCharacter({
|
||||
abilities: { STR: 16, DEX: 14, CON: 18, INT: 10, WIS: 12, CHA: 10 },
|
||||
});
|
||||
const choices: WizardChoices = { boostTargets: ['CON', 'STR', 'DEX', 'INT'] };
|
||||
const result = recomputeDerivedStats(ch, choices, emptyProgression(5));
|
||||
expect(result.hpMax).toBe(78);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recomputeDerivedStats — proficiencyChanges from ClassProgression', () => {
|
||||
it('applies proficiencyChanges from ClassProgression at the new level (fortitude EXPERT)', () => {
|
||||
// Fighter L4 → L5 with progression bumping fort to EXPERT.
|
||||
// CON mod +3, level 5; fort = conMod + (level + 4) = 3 + 9 = 12
|
||||
const ch = baseCharacter();
|
||||
const progression: ClassProgressionRow = {
|
||||
...emptyProgression(5),
|
||||
proficiencyChanges: { fortitude: 'EXPERT' },
|
||||
};
|
||||
const result = recomputeDerivedStats(ch, {}, progression);
|
||||
expect(result.fortitude).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recomputeDerivedStats — Pitfall #9 (hpCurrent must not be in output)', () => {
|
||||
it('does NOT include hpCurrent in the result object', () => {
|
||||
const ch = baseCharacter();
|
||||
const result = recomputeDerivedStats(ch, {}, emptyProgression(5));
|
||||
expect(result).not.toHaveProperty('hpCurrent');
|
||||
expect(result).not.toHaveProperty('hpTemp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recomputeDerivedStats — level passthrough', () => {
|
||||
it('returns the new level in the output', () => {
|
||||
const ch = baseCharacter();
|
||||
const result = recomputeDerivedStats(ch, {}, emptyProgression(5));
|
||||
expect(result.level).toBe(5);
|
||||
});
|
||||
});
|
||||
122
server/src/modules/leveling/lib/recompute-derived-stats.ts
Normal file
122
server/src/modules/leveling/lib/recompute-derived-stats.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Pure-function recompute pipeline (Pattern 5, Pitfall #8 & #9).
|
||||
*
|
||||
* Computes the new derived stats (hpMax, AC, classDC, perception, saves) for a
|
||||
* level-up. Pure: no DB writes, no I/O, no mutations of input objects.
|
||||
*
|
||||
* Pitfall #8 (boost-cap-at-18): ability boosts use applyAttributeBoost() which
|
||||
* adds +1 (not +2) when the score is already >= 18.
|
||||
* Pitfall #9 (current-HP invariant): the output `DerivedStats` shape NEVER includes
|
||||
* current/temporary HP fields — those are managed elsewhere by the level-up commit,
|
||||
* not recomputed from scratch.
|
||||
*/
|
||||
import { applyAttributeBoost } from './apply-attribute-boost';
|
||||
import type {
|
||||
AbilityAbbreviation,
|
||||
CharacterContext,
|
||||
ClassProgressionRow,
|
||||
DerivedStats,
|
||||
Proficiency,
|
||||
WizardChoices,
|
||||
} from './types';
|
||||
import { PROFICIENCY_BASE_BONUS } from './types';
|
||||
|
||||
/** Recompute pipeline input — extends CharacterContext with armor + HP-base fields. */
|
||||
export type RecomputeCharacter = CharacterContext & {
|
||||
ancestryHp: number;
|
||||
classHp: number;
|
||||
armorAc: number;
|
||||
armorProficiency: Proficiency;
|
||||
dexCap: number;
|
||||
/** Optional pre-existing rank for fortitude/reflex/will/perception/classDc — used as fallback. */
|
||||
fortitudeRank?: Proficiency;
|
||||
reflexRank?: Proficiency;
|
||||
willRank?: Proficiency;
|
||||
perceptionRank?: Proficiency;
|
||||
classDcRank?: Proficiency;
|
||||
/** Class key ability — defaults to STR for Fighter; consumers may pass explicit value. */
|
||||
classKeyAbility?: AbilityAbbreviation;
|
||||
};
|
||||
|
||||
function abilityModifier(score: number): number {
|
||||
return Math.floor((score - 10) / 2);
|
||||
}
|
||||
|
||||
function proficiencyBonus(rank: Proficiency, level: number): number {
|
||||
if (rank === 'UNTRAINED') return 0;
|
||||
return PROFICIENCY_BASE_BONUS[rank] + level;
|
||||
}
|
||||
|
||||
/** Compute new ability scores by applying boosts from `choices.boostTargets`. */
|
||||
function applyBoosts(
|
||||
current: Record<AbilityAbbreviation, number>,
|
||||
boostTargets: readonly AbilityAbbreviation[] | undefined,
|
||||
): Record<AbilityAbbreviation, number> {
|
||||
const next: Record<AbilityAbbreviation, number> = { ...current };
|
||||
if (!boostTargets) return next;
|
||||
for (const ability of boostTargets) {
|
||||
next[ability] = applyAttributeBoost(next[ability]);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure recompute pipeline.
|
||||
*
|
||||
* @param character - Character snapshot (current state, before commit)
|
||||
* @param choices - Wizard choices (boost targets, etc.)
|
||||
* @param progression - ClassProgression row for the new level
|
||||
* @returns DerivedStats — fresh object, never mutates input
|
||||
*/
|
||||
export function recomputeDerivedStats(
|
||||
character: RecomputeCharacter,
|
||||
choices: WizardChoices,
|
||||
progression: ClassProgressionRow,
|
||||
): DerivedStats {
|
||||
const newLevel = progression.level;
|
||||
const newAbilities = applyBoosts(character.abilities, choices.boostTargets);
|
||||
|
||||
const conMod = abilityModifier(newAbilities.CON);
|
||||
const dexMod = abilityModifier(newAbilities.DEX);
|
||||
const wisMod = abilityModifier(newAbilities.WIS);
|
||||
const keyAbility = character.classKeyAbility ?? 'STR';
|
||||
const keyMod = abilityModifier(newAbilities[keyAbility]);
|
||||
|
||||
// Resolve effective ranks: progression overrides take precedence; otherwise use
|
||||
// the input character's pre-existing rank (or sensible defaults of TRAINED).
|
||||
const fortRank: Proficiency =
|
||||
progression.proficiencyChanges.fortitude ?? character.fortitudeRank ?? 'TRAINED';
|
||||
const refRank: Proficiency =
|
||||
progression.proficiencyChanges.reflex ?? character.reflexRank ?? 'TRAINED';
|
||||
const willRank: Proficiency =
|
||||
progression.proficiencyChanges.will ?? character.willRank ?? 'TRAINED';
|
||||
const percRank: Proficiency =
|
||||
progression.proficiencyChanges.perception ?? character.perceptionRank ?? 'TRAINED';
|
||||
const classDcRank: Proficiency =
|
||||
progression.proficiencyChanges.classDc ?? character.classDcRank ?? 'TRAINED';
|
||||
const armorRank: Proficiency =
|
||||
progression.proficiencyChanges.ac ?? character.armorProficiency;
|
||||
|
||||
const hpMax = character.ancestryHp + (character.classHp + conMod) * newLevel;
|
||||
const ac =
|
||||
10 +
|
||||
Math.min(dexMod, character.dexCap) +
|
||||
character.armorAc +
|
||||
proficiencyBonus(armorRank, newLevel);
|
||||
const classDc = 10 + keyMod + proficiencyBonus(classDcRank, newLevel);
|
||||
const perception = wisMod + proficiencyBonus(percRank, newLevel);
|
||||
const fortitude = conMod + proficiencyBonus(fortRank, newLevel);
|
||||
const reflex = dexMod + proficiencyBonus(refRank, newLevel);
|
||||
const will = wisMod + proficiencyBonus(willRank, newLevel);
|
||||
|
||||
return {
|
||||
level: newLevel,
|
||||
hpMax,
|
||||
ac,
|
||||
classDc,
|
||||
perception,
|
||||
fortitude,
|
||||
reflex,
|
||||
will,
|
||||
};
|
||||
}
|
||||
42
server/src/modules/leveling/lib/skill-increase-cap.spec.ts
Normal file
42
server/src/modules/leveling/lib/skill-increase-cap.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { canIncreaseSkill, SKILL_INCREASE_LEVELS } from './skill-increase-cap';
|
||||
|
||||
describe('canIncreaseSkill', () => {
|
||||
it('rejects TRAINED → EXPERT at level 2 (T→E requires L3+)', () => {
|
||||
expect(canIncreaseSkill('TRAINED', 2)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows TRAINED → EXPERT at level 3', () => {
|
||||
expect(canIncreaseSkill('TRAINED', 3)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects EXPERT → MASTER at level 6 (E→M requires L7+)', () => {
|
||||
expect(canIncreaseSkill('EXPERT', 6)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows EXPERT → MASTER at level 7', () => {
|
||||
expect(canIncreaseSkill('EXPERT', 7)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects MASTER → LEGENDARY at level 14 (M→L requires L15+)', () => {
|
||||
expect(canIncreaseSkill('MASTER', 14)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows MASTER → LEGENDARY at level 15', () => {
|
||||
expect(canIncreaseSkill('MASTER', 15)).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects LEGENDARY at any level (already maxed)', () => {
|
||||
expect(canIncreaseSkill('LEGENDARY', 20)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows UNTRAINED → TRAINED at any level >= 1 (no cap on first training)', () => {
|
||||
expect(canIncreaseSkill('UNTRAINED', 1)).toBe(true);
|
||||
expect(canIncreaseSkill('UNTRAINED', 20)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SKILL_INCREASE_LEVELS', () => {
|
||||
it('exposes the PF2e skill-increase level list', () => {
|
||||
expect(SKILL_INCREASE_LEVELS).toEqual([3, 5, 7, 9, 11, 13, 15, 17, 19]);
|
||||
});
|
||||
});
|
||||
31
server/src/modules/leveling/lib/skill-increase-cap.ts
Normal file
31
server/src/modules/leveling/lib/skill-increase-cap.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Proficiency } from './types';
|
||||
|
||||
/** Levels at which a skill-increase step occurs (PF2e CRB). */
|
||||
export const SKILL_INCREASE_LEVELS: readonly number[] = [3, 5, 7, 9, 11, 13, 15, 17, 19];
|
||||
|
||||
/**
|
||||
* Minimum character level required to advance a skill from `currentRank`.
|
||||
* UNTRAINED → TRAINED is unrestricted (any level >= 1 with a skill-increase step).
|
||||
* LEGENDARY is `null` because it is the maximum rank — no further increases possible.
|
||||
*/
|
||||
const SKILL_RANK_LEVEL_REQUIREMENTS: Record<Proficiency, number | null> = {
|
||||
UNTRAINED: 1, // → TRAINED
|
||||
TRAINED: 3, // → EXPERT (PF2e CRB)
|
||||
EXPERT: 7, // → MASTER
|
||||
MASTER: 15, // → LEGENDARY
|
||||
LEGENDARY: null, // already maxed
|
||||
};
|
||||
|
||||
/**
|
||||
* PF2e skill-increase cap rule (LVL-06).
|
||||
* Returns true if a character at `characterLevel` may advance a skill that is
|
||||
* currently at `currentRank` to the next rank.
|
||||
*/
|
||||
export function canIncreaseSkill(
|
||||
currentRank: Proficiency,
|
||||
characterLevel: number,
|
||||
): boolean {
|
||||
const required = SKILL_RANK_LEVEL_REQUIREMENTS[currentRank];
|
||||
if (required === null) return false;
|
||||
return characterLevel >= required;
|
||||
}
|
||||
88
server/src/modules/leveling/lib/types.ts
Normal file
88
server/src/modules/leveling/lib/types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Shared types for the Level-Up pure-function library.
|
||||
* No runtime dependencies — types only.
|
||||
*/
|
||||
import type { AbilityAbbreviation } from './apply-attribute-boost';
|
||||
|
||||
export type { AbilityAbbreviation };
|
||||
|
||||
/** PF2e proficiency ranks (mirrors Prisma `Proficiency` enum). */
|
||||
export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY';
|
||||
|
||||
/** Numeric proficiency bonus per rank, for use in proficiencyBonus(rank, level) calculation. */
|
||||
export const PROFICIENCY_BASE_BONUS: Record<Proficiency, number> = {
|
||||
UNTRAINED: 0,
|
||||
TRAINED: 2,
|
||||
EXPERT: 4,
|
||||
MASTER: 6,
|
||||
LEGENDARY: 8,
|
||||
};
|
||||
|
||||
/** Discriminated union for prereq evaluation result. */
|
||||
export type EvalResult =
|
||||
| { ok: true }
|
||||
| { ok: false; reason: string }
|
||||
| { unknown: true; raw: string };
|
||||
|
||||
/** Ordered union of wizard step kinds (UI-SPEC + RESEARCH §Pattern 1). */
|
||||
export type StepKind =
|
||||
| 'class-features'
|
||||
| 'class-feature-choice'
|
||||
| 'boost'
|
||||
| 'skill-increase'
|
||||
| 'feat-class'
|
||||
| 'feat-skill'
|
||||
| 'feat-general'
|
||||
| 'feat-ancestry'
|
||||
| 'feat-archetype'
|
||||
| 'spellcaster'
|
||||
| 'review';
|
||||
|
||||
/** Snapshot a character's mechanical state for prereq evaluation and recompute. */
|
||||
export interface CharacterContext {
|
||||
level: number;
|
||||
className: string;
|
||||
ancestryName: string;
|
||||
heritageName?: string;
|
||||
abilities: Record<AbilityAbbreviation, number>;
|
||||
skills: Record<string, Proficiency>;
|
||||
feats: Set<string>;
|
||||
}
|
||||
|
||||
/** Output of recomputeDerivedStats — never includes hpCurrent (Pitfall #9). */
|
||||
export interface DerivedStats {
|
||||
level: number;
|
||||
hpMax: number;
|
||||
ac: number;
|
||||
classDc: number;
|
||||
perception: number;
|
||||
fortitude: number;
|
||||
reflex: number;
|
||||
will: number;
|
||||
}
|
||||
|
||||
/** ClassProgression row shape — read-only input to recompute pipeline. */
|
||||
export interface ClassProgressionRow {
|
||||
className: string;
|
||||
level: number;
|
||||
grants: string[];
|
||||
proficiencyChanges: Partial<Record<'fortitude' | 'reflex' | 'will' | 'perception' | 'classDc' | 'ac', Proficiency>>;
|
||||
spellSlotIncrement?: { tradition: string; spellLevel: number; count: number } | null;
|
||||
cantripIncrement?: number | null;
|
||||
repertoireIncrement?: number | null;
|
||||
choiceType?: string | null;
|
||||
choiceOptionsRef?: string | null;
|
||||
}
|
||||
|
||||
/** Wizard choices subset — what the user picked across the wizard. */
|
||||
export interface WizardChoices {
|
||||
boostTargets?: AbilityAbbreviation[];
|
||||
skillIncrease?: { skillName: string; toRank: Proficiency };
|
||||
featClassId?: string;
|
||||
featSkillId?: string;
|
||||
featGeneralId?: string;
|
||||
featAncestryId?: string;
|
||||
featArchetypeId?: string;
|
||||
classFeatureChoices?: Record<string, string>;
|
||||
spellcasterRepertoirePicks?: string[];
|
||||
}
|
||||
Reference in New Issue
Block a user