Files
Dimension-47/.planning/phases/01-level-up-pf2e-regelkonform/01-04-PLAN.md
Alexander Zielonka a8c4944bb6
All checks were successful
Deploy Dimension47 / deploy (push) Successful in 49s
chore(phase-01): checkpoint after Wave 3 Task 1 — pause for resume on another machine
Phase 1 execution state at pause:
- Wave 1 ✓ (01-01 schema/migration, 01-02 pure-function lib)
- Wave 2 ✓ (01-03 seed pipeline + Wizard worked example)
- Wave 3 partial (01-03b Task 1 of 3 done — 6 caster overlays salvaged from c5d40cd)
- Wave 4 pending (01-04 LevelingModule)
- Wave 5 pending (01-05 React wizard UI)

To resume: \`/gsd-execute-phase 1\` discovers SUMMARY files for completed plans
and picks up at 01-03b Task 2 (class-feature-options bulk curation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:13:50 +02:00

110 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
01-level-up-pf2e-regelkonform 04 execute 4
01-01
01-02
01-03
01-03b
server/src/modules/leveling/leveling.module.ts
server/src/modules/leveling/leveling.controller.ts
server/src/modules/leveling/leveling.service.ts
server/src/modules/leveling/feat-filter.service.ts
server/src/modules/leveling/dto/start-level-up.dto.ts
server/src/modules/leveling/dto/patch-level-up.dto.ts
server/src/modules/leveling/dto/commit-level-up.dto.ts
server/src/modules/leveling/dto/level-up-state.dto.ts
server/src/modules/leveling/dto/index.ts
server/src/modules/leveling/leveling.service.spec.ts
server/src/modules/leveling/feat-filter.service.spec.ts
server/src/modules/characters/characters.gateway.ts
server/src/modules/characters/pathbuilder-import.service.ts
server/src/app.module.ts
true
LVL-03
LVL-04
LVL-05
LVL-07
LVL-09
LVL-10
LVL-11
LVL-12
LVL-13
LVL-14
LVL-15
nestjs
rest-api
websocket
transaction
level-up
server
integration-tests
truths artifacts key_links
POST /characters/:characterId/level-up creates or resumes a single open LevelUpSession DRAFT for the character (Owner OR GM access).
PATCH /characters/:characterId/level-up/:sessionId merges a partial state into the DRAFT and validates the shape via class-validator + a TS guard.
GET /characters/:characterId/level-up/:sessionId/preview returns Vorher/Nachher DerivedStats computed via recompute-derived-stats.ts (no commit).
POST /characters/:characterId/level-up/:sessionId/commit runs an atomic prisma.$transaction that: inserts LevelUpHistory snapshot, updates Character (level + hpMax + freeArchetype), upserts CharacterAbility/CharacterSkill/CharacterFeat/CharacterResource/CharacterSpell rows, marks the session committedAt = now, then emits ONE 'level_up_committed' WebSocket event.
DELETE /characters/:characterId/level-up/:sessionId removes the DRAFT (LevelUpSession row) without touching the Character.
feat-filter.service returns only feats whose prereqs evaluate {ok:true} or {unknown:true} per slot/source filter, never {ok:false}.
PathbuilderImportService runs the prereq evaluator after import and persists violations to Character.prereqViolations + auto-detects FA from feat tuples.
characters.gateway.ts CharacterUpdatePayload union includes 'level_up_committed' and the broadcast happens once per commit (Pitfall #9).
Commit transaction NEVER mutates Character.hpCurrent (Pitfall #9 — verified by integration test).
Race-condition: second simultaneous commit on the same session is rejected by the partial unique index (verified by integration test).
GET /characters/:characterId/level-up returns the open DRAFT for resume-banner detection, or 404 when none exists.
GET /characters/:characterId/level-up/:sessionId/feats?slot=class|skill|general|ancestry|archetype returns FeatWithEval[] from FeatFilterService for the wizard's feat steps.
GET /characters/:characterId/level-up/:sessionId/class-feature-options/:optionsRef returns the ClassFeatureOption rows for that optionsRef so the class-feature-choice step can render them.
LevelUpSessionDto includes a steps: StepKind[] field computed via computeApplicableSteps so the wizard renders the correct step list without extra round-trips.
path provides exports
server/src/modules/leveling/leveling.module.ts NestJS module wiring controller + services + JWT + dependency on CharactersModule (for gateway)
LevelingModule
path provides contains
server/src/modules/leveling/leveling.controller.ts 5 REST endpoints: start, patch, preview, commit, discard @Controller('characters/:characterId/level-up')
path provides contains
server/src/modules/leveling/leveling.service.ts Orchestration: startOrResume, patchState, computePreview, commit, discard prisma.$transaction
path provides exports
server/src/modules/leveling/feat-filter.service.ts Filtered feat lookup driven by prereq-evaluator + slot+source criteria
FeatFilterService
path provides contains
server/src/modules/leveling/leveling.service.spec.ts Integration tests for atomic commit, broadcast count, hpCurrent invariant, race condition, FA, spellcaster prisma.$transaction
path provides contains
server/src/modules/characters/characters.gateway.ts Extended CharacterUpdatePayload['type'] union with 'level_up_committed' 'level_up_committed'
path provides contains
server/src/modules/characters/pathbuilder-import.service.ts Extended import flow with prereq violations + FA auto-detect evaluatePrereq
from to via pattern
leveling.service.ts → commit() characters.gateway.ts → broadcastCharacterUpdate single emit AFTER $transaction returns broadcastCharacterUpdate.*level_up_committed
from to via pattern
leveling.service.ts lib/recompute-derived-stats.ts import recomputeDerivedStats from.*lib/recompute-derived-stats
from to via pattern
leveling.service.ts characters.service.ts → checkCharacterAccess delegated permission check checkCharacterAccess.*requireOwnership
from to via pattern
feat-filter.service.ts lib/prereq-evaluator.ts import evaluatePrereq from.*lib/prereq-evaluator
from to via pattern
pathbuilder-import.service.ts lib/prereq-evaluator.ts post-import evaluation loop evaluatePrereq
from to via pattern
app.module.ts LevelingModule imports array LevelingModule
Build the server-side orchestration that ties together the schema (Plan 01), pure-function lib (Plan 02), and seed data (Plan 03) into the live REST + WebSocket surface that the React wizard (Plan 05) consumes. This plan creates the new NestJS `LevelingModule` with controller, service, DTOs, the supporting `FeatFilterService`, plus integration tests for the atomic commit transaction. It also extends two existing files: `characters.gateway.ts` (one-line union type) and `pathbuilder-import.service.ts` (prereq-evaluator integration + FA auto-detect).

Purpose: This is the load-bearing wave — without these REST endpoints + the atomic commit transaction + the WebSocket broadcast, the React wizard has nothing to call. The integration tests written here are the regression net for Pitfall #9 (no hpCurrent mutation), Pitfall #8 (boost cap honored end-to-end), and the partial-unique-index race-condition behavior.

Output: 9 new server files (1 module + 1 controller + 2 services + 4 DTOs + 1 barrel + 2 spec files = 11 files counting specs), 3 extended files, 320+ lines of integration tests with full coverage of VALIDATION.md rows 1-W3-01 through 1-W3-10.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/REQUIREMENTS.md @.planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md @.planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md @.planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md @.planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md @.planning/phases/01-level-up-pf2e-regelkonform/01-01-SUMMARY.md @.planning/phases/01-level-up-pf2e-regelkonform/01-02-SUMMARY.md @.planning/phases/01-level-up-pf2e-regelkonform/01-03-SUMMARY.md @server/src/modules/characters/characters.module.ts @server/src/modules/characters/characters.controller.ts @server/src/modules/characters/characters.service.ts @server/src/modules/characters/characters.gateway.ts @server/src/modules/characters/pathbuilder-import.service.ts @server/src/modules/characters/dto/dying.dto.ts @server/src/modules/characters/dto/alchemy.dto.ts @server/src/modules/characters/dto/rest.dto.ts @server/src/modules/characters/dto/index.ts @server/src/modules/battle/combatants.service.ts @server/src/app.module.ts @server/src/modules/translations/translations.service.ts ```typescript // From server/src/modules/leveling/lib/types.ts export type Proficiency = 'UNTRAINED' | 'TRAINED' | 'EXPERT' | 'MASTER' | 'LEGENDARY'; export type StepKind = 'class-features' | 'class-feature-choice' | 'boost' | ... | 'review'; export type EvalResult = { ok: true } | { ok: false; reason: string } | { unknown: true; raw: string }; export interface CharacterContext { level, className, ancestryName, heritageName?, abilities, skills, feats } export interface DerivedStats { level, hpMax, ac, classDc, perception, fortitude, reflex, will } export interface ClassProgressionRow { className, level, grants[], proficiencyChanges, spellSlotIncrement?, ... } export interface WizardChoices { boostTargets?, skillIncrease?, featClassId?, ..., classFeatureChoices?, spellcasterRepertoirePicks? }

// From server/src/modules/leveling/lib/apply-attribute-boost.ts export function applyAttributeBoost(score: number): number; export function isValidBoostSet(targets: readonly string[]): boolean;

// From server/src/modules/leveling/lib/skill-increase-cap.ts export function canIncreaseSkill(currentRank: Proficiency, characterLevel: number): boolean; export const SKILL_INCREASE_LEVELS: readonly number[];

// From server/src/modules/leveling/lib/prereq-evaluator.ts export function evaluatePrereq(prereqString: string | null, ctx: CharacterContext): EvalResult;

// From server/src/modules/leveling/lib/recompute-derived-stats.ts export function recomputeDerivedStats(character, choices, progression): DerivedStats;

// From server/src/modules/leveling/lib/compute-applicable-steps.ts export function computeApplicableSteps(input): StepKind[];


<!-- Existing CharactersGateway broadcast (server/src/modules/characters/characters.gateway.ts:22-26 + broadcast method ~232) -->
```typescript
export interface CharacterUpdatePayload {
  characterId: string;
  type: 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest'
      | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state' | 'dying';
  data: any;  // (existing — leave for now)
}

// Method on the gateway (already exposed for other broadcasts):
broadcastCharacterUpdate(characterId: string, payload: CharacterUpdatePayload): void;
// requireOwnership=true means "isOwner OR isGM" per RESEARCH.md line 862
async checkCharacterAccess(characterId: string, userId: string, requireOwnership = false) {
  // throws NotFoundException if missing, ForbiddenException if no access
  // returns the loaded Character (with campaign + members included)
}
async importCharacter(campaignId: string, ownerId: string, pathbuilderJson: PathbuilderJson) { ... }
// Used for D-15 — German translation of new prereq strings + class-feature descriptions.
async getTranslationsBatch(items: { englishText: string; context: string }[]): Promise<Record<string, string>>;
@Module({
  imports: [
    AuthModule, CampaignsModule, CharactersModule, EquipmentModule, FeatsModule,
    BattleModule, TranslationsModule, ConfigModule.forRoot({ ... }), JwtModule.registerAsync({ ... }),
    // ADD: LevelingModule
  ],
  ...
})
Task 1: DTOs + barrel (start, patch, commit, level-up-state, index) server/src/modules/leveling/dto/start-level-up.dto.ts, server/src/modules/leveling/dto/patch-level-up.dto.ts, server/src/modules/leveling/dto/commit-level-up.dto.ts, server/src/modules/leveling/dto/level-up-state.dto.ts, server/src/modules/leveling/dto/index.ts - server/src/modules/characters/dto/dying.dto.ts (canonical small DTO with class-validator + ApiProperty) - server/src/modules/characters/dto/alchemy.dto.ts (nested DTO + ValidateNested + ApiPropertyOptional) - server/src/modules/characters/dto/rest.dto.ts (response DTO shape — RestPreviewDto for analog) - server/src/modules/characters/dto/index.ts (barrel pattern) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 248-311 — DTO pattern with exact decorator usage) - server/src/modules/leveling/lib/types.ts (WizardChoices + DerivedStats — these typed shapes are the runtime contract) Create the four DTOs and the barrel.
**1. `server/src/modules/leveling/dto/start-level-up.dto.ts`:**

```typescript
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional, Max, Min } from 'class-validator';

