chore: merge executor worktree (worktree-agent-a58e8ff5fc0a2fc4e)
This commit is contained in:
217
.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md
Normal file
217
.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md
Normal 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 2–5, 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
|
||||
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