Files
Dimension-47/.planning/phases/01-level-up-pf2e-regelkonform/01-04-PLAN.md

1626 lines
80 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 01-level-up-pf2e-regelkonform
plan: 04
type: execute
wave: 3
depends_on: ["01-01", "01-02", "01-03"]
files_modified:
- 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
autonomous: true
requirements: [LVL-03, LVL-04, LVL-05, LVL-07, LVL-09, LVL-10, LVL-11, LVL-12, LVL-13, LVL-14, LVL-15]
tags: [nestjs, rest-api, websocket, transaction, level-up, server, integration-tests]
must_haves:
truths:
- "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)."
artifacts:
- path: "server/src/modules/leveling/leveling.module.ts"
provides: "NestJS module wiring controller + services + JWT + dependency on CharactersModule (for gateway)"
exports: ["LevelingModule"]
- path: "server/src/modules/leveling/leveling.controller.ts"
provides: "5 REST endpoints: start, patch, preview, commit, discard"
contains: "@Controller('characters/:characterId/level-up')"
- path: "server/src/modules/leveling/leveling.service.ts"
provides: "Orchestration: startOrResume, patchState, computePreview, commit, discard"
contains: "prisma.$transaction"
- path: "server/src/modules/leveling/feat-filter.service.ts"
provides: "Filtered feat lookup driven by prereq-evaluator + slot+source criteria"
exports: ["FeatFilterService"]
- path: "server/src/modules/leveling/leveling.service.spec.ts"
provides: "Integration tests for atomic commit, broadcast count, hpCurrent invariant, race condition, FA, spellcaster"
contains: "prisma.$transaction"
- path: "server/src/modules/characters/characters.gateway.ts"
provides: "Extended CharacterUpdatePayload['type'] union with 'level_up_committed'"
contains: "'level_up_committed'"
- path: "server/src/modules/characters/pathbuilder-import.service.ts"
provides: "Extended import flow with prereq violations + FA auto-detect"
contains: "evaluatePrereq"
key_links:
- from: "leveling.service.ts → commit()"
to: "characters.gateway.ts → broadcastCharacterUpdate"
via: "single emit AFTER $transaction returns"
pattern: "broadcastCharacterUpdate.*level_up_committed"
- from: "leveling.service.ts"
to: "lib/recompute-derived-stats.ts"
via: "import recomputeDerivedStats"
pattern: "from.*lib/recompute-derived-stats"
- from: "leveling.service.ts"
to: "characters.service.ts → checkCharacterAccess"
via: "delegated permission check"
pattern: "checkCharacterAccess.*requireOwnership"
- from: "feat-filter.service.ts"
to: "lib/prereq-evaluator.ts"
via: "import evaluatePrereq"
pattern: "from.*lib/prereq-evaluator"
- from: "pathbuilder-import.service.ts"
to: "lib/prereq-evaluator.ts"
via: "post-import evaluation loop"
pattern: "evaluatePrereq"
- from: "app.module.ts"
to: "LevelingModule"
via: "imports array"
pattern: "LevelingModule"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- The Plan 02 lib that this plan consumes -->
```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;
```
<!-- Existing CharactersService access pattern (server/src/modules/characters/characters.service.ts:63-86) -->
```typescript
// 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)
}
```
<!-- Existing PathbuilderImportService.importCharacter signature (~248-272) -->
```typescript
async importCharacter(campaignId: string, ownerId: string, pathbuilderJson: PathbuilderJson) { ... }
```
<!-- Existing TranslationsService (server/src/modules/translations/translations.service.ts) -->
```typescript
// Used for D-15 — German translation of new prereq strings + class-feature descriptions.
async getTranslationsBatch(items: { englishText: string; context: string }[]): Promise<Record<string, string>>;
```
<!-- Pattern: app.module.ts registers each feature module in `imports: []` (line ~25) -->
```typescript
@Module({
imports: [
AuthModule, CampaignsModule, CharactersModule, EquipmentModule, FeatsModule,
BattleModule, TranslationsModule, ConfigModule.forRoot({ ... }), JwtModule.registerAsync({ ... }),
// ADD: LevelingModule
],
...
})
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="false">
<name>Task 1: DTOs + barrel (start, patch, commit, level-up-state, index)</name>
<files>
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
</files>
<read_first>
- 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)
</read_first>
<action>
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.
*/
export class LevelUpSessionDto {
@ApiProperty() id: string;
@ApiProperty() characterId: string;
@ApiProperty() targetLevel: number;
@ApiProperty({ type: 'object', additionalProperties: true }) state: Record<string, unknown>;
@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`.
</action>
<verify>
<automated>cd server &amp;&amp; npx tsc --noEmit -p tsconfig.json 2&gt;&amp;1 | grep -E "leveling/dto" || echo "tsc clean"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
All DTOs typed, validated with class-validator decorators, documented with Swagger, exported through barrel.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 2: Extend characters.gateway.ts union — add 'level_up_committed' (single-line)</name>
<files>server/src/modules/characters/characters.gateway.ts</files>
<read_first>
- 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)
</read_first>
<action>
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 {
...
}
```
</action>
<verify>
<automated>cd server &amp;&amp; grep -c "level_up_committed" src/modules/characters/characters.gateway.ts</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
Single-line union extension applied; payload contract documented; gateway file otherwise unchanged.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 3: FeatFilterService — filtered feat lookup driven by prereq-evaluator</name>
<files>server/src/modules/leveling/feat-filter.service.ts</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<verify>
<automated>cd server &amp;&amp; npx tsc --noEmit -p tsconfig.json 2&gt;&amp;1 | grep -E "feat-filter" || echo "tsc clean"</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
Service queries the existing Feat table, applies slot+source+class+level filters, evaluates prereqs, returns annotated results.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 4: LevelingService — orchestration (start, patch, preview, commit, discard)</name>
<files>server/src/modules/leveling/leveling.service.ts</files>
<read_first>
- 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)
</read_first>
<action>
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 { 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 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,
) {}
// ============================================================
// 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} möglich`);
}
// 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 existing;
}
// 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 created;
} 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 fallback;
throw e;
}
}
/**
* 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 updated;
}
/**
* 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 character = await this.loadCharacterFullForRecompute(characterId);
const progression = await this.loadProgression(character.className, session.targetLevel);
const before: DerivedStats = this.computeBeforeStats(character);
const choices = (session.state as Record<string, unknown>) as unknown as WizardChoices;
const after: DerivedStats = recomputeDerivedStats(character, 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 gehört nicht zu diesem Charakter');
}
if (session.committedAt !== null) {
throw new ConflictException('Session ist bereits committed');
}
const character = await this.loadCharacterFullForRecompute(characterId);
if (character.level + 1 !== session.targetLevel) {
throw new BadRequestException(
`Charakter ist auf Stufe ${character.level}; Session erwartet Aufstieg auf Stufe ${session.targetLevel}`,
);
}
const choices = (session.state as Record<string, unknown>) as unknown as WizardChoices;
this.assertValidWizardChoices(choices);
if (choices.boostTargets && !isValidBoostSet(choices.boostTargets)) {
throw new BadRequestException('Boost-Set muss genau 4 verschiedene Attribute enthalten');
}
const progression = await this.loadProgression(character.className, session.targetLevel);
const newDerived = recomputeDerivedStats(character, choices, progression);
const snapshotJson = this.buildSnapshot(character);
// ============ ATOMIC TRANSACTION ============
const result = await this.prisma.$transaction(async (tx) => {
// 1. Snapshot history
await tx.levelUpHistory.create({
data: {
characterId,
levelFrom: character.level,
levelTo: session.targetLevel,
snapshotBefore: snapshotJson,
choices: session.state as never,
},
});
// 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) {
for (const ability of choices.boostTargets) {
const current = character.abilities[ability];
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 as Record<string, unknown>)[field as string] as string | undefined;
if (featId) {
await tx.characterFeat.create({
data: {
characterId,
featId,
source,
level: session.targetLevel,
},
});
}
}
// 6. Class-feature choice picks → CharacterFeat creates with optionKey
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 as never, // class-feature choice may have no featId — schema permitting
name: option.name,
source: 'CLASS',
level: session.targetLevel,
},
});
}
}
}
// 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 } as never,
});
}
}
// 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=${character.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}`);
}
// ============================================================
// 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('Ungültiges 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');
}
}
}
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;
}
private async loadCharacterFullForRecompute(characterId: string) {
// Load character + relations needed by recompute (abilities, skills, feats, ancestry, class)
// Returns shape compatible with CharacterContext + the extra fields recompute needs
// (ancestryHp, classHp, armorAc, armorProficiency, dexCap, className).
// Executor implements actual joins per existing schema.
throw new Error('TODO: implement loadCharacterFullForRecompute');
}
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 für ${className} Stufe ${level}`);
return row as unknown as ClassProgressionRow;
}
private computeBeforeStats(character: never): DerivedStats {
// Read existing Character.hpMax/ac/etc. directly — no recompute (this is the "Vorher" snapshot)
throw new Error('TODO: read existing stats from character object');
}
private buildSnapshot(character: never): Record<string, unknown> {
// Capture full character + relations subset for LevelUpHistory.snapshotBefore
throw new Error('TODO: build snapshot JSON');
}
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.
</action>
<verify>
<automated>cd server &amp;&amp; npx tsc --noEmit -p tsconfig.json 2&gt;&amp;1 | grep -E "leveling.service" || echo "tsc clean"</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>
Service compiles, exposes 5 public methods, uses lib helpers, atomic transaction pattern in place, broadcast emitted once, hpCurrent never written, German messages.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 5: LevelingController — 5 REST endpoints</name>
<files>server/src/modules/leveling/leveling.controller.ts</files>
<read_first>
- 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)
</read_first>
<action>
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);
}
}
```
**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.
</action>
<verify>
<automated>cd server &amp;&amp; npx tsc --noEmit -p tsconfig.json 2&gt;&amp;1 | grep -E "leveling.controller" || echo "tsc clean"</automated>
</verify>
<acceptance_criteria>
- 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')`
- All 5 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
</acceptance_criteria>
<done>
Controller exposes 5 REST endpoints with full Swagger annotations, JWT auth via global guard, delegates to LevelingService.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 6: LevelingModule + register in AppModule</name>
<files>
server/src/modules/leveling/leveling.module.ts,
server/src/app.module.ts
</files>
<read_first>
- 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)
</read_first>
<action>
**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.
</action>
<verify>
<automated>cd server &amp;&amp; npm run build 2&gt;&amp;1 | tail -20</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>
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).
</done>
</task>
<task type="auto" tdd="false">
<name>Task 7: Integration tests for LevelingService — atomic commit, broadcast count, hpCurrent invariant, race condition</name>
<files>
server/src/modules/leveling/leveling.service.spec.ts,
server/src/modules/leveling/feat-filter.service.spec.ts
</files>
<read_first>
- .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)
</read_first>
<action>
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();
});
// 1-W3-01 + 1-W3-02
describe('commit transaction atomicity', () => {
it('rolls back ALL writes when mid-transaction throws', async () => {
// Arrange: create a session whose commit will fail at the broadcast step
// (mock something inside the tx to throw, e.g. a missing ClassProgression row).
// Assert: Character.level UNCHANGED, no LevelUpHistory row created.
// Executor implements with a forced error injection.
});
it('creates exactly one LevelUpHistory row with snapshotBefore + choices populated', async () => {
// Happy path: full commit. Assert LevelUpHistory row count = 1, fields non-null.
});
});
// 1-W3-03
describe('broadcast count', () => {
it('emits level_up_committed exactly once per commit', async () => {
gatewayBroadcastSpy.mockClear();
// ... run commit ...
expect(gatewayBroadcastSpy).toHaveBeenCalledTimes(1);
expect(gatewayBroadcastSpy.mock.calls[0][1].type).toBe('level_up_committed');
});
});
// Pitfall #9 — explicit hpCurrent invariant
describe('Pitfall #9 — never mutates hpCurrent', () => {
it('does NOT include hpCurrent in the character update payload', async () => {
// Capture the prisma.character.update call (spy on prisma.character.update).
// Assert: the data: object passed to update does NOT contain hpCurrent.
});
});
// 1-W3-04
describe('discard', () => {
it('removes the DRAFT and leaves the character untouched', async () => {
// Create DRAFT, change Character.level snapshot, discard, assert Character unchanged + no DRAFT row.
});
});
// 1-W3-05 — race condition / partial unique index
describe('partial unique index', () => {
it('allows creating a new DRAFT after the previous was committed', async () => {
// Create DRAFT, commit it (committedAt becomes non-null), create a second DRAFT — should succeed.
});
it('rejects creating a second open DRAFT for the same character', async () => {
// Create DRAFT, attempt to create another via raw insert — should hit partial unique violation.
// (Note: startOrResume() returns the existing one — the test exercises the DB constraint via raw SQL.)
});
});
// 1-W3-06 + 1-W3-07
describe('spellcaster slot increments', () => {
it('applies spellSlotIncrement from ClassProgression for caster classes', async () => {
// Use a Wizard character; commit L1→L2; assert CharacterResource (or whatever stores slots) reflects +1 grade-1 slot.
});
it('does NOT add slots for non-casters (Fighter)', async () => {
// Use a Fighter character; commit; assert no spell-slot writes.
});
});
// 1-W3-08 — translation pipeline
describe('translation kick-off', () => {
it('calls TranslationsService.getTranslationsBatch with new prereq strings during commit', async () => {
const spy = jest.spyOn(/* TranslationsService instance */ {} as never, 'getTranslationsBatch');
// ... commit ...
expect(spy).toHaveBeenCalled();
});
});
// 1-W3-10 — access control
describe('access control (D-13)', () => {
it('rejects when user is neither Owner nor GM', async () => {
await expect(
service.startOrResume(testCharacterId, {}, 'random-user-id'),
).rejects.toThrow(/Forbidden|Kein Zugriff/);
});
});
});
```
**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';
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 ctx = { /* CharacterContext */ } as never;
const result = await service.getFilteredFeats({ slot: 'class', character: ctx });
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 ctx = { /* ... */ } as never;
const result = await service.getFilteredFeats({ slot: 'class', character: ctx });
for (const f of result) expect(f.source).toBe('CLASS');
});
it('respects slot=general → returns BOTH GENERAL and SKILL feats (LVL-05)', async () => {
const ctx = { /* ... */ } as never;
const result = await service.getFilteredFeats({ slot: 'general', character: ctx });
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 ctx = { /* … */ } as never;
const result = await service.getFilteredFeats({ slot: 'class', character: ctx, includeUnavailable: true });
// At least some result must have eval.ok === false (assuming the seeded Feat table has any)
});
});
});
```
**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".
</action>
<verify>
<automated>cd server &amp;&amp; npm test -- --testPathPattern=leveling</automated>
</verify>
<acceptance_criteria>
- 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)
</acceptance_criteria>
<done>
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.
</done>
</task>
<task type="auto" tdd="false">
<name>Task 8: Extend pathbuilder-import.service.ts — prereq violations + FA auto-detect</name>
<files>server/src/modules/characters/pathbuilder-import.service.ts</files>
<read_first>
- 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)
</read_first>
<action>
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)`).
</action>
<verify>
<automated>cd server &amp;&amp; grep -c "evaluatePrereq" src/modules/characters/pathbuilder-import.service.ts</automated>
</verify>
<acceptance_criteria>
- 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
</acceptance_criteria>
<done>
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.
</done>
</task>
</tasks>
<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>
<verification>
After all tasks complete:
```bash
# 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
```
</verification>
<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>
<output>
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
</output>