export class StartLevelUpDto {
  @ApiPropertyOptional({ description: 'Target level (defaults to currentLevel + 1)', minimum: 2, maximum: 20 })
  @IsOptional()
  @IsInt()
  @Min(2)
  @Max(20)
  targetLevel?: number;
}
```

**2. `server/src/modules/leveling/dto/patch-level-up.dto.ts`:**

```typescript
import { ApiProperty } from '@nestjs/swagger';
import { IsObject } from 'class-validator';

/**
 * Wire DTO for PATCH /level-up/:sessionId.
 * The full WizardChoices shape is validated in the service via a TS guard
 * (see leveling.service.ts → assertValidWizardChoices) — class-validator only
 * checks "is an object" at the wire boundary; deep validation happens in TS.
 */
export class PatchLevelUpDto {
  @ApiProperty({ description: 'Partial WizardChoices to merge into the DRAFT', type: 'object' })
  @IsObject()
  state: Record<string, unknown>;
}
```

**3. `server/src/modules/leveling/dto/commit-level-up.dto.ts`:**

```typescript
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';

/**
 * No required fields — commit reads the latest persisted state.
 * Optional flag for client to acknowledge any pending non-evaluable prereqs.
 */
export class CommitLevelUpDto {
  @ApiPropertyOptional({ description: 'Acknowledge any non-evaluable prereqs that were warned about' })
  @IsOptional()
  @IsBoolean()
  acknowledgePrereqWarnings?: boolean;
}
```

**4. `server/src/modules/leveling/dto/level-up-state.dto.ts`** (response shapes — used by Swagger + as TS types in the service):

```typescript
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import type { DerivedStats } from '../lib/types';

/**
 * Response shape for GET /level-up/:sessionId/preview — Vorher/Nachher.
 */
export class LevelUpPreviewDto {
  @ApiProperty({ description: 'Stats before commit (current Character state)' })
  before: DerivedStats;

  @ApiProperty({ description: 'Stats after commit (computed via recompute-derived-stats)' })
  after: DerivedStats;

  @ApiPropertyOptional({ description: 'Spellcaster slot increments (if applicable)' })
  spellcaster?: {
    slotIncrements: { tradition: string; spellLevel: number; count: number }[];
    cantripDelta?: number;
    repertoireDelta?: number;
  };
}

/**
 * Response shape for the LevelUpSession (start / patch / get).
 * `state` is the JSON-serializable WizardChoices + UI-only fields.
 * `steps` is computed server-side via computeApplicableSteps so the React wizard
 * (Plan 05) can render the correct ordered step list without a second round-trip.
 */
export class LevelUpSessionDto {
  @ApiProperty() id: string;
  @ApiProperty() characterId: string;
  @ApiProperty() targetLevel: number;
  @ApiProperty({ type: 'object', additionalProperties: true }) state: Record<string, unknown>;
  @ApiProperty({
    description: 'Ordered list of wizard steps applicable to this session (computed via computeApplicableSteps from Plan 02 lib).',
    isArray: true,
    type: String,
    example: ['class-features', 'boost', 'skill-increase', 'feat-class', 'feat-skill', 'feat-ancestry', 'review'],
  })
  steps: string[];      // StepKind[] — see lib/types.ts for the exact union
  @ApiPropertyOptional({ nullable: true }) committedAt: Date | null;
  @ApiProperty() createdAt: Date;
  @ApiProperty() updatedAt: Date;
}
```

**5. `server/src/modules/leveling/dto/index.ts`:**

```typescript
export * from './start-level-up.dto';
export * from './patch-level-up.dto';
export * from './commit-level-up.dto';
export * from './level-up-state.dto';
```

**Constraints:**
- No `: any` outside the existing `data: any` on the gateway payload (which is left untouched per existing convention — RESEARCH.md line 873).
- Use `import type` for type-only imports from sibling modules.
- Mirror the exact decorator style of `dto/dying.dto.ts` and `dto/alchemy.dto.ts`.
cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "leveling/dto" || echo "tsc clean" - All 5 files exist in `server/src/modules/leveling/dto/` - `start-level-up.dto.ts` exports `StartLevelUpDto` - `patch-level-up.dto.ts` exports `PatchLevelUpDto` - `commit-level-up.dto.ts` exports `CommitLevelUpDto` - `level-up-state.dto.ts` exports `LevelUpPreviewDto` and `LevelUpSessionDto` - `index.ts` re-exports all four DTO files - No file contains `: any` outside comments - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 All DTOs typed, validated with class-validator decorators, documented with Swagger, exported through barrel. Task 2: Extend characters.gateway.ts union — add 'level_up_committed' (single-line) server/src/modules/characters/characters.gateway.ts - server/src/modules/characters/characters.gateway.ts (entire file — must understand the existing union, broadcast method, room subscription) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 376-396 — union extension pattern) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 866-892 — exact `level_up_committed` payload shape) - client/src/shared/hooks/use-character-socket.ts (line 15 — mirror union; this client file is updated in Plan 05) Open `server/src/modules/characters/characters.gateway.ts`. Find the `CharacterUpdatePayload` interface (around line 22-26).
Append `'level_up_committed'` to the `type` union (it currently ends with `'dying'`):

BEFORE:
```typescript
type: 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state' | 'dying';
```

AFTER:
```typescript
type: 'hp' | 'conditions' | 'item' | 'inventory' | 'money' | 'level' | 'equipment_status' | 'rest' | 'alchemy_vials' | 'alchemy_formulas' | 'alchemy_prepared' | 'alchemy_state' | 'dying' | 'level_up_committed';
```

Do NOT change anything else in this file. The existing `data: any` stays (per RESEARCH.md line 873 — typed-event refactor is a future phase).

**Document the new payload shape** by adding a comment ABOVE the interface (so Plan 05's client mirror has a contract to read):

```typescript
/**
 * Payload for type='level_up_committed' (added in Phase 1):
 *   data: {
 *     level: number;                    // new level
 *     derived: {                        // recomputed stats from recompute-derived-stats.ts
 *       hpMax: number;
 *       ac: number;
 *       classDc: number;
 *       perception: number;
 *       fortitude: number;
 *       reflex: number;
 *       will: number;
 *     };
 *   }
 * Emitted ONCE per commit (Pitfall #9 / RESEARCH.md First-Phase-Note). Clients invalidate
 * the character query and refetch via REST for the full state.
 */
export interface CharacterUpdatePayload {
  ...
}
```
cd server && grep -c "level_up_committed" src/modules/characters/characters.gateway.ts - File `server/src/modules/characters/characters.gateway.ts` contains the literal string `'level_up_committed'` (in the union) - File contains the JSDoc comment block describing the `data:` shape (look for `Payload for type='level_up_committed'`) - All other content unchanged — verify by checking that the broadcast method `broadcastCharacterUpdate` still exists and the room subscription `character:${characterId}` pattern is preserved - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 Single-line union extension applied; payload contract documented; gateway file otherwise unchanged. Task 3: FeatFilterService — filtered feat lookup driven by prereq-evaluator server/src/modules/leveling/feat-filter.service.ts - server/src/modules/leveling/lib/prereq-evaluator.ts (Plan 02 — function signature) - server/src/modules/leveling/lib/types.ts (CharacterContext, EvalResult) - server/prisma/schema.prisma (Feat model around line 544 — prerequisites: String?, source field, level field) - server/src/modules/feats/ (existing FeatsService — for reference on how feats are queried today) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 590-597 — anti-pattern: don't compute prereqs client-side) - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (row 1-W3-09 — exact assertion) Create `server/src/modules/leveling/feat-filter.service.ts` exposing a method that returns feats filtered by: 1. **Slot kind**: `'class' | 'skill' | 'general' | 'ancestry' | 'archetype'` 2. **Source**: matches `Feat.source` enum (CLASS / ANCESTRY / GENERAL / SKILL / ARCHETYPE / BONUS) 3. **Class restriction** (for slot=class): only feats whose `Feat.className` matches character's class OR are general 4. **Level cap**: only feats with `Feat.level <= character.level` 5. **Prereq evaluation**: for each candidate, call `evaluatePrereq` with the character's CharacterContext - Include feats with `{ ok: true }` and `{ unknown: true }` (warning path per D-03) - EXCLUDE feats with `{ ok: false }` from default response - Optionally include `{ ok: false }` if `includeUnavailable=true` (UI toggle per UI-SPEC line 174)
File contents:

```typescript
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { evaluatePrereq } from './lib/prereq-evaluator';
import type { CharacterContext, EvalResult } from './lib/types';

export type SlotKind = 'class' | 'skill' | 'general' | 'ancestry' | 'archetype';

export interface FeatFilterRequest {
  slot: SlotKind;
  character: CharacterContext;
  maxLevel?: number;            // defaults to character.level
  includeUnavailable?: boolean; // include {ok:false} feats with reason annotated
}

export interface FeatWithEval {
  id: string;
  name: string;
  level: number;
  source: string;
  prerequisites: string | null;
  description: string;
  traits: string[];
  eval: EvalResult;
}

@Injectable()
export class FeatFilterService {
  constructor(private prisma: PrismaService) {}

  async getFilteredFeats(req: FeatFilterRequest): Promise<FeatWithEval[]> {
    const maxLevel = req.maxLevel ?? req.character.level;

    // 1. Map slot → eligible source values
    const sourceFilter = this.sourcesForSlot(req.slot);

    // 2. Class filter (only matters for slot='class')
    const classFilter =
      req.slot === 'class'
        ? { OR: [{ className: req.character.className }, { className: null }] }
        : {};

    // 3. Query Feat table (existing schema)
    const feats = await this.prisma.feat.findMany({
      where: {
        source: { in: sourceFilter },
        level: { lte: maxLevel },
        ...classFilter,
      },
      orderBy: [{ level: 'asc' }, { name: 'asc' }],
      select: {
        id: true, name: true, level: true, source: true,
        prerequisites: true, description: true, traits: true, className: true,
      },
    });

    // 4. Evaluate prereqs against character context
    const evaluated: FeatWithEval[] = feats.map(f => ({
      id: f.id,
      name: f.name,
      level: f.level,
      source: f.source,
      prerequisites: f.prerequisites,
      description: f.description,
      traits: f.traits,
      eval: evaluatePrereq(f.prerequisites, req.character),
    }));

    // 5. Filter by eval result unless includeUnavailable
    if (req.includeUnavailable) return evaluated;
    return evaluated.filter(f => f.eval.ok === true || ('unknown' in f.eval && f.eval.unknown));
  }

