chore: merge executor worktree (worktree-agent-a58e8ff5fc0a2fc4e)

This commit is contained in:
2026-04-27 14:36:10 +02:00
10 changed files with 1356 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
---
phase: 01-level-up-pf2e-regelkonform
plan: 02
subsystem: testing
tags: [pure-functions, jest, tdd, level-up, prereq-evaluator, recompute, pf2e-rules]
# Dependency graph
requires:
- phase: 01-level-up-pf2e-regelkonform
provides: applyAttributeBoost + AbilityAbbreviation (Plan 01-01) — boost-cap-at-18 helper
provides:
- Five pure-function modules (types.ts + 4 logic modules) covering all PF2e level-up math
- skill-increase-cap (LVL-06) — TRAINED→EXPERT@L3, EXPERT→MASTER@L7, MASTER→LEGENDARY@L15
- prereq-evaluator (D-01..D-04) — DSL parser + evaluator + German formatter, UNKNOWN-aggressive
- recompute-derived-stats (Pitfall #8/#9 safe) — pure pipeline, never outputs current/temp HP fields
- compute-applicable-steps (D-10 ordering) — wizard step list per (level, FA, caster, choiceType)
- 46 passing Jest tests across 4 specs (≥17 prereq, 13 steps, 9 skill, 5 recompute)
- Test discipline pattern: strict RED→GREEN per module, separate commits
affects: [Plan 01-04 LevelingService integration, Plan 01-05 wizard UI preview, Plan 01-03 progression seed]
# Tech tracking
tech-stack:
added: [] # No new dependencies — ts-jest + jest already present
patterns:
- "Pure-function lib (no NestJS, no Prisma, no I/O) under server/src/modules/<feature>/lib/"
- "Discriminated-union return values with explicit type-guard helpers for TS-strict narrowing"
- "Sibling .spec.ts test files using Jest's testRegex auto-discovery"
- "TDD: spec written first (RED commit) → minimal implementation (GREEN commit)"
key-files:
created:
- server/src/modules/leveling/lib/types.ts
- server/src/modules/leveling/lib/skill-increase-cap.ts
- server/src/modules/leveling/lib/skill-increase-cap.spec.ts
- server/src/modules/leveling/lib/prereq-evaluator.ts
- server/src/modules/leveling/lib/prereq-evaluator.spec.ts
- server/src/modules/leveling/lib/recompute-derived-stats.ts
- server/src/modules/leveling/lib/recompute-derived-stats.spec.ts
- server/src/modules/leveling/lib/compute-applicable-steps.ts
- server/src/modules/leveling/lib/compute-applicable-steps.spec.ts
- server/src/modules/leveling/lib/apply-attribute-boost.ts # Rule 3 dependency from Plan 01-01
modified: []
key-decisions:
- "Type-guard helpers (isUnknown, isOk, isFail) preferred over discriminant property access for TS-strict narrowing"
- "OR-list parser uses Oxford-comma normalization (', or ' → ' or ') before tokenizing to avoid leftover 'or' prefix"
- "Feat-name heuristic restricts to ≤4 words and rejects free-text function-words (you/a/the/of/and) to prevent sentence-as-feat misclassification"
- "L5 step list deviates from plan: PF2e CRB has class/skill feats only at EVEN levels; plan's L5 list was rules-incorrect"
- "Boost-cap-at-18 enforced via applyAttributeBoost() import — math is not duplicated in recompute"
- "DerivedStats output object NEVER includes hpCurrent/hpTemp fields — Pitfall #9 enforced by spec assertion AND by absence of those literal strings in production code"
patterns-established:
- "RED commit (failing spec) → GREEN commit (minimal impl) gate sequence per module"
- "UNKNOWN-aggressive prereq evaluation: any non-classifiable atom poisons the whole clause (per D-03)"
- "ClassProgression-driven proficiency overrides with character pre-existing rank as fallback"
- "German user-facing reason strings: 'Du benötigst...', 'Dir fehlt das Talent...', 'Voraussetzung nicht erfüllt: ...'"
requirements-completed: [LVL-02, LVL-06, LVL-09, LVL-10, LVL-01, LVL-13, LVL-14]
# Metrics
duration: ~30min
completed: 2026-04-27
---
# Phase 1 Plan 02: Level-Up Pure-Function Library Summary
**Five pure-function modules (skill-cap, prereq-DSL, recompute, step-ordering, shared types) implemented strict-TDD with 46 passing Jest tests, establishing the test discipline for the Level-Up subsystem and isolating bug-prone PF2e math (Pitfall #8/#9) behind a fully-tested boundary.**
## Tasks
| # | Task | Status | Commits |
|---|------|--------|---------|
| 0 | Add apply-attribute-boost dependency (Rule 3 — Plan 01-01 work not merged into worktree base) | Done | 7e40449 |
| 1 | types.ts — shared type vocabulary | Done | 4d2cb5e |
| 2 | skill-increase-cap (RED→GREEN) | Done | 3a4267d, f189750 |
| 3 | prereq-evaluator (RED→GREEN) | Done | 66d9d5c, da82d9b |
| 4 | recompute-derived-stats (RED→GREEN) | Done | 8dd55b6, 6011024 |
| 5 | compute-applicable-steps (RED→GREEN) | Done | 70ec7bb, de07fc8, d9ed18c |
| 6 | Full leveling test suite gate | Done (46 passing) | (verification only) |
| — | TS-strict narrowing fix for discriminated unions | Done (Rule 1) | be6eaee |
## Test Counts
| Spec File | Tests |
|-----------|-------|
| skill-increase-cap.spec.ts | 9 |
| prereq-evaluator.spec.ts | 19 |
| recompute-derived-stats.spec.ts | 5 |
| compute-applicable-steps.spec.ts | 13 |
| **Total** | **46** |
The plan target of "≥50 tests" assumed Plan 01-01's 9-test apply-attribute-boost.spec.ts would be present (5 specs, ≥50 tests). In this worktree, Plan 01-01 work is not yet merged into the base — only its production module was created here as a Rule 3 dependency. After orchestrator merge, the combined count will reach 55. This plan delivers all of its own 46 tests, which fully cover VALIDATION.md rows 1-W1-06 through 1-W1-29.
Run command:
```
cd server && npm test -- --testPathPatterns=leveling
```
Output:
```
PASS src/modules/leveling/lib/recompute-derived-stats.spec.ts
PASS src/modules/leveling/lib/skill-increase-cap.spec.ts
PASS src/modules/leveling/lib/compute-applicable-steps.spec.ts
PASS src/modules/leveling/lib/prereq-evaluator.spec.ts
Test Suites: 4 passed, 4 total
Tests: 46 passed, 46 total
```
`cd server && npx tsc --noEmit -p tsconfig.json` — exits 0 for the leveling lib (errors elsewhere are pre-existing Prisma-client codegen issues out of scope per Scope Boundary rule).
## Verification Checklist
- [x] All 5 production modules + types.ts created in `server/src/modules/leveling/lib/`
- [x] All 4 spec files created (5th — apply-attribute-boost.spec.ts — owned by Plan 01-01)
- [x] TDD discipline: each module's spec was written first and observed failing (RED commit) before implementation (GREEN commit)
- [x] Pitfall #8 (boost-cap-at-18) enforced — recompute test for CON 18 → 19 (not 20)
- [x] Pitfall #9 (no hpCurrent in output) enforced — `expect(result).not.toHaveProperty('hpCurrent')` + literal string absent from production code
- [x] D-01 evaluable patterns covered: skill rank, OR-list, AND-list, feat name, heritage, class
- [x] D-02 non-evaluable patterns return `{unknown:true}`: spellcasting, deity, age, vision, free-text
- [x] Step ordering matches D-10 (with PF2e correction at L5 — see Deviations)
- [x] Zero `: any` types in production code (only English-prose use of word "any" in a JSDoc comment)
- [x] Zero `@nestjs/` imports in any lib file
- [x] Zero Prisma client imports in any lib file
- [x] German failure reasons in prereq-evaluator (D-15)
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 — Blocking] Created apply-attribute-boost.ts in this worktree**
- **Found during:** Task 1 (types.ts imports `AbilityAbbreviation` from `./apply-attribute-boost`)
- **Issue:** Plan 01-02 depends on Plan 01-01's `apply-attribute-boost.ts` module, but Plan 01-01's work is on a separate parallel-execution worktree branch and not yet merged into this worktree's base (`096edbf`). Without it, types.ts and recompute-derived-stats.ts cannot compile.
- **Fix:** Created `server/src/modules/leveling/lib/apply-attribute-boost.ts` with content that exactly matches the Plan 01-01 specification (verbatim from 01-01-PLAN.md lines 351-380). When orchestrator merges Plan 01-01's worktree branch, content will be byte-identical and merge will be conflict-free.
- **Files modified:** `server/src/modules/leveling/lib/apply-attribute-boost.ts`
- **Commit:** 7e40449
**2. [Rule 1 — Bug] Fixed L5 expected step list (PF2e rules correction)**
- **Found during:** Task 5 GREEN phase (one of 13 tests failed against the implementation)
- **Issue:** The plan's expected step list for `computeApplicableSteps(5, 'Fighter', ...)` included `feat-class` and `feat-skill`. PF2e CRB places class feats and skill feats at EVEN levels only (2, 4, 6, ..., 20). L5 is odd → no class/skill feat slot. The plan's other tests at L4 (even, has feat-class+feat-skill) and L3 (odd, NOT has feat-class) are internally consistent with the even-level rule; only L5 was misstated.
- **Fix:** Changed expected list at L5 to `['class-features', 'boost', 'skill-increase', 'feat-ancestry', 'review']` and added a comment block explaining the deviation. The implementation rule "feat-class/feat-skill on even levels" is unchanged because the project's "regelkonform" goal (CLAUDE.md / PROJECT.md) requires PF2e correctness above plan literalism.
- **Files modified:** `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts`
- **Commit:** de07fc8
**3. [Rule 1 — Bug] Type-guard helpers for TS-strict EvalResult narrowing**
- **Found during:** Task 6 (`tsc --noEmit` after all RED/GREEN commits)
- **Issue:** TypeScript strict mode could not narrow `EvalResult = {ok:true} | {ok:false; reason} | {unknown:true; raw}` via `r.ok` access — the unknown variant has no `ok` property. Tests passed at runtime but `tsc` reported `error TS2339: Property 'ok' does not exist on type 'EvalResult'` in 8 lines of the spec and 4 lines of the production walker.
- **Fix:** Added explicit type-guard helpers (`isUnknown`, `isOk`, `isFail` in production; `isFail` in spec) that use `'ok' in r` membership checks. Replaced direct `r.ok` accesses with guard calls. Runtime behavior unchanged.
- **Files modified:** `server/src/modules/leveling/lib/prereq-evaluator.ts`, `server/src/modules/leveling/lib/prereq-evaluator.spec.ts`
- **Commit:** be6eaee
### Authentication Gates
None — this plan introduces no I/O.
### Architectural Changes
None — pure-function modules only.
## Notes on Prereq-Evaluator Edge Cases (for future grammar tuning)
While implementing the parser, two interesting edge cases came up that future PF2e prereq corpora may stress:
1. **Oxford-comma "X, Y, or Z" splitting.** A naive `,\s*|\s+or\s+` regex split leaves a stray leading "or " on the last segment because the `,\s*` alternative consumes the comma but stops before the "or" keyword. Solution: pre-normalize `,\s*or\s+`` or ` before splitting.
2. **Free-text-as-feat misclassification.** Without guards, a sentence like "You worship a god of fire and destruction" matches the feat fallback regex `^[A-Z][A-Za-z' \-]*$` (it's all letters/spaces and starts with uppercase). Added two heuristics: ≤4 words AND no presence of common function-words (you, a, the, of, and, to, with, from, by). Future PF2e feats with names like "Heir to the Vows of Asmodeus" would fail this guard — but no such canonical feat exists today, and the planner's UNKNOWN-aggressive intent (D-03) prefers false-unknown over false-evaluable.
## TDD Gate Compliance
For each of Tasks 25, a `test(...)` commit (RED gate) precedes the corresponding `feat(...)` commit (GREEN gate). Verified in git log:
| Module | RED commit | GREEN commit |
|--------|-----------|---------------|
| skill-increase-cap | 3a4267d | f189750 |
| prereq-evaluator | 66d9d5c | da82d9b |
| recompute-derived-stats | 8dd55b6 | 6011024 |
| compute-applicable-steps | 70ec7bb | d9ed18c |
The compute-applicable-steps module additionally has commit de07fc8 (test fix between RED and GREEN, separated as a deviation rather than amended into RED to preserve the audit trail of the plan-vs-PF2e-rules conflict).
## Confirmation: types.ts Imported by All Downstream Modules
```bash
grep -l "from './types'" server/src/modules/leveling/lib/*.ts
```
Result:
- `compute-applicable-steps.ts` (StepKind)
- `prereq-evaluator.ts` (CharacterContext, EvalResult, Proficiency)
- `recompute-derived-stats.ts` (CharacterContext, ClassProgressionRow, DerivedStats, Proficiency, WizardChoices, PROFICIENCY_BASE_BONUS, AbilityAbbreviation)
- `skill-increase-cap.ts` (Proficiency)
All four logic modules import from `./types` — no duplicate type definitions.
## Self-Check: PASSED
- `server/src/modules/leveling/lib/types.ts` — FOUND
- `server/src/modules/leveling/lib/skill-increase-cap.ts` — FOUND
- `server/src/modules/leveling/lib/skill-increase-cap.spec.ts` — FOUND
- `server/src/modules/leveling/lib/prereq-evaluator.ts` — FOUND
- `server/src/modules/leveling/lib/prereq-evaluator.spec.ts` — FOUND
- `server/src/modules/leveling/lib/recompute-derived-stats.ts` — FOUND
- `server/src/modules/leveling/lib/recompute-derived-stats.spec.ts` — FOUND
- `server/src/modules/leveling/lib/compute-applicable-steps.ts` — FOUND
- `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts` — FOUND
- `server/src/modules/leveling/lib/apply-attribute-boost.ts` — FOUND (Rule 3 dependency)
- Commit 7e40449 — FOUND
- Commit 4d2cb5e — FOUND
- Commit 3a4267d — FOUND
- Commit f189750 — FOUND
- Commit 66d9d5c — FOUND
- Commit da82d9b — FOUND
- Commit 8dd55b6 — FOUND
- Commit 6011024 — FOUND
- Commit 70ec7bb — FOUND
- Commit de07fc8 — FOUND
- Commit d9ed18c — FOUND
- Commit be6eaee — FOUND

View 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');
}
});
});

View 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;
}

View 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);
});
});

View 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 };

View File

@@ -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);
});
});

View 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,
};
}

View 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]);
});
});

View 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;
}

View 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[];
}