+{preview.hpToHeal} HP (auf {preview.hpAfterRest})
+
+
+```
+
+For Level-Up Review use the Vorher/Nachher Card layout from `01-UI-SPEC.md` Section B (line 537+) which uses `Card` + `CardHeader` + `CardContent` (already imported pattern in `character-sheet-page.tsx:24-30`).
+
+---
+
+### `client/src/features/characters/components/character-sheet-page.tsx` (EXTEND — header button + banner mounts)
+
+**Analog:** `character-sheet-page.tsx:1607-1626` (existing header button cluster).
+
+**Insert position for "Stufe steigen" button (UI-SPEC line 210 — first in the cluster, left of Download):**
+```tsx
+
+ {/* NEW: Stufe steigen — only when isOwner-or-isGM AND level < 20 AND no foreign DRAFT */}
+ {(isOwner || isGM) && character.level < 20 && (
+
+ )}
+
+ {isOwner && (<>...>)}
+
+```
+
+**Modal mount pattern (analog: lines 1652-1666):**
+```tsx
+{showLevelUpWizard && (
+ setShowLevelUpWizard(false)}
+ onCommitted={() => { setShowLevelUpWizard(false); fetchCharacter(); }}
+ />
+)}
+```
+
+**State variable pattern (analog: lines 122-132):**
+```tsx
+const [showLevelUpWizard, setShowLevelUpWizard] = useState(false);
+```
+
+**Banner mount position:** above the avatar header (UI-SPEC §"Component Contract — DRAFT-Resume Banner") and above the tab-navigation (UI-SPEC §"Pathbuilder-Import-Violations Banner").
+
+---
+
+### `client/src/shared/lib/api.ts` (EXTEND — add level-up REST methods)
+
+**Analog (lines 381-388 — `getRestPreview` / `performRest`):**
+```typescript
+async getRestPreview(campaignId: string, characterId: string) {
+ const response = await this.client.get(`/campaigns/${campaignId}/characters/${characterId}/rest/preview`);
+ return response.data;
+}
+
+async performRest(campaignId: string, characterId: string) {
+ const response = await this.client.post(`/campaigns/${campaignId}/characters/${characterId}/rest`);
+ return response.data;
+}
+```
+
+**New methods to add (use the `/characters/:characterId/level-up` route prefix from the controller):**
+```typescript
+async startLevelUp(characterId: string, targetLevel?: number): Promise {
+ const response = await this.client.post(`/characters/${characterId}/level-up`, { targetLevel });
+ return response.data;
+}
+
+async patchLevelUp(characterId: string, sessionId: string, state: Partial): Promise {
+ const response = await this.client.patch(`/characters/${characterId}/level-up/${sessionId}`, { state });
+ return response.data;
+}
+
+async getLevelUpPreview(characterId: string, sessionId: string): Promise {
+ const response = await this.client.get(`/characters/${characterId}/level-up/${sessionId}/preview`);
+ return response.data;
+}
+
+async commitLevelUp(characterId: string, sessionId: string): Promise {
+ const response = await this.client.post(`/characters/${characterId}/level-up/${sessionId}/commit`);
+ return response.data;
+}
+
+async discardLevelUp(characterId: string, sessionId: string): Promise {
+ await this.client.delete(`/characters/${characterId}/level-up/${sessionId}`);
+}
+```
+
+---
+
+### `client/src/features/characters/components/level-up/use-level-up-session.ts` (react-query hook stack)
+
+**Partial analog:** no existing react-query hooks in the codebase (`@tanstack/react-query` 5.90.19 is installed but the codebase still uses raw `useState` + `api.x()` per `rest-modal.tsx`). For Phase 1 the planner has discretion: either (a) introduce react-query usage here and stay consistent with the installed package, or (b) follow the existing `useState + api.x()` pattern for codebase consistency.
+
+**Recommended (following Research §Standard Stack which lists react-query):** add hooks like `useStartLevelUpMutation`, `usePatchLevelUpMutation`, `useCommitLevelUpMutation`, `useLevelUpPreviewQuery`. Naming follows the `useX` hook convention (CONVENTIONS.md).
+
+---
+
+### `client/src/features/characters/components/level-up/wizard-state-reducer.ts` (useReducer + types)
+
+**No existing analog** — this is the first useReducer in the codebase. Implementation guidance is in `01-RESEARCH.md` lines 354-399 (the full `WizardState` + `WizardEvent` discriminated unions). Apply CONVENTIONS.md: PascalCase types, kebab-case file, named exports only, no `any`.
+
+---
+
+## Shared Patterns
+
+### Authentication / Permission Gate
+
+**Source:** `server/src/modules/characters/characters.service.ts:63-86` (`checkCharacterAccess`).
+
+**Apply to:** All `LevelingService` methods. Owner OR GM-of-campaign passes; pure members get read-only; mutations require `requireOwnership=true` (which actually means owner-or-GM per the helper's logic).
+
+**Excerpt:**
+```typescript
+private async checkCharacterAccess(characterId: string, userId: string, requireOwnership = false) {
+ const character = await this.prisma.character.findUnique({
+ where: { id: characterId },
+ include: { campaign: { include: { members: true } } },
+ });
+ if (!character) throw new NotFoundException('Character not found');
+
+ const isGM = character.campaign.gmId === userId;
+ const isOwner = character.ownerId === userId;
+ if (requireOwnership && !isOwner && !isGM) {
+ throw new ForbiddenException('Only the owner or GM can modify this character');
+ }
+ const isMember = character.campaign.members.some((m) => m.userId === userId);
+ if (!isGM && !isMember) {
+ throw new ForbiddenException('No access to this character');
+ }
+ return character;
+}
+```
+
+Global `JwtAuthGuard` (`server/src/app.module.ts:53-56`) authenticates the request; `@CurrentUser('id')` extracts `userId`. No additional `@UseGuards` on level-up endpoints needed.
+
+---
+
+### Error Handling
+
+**Source:** Across the codebase, e.g. `characters.service.ts:46-58, 76-83`.
+
+**Apply to:** All service methods.
+
+**Pattern — NestJS exception classes, German user-facing messages:**
+```typescript
+throw new NotFoundException('Charakter nicht gefunden');
+throw new ForbiddenException('Kein Zugriff auf diesen Charakter');
+throw new BadRequestException('Charakter ist bereits auf maximaler Stufe');
+throw new ConflictException('Eine offene Stufenaufstiegs-Session existiert bereits');
+```
+
+`class-validator` decorators on DTOs auto-throw `BadRequestException` for shape violations (NestJS `ValidationPipe` is global per `app.module.ts`).
+
+---
+
+### Validation
+
+**Source:** `server/src/modules/characters/dto/create-character.dto.ts`, `dto/dying.dto.ts`, `dto/alchemy.dto.ts`.
+
+**Apply to:** All DTO classes in `server/src/modules/leveling/dto/`.
+
+**Pattern — class-validator decorators + Swagger annotations:**
+```typescript
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+import { IsString, IsOptional, IsInt, Min, Max, IsEnum, IsArray, ValidateNested } from 'class-validator';
+import { Type } from 'class-transformer';
+
+export class XxxDto {
+ @ApiProperty({ description: '...' })
+ @IsInt()
+ @Min(2)
+ @Max(20)
+ fieldName: number;
+}
+```
+
+For nested objects: `@ValidateNested() @Type(() => NestedDto)`. For enums: `@IsEnum(EnumType)`.
+
+---
+
+### WebSocket Broadcast
+
+**Source:** `server/src/modules/characters/characters.service.ts:294-299` (`broadcastCharacterUpdate`).
+
+**Apply to:** `LevelingService.commit()` after `prisma.$transaction` returns. Single emit (Pitfall #9 / ROADMAP First-Phase-Note).
+
+**Pattern:**
+```typescript
+this.charactersGateway.broadcastCharacterUpdate(characterId, {
+ characterId,
+ type: 'level_up_committed',
+ data: {
+ level: newLevel,
+ derivedStats: { hpMax, ac, fortitude, reflex, will, perception, classDC },
+ },
+});
+```
+
+The `CharactersGateway.broadcastCharacterUpdate` method (`characters.gateway.ts:232-236`) emits `'character_update'` to the room `character:{characterId}`. Clients with the character open receive it via `useCharacterSocket`.
+
+---
+
+### Mobile-First Modal Chrome
+
+**Source:** `client/src/features/characters/components/add-feat-modal.tsx:246-260`.
+
+**Apply to:** `level-up-wizard.tsx`, `level-up-prereq-confirm-dialog.tsx` (use `z-60` per UI-SPEC for the dialog).
+
+**Pattern:**
+```tsx
+
+
+
+ {/* Header / Body / Footer */}
+
+
+```
+
+Bottom-sheet on mobile (`items-end` + `rounded-t-2xl`); centered modal on `sm:` (≥ 640px).
+
+---
+
+### Header Pattern Inside Modals
+
+**Source:** `add-feat-modal.tsx:253-260`, `add-condition-modal.tsx:98-103`.
+
+**Apply to:** All wizard step containers and the wizard root.
+
+**Pattern:**
+```tsx
+
+
{title}
+
+
+```
+
+---
+
+### Loading State
+
+**Source:** `rest-modal.tsx:72-76`.
+
+**Apply to:** All wizard step bodies + the wizard root while session loads.
+
+**Pattern:**
+```tsx
+{isLoading ? (
+
+
+
+) : ...}
+```
+
+(or use the existing `` from `@/shared/components/ui` — visible in `add-feat-modal.tsx:3`).
+
+---
+
+### Imports
+
+**Server pattern (analog: characters.service.ts:1-29):**
+- NestJS imports first.
+- `PrismaService` from `'../../prisma/prisma.service'`.
+- Sibling-module services from `'./xxx.service'` (relative).
+- DTOs from `'./dto'` barrel.
+- Generated Prisma types from `'../../generated/prisma/client.js'` (note `.js` extension — required for ESM compat).
+- Service-level `@Injectable()` decorator.
+
+**Client pattern (analog: rest-modal.tsx:1-5):**
+- React first: `import { useState, useEffect } from 'react';`.
+- Lucide icons next: `import { X, Search } from 'lucide-react';`.
+- UI components via barrel: `import { Button, Card, CardContent } from '@/shared/components/ui';`.
+- API client: `import { api } from '@/shared/lib/api';`.
+- Types as type-only import: `import type { Character } from '@/shared/types';`.
+- ALWAYS use `@/` alias on client (vite.config.ts), never relative paths.
+
+---
+
+### Naming
+
+| Surface | Convention | Example |
+|---------|------------|---------|
+| All file names | kebab-case | `level-up-wizard.tsx`, `apply-attribute-boost.ts`, `seed-class-progression.ts` |
+| Folder names | kebab-case | `leveling/`, `level-up/` |
+| Component exports | PascalCase + named | `export function LevelUpWizard()` |
+| Service classes | PascalCase + `Service` suffix | `LevelingService`, `FeatFilterService` |
+| Controller classes | PascalCase + `Controller` suffix | `LevelingController` |
+| Module classes | PascalCase + `Module` suffix | `LevelingModule` |
+| DTOs | PascalCase + `Dto` suffix | `StartLevelUpDto`, `LevelUpPreviewDto` |
+| Hooks | `use` prefix | `useLevelUpSession`, `useCommitLevelUpMutation` |
+| Pure functions | camelCase | `applyAttributeBoost`, `evaluatePrereq` |
+| Constants | UPPER_SNAKE_CASE | `BOOST_CAP`, `SKILL_INCREASE_CAPS_BY_LEVEL` |
+| TypeScript types | PascalCase | `WizardState`, `StepKind`, `LevelUpSession` |
+| Props interfaces | `XxxProps` suffix | `LevelUpWizardProps`, `ChoiceCardProps` |
+| Event handlers | `handle` prefix | `handleCommit`, `handleStepChange` |
+| Booleans | `is`, `has`, `can` prefix | `isCommitting`, `hasDraft`, `canIncrement` |
+| Test files | `*.spec.ts` next to source | `apply-attribute-boost.spec.ts` |
+
+---
+
+## No Analog Found
+
+| File | Role | Data Flow | Reason | Fallback Guidance |
+|------|------|-----------|--------|-------------------|
+| `server/src/modules/leveling/lib/*.spec.ts` (all four) | Jest unit tests | n/a | No `*.spec.ts` files exist in `server/src/` today. This phase establishes the test-discipline (CONTEXT.md line 50, RESEARCH.md "First Phase Note"). | Use Jest config from `server/package.json:88-104`. Minimal spec example provided in the Pure-Function Lib section above. |
+| `server/src/modules/leveling/leveling.service.spec.ts` | NestJS integration test | n/a | No NestJS integration tests exist today (Supertest is installed but unused for `*.spec.ts`). | Use `Test.createTestingModule({ imports: [LevelingModule], providers: [{ provide: PrismaService, useValue: mockPrisma }] })` per `@nestjs/testing` 11.x docs. Reference: Research §Standard Stack lists `@nestjs/testing` and `supertest`. |
+| `client/src/features/characters/components/level-up/level-up-resume-banner.tsx` | Banner component | display + CTA | No banner pattern exists in the client (warning chips inline only). | Implement per `01-UI-SPEC.md` §"Component Contract — DRAFT-Resume Banner" (lines 583-613). The exact JSX is fully specified there. |
+| `client/src/features/characters/components/level-up/level-up-violations-banner.tsx` | Banner component | display | Same — no analog. | Implement per `01-UI-SPEC.md` §"Component Contract — Pathbuilder-Import-Violations Banner" (lines 617-649). |
+| `client/src/features/characters/components/level-up/wizard-state-reducer.ts` | useReducer + discriminated union | transform | No `useReducer` exists in the codebase (`useState` is used everywhere). | Implementation fully specified in `01-RESEARCH.md` lines 354-399 (`WizardState`, `WizardEvent`). Standard React idiom — no project precedent needed. |
+| `client/src/features/characters/components/level-up/use-level-up-session.ts` | react-query hook stack | request-response | No react-query hooks exist in the codebase yet (only raw `useState + api.x()`). | Planner discretion (see Pattern Assignment above). Recommend introducing react-query for Phase 1 since `@tanstack/react-query` 5.90.19 is installed and Research §Standard Stack assumes it. |
+
+---
+
+## Key Patterns Identified
+
+1. **NestJS Service Pattern: `@Injectable() + constructor(private prisma, private translationsService, @Inject(forwardRef()) gateway)`**
+ Every service follows the same constructor shape with the gateway via `forwardRef` to break the circular dependency.
+
+2. **`checkCharacterAccess` is the One Permission Helper.**
+ Owner-or-GM gate. New `LevelingService` should reuse it (import from CharactersService or duplicate). Three-tier check: NotFoundException → owner/GM check (if mutation) → member check.
+
+3. **REST Endpoints Use `prisma.$transaction(async tx => ...)` for Atomic Multi-Table Writes.**
+ Already in use in `combatants.service.ts:82-118`. Apply to `LevelingService.commit()` for snapshot + character mutation + history insert + session-mark-committed.
+
+4. **Single WebSocket Broadcast After Transaction Commit.**
+ Existing pattern: `this.charactersGateway.broadcastCharacterUpdate(id, { type: 'xxx', data: ... })` called once after `await prisma.x.update()`. New `'level_up_committed'` type added to the union — same call site pattern.
+
+5. **DTO + class-validator + Swagger.**
+ `@ApiProperty({ description })` paired with `@IsInt() @Min() @Max()` etc. Barrel `index.ts` re-exports all DTOs. NestJS global `ValidationPipe` auto-rejects bad shapes.
+
+6. **Prisma Migration Naming: `YYYYMMDDHHMMSS_snake_case_description/migration.sql`** generated via `npm run db:migrate:dev -- --name xxx`. Hand-add raw SQL for partial unique indexes (Prisma 7 limitation).
+
+7. **Seed Scripts: idempotent find-then-update-or-create with `'dotenv/config'` + `PrismaPg adapter`.**
+ `seed-equipment.ts` is the canonical example. New `seed-class-progression.ts` mirrors imports and idempotency exactly.
+
+8. **Mobile-First Modal Chrome: `fixed inset-0 z-50 flex items-end sm:items-center` + `rounded-t-2xl sm:rounded-2xl`.**
+ Bottom-sheet on mobile, centered on desktop. Backdrop `bg-black/60`. Close button in header `