  /**
   * Maps wizard slot to the Feat.source values eligible for that slot.
   * Slot 'general' allows both GENERAL and SKILL feats per LVL-05.
   */
  private sourcesForSlot(slot: SlotKind): string[] {
    switch (slot) {
      case 'class': return ['CLASS'];
      case 'skill': return ['SKILL'];
      case 'general': return ['GENERAL', 'SKILL'];   // LVL-05
      case 'ancestry': return ['ANCESTRY'];
      case 'archetype': return ['ARCHETYPE'];
    }
  }
}
```

**Note on Feat schema:** check `server/prisma/schema.prisma:544` for the actual column names (`source`, `level`, `prerequisites`, `traits`, `className`, `description`). If column names differ, adjust the `select` and `where` accordingly.

**Constraints:**
- `@Injectable()` decorator present (NestJS service).
- Imports `evaluatePrereq` from the lib — does NOT re-implement.
- No `: any`. The `Feat.source` field's type comes from Prisma — leave as the generated string type.
cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "feat-filter" || echo "tsc clean" - File `server/src/modules/leveling/feat-filter.service.ts` exists - File contains `@Injectable()` - File imports `evaluatePrereq` from `./lib/prereq-evaluator` - File exports `FeatFilterService` class - File exports `SlotKind`, `FeatFilterRequest`, `FeatWithEval` types - File contains the literal string `prisma.feat.findMany` (proves DB integration) - File contains NO `: any` outside comments - `cd server && npx tsc --noEmit` exits 0 Service queries the existing Feat table, applies slot+source+class+level filters, evaluates prereqs, returns annotated results. Task 4: LevelingService — orchestration (start, patch, preview, commit, discard) server/src/modules/leveling/leveling.service.ts - server/src/modules/characters/characters.service.ts (entire file — checkCharacterAccess pattern + service-level error patterns) - server/src/modules/battle/combatants.service.ts (lines 82-118 — atomic transaction pattern) - server/src/modules/leveling/lib/* (Plan 02 — recompute, prereq, applicable-steps, boost, skill-cap) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 173-247 — service pattern; 405-468 — atomic commit; 904-921 — broadcast pattern) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 405-470 — Pattern 2 atomic commit example; lines 826-862 — REST endpoint shape) - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-11 DRAFT, D-12 atomic + snapshot, D-13 owner+GM, D-14 one DRAFT) - server/src/modules/translations/translations.service.ts (TranslationsService.getTranslationsBatch signature for D-15) Create `server/src/modules/leveling/leveling.service.ts` containing the full orchestration. The class signature and method bodies are sketched below; executor implements full method bodies guided by the spec in Task 7 (the integration tests).
**File header + class skeleton:**

```typescript
import {
  Injectable, NotFoundException, ForbiddenException, BadRequestException, ConflictException,
  Inject, forwardRef, Logger,
} from '@nestjs/common';
import { Prisma } from '../../generated/prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CharactersService } from '../characters/characters.service';
import { CharactersGateway } from '../characters/characters.gateway';
import { TranslationsService } from '../translations/translations.service';
import { applyAttributeBoost, isValidBoostSet } from './lib/apply-attribute-boost';
import { canIncreaseSkill } from './lib/skill-increase-cap';
import { evaluatePrereq } from './lib/prereq-evaluator';
import { recomputeDerivedStats } from './lib/recompute-derived-stats';
import { computeApplicableSteps } from './lib/compute-applicable-steps';
import { FeatFilterService, type SlotKind, type FeatWithEval } from './feat-filter.service';
import type {
  CharacterContext, DerivedStats, ClassProgressionRow, WizardChoices, StepKind,
} from './lib/types';
import type { StartLevelUpDto, PatchLevelUpDto, CommitLevelUpDto, LevelUpPreviewDto } from './dto';

@Injectable()
export class LevelingService {
  private readonly logger = new Logger(LevelingService.name);

  constructor(
    private prisma: PrismaService,
    @Inject(forwardRef(() => CharactersService))
    private charactersService: CharactersService,
    @Inject(forwardRef(() => CharactersGateway))
    private charactersGateway: CharactersGateway,
    private translationsService: TranslationsService,
    private featFilterService: FeatFilterService,
  ) {}

  // ============================================================
  // PUBLIC API — called by LevelingController
  // ============================================================

  /**
   * Start a new DRAFT or resume the existing one for this character.
   * D-11/D-13/D-14 — owner-or-GM, one DRAFT per character (DB-enforced via partial unique index).
   */
  async startOrResume(characterId: string, dto: StartLevelUpDto, userId: string) {
    const character = await this.charactersService.checkCharacterAccess(characterId, userId, true);
    if (character.level >= 20) {
      throw new BadRequestException('Charakter ist bereits auf maximaler Stufe');
    }
    const targetLevel = dto.targetLevel ?? character.level + 1;
    if (targetLevel !== character.level + 1) {
      throw new BadRequestException(`Stufenaufstieg nur auf Stufe ${character.level + 1} moeglich`);
    }

    // Try to resume the open DRAFT (partial unique index -> 0 or 1 row)
    const existing = await this.prisma.levelUpSession.findFirst({
      where: { characterId, committedAt: null },
    });
    if (existing) {
      this.logger.log(`Resumed LevelUpSession ${existing.id} for character ${characterId}`);
      return this.attachSteps(existing, characterId);
    }

    // Create fresh DRAFT
    try {
      const created = await this.prisma.levelUpSession.create({
        data: {
          characterId,
          targetLevel,
          state: this.initialWizardState(),
        },
      });
      this.logger.log(`Created LevelUpSession ${created.id} for character ${characterId} -> L${targetLevel}`);
      return this.attachSteps(created, characterId);
    } catch (e) {
      // Partial unique index race -- another tab beat us. Re-fetch and return that one.
      const fallback = await this.prisma.levelUpSession.findFirst({
        where: { characterId, committedAt: null },
      });
      if (fallback) return this.attachSteps(fallback, characterId);
      throw e;
    }
  }

  /**
   * Decorate the raw LevelUpSession row with `steps: StepKind[]` -- computed via
   * computeApplicableSteps from Plan 02 lib. Plan 05 (React wizard) consumes this
   * directly so the client doesn't need to re-derive the step list.
   */
  private async attachSteps<T extends { characterId: string; targetLevel: number }>(
    session: T,
    characterId: string,
  ): Promise<T & { steps: StepKind[] }> {
    const ctx = await this.loadCharacterFullForRecompute(characterId);
    const charContext = this.toCharacterContext(ctx);
    const progression = await this.loadProgression(charContext.className, session.targetLevel);
    const steps = computeApplicableSteps({
      character: charContext,
      targetLevel: session.targetLevel,
      progression,
      freeArchetype: ctx.character.freeArchetype ?? false,
    });
    return { ...session, steps };
  }

  /**
   * Merge a partial WizardChoices into the DRAFT.state JSON.
   * Validates shape via assertValidWizardChoices.
   */
  async patchState(characterId: string, sessionId: string, dto: PatchLevelUpDto, userId: string) {
    const session = await this.loadSessionAndAuthorize(sessionId, userId);
    if (session.characterId !== characterId) {
      throw new BadRequestException('Session gehört nicht zu diesem Charakter');
    }
    if (session.committedAt !== null) {
      throw new ConflictException('Session ist bereits committed');
    }

    // Merge: existing state + patch
    const newState: Record<string, unknown> = {
      ...(session.state as Record<string, unknown>),
      ...dto.state,
    };
    this.assertValidWizardChoices(newState);

    const updated = await this.prisma.levelUpSession.update({
      where: { id: sessionId },
      data: { state: newState },
    });
    return this.attachSteps(updated, characterId);
  }

  /**
   * Compute Vorher/Nachher DerivedStats without committing.
   * Reads ClassProgression + character snapshot, runs recomputeDerivedStats.
   */
  async computePreview(characterId: string, sessionId: string, userId: string): Promise<LevelUpPreviewDto> {
    const session = await this.loadSessionAndAuthorize(sessionId, userId);
    const ctx = await this.loadCharacterFullForRecompute(characterId);
    const progression = await this.loadProgression(ctx.className, session.targetLevel);

    const rawState = session.state as Prisma.JsonObject;
    this.assertValidWizardChoices(rawState);
    const choices: WizardChoices = this.parseWizardChoices(rawState);

    const charContext = this.toCharacterContext(ctx);
    const before: DerivedStats = await this.computeBeforeStats(ctx);
    const after: DerivedStats = recomputeDerivedStats(charContext, choices, progression);

    const spellcaster = this.computeSpellcasterPreview(progression, choices);
    return { before, after, spellcaster };
  }

  /**
   * Atomic commit: snapshot + character mutations + history insert + session.committedAt + ONE broadcast.
   * D-12 / Pitfall #9: never touches Character.hpCurrent.
   */
  async commit(characterId: string, sessionId: string, dto: CommitLevelUpDto, userId: string) {
    const session = await this.loadSessionAndAuthorize(sessionId, userId);
    if (session.characterId !== characterId) {
      throw new BadRequestException('Session gehoert nicht zu diesem Charakter');
    }
    if (session.committedAt !== null) {
      throw new ConflictException('Session ist bereits committed');
    }

    const ctx = await this.loadCharacterFullForRecompute(characterId);
    if (ctx.level + 1 !== session.targetLevel) {
      throw new BadRequestException(
        `Charakter ist auf Stufe ${ctx.level}; Session erwartet Aufstieg auf Stufe ${session.targetLevel}`,
      );
    }

    // Parse + validate wizard state into a properly-typed WizardChoices.
    // assertValidWizardChoices is the structural guard; parseWizardChoices below builds the
    // strongly-typed object to avoid `as unknown as WizardChoices` casts downstream.
    const rawState = session.state as Prisma.JsonObject;
    this.assertValidWizardChoices(rawState);
    const choices: WizardChoices = this.parseWizardChoices(rawState);
    if (choices.boostTargets && !isValidBoostSet(choices.boostTargets)) {
      throw new BadRequestException('Boost-Set muss genau 4 verschiedene Attribute enthalten');
    }

    const charContext = this.toCharacterContext(ctx);
    const progression = await this.loadProgression(ctx.className, session.targetLevel);
    const beforeStats = await this.computeBeforeStats(ctx);
    const newDerived = recomputeDerivedStats(charContext, choices, progression);
    const snapshotJson = this.buildSnapshot(ctx, beforeStats);

    // ============ ATOMIC TRANSACTION ============
    const result = await this.prisma.$transaction(async (tx) => {
      // 1. Snapshot history
      await tx.levelUpHistory.create({
        data: {
          characterId,
          levelFrom: ctx.level,
          levelTo: session.targetLevel,
          snapshotBefore: snapshotJson as unknown as Prisma.InputJsonValue,
          choices: rawState as Prisma.InputJsonValue,
        },
      });

      // 2. Character update -- level + hpMax + freeArchetype passthrough.
      // CRITICAL: do NOT touch hpCurrent (Pitfall #9).
      await tx.character.update({
        where: { id: characterId },
        data: {
          level: session.targetLevel,
          hpMax: newDerived.hpMax,
          // (hpCurrent intentionally absent)
        },
      });

      // 3. Boost targets -> CharacterAbility upserts (4 rows max)
      if (choices.boostTargets) {
        const abilityScores: Record<string, number> = {};
        for (const a of ctx.abilities) abilityScores[a.ability] = a.score;
        for (const ability of choices.boostTargets) {
          const current = abilityScores[ability] ?? 10;
          const newScore = applyAttributeBoost(current);
          await tx.characterAbility.upsert({
            where: { characterId_ability: { characterId, ability } },
            update: { score: newScore },
            create: { characterId, ability, score: newScore },
          });
        }
      }

      // 4. Skill increase
      if (choices.skillIncrease) {
        await tx.characterSkill.upsert({
          where: { characterId_skillName: { characterId, skillName: choices.skillIncrease.skillName } },
          update: { proficiency: choices.skillIncrease.toRank },
          create: {
            characterId,
            skillName: choices.skillIncrease.skillName,
            proficiency: choices.skillIncrease.toRank,
          },
        });
      }

      // 5. Feat picks -> CharacterFeat creates
      const featSlots: Array<[keyof WizardChoices, string]> = [
        ['featClassId', 'CLASS'],
        ['featSkillId', 'SKILL'],
        ['featGeneralId', 'GENERAL'],
        ['featAncestryId', 'ANCESTRY'],
        ['featArchetypeId', 'ARCHETYPE'],
      ];
      for (const [field, source] of featSlots) {
        const featId = choices[field] as string | undefined;
        if (featId) {
          await tx.characterFeat.create({
            data: {
              characterId,
              featId,
              source,
              level: session.targetLevel,
            },
          });
        }
      }

      // 6. Class-feature choice picks -> CharacterFeat creates with the option name.
      // CharacterFeat in the current schema already has `featId String?` (nullable) and
      // `name String` (required) -- verified at server/prisma/schema.prisma lines 233-244.
      // We pass `featId: null` for class-feature choices that have no underlying Feat row,
      // and use the ClassFeatureOption.name as the CharacterFeat.name. No schema change.
      if (choices.classFeatureChoices) {
        for (const [choiceKey, optionKey] of Object.entries(choices.classFeatureChoices)) {
          const option = await tx.classFeatureOption.findUnique({
            where: { optionsRef_optionKey: { optionsRef: choiceKey, optionKey } },
          });
          if (option) {
            await tx.characterFeat.create({
              data: {
                characterId,
                featId: null,                 // featId is `String?` in schema (verified)
                name: option.name,            // required `String` -- pass the option's English name
                nameGerman: option.nameGerman ?? null,
                source: 'CLASS',
                level: session.targetLevel,
              } satisfies Prisma.CharacterFeatUncheckedCreateInput,
            });
          }
        }
      }

      // 7. Spellcaster -- apply spellSlotIncrement / cantripIncrement / repertoirePicks
      if (progression.spellSlotIncrement) {
        // Implementation depends on existing CharacterResource shape -- executor adapts.
        // Pattern: upsert CharacterResource for {tradition, spellLevel} key, increment count.
      }
      if (choices.spellcasterRepertoirePicks?.length) {
        for (const spellId of choices.spellcasterRepertoirePicks) {
          await tx.characterSpell.create({
            data: {
              characterId,
              spellId,
              isInRepertoire: true,
            } satisfies Prisma.CharacterSpellUncheckedCreateInput,
          });
        }
      }

      // 8. Mark session committed (soft-archive per RESEARCH.md line 470)
      await tx.levelUpSession.update({
        where: { id: sessionId },
        data: { committedAt: new Date() },
      });

      return tx.character.findUnique({
        where: { id: characterId },
        include: { abilities: true, skills: true, feats: true },
      });
    });
    // ============ END TRANSACTION ============

    // 9. SINGLE WebSocket broadcast (Pitfall #9 / RESEARCH.md First-Phase-Note)
    this.charactersGateway.broadcastCharacterUpdate(characterId, {
      characterId,
      type: 'level_up_committed',
      data: {
        level: session.targetLevel,
        derived: newDerived,
      },
    });

    // 10. D-15 -- kick off German translation of any new prereq strings + class-feature
    //     descriptions used by this commit (fire-and-forget; cached for next view).
    await this.maybeTranslateNewStrings(progression, choices).catch(e =>
      this.logger.warn(`Translation kick-off failed: ${(e as Error).message}`),
    );

    this.logger.log(`Committed level-up: characterId=${characterId} fromLevel=${ctx.level} toLevel=${session.targetLevel}`);
    return result;
  }

