1626 lines
80 KiB
Markdown
1626 lines
80 KiB
Markdown
---
|
||
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 && npx tsc --noEmit -p tsconfig.json 2>&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 && 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 && npx tsc --noEmit -p tsconfig.json 2>&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 && npx tsc --noEmit -p tsconfig.json 2>&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 && npx tsc --noEmit -p tsconfig.json 2>&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 && npm run build 2>&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 && 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 && 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>
|