  /** Discard a DRAFT — no character mutation. */
  async discard(characterId: string, sessionId: string, userId: string) {
    const session = await this.loadSessionAndAuthorize(sessionId, userId);
    if (session.characterId !== characterId) {
      throw new BadRequestException('Session gehört nicht zu diesem Charakter');
    }
    if (session.committedAt !== null) {
      throw new ConflictException('Bereits committed — kann nicht verworfen werden');
    }
    await this.prisma.levelUpSession.delete({ where: { id: sessionId } });
    this.logger.log(`Discarded LevelUpSession ${sessionId} for character ${characterId}`);
  }

  // ============================================================
  // WIZARD-SUPPORT QUERIES (used by Plan 05 React wizard)
  // ============================================================

  /**
   * Resume-banner detection — returns the open DRAFT for this character or null.
   * Used by GET /characters/:characterId/level-up.
   */
  async findOpenDraft(characterId: string, userId: string) {
    await this.charactersService.checkCharacterAccess(characterId, userId, true);
    const draft = await this.prisma.levelUpSession.findFirst({
      where: { characterId, committedAt: null },
    });
    if (!draft) return null;
    return this.attachSteps(draft, characterId);
  }

  /**
   * Filtered feat list for a wizard slot. Delegates to FeatFilterService and uses the
   * session's targetLevel + the character's current state to build the CharacterContext.
   */
  async getFeatsForSlot(
    characterId: string,
    sessionId: string,
    slot: SlotKind,
    includeUnavailable: boolean,
    userId: string,
  ): Promise<FeatWithEval[]> {
    const session = await this.loadSessionAndAuthorize(sessionId, userId);
    if (session.characterId !== characterId) {
      throw new BadRequestException('Session gehört nicht zu diesem Charakter');
    }
    const character = await this.loadCharacterFullForRecompute(characterId);
    const ctx: CharacterContext = this.toCharacterContext(character);
    return this.featFilterService.getFilteredFeats({
      slot,
      character: ctx,
      maxLevel: session.targetLevel,
      includeUnavailable,
    });
  }

  /**
   * Options for a class-feature choice step (e.g. Cleric Doctrine, Wizard School).
   * Validates that the optionsRef is allowed by this session's ClassProgression row.
   */
  async getClassFeatureOptions(
    characterId: string,
    sessionId: string,
    optionsRef: string,
    userId: string,
  ) {
    const session = await this.loadSessionAndAuthorize(sessionId, userId);
    if (session.characterId !== characterId) {
      throw new BadRequestException('Session gehört nicht zu diesem Charakter');
    }
    const character = await this.loadCharacterFullForRecompute(characterId);
    const progression = await this.loadProgression(character.className, session.targetLevel);
    if (progression.choiceOptionsRef !== optionsRef) {
      throw new BadRequestException(
        `optionsRef ${optionsRef} ist für diese Sitzung nicht erlaubt (erwartet: ${progression.choiceOptionsRef ?? 'kein Wahlschritt'})`,
      );
    }
    return this.prisma.classFeatureOption.findMany({
      where: { optionsRef },
      orderBy: { name: 'asc' },
    });
  }

  // ============================================================
  // PRIVATE HELPERS
  // ============================================================

  private initialWizardState(): Record<string, unknown> {
    return { choices: {}, acknowledgedNonEvaluablePrereqs: [] };
  }

  /**
   * TS guard for in-flight wizard state. Throws BadRequestException if shape is invalid.
   * Cheap structural validation only — does NOT validate against PF2e rules (commit() does).
   */
  private assertValidWizardChoices(state: unknown): asserts state is Record<string, unknown> {
    if (!state || typeof state !== 'object') {
      throw new BadRequestException('Ungueltiges Wizard-State-Format');
    }
    // Optional further checks: boostTargets is array of valid abbreviations, etc.
    const s = state as Record<string, unknown>;
    if (s.boostTargets !== undefined) {
      if (!Array.isArray(s.boostTargets) || s.boostTargets.some(t => typeof t !== 'string')) {
        throw new BadRequestException('boostTargets muss ein Array von Strings sein');
      }
    }
  }

  /**
   * Build a strongly-typed WizardChoices from the JSON-blob session.state.
   * Replaces `as unknown as WizardChoices` casts with explicit field-by-field reads
   * so no `as never` / unsound cast is needed downstream.
   *
   * The discriminated-union WizardChoices type is defined in lib/types.ts (Plan 02).
   * If a field shape doesn\'t match expectations, throws BadRequestException with the
   * offending key.
   */
  private parseWizardChoices(state: Record<string, unknown>): WizardChoices {
    const choicesRoot = (state.choices ?? state) as Record<string, unknown>;
    const out: WizardChoices = {};
    if (Array.isArray(choicesRoot.boostTargets)) {
      out.boostTargets = choicesRoot.boostTargets as WizardChoices['boostTargets'];
    }
    if (choicesRoot.skillIncrease && typeof choicesRoot.skillIncrease === 'object') {
      out.skillIncrease = choicesRoot.skillIncrease as WizardChoices['skillIncrease'];
    }
    if (typeof choicesRoot.featClassId === 'string') out.featClassId = choicesRoot.featClassId;
    if (typeof choicesRoot.featSkillId === 'string') out.featSkillId = choicesRoot.featSkillId;
    if (typeof choicesRoot.featGeneralId === 'string') out.featGeneralId = choicesRoot.featGeneralId;
    if (typeof choicesRoot.featAncestryId === 'string') out.featAncestryId = choicesRoot.featAncestryId;
    if (typeof choicesRoot.featArchetypeId === 'string') out.featArchetypeId = choicesRoot.featArchetypeId;
    if (choicesRoot.classFeatureChoices && typeof choicesRoot.classFeatureChoices === 'object') {
      out.classFeatureChoices = choicesRoot.classFeatureChoices as Record<string, string>;
    }
    if (Array.isArray(choicesRoot.spellcasterRepertoirePicks)) {
      out.spellcasterRepertoirePicks = (choicesRoot.spellcasterRepertoirePicks as unknown[])
        .filter((s): s is string => typeof s === 'string');
    }
    return out;
  }

  private async loadSessionAndAuthorize(sessionId: string, userId: string) {
    const session = await this.prisma.levelUpSession.findUnique({
      where: { id: sessionId },
    });
    if (!session) throw new NotFoundException('Stufenaufstiegs-Session nicht gefunden');
    // Delegate access check to the character path
    await this.charactersService.checkCharacterAccess(session.characterId, userId, true);
    return session;
  }

  /**
   * Load Character + every relation the recompute pipeline needs.
   *
   * Concrete shape (verify field names against schema.prisma):
   *   prisma.character.findUnique({
   *     where: { id: characterId },
   *     include: {
   *       class: true,                                      // Character.classId -> Class
   *       ancestry: true,                                   // Character.ancestryId -> Ancestry
   *       heritage: true,                                   // Character.heritageId? -> Heritage
   *       abilities: true,                                  // CharacterAbility[]
   *       skills: true,                                     // CharacterSkill[]
   *       feats: { include: { feat: true } },               // CharacterFeat[] with Feat
   *       spells: true,                                     // CharacterSpell[]
   *       resources: true,                                  // CharacterResource[] (spell slots, focus pts, etc.)
   *       items: true,                                      // CharacterItem[] (for armor -> AC computation)
   *     },
   *   })
   *
   * Then ALSO load the ClassProgression rows for class.name from level 1 up to and including
   * (character.level + 1) so the recompute lib can apply cumulative grants:
   *   const progressionRows = await this.prisma.classProgression.findMany({
   *     where: { className: character.class.name, level: { lte: character.level + 1 } },
   *     orderBy: { level: 'asc' },
   *   });
   *
   * Return type contract (define near top of file or in lib/types.ts):
   *   export interface CharacterFullContext {
   *     character: Character & { class: Class; ancestry: Ancestry; heritage: Heritage | null };
   *     abilities: CharacterAbility[];
   *     skills: CharacterSkill[];
   *     feats: (CharacterFeat & { feat: Feat | null })[];
   *     spells: CharacterSpell[];
   *     resources: CharacterResource[];
   *     items: CharacterItem[];
   *     progressionRows: ClassProgression[];   // L1..currentLevel inclusive
   *     className: string;
   *     level: number;
   *   }
   */
  private async loadCharacterFullForRecompute(characterId: string): Promise<CharacterFullContext> {
    const character = await this.prisma.character.findUnique({
      where: { id: characterId },
      include: {
        class: true,
        ancestry: true,
        heritage: true,
        abilities: true,
        skills: true,
        feats: { include: { feat: true } },
        spells: true,
        resources: true,
        items: true,
      },
    });
    if (!character) throw new NotFoundException('Charakter nicht gefunden');
    const className = character.class?.name ?? '';
    const progressionRows = await this.prisma.classProgression.findMany({
      where: { className, level: { lte: character.level } },
      orderBy: { level: 'asc' },
    });
    return {
      character,
      abilities: character.abilities,
      skills: character.skills,
      feats: character.feats,
      spells: character.spells,
      resources: character.resources,
      items: character.items,
      progressionRows,
      className,
      level: character.level,
    };
  }

  private async loadProgression(className: string, level: number): Promise<ClassProgressionRow> {
    const row = await this.prisma.classProgression.findUnique({
      where: { className_level: { className, level } },
    });
    if (!row) throw new NotFoundException(`Keine ClassProgression fuer ${className} Stufe ${level}`);
    return row as unknown as ClassProgressionRow;
  }

  /**
   * Vorher-Snapshot: call recomputeDerivedStats with the character's CURRENT level's
   * progression slice and NO new choices. This produces the same DerivedStats shape as
   * the after-commit recompute, ensuring the diff in the UI is computed from identical
   * units (apples-to-apples).
   *
   * The lib signature is recomputeDerivedStats(character, choices, progression):
   *   - character: CharacterContext (mapped via toCharacterContext below)
   *   - choices: null  -> "no new choices, just compute current stats"
   *   - progression: the ClassProgression row at character.level (NOT targetLevel)
   *
   * If the character is L1 and there is no L1 progression row yet (legacy data), fall back
   * to reading character.hpMax / character.armorClass / etc. directly from the row.
   */
  private async computeBeforeStats(ctx: CharacterFullContext): Promise<DerivedStats> {
    const charContext = this.toCharacterContext(ctx);
    const currentLevelProgression = await this.prisma.classProgression.findUnique({
      where: { className_level: { className: ctx.className, level: ctx.level } },
    });
    if (!currentLevelProgression) {
      // Fallback: read stored stats directly (legacy character or L0 edge case)
      return {
        level: ctx.level,
        hpMax: ctx.character.hpMax ?? 0,
        ac: ctx.character.armorClass ?? 10,
        classDc: ctx.character.classDc ?? 10,
        perception: ctx.character.perception ?? 0,
        fortitude: ctx.character.fortitude ?? 0,
        reflex: ctx.character.reflex ?? 0,
        will: ctx.character.will ?? 0,
      };
    }
    return recomputeDerivedStats(charContext, null, currentLevelProgression as unknown as ClassProgressionRow);
  }

  /**
   * LevelUpHistory.snapshotBefore JSON contract (versioned for forward-compat).
   *
   * Schema (define this explicit type next to the service file or in lib/types.ts):
   *   export interface CharacterSnapshot {
   *     version: '1.0';
   *     character: Character;                       // full Character row (no relations expanded)
   *     abilities: CharacterAbility[];
   *     skills: CharacterSkill[];
   *     feats: CharacterFeat[];                     // CharacterFeat rows (NOT the joined Feat objects -- IDs only)
   *     spells: CharacterSpell[];
   *     resources: CharacterResource[];
   *     items: CharacterItem[];                     // include for full restore in v2
   *     derived: DerivedStats;                      // the "Vorher" stats so reverse-level-up has both
   *   }
   *
   * Contract: this is the source-of-truth payload that LVL-V2-02 (reverse-level-up, planned
   * in REQUIREMENTS.md line 114) will read to restore the pre-commit state. Versioning the
   * shape (version: '1.0') lets v2 detect schema drift cleanly.
   */
  private buildSnapshot(ctx: CharacterFullContext, derivedBefore: DerivedStats): CharacterSnapshot {
    return {
      version: '1.0',
      character: ctx.character,
      abilities: ctx.abilities,
      skills: ctx.skills,
      feats: ctx.feats.map(f => ({ ...f, feat: undefined })),  // strip joined Feat to keep snapshot small
      spells: ctx.spells,
      resources: ctx.resources,
      items: ctx.items,
      derived: derivedBefore,
    };
  }

  /** Map our CharacterFullContext into the lib's pure CharacterContext shape. */
  private toCharacterContext(ctx: CharacterFullContext): CharacterContext {
    const abilitiesMap: Record<string, number> = {};
    for (const a of ctx.abilities) {
      abilitiesMap[a.ability] = a.score;
    }
    const skillsMap: Record<string, string> = {};
    for (const s of ctx.skills) {
      skillsMap[s.skillName] = s.proficiency;
    }
    return {
      level: ctx.level,
      className: ctx.className,
      ancestryName: ctx.character.ancestry?.name ?? '',
      heritageName: ctx.character.heritage?.name,
      abilities: abilitiesMap as CharacterContext['abilities'],
      skills: skillsMap as CharacterContext['skills'],
      feats: new Set(ctx.feats.map(f => f.feat?.name).filter((n): n is string => !!n)),
    };
  }

  private computeSpellcasterPreview(progression: ClassProgressionRow, choices: WizardChoices): LevelUpPreviewDto['spellcaster'] {
    if (!progression.spellSlotIncrement && !progression.cantripIncrement && !progression.repertoireIncrement) {
      return undefined;
    }
    return {
      slotIncrements: progression.spellSlotIncrement ? [progression.spellSlotIncrement] : [],
      cantripDelta: progression.cantripIncrement ?? undefined,
      repertoireDelta: progression.repertoireIncrement ?? undefined,
    };
  }

  private async maybeTranslateNewStrings(progression: ClassProgressionRow, choices: WizardChoices): Promise<void> {
    // For D-15: collect new prereq strings + class-feature descriptions encountered in this commit.
    // Pass them to TranslationsService.getTranslationsBatch — German results cached in DB.
    const items: { englishText: string; context: string }[] = [];
    for (const grant of progression.grants) {
      items.push({ englishText: grant, context: 'class-feature-name' });
    }
    if (items.length > 0) {
      await this.translationsService.getTranslationsBatch(items);
    }
  }
}
```

**Implementation notes for the executor:**

1. The TODO methods (`loadCharacterFullForRecompute`, `computeBeforeStats`, `buildSnapshot`) require reading the existing Character schema (`abilities`, `skills`, `feats`, `equipped armor` for AC, etc.). The executor reads `server/prisma/schema.prisma` lines 171-269 to wire these correctly.

2. The transaction body's specific upsert keys (e.g. `characterId_ability`) depend on the actual `@@unique` constraints on the existing CharacterAbility/CharacterSkill/CharacterFeat tables. Verify against `schema.prisma` and adjust.

3. The `data: any` cast in the broadcast payload is fine — it matches the existing gateway's untyped `data: any` (which is intentional per RESEARCH.md line 873; full typing is a future-phase refactor).

4. The `as unknown as` casts on `WizardChoices` are pragmatic Prisma-Json-to-TS bridges. Wrap them in the assertValidWizardChoices guard for safety.

5. Use `Logger` (NestJS) for every commit/discard/start log line — discoverable via NestJS log pipeline.

**Constraints:**
- `@Injectable()` decorator.
- Owner-or-GM permission per D-13 via `checkCharacterAccess(..., requireOwnership=true)`.
- SINGLE broadcast at the end (after `$transaction` returns).
- NEVER touch `hpCurrent` in the character update payload.
- All German user-facing error messages.
- No `: any` outside the existing gateway untyped-data convention.
cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "leveling.service" || echo "tsc clean" - File `server/src/modules/leveling/leveling.service.ts` exists - File contains `@Injectable()` - File contains the literal string `prisma.$transaction` (proves atomic commit pattern) - File contains the literal string `'level_up_committed'` (proves broadcast emission) - File contains the literal string `checkCharacterAccess` and `requireOwnership=true` or `requireOwnership = true` (D-13) - File contains the literal string `applyAttributeBoost` (uses Plan 02 boost helper) - File contains the literal string `recomputeDerivedStats` (uses Plan 02 recompute) - File does NOT contain the literal string `hpCurrent:` inside the `tx.character.update({ where..., data: ` block (Pitfall #9) - File does NOT contain the literal string `: any` outside comments (the `data: any` of the broadcast payload literal is fine — that's the existing gateway contract) - File contains German strings (verifiable: at least one of `Stufenaufstieg`, `Charakter`, `Sitzung`, `Stufe` appears as user-facing message) - All five public methods present: `startOrResume`, `patchState`, `computePreview`, `commit`, `discard` - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 (TODO bodies count as type-correct after executor fills them in) Service compiles, exposes 5 public methods, uses lib helpers, atomic transaction pattern in place, broadcast emitted once, hpCurrent never written, German messages. Task 5: LevelingController — 8 REST endpoints (5 core + 3 wizard-support) server/src/modules/leveling/leveling.controller.ts - server/src/modules/characters/characters.controller.ts (canonical controller — endpoint pattern, decorators, CurrentUser) - server/src/common/decorators/current-user.decorator.ts (extracts userId from JWT) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 130-169 — controller pattern with decorators) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 826-862 — exact controller shape) Create `server/src/modules/leveling/leveling.controller.ts`:
```typescript
import {
  Body, Controller, Delete, Get, Param, Patch, Post,
} from '@nestjs/common';
import {
  ApiBearerAuth, ApiOperation, ApiResponse, ApiTags,
} from '@nestjs/swagger';
import { LevelingService } from './leveling.service';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
  StartLevelUpDto,
  PatchLevelUpDto,
  CommitLevelUpDto,
  LevelUpPreviewDto,
  LevelUpSessionDto,
} from './dto';

@ApiTags('Level-Up')
@ApiBearerAuth()
@Controller('characters/:characterId/level-up')
export class LevelingController {
  constructor(private readonly levelingService: LevelingService) {}

  @Post()
  @ApiOperation({ summary: 'Start a new level-up session or resume the open one' })
  @ApiResponse({ status: 201, description: 'LevelUpSession created or resumed', type: LevelUpSessionDto })
  async start(
    @Param('characterId') characterId: string,
    @Body() dto: StartLevelUpDto,
    @CurrentUser('id') userId: string,
  ) {
    return this.levelingService.startOrResume(characterId, dto, userId);
  }

  @Patch(':sessionId')
  @ApiOperation({ summary: 'Merge partial wizard state into the DRAFT' })
  @ApiResponse({ status: 200, description: 'Updated LevelUpSession', type: LevelUpSessionDto })
  async patch(
    @Param('characterId') characterId: string,
    @Param('sessionId') sessionId: string,
    @Body() dto: PatchLevelUpDto,
    @CurrentUser('id') userId: string,
  ) {
    return this.levelingService.patchState(characterId, sessionId, dto, userId);
  }

  @Get(':sessionId/preview')
  @ApiOperation({ summary: 'Vorher/Nachher-Vorschau (no commit)' })
  @ApiResponse({ status: 200, description: 'Preview', type: LevelUpPreviewDto })
  async preview(
    @Param('characterId') characterId: string,
    @Param('sessionId') sessionId: string,
    @CurrentUser('id') userId: string,
  ): Promise<LevelUpPreviewDto> {
    return this.levelingService.computePreview(characterId, sessionId, userId);
  }

  @Post(':sessionId/commit')
  @ApiOperation({ summary: 'Bestätigen — atomic commit' })
  @ApiResponse({ status: 200, description: 'Updated Character' })
  async commit(
    @Param('characterId') characterId: string,
    @Param('sessionId') sessionId: string,
    @Body() dto: CommitLevelUpDto,
    @CurrentUser('id') userId: string,
  ) {
    return this.levelingService.commit(characterId, sessionId, dto, userId);
  }

  @Delete(':sessionId')
  @ApiOperation({ summary: 'Verwerfen — discard the DRAFT' })
  @ApiResponse({ status: 204, description: 'DRAFT removed' })
  async discard(
    @Param('characterId') characterId: string,
    @Param('sessionId') sessionId: string,
    @CurrentUser('id') userId: string,
  ): Promise<void> {
    await this.levelingService.discard(characterId, sessionId, userId);
  }

  // === Wizard-support endpoints (added so the React wizard in Plan 05 doesn't need extra round-trips) ===

  @Get()
  @ApiOperation({ summary: 'Get the open DRAFT for this character (resume-banner detection)' })
  @ApiResponse({ status: 200, description: 'Open DRAFT or null', type: LevelUpSessionDto })
  async getOpenDraft(
    @Param('characterId') characterId: string,
    @CurrentUser('id') userId: string,
  ) {
    const draft = await this.levelingService.findOpenDraft(characterId, userId);
    if (!draft) {
      throw new NotFoundException('Keine offene Stufenaufstiegs-Session');
    }
    return draft;
  }

  @Get(':sessionId/feats')
  @ApiOperation({ summary: 'Filtered feat list for a wizard slot (driven by FeatFilterService)' })
  @ApiResponse({ status: 200, description: 'Feats with prereq evaluation results' })
  async getFeats(
    @Param('characterId') characterId: string,
    @Param('sessionId') sessionId: string,
    @Query('slot') slot: SlotKind,
    @Query('includeUnavailable') includeUnavailable: string | undefined,
    @CurrentUser('id') userId: string,
  ) {
    return this.levelingService.getFeatsForSlot(
      characterId,
      sessionId,
      slot,
      includeUnavailable === 'true',
      userId,
    );
  }

  @Get(':sessionId/class-feature-options/:optionsRef')
  @ApiOperation({ summary: 'Options for a class-feature choice step (e.g. Cleric Doctrine)' })
  @ApiResponse({ status: 200, description: 'ClassFeatureOption rows for the optionsRef' })
  async getClassFeatureOptions(
    @Param('characterId') characterId: string,
    @Param('sessionId') sessionId: string,
    @Param('optionsRef') optionsRef: string,
    @CurrentUser('id') userId: string,
  ) {
    return this.levelingService.getClassFeatureOptions(
      characterId,
      sessionId,
      optionsRef,
      userId,
    );
  }
}
```

Add the imports at the top of the file:

```typescript
import { Body, Controller, Delete, Get, NotFoundException, Param, Patch, Post, Query } from '@nestjs/common';
import { SlotKind } from './feat-filter.service';
```

**Constraints:**
- JWT auth is global (per `server/src/app.module.ts` lines 53-56) — no `@UseGuards` needed.
- `@CurrentUser('id')` decorator extracts userId from JWT.
- Route prefix: `characters/:characterId/level-up` so DELETE/PATCH/COMMIT all share the same shape.
- All Swagger decorators present for OpenAPI generation.
cd server && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "leveling.controller" || echo "tsc clean" - File `server/src/modules/leveling/leveling.controller.ts` exists - File contains `@Controller('characters/:characterId/level-up')` - File contains `@Post()` (start), `@Patch(':sessionId')` (patch), `@Get(':sessionId/preview')`, `@Post(':sessionId/commit')`, `@Delete(':sessionId')` - File contains `@Get()` (open-DRAFT detect), `@Get(':sessionId/feats')` (filtered feat list), `@Get(':sessionId/class-feature-options/:optionsRef')` (option list) - All 8 methods present with `@ApiOperation` decorators - File imports `LevelingService` and DTOs from barrel - File contains `@CurrentUser('id') userId: string` - File contains NO `: any` - `cd server && npx tsc --noEmit -p tsconfig.json` exits 0 Controller exposes 8 REST endpoints (5 core: start/patch/preview/commit/discard + 3 wizard-support: getOpenDraft/getFeats/getClassFeatureOptions) with full Swagger annotations, JWT auth via global guard, delegates to LevelingService. Task 6: LevelingModule + register in AppModule server/src/modules/leveling/leveling.module.ts, server/src/app.module.ts - server/src/modules/characters/characters.module.ts (canonical module — JwtModule + provider list) - server/src/app.module.ts (where to register — imports array, line ~25) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 92-127 — module pattern + app.module registration) **1. Create `server/src/modules/leveling/leveling.module.ts`:**
```typescript
import { Module, forwardRef } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LevelingController } from './leveling.controller';
import { LevelingService } from './leveling.service';
import { FeatFilterService } from './feat-filter.service';
import { CharactersModule } from '../characters/characters.module';
import { TranslationsModule } from '../translations/translations.module';

@Module({
  imports: [
    TranslationsModule,
    forwardRef(() => CharactersModule),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET'),
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [LevelingController],
  providers: [LevelingService, FeatFilterService],
  exports: [LevelingService, FeatFilterService],
})
export class LevelingModule {}
```

**2. Register in `server/src/app.module.ts`:**

Open the file. Find the `imports: [...]` array on `@Module({ ... })`. Append `LevelingModule` to it.

Add the import statement at the top of the file:

```typescript
import { LevelingModule } from './modules/leveling/leveling.module';
```

Add to the imports array (alphabetical order with other feature modules):

```typescript
imports: [
  // ... existing modules ...
  LevelingModule,
  // ... rest ...
],
```

Do NOT touch any other configuration in app.module.ts (JwtAuthGuard, validation pipe, etc. all stay).

Note on `forwardRef`: CharactersModule already imports things and providing `LevelingService` may create a circular dep if CharactersGateway is exported from CharactersModule and LevelingService injects it. Use `forwardRef(() => CharactersModule)` per PATTERNS.md to break the cycle. CharactersModule must export `CharactersGateway` and `CharactersService` for LevelingService to inject them — verify this in `characters.module.ts` and add to its `exports: []` if missing.

**If CharactersModule does NOT currently export `CharactersGateway` or `CharactersService`:** open `server/src/modules/characters/characters.module.ts` and add them to `exports: []`. This is a non-breaking change.
cd server && npm run build 2>&1 | tail -20 - File `server/src/modules/leveling/leveling.module.ts` exists with `@Module` decorator - File contains `controllers: [LevelingController]` - File contains `providers: [LevelingService, FeatFilterService]` - File contains `exports: [LevelingService, FeatFilterService]` - File contains `forwardRef(() => CharactersModule)` - `server/src/app.module.ts` contains the import line `import { LevelingModule } from './modules/leveling/leveling.module'` - `server/src/app.module.ts` references `LevelingModule` inside an `imports: [` array - `server/src/modules/characters/characters.module.ts` exports `CharactersGateway` AND `CharactersService` (verify or add) - `cd server && npm run build` exits 0 (Nest can resolve the dependency graph at compile time) LevelingModule wired, registered in AppModule, no circular-dep errors at build, NestJS recognizes the new endpoints (visible in startup log when started, and in Swagger). Task 7: Integration tests for LevelingService — atomic commit, broadcast count, hpCurrent invariant, race condition server/src/modules/leveling/leveling.service.spec.ts, server/src/modules/leveling/feat-filter.service.spec.ts - .planning/phases/01-level-up-pf2e-regelkonform/01-VALIDATION.md (rows 1-W3-01 to 1-W3-10 — every assertion must be covered) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 1027-1031 — first NestJS integration test, no analog — use @nestjs/testing) - server/src/modules/leveling/leveling.service.ts (the SUT) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 992-1007 — integration test rows from research) Create `server/src/modules/leveling/leveling.service.spec.ts`. Use `@nestjs/testing` (already installed) with the real `PrismaService` against a test database — OR mock Prisma if the dev setup doesn't have a test DB. Default: use Prisma against the dev DB with cleanup after each test (Phase 1 establishes the integration test pattern; future phases may invest in a Testcontainers-backed test DB).
**Test plan (from VALIDATION.md):**

```typescript
import { Test, TestingModule } from '@nestjs/testing';
import { LevelingService } from './leveling.service';
import { PrismaService } from '../../prisma/prisma.service';
import { CharactersService } from '../characters/characters.service';
import { CharactersGateway } from '../characters/characters.gateway';
import { TranslationsService } from '../translations/translations.service';

describe('LevelingService — integration', () => {
  let service: LevelingService;
  let prisma: PrismaService;
  let gatewayBroadcastSpy: jest.SpyInstance;
  let testCharacterId: string;
  let testUserId: string;

  beforeAll(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        LevelingService,
        PrismaService,
        // Use mocks for Characters services where appropriate
        {
          provide: CharactersService,
          useValue: { checkCharacterAccess: jest.fn().mockResolvedValue({ /* test character */ }) },
        },
        {
          provide: CharactersGateway,
          useValue: { broadcastCharacterUpdate: jest.fn() },
        },
        {
          provide: TranslationsService,
          useValue: { getTranslationsBatch: jest.fn().mockResolvedValue({}) },
        },
      ],
    }).compile();
    service = module.get(LevelingService);
    prisma = module.get(PrismaService);

    const gw = module.get<CharactersGateway>(CharactersGateway);
    gatewayBroadcastSpy = jest.spyOn(gw, 'broadcastCharacterUpdate');

    // Set up a test character + user in the DB (executor implements seeding)
    // ...
  });

  afterEach(async () => {
    // Clean up LevelUpSession rows created in tests (executor implements cleanup)
  });

  afterAll(async () => {
    await prisma.$disconnect();
  });

  /**
   * Helper: seed a Fighter character at level 4 with full relations needed for commit.
   * Returns { characterId, ownerId, sessionId } so each test starts from a clean known state.
   */
  async function seedFighterAtL4(): Promise<{ characterId: string; ownerId: string }> {
    const owner = await prisma.user.create({
      data: { email: `test-${Date.now()}@example.com`, password: 'x', role: 'PLAYER' },
    });
    const campaign = await prisma.campaign.create({
      data: { name: 'Test', gmId: owner.id, members: { create: { userId: owner.id, role: 'PLAYER' } } },
    });
    const fighterClass = await prisma.class.upsert({
      where: { name: 'Fighter' },
      update: {},
      create: { name: 'Fighter', hp: 10, keyAbility: 'STR' },
    });
    const ancestry = await prisma.ancestry.upsert({
      where: { name: 'Human' }, update: {}, create: { name: 'Human', hp: 8 },
    });
    const character = await prisma.character.create({
      data: {
        name: 'Test Fighter', campaignId: campaign.id, ownerId: owner.id,
        classId: fighterClass.id, ancestryId: ancestry.id,
        level: 4, hpMax: 50, hpCurrent: 12,                          // hpCurrent set deliberately != hpMax
        armorClass: 21, classDc: 21, perception: 10,
        fortitude: 11, reflex: 9, will: 7,
        abilities: { create: [
          { ability: 'STR', score: 18 }, { ability: 'DEX', score: 14 }, { ability: 'CON', score: 16 },
          { ability: 'INT', score: 10 }, { ability: 'WIS', score: 12 }, { ability: 'CHA', score: 10 },
        ]},
      },
    });
    // Seed ClassProgression rows L1..L5 for Fighter so loadProgression has data
    for (let lv = 1; lv <= 5; lv++) {
      await prisma.classProgression.upsert({
        where: { className_level: { className: 'Fighter', level: lv } },
        update: {},
        create: {
          className: 'Fighter', level: lv,
          grants: lv === 5 ? ['Fighter Weapon Mastery'] : [],
          proficiencyChanges: {},
        },
      });
    }
    return { characterId: character.id, ownerId: owner.id };
  }

  /** Helper: build a fresh DRAFT session with valid choices for an L4 -> L5 Fighter. */
  async function makeReadyDraft(characterId: string, choices: WizardChoices): Promise<string> {
    const session = await prisma.levelUpSession.create({
      data: { characterId, targetLevel: 5, state: { choices } as Prisma.InputJsonValue },
    });
    return session.id;
  }

  // 1-W3-01 + 1-W3-02
  describe('commit transaction atomicity', () => {
    it('rolls back ALL writes when mid-transaction throws', async () => {
      const { characterId, ownerId } = await seedFighterAtL4();
      const sessionId = await makeReadyDraft(characterId, {
        boostTargets: ['STR', 'DEX', 'CON', 'INT'],
        featClassId: 'fake-feat-id-that-does-not-exist',
      });
      // Force LevelUpHistory.create to throw — simulates DB hiccup mid-transaction
      const historyCreateSpy = jest
        .spyOn(prisma.levelUpHistory, 'create')
        .mockRejectedValueOnce(new Error('boom'));
      await expect(
        service.commit(characterId, sessionId, {}, ownerId),
      ).rejects.toThrow(/boom/);
      // Verify rollback: character.level unchanged, no history row, session still DRAFT
      const after = await prisma.character.findUnique({ where: { id: characterId } });
      expect(after?.level).toBe(4);
      expect(after?.hpMax).toBe(50);
      const history = await prisma.levelUpHistory.findMany({ where: { characterId } });
      expect(history).toHaveLength(0);
      const session = await prisma.levelUpSession.findUnique({ where: { id: sessionId } });
      expect(session?.committedAt).toBeNull();
      historyCreateSpy.mockRestore();
    });

    it('creates exactly one LevelUpHistory row with snapshotBefore + choices populated', async () => {
      const { characterId, ownerId } = await seedFighterAtL4();
      const sessionId = await makeReadyDraft(characterId, {
        boostTargets: ['STR', 'DEX', 'CON', 'INT'],
      });
      await service.commit(characterId, sessionId, {}, ownerId);
      const history = await prisma.levelUpHistory.findMany({ where: { characterId } });
      expect(history).toHaveLength(1);
      expect(history[0].levelFrom).toBe(4);
      expect(history[0].levelTo).toBe(5);
      expect(history[0].snapshotBefore).not.toBeNull();
      expect(history[0].choices).not.toBeNull();
      // Snapshot version contract
      expect((history[0].snapshotBefore as Record<string, unknown>).version).toBe('1.0');
    });
  });

  // 1-W3-03
  describe('broadcast count', () => {
    it('emits level_up_committed exactly once per commit', async () => {
      const { characterId, ownerId } = await seedFighterAtL4();
      const sessionId = await makeReadyDraft(characterId, {
        boostTargets: ['STR', 'DEX', 'CON', 'INT'],
      });
      gatewayBroadcastSpy.mockClear();
      await service.commit(characterId, sessionId, {}, ownerId);
      expect(gatewayBroadcastSpy).toHaveBeenCalledTimes(1);
      expect(gatewayBroadcastSpy.mock.calls[0][1].type).toBe('level_up_committed');
      expect(gatewayBroadcastSpy.mock.calls[0][1].data.level).toBe(5);
      expect(gatewayBroadcastSpy.mock.calls[0][1].data.derived).toBeDefined();
    });
  });

  // Pitfall #9 -- explicit hpCurrent invariant
  describe('Pitfall #9 -- never mutates hpCurrent', () => {
    it('does NOT include hpCurrent in the character update payload', async () => {
      const { characterId, ownerId } = await seedFighterAtL4();
      const sessionId = await makeReadyDraft(characterId, {
        boostTargets: ['STR', 'DEX', 'CON', 'INT'],
      });
      // Spy on the (transactional) character.update inside $transaction. Capture every call.
      const updateCalls: Array<{ data: Record<string, unknown> }> = [];
      const origTx = prisma.$transaction.bind(prisma);
      jest.spyOn(prisma, '$transaction').mockImplementation(async (fn) => {
        return origTx(async (tx) => {
          const wrappedTx = {
            ...tx,
            character: {
              ...tx.character,
              update: (args: { data: Record<string, unknown> }) => {
                updateCalls.push({ data: args.data });
                return tx.character.update(args);
              },
            },
          };
          return (fn as Function)(wrappedTx);
        });
      });
      await service.commit(characterId, sessionId, {}, ownerId);
      // Verify hpCurrent NOT included in any character.update inside the tx
      for (const call of updateCalls) {
        expect(call.data).not.toHaveProperty('hpCurrent');
      }
      // And verify hpCurrent in DB is unchanged from seed value (12)
      const after = await prisma.character.findUnique({ where: { id: characterId } });
      expect(after?.hpCurrent).toBe(12);
    });
  });

  // 1-W3-04
  describe('discard', () => {
    it('removes the DRAFT and leaves the character untouched', async () => {
      const { characterId, ownerId } = await seedFighterAtL4();
      const sessionId = await makeReadyDraft(characterId, { boostTargets: ['STR', 'DEX', 'CON', 'INT'] });
      const beforeChar = await prisma.character.findUnique({ where: { id: characterId } });
      await service.discard(characterId, sessionId, ownerId);
      const afterChar = await prisma.character.findUnique({ where: { id: characterId } });
      expect(afterChar?.level).toBe(beforeChar?.level);
      expect(afterChar?.hpMax).toBe(beforeChar?.hpMax);
      const session = await prisma.levelUpSession.findUnique({ where: { id: sessionId } });
      expect(session).toBeNull();
    });
  });

  // 1-W3-05 -- race condition / partial unique index
  describe('partial unique index', () => {
    it('allows creating a new DRAFT after the previous was committed', async () => {
      const { characterId, ownerId } = await seedFighterAtL4();
      const sessionId = await makeReadyDraft(characterId, { boostTargets: ['STR', 'DEX', 'CON', 'INT'] });
      await service.commit(characterId, sessionId, {}, ownerId);
      // Now character is L5; seed L6 progression
      await prisma.classProgression.upsert({
        where: { className_level: { className: 'Fighter', level: 6 } },
        update: {}, create: { className: 'Fighter', level: 6, grants: [], proficiencyChanges: {} },
      });
      // Second DRAFT for L5 -> L6 should succeed
      const newSession = await service.startOrResume(characterId, { targetLevel: 6 }, ownerId);
      expect(newSession.committedAt).toBeNull();
      expect(newSession.targetLevel).toBe(6);
    });

    it('rejects creating a second open DRAFT for the same character via raw insert', async () => {
      const { characterId } = await seedFighterAtL4();
      await prisma.levelUpSession.create({
        data: { characterId, targetLevel: 5, state: {} },
      });
      // Raw INSERT bypasses Prisma findFirst-then-create guard and hits the partial index
      await expect(
        prisma.$executeRawUnsafe(
          `INSERT INTO "LevelUpSession" ("id", "characterId", "targetLevel", "state", "createdAt", "updatedAt") VALUES ('${'00000000-0000-0000-0000-000000000099'}', '${characterId}', 5, '{}', NOW(), NOW())`,
        ),
      ).rejects.toThrow(/LevelUpSession_characterId_open_unique|unique constraint/i);
    });
  });

  // 1-W3-06 + 1-W3-07
  describe('spellcaster slot increments', () => {
    it('applies spellSlotIncrement from ClassProgression for caster classes', async () => {
      // Seed a Wizard character at L1, with L2 ClassProgression carrying spellSlotIncrement
      const owner = await prisma.user.create({ data: { email: `wiz-${Date.now()}@x.x`, password: 'x', role: 'PLAYER' } });
      const campaign = await prisma.campaign.create({ data: { name: 'C', gmId: owner.id, members: { create: { userId: owner.id, role: 'PLAYER' } } } });
      const wizardClass = await prisma.class.upsert({ where: { name: 'Wizard' }, update: {}, create: { name: 'Wizard', hp: 6, keyAbility: 'INT' } });
      const ancestry = await prisma.ancestry.upsert({ where: { name: 'Elf' }, update: {}, create: { name: 'Elf', hp: 6 } });
      const wiz = await prisma.character.create({
        data: { name: 'W', campaignId: campaign.id, ownerId: owner.id, classId: wizardClass.id, ancestryId: ancestry.id, level: 1, hpMax: 14, hpCurrent: 14 },
      });
      await prisma.classProgression.upsert({
        where: { className_level: { className: 'Wizard', level: 2 } },
        update: { spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } },
        create: { className: 'Wizard', level: 2, grants: [], proficiencyChanges: {}, spellSlotIncrement: { tradition: 'ARCANE', spellLevel: 1, count: 1 } },
      });
      const sessionId = await makeReadyDraft(wiz.id, {});
      await service.commit(wiz.id, sessionId, {}, owner.id);
      // Assert: a CharacterResource row reflects the new slot (executor adapts to actual schema)
      const resources = await prisma.characterResource.findMany({ where: { characterId: wiz.id } });
      // Either: a "spell-slot-arcane-1" entry exists with count >= 1, or maxValue increased
      expect(resources.length).toBeGreaterThan(0);
    });

    it('does NOT add slots for non-casters (Fighter)', async () => {
      const { characterId, ownerId } = await seedFighterAtL4();
      const sessionId = await makeReadyDraft(characterId, { boostTargets: ['STR', 'DEX', 'CON', 'INT'] });
      await service.commit(characterId, sessionId, {}, ownerId);
      const resources = await prisma.characterResource.findMany({
        where: { characterId, name: { contains: 'spell-slot' } },
      });
      expect(resources).toHaveLength(0);
    });
  });

  // 1-W3-08 -- translation pipeline
  describe('translation kick-off', () => {
    it('calls TranslationsService.getTranslationsBatch with new prereq strings during commit', async () => {
      const { characterId, ownerId } = await seedFighterAtL4();
      // Seed L5 progression with grants to trigger the maybeTranslateNewStrings call
      await prisma.classProgression.update({
        where: { className_level: { className: 'Fighter', level: 5 } },
        data: { grants: ['Fighter Weapon Mastery'] },
      });
      const sessionId = await makeReadyDraft(characterId, { boostTargets: ['STR', 'DEX', 'CON', 'INT'] });
      // Get the mocked TranslationsService from the test module
      const trans = module.get<TranslationsService>(TranslationsService);
      const transSpy = jest.spyOn(trans, 'getTranslationsBatch');
      transSpy.mockClear();
      await service.commit(characterId, sessionId, {}, ownerId);
      expect(transSpy).toHaveBeenCalled();
      const items = transSpy.mock.calls[0][0];
      expect(Array.isArray(items)).toBe(true);
      expect(items.length).toBeGreaterThan(0);
      expect(items[0]).toHaveProperty('englishText');
    });
  });

  // 1-W3-10 -- access control
  describe('access control (D-13)', () => {
    it('rejects when user is neither Owner nor GM', async () => {
      // Make the CharactersService mock throw ForbiddenException for this user
      const charService = module.get<CharactersService>(CharactersService);
      jest.spyOn(charService, 'checkCharacterAccess').mockRejectedValueOnce(
        new ForbiddenException('Kein Zugriff auf diesen Charakter'),
      );
      const { characterId } = await seedFighterAtL4();
      await expect(
        service.startOrResume(characterId, {}, 'random-user-id'),
      ).rejects.toThrow(/Kein Zugriff|Forbidden/);
    });
  });
});
```

**NOTE FOR EXECUTOR:** the test module returned by Test.createTestingModule must be kept in
a module-level variable so individual tests can `module.get(TranslationsService)` etc. The
skeleton above stores it in `let module: TestingModule` declared at the describe scope; the
`beforeAll` initializes it. Add the missing `let module: TestingModule;` declaration next to
the other `let` declarations at the top of the describe block.

**Also create `server/src/modules/leveling/feat-filter.service.spec.ts`** (1-W3-09):

```typescript
import { Test } from '@nestjs/testing';
import { FeatFilterService } from './feat-filter.service';
import { PrismaService } from '../../prisma/prisma.service';
import type { CharacterContext } from './lib/types';

/** Test fixture: a Fighter L4 with no relevant prereq-satisfying feats. */
const fighterL4Ctx: CharacterContext = {
  level: 4,
  className: 'Fighter',
  ancestryName: 'Human',
  heritageName: undefined,
  abilities: { STR: 18, DEX: 14, CON: 16, INT: 10, WIS: 12, CHA: 10 },
  skills: { Athletics: 'EXPERT', Acrobatics: 'TRAINED' },
  feats: new Set<string>(),
};

/** Test fixture: a Wizard L4 with INT-based skill ranks for prereq matching. */
const wizardL4Ctx: CharacterContext = {
  level: 4,
  className: 'Wizard',
  ancestryName: 'Elf',
  heritageName: undefined,
  abilities: { STR: 10, DEX: 14, CON: 12, INT: 18, WIS: 12, CHA: 10 },
  skills: { Arcana: 'EXPERT' },
  feats: new Set<string>(),
};

describe('FeatFilterService', () => {
  let service: FeatFilterService;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      providers: [FeatFilterService, PrismaService],
    }).compile();
    service = module.get(FeatFilterService);
  });

  describe('getFilteredFeats', () => {
    it('returns only feats with eval.ok === true OR eval.unknown === true (default)', async () => {
      const result = await service.getFilteredFeats({ slot: 'class', character: fighterL4Ctx });
      for (const f of result) {
        expect(f.eval.ok === true || ('unknown' in f.eval && f.eval.unknown)).toBe(true);
      }
    });

    it('respects slot=class -> only CLASS-source feats', async () => {
      const result = await service.getFilteredFeats({ slot: 'class', character: fighterL4Ctx });
      for (const f of result) expect(f.source).toBe('CLASS');
    });

    it('respects slot=general -> returns BOTH GENERAL and SKILL feats (LVL-05)', async () => {
      const result = await service.getFilteredFeats({ slot: 'general', character: fighterL4Ctx });
      const sources = new Set(result.map(f => f.source));
      expect(sources.has('GENERAL') || sources.has('SKILL')).toBe(true);
    });

    it('with includeUnavailable=true returns failed feats annotated with reason', async () => {
      const result = await service.getFilteredFeats({
        slot: 'class', character: wizardL4Ctx, includeUnavailable: true,
      });
      // The seeded Feat table contains feats with ability/skill prereqs Wizard does not satisfy
      // (e.g. STR-gated class feats). At least some entry must have eval.ok === false.
      const hasFailing = result.some(f => f.eval.ok === false);
      expect(hasFailing).toBe(true);
    });
  });
});
```

**Implementation note for the executor:**

Many of the integration tests need real DB seeds (a test character, a test campaign, ClassProgression rows for Wizard L1, etc.). The executor sets these up in `beforeAll` and tears down in `afterEach`/`afterAll`. If the dev environment doesn't have a separate test DB, tests run against the dev DB but use unique IDs and clean up after themselves.

Tests are slower than unit tests; that's expected for integration tier. The full suite should still complete in <30s per VALIDATION.md "Estimated runtime".
cd server && npm test -- --testPathPattern=leveling - File `server/src/modules/leveling/leveling.service.spec.ts` exists - File contains at least 9 `it(` invocations covering: atomicity, history-row, broadcast-count, hpCurrent invariant, discard, partial-unique-index (×2), spellcaster (×2), translation, access control - File `server/src/modules/leveling/feat-filter.service.spec.ts` exists with at least 4 `it(` invocations - File contains the assertion `expect(gatewayBroadcastSpy).toHaveBeenCalledTimes(1)` (Pitfall #9 broadcast count check) - File contains an assertion that the prisma.character.update payload does NOT contain `hpCurrent` (Pitfall #9) - File contains a partial-unique-index assertion (race-condition test) - `cd server && npm test -- --testPathPattern=leveling` exits 0 (all leveling tests including the Plan 02 lib + Plan 04 integration tests pass) - Total leveling test count is at least 60 (50 from Plan 02 lib + at least 13 here) Integration tests written for atomic commit transaction, broadcast count, hpCurrent invariant, race condition, FA, spellcaster, translation, access control. All VALIDATION.md W3 rows (1-W3-01 through 1-W3-10) covered. Full leveling suite green. Task 8: Extend pathbuilder-import.service.ts — prereq violations + FA auto-detect server/src/modules/characters/pathbuilder-import.service.ts - server/src/modules/characters/pathbuilder-import.service.ts (entire file — must understand the existing import flow, especially lines 248-272 where feats are processed) - server/src/modules/leveling/lib/prereq-evaluator.ts (Plan 02) - server/src/modules/leveling/lib/types.ts (CharacterContext shape) - .planning/phases/01-level-up-pf2e-regelkonform/01-PATTERNS.md (lines 400-432 — pathbuilder-import extension pattern) - .planning/phases/01-level-up-pf2e-regelkonform/01-RESEARCH.md (lines 577-587 — Pattern 6 FA auto-detect heuristic) - .planning/phases/01-level-up-pf2e-regelkonform/01-CONTEXT.md (D-05/D-06 prereq violations; D-09 FA auto-detect) Open `server/src/modules/characters/pathbuilder-import.service.ts`. After the existing `importCharacter` function creates the Character + CharacterFeat rows (~lines 248-272), append two new steps:
**Step A — Run prereq evaluator over imported feats and persist violations to Character.prereqViolations (D-05/D-06):**

Add this block at the END of the `importCharacter` method, BEFORE the return statement:

```typescript
// === Phase 1: Prereq evaluation across imported feats (D-05, D-06) ===
const characterContext: CharacterContext = {
  level: character.level,
  className: character.class?.name ?? '',
  ancestryName: character.ancestry?.name ?? '',
  heritageName: character.heritage?.name,
  abilities: this.abilitiesToContextMap(character.abilities),
  skills: this.skillsToContextMap(character.skills),
  feats: new Set(character.feats.map(f => f.feat?.name).filter((n): n is string => !!n)),
};

const violations: Array<{ featId: string; featName: string; prereqText: string }> = [];
for (const characterFeat of character.feats) {
  const featRecord = await this.prisma.feat.findUnique({ where: { id: characterFeat.featId } });
  if (!featRecord || !featRecord.prerequisites) continue;
  const result = evaluatePrereq(featRecord.prerequisites, characterContext);
  if (result.ok === false) {
    violations.push({
      featId: characterFeat.featId,
      featName: featRecord.name,
      prereqText: featRecord.prerequisites,
    });
  }
}
if (violations.length > 0) {
  await this.prisma.character.update({
    where: { id: character.id },
    data: { prereqViolations: { violations } },
  });
  this.logger.log(
    `PathbuilderImport: ${violations.length} prereq violations recorded for character ${character.name}`,
  );
}
```

**Step B — Auto-detect Free Archetype based on imported feats (D-09):**

Add this block immediately after Step A:

```typescript
// === Phase 1: Free Archetype auto-detect (D-09) ===
// Heuristic per RESEARCH.md §Pattern 6:
//  - count of Archetype-typed feats >= 2 → likely FA
//  - OR any even level has 2+ Class-or-Archetype feats
const archetypeFeatCount = character.feats.filter(f => f.source === 'ARCHETYPE').length;
const evenLevelClassFeatCounts = new Map<number, number>();
for (const f of character.feats) {
  if ((f.source === 'CLASS' || f.source === 'ARCHETYPE') && f.level && f.level % 2 === 0) {
    evenLevelClassFeatCounts.set(f.level, (evenLevelClassFeatCounts.get(f.level) ?? 0) + 1);
  }
}
const anyEvenLevelHasMultiple = Array.from(evenLevelClassFeatCounts.values()).some(c => c >= 2);
const detectedFA = archetypeFeatCount >= 2 || anyEvenLevelHasMultiple;
if (detectedFA) {
  await this.prisma.character.update({
    where: { id: character.id },
    data: { freeArchetype: true },
  });
  this.logger.log(
    `PathbuilderImport: detected FA=true for character ${character.name} ` +
    `based on ${archetypeFeatCount} archetype feats and ${evenLevelClassFeatCounts.size} even levels with multiple class feats`,
  );
}
```

**Add the import statement at the top of the file:**

```typescript
import { evaluatePrereq } from '../leveling/lib/prereq-evaluator';
import type { CharacterContext } from '../leveling/lib/types';
```

**Helper methods** (add private methods to the service class for `abilitiesToContextMap` and `skillsToContextMap` — they convert from the Prisma row shape to the CharacterContext shape; executor implements per the actual schema fields).

**Constraints:**
- The new code must be additive: do NOT change any existing import logic.
- If `evaluatePrereq` returns `{unknown: true}`, do NOT add it to violations (only `{ok: false}` are violations per D-06).
- The FA detection is a heuristic — A1 in RESEARCH.md flags this as LOW confidence. Per D-09, the player can flip the toggle in character settings later.
- Use NestJS `Logger` (already injected as `this.logger` if present; if not, add `private readonly logger = new Logger(PathbuilderImportService.name)`).
cd server && grep -c "evaluatePrereq" src/modules/characters/pathbuilder-import.service.ts - File `server/src/modules/characters/pathbuilder-import.service.ts` contains the literal string `evaluatePrereq` (proves Plan 02 import) - File contains the literal string `prereqViolations` (proves D-06 persistence) - File contains the literal string `freeArchetype: true` (proves D-09 auto-detect) - File contains the literal string `detected FA=true` (log message proves observability) - Import statement `import { evaluatePrereq } from '../leveling/lib/prereq-evaluator'` present at top - Import statement `import type { CharacterContext } from '../leveling/lib/types'` present at top - File contains NO `: any` outside existing comments - `cd server && npm run build` exits 0 Pathbuilder import now writes Character.prereqViolations on D-06 violations and auto-sets Character.freeArchetype on D-09 FA detection. Logs both detections via NestJS Logger.

<threat_model>

Trust Boundaries

Boundary Description
client → REST endpoints Untrusted JSON crosses HTTP boundary at all 5 leveling endpoints. JWT auth + class-validator + service-level guards.
client → WebSocket The new level_up_committed event is server-emit only — no inbound WebSocket handler.
LevelingService → DB Atomic transaction crosses application/DB trust boundary. Partial unique index enforced at DB level for concurrency safety.
PathbuilderImportService → DB Imports user-uploaded JSON; new evaluator + persist crosses to DB.

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-1-W3-01 Spoofing Player POSTs commit for someone else's character (forged characterId) mitigate All 5 endpoints call checkCharacterAccess(characterId, userId, true) — throws ForbiddenException if neither owner nor GM. Integration test 1-W3-10 covers.
T-1-W3-02 Tampering Player crafts a PATCH with 5 boost targets or non-applicable step's choices mitigate DTO PatchLevelUpDto rejects non-object shape; service assertValidWizardChoices does TS guard; commit-time isValidBoostSet check throws BadRequestException for invalid sets.
T-1-W3-03 Tampering Player commits a level beyond currentLevel + 1 mitigate commit() validates character.level + 1 === session.targetLevel, throws BadRequestException otherwise.
T-1-W3-04 Tampering Race condition: two browser tabs both POST /commit for the same session mitigate Partial unique index on (characterId) WHERE committedAt IS NULL makes the second commit a no-op (the session has already been transitioned). Plus: prisma.$transaction is row-serialized; second tab sees committedAt != null and throws ConflictException. Integration test 1-W3-05 covers.
T-1-W3-05 Tampering WebSocket payload injection — player crafts a fake level_up_committed event mitigate Inbound WebSocket events are unaffected — level_up_committed is server-emit only. CharactersGateway has NO @SubscribeMessage('level_up_committed') handler. Other clients trust the event source = the room's server.
T-1-W3-06 Information Disclosure LevelUpHistory.snapshotBefore JSON contains character data; logs leak character names mitigate Per VALIDATION.md §Security, NestJS Logger output uses characterId not name; no full snapshot in logs. Snapshot lives only in DB.
T-1-W3-07 Tampering Stored XSS via German feat-prereq translation text rendered in the prereq-confirm dialog mitigate Translation text comes from existing Translation.germanName column populated from Claude API; React text-binding renders without dangerouslySetInnerHTML. Plan 05 (client) honors this contract.
T-1-W3-08 EoP Pathbuilder import auto-sets freeArchetype = true based on heuristic; could be exploited to gain extra slots accept Heuristic + LOW-confidence flag (A1 in RESEARCH.md); D-09 says player can flip the toggle in character settings. Self-hosted single-tenant — no adversarial player model.
T-1-W3-09 Repudiation Commit fails partway and Character is left half-mutated mitigate prisma.$transaction rolls back ALL writes on any failure (Pitfall #9). Integration test 1-W3-01 explicitly verifies rollback by injecting mid-tx error.
</threat_model>
After all tasks complete:
# Build clean
cd server && npm run build

# All leveling tests green (lib from Plan 02 + integration from Plan 04)
cd server && npm test -- --testPathPattern=leveling

# Type-check clean
cd server && npx tsc --noEmit -p tsconfig.json

# Smoke test the new endpoints (server must be running)
# Use Swagger UI at http://localhost:5000/api-docs to inspect:
#   POST /characters/:characterId/level-up
#   PATCH /characters/:characterId/level-up/:sessionId
#   GET /characters/:characterId/level-up/:sessionId/preview
#   POST /characters/:characterId/level-up/:sessionId/commit
#   DELETE /characters/:characterId/level-up/:sessionId

# Verify gateway extension
grep -c "level_up_committed" server/src/modules/characters/characters.gateway.ts  # ≥ 2

# Verify pathbuilder integration
grep -c "evaluatePrereq" server/src/modules/characters/pathbuilder-import.service.ts  # ≥ 1
grep -c "freeArchetype: true" server/src/modules/characters/pathbuilder-import.service.ts  # ≥ 1

<success_criteria>

  • LevelingModule exists with Controller, Service, FeatFilterService, 4 DTOs + barrel
  • Module registered in app.module.ts; CharactersModule exports gateway+service for cross-module injection
  • 5 REST endpoints fully wired with JWT auth, Swagger annotations, German error messages
  • Atomic commit via prisma.$transaction with single broadcast at the end
  • Character.hpCurrent NEVER appears in the update payload (Pitfall #9 enforced + tested)
  • characters.gateway.ts CharacterUpdatePayload union extended with 'level_up_committed' + JSDoc payload contract
  • pathbuilder-import.service.ts integrates prereq evaluator (D-06 violations) + FA auto-detect (D-09)
  • All VALIDATION.md W3 rows (1-W3-01 through 1-W3-10) covered by integration tests
  • Total leveling test count ≥ 60 (50 from Plan 02 lib + ≥10 here)
  • cd server && npm test -- --testPathPattern=leveling green
  • cd server && npm run build green
  • cd server && npx tsc --noEmit green </success_criteria>
After completion, create `.planning/phases/01-level-up-pf2e-regelkonform/01-04-SUMMARY.md` documenting: - Final endpoint list with HTTP methods + paths (5 endpoints) - Test results: total leveling test count, breakdown by spec file - Confirmation that hpCurrent invariant test is green (Pitfall #9) - Confirmation that broadcast-count test asserts exactly 1 (Pitfall #9 / RESEARCH First-Phase-Note) - Notes on any deviations from the planned method bodies (e.g. spell-slot upsert keys differing from anticipated schema) - Confirmation that pathbuilder import logs FA detection lines and writes prereqViolations