# Phase 1: Level-Up (PF2e regelkonform) - Pattern Map

**Mapped:** 2026-04-27
**Files analyzed:** 35 (29 new, 6 extended)
**Analogs found:** 33 / 35 (94%)

---

## File Classification

### Server — NestJS Module (`server/src/modules/leveling/`)

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `server/src/modules/leveling/leveling.module.ts` | NestJS module | n/a | `server/src/modules/characters/characters.module.ts` | exact |
| `server/src/modules/leveling/leveling.controller.ts` | REST controller | request-response (CRUD) | `server/src/modules/characters/characters.controller.ts` | exact |
| `server/src/modules/leveling/leveling.service.ts` | NestJS service | CRUD + transactional | `server/src/modules/characters/characters.service.ts` | exact |
| `server/src/modules/leveling/leveling.service.spec.ts` | integration test | n/a | (no existing analog — first integration test) | none |
| `server/src/modules/leveling/feat-filter.service.ts` | NestJS service | request-response | `server/src/modules/characters/alchemy.service.ts` | role-match |
| `server/src/modules/leveling/dto/start-level-up.dto.ts` | DTO | n/a | `server/src/modules/characters/dto/dying.dto.ts` | exact |
| `server/src/modules/leveling/dto/patch-level-up.dto.ts` | DTO | n/a | `server/src/modules/characters/dto/alchemy.dto.ts` (`UpdateVialsDto`) | exact |
| `server/src/modules/leveling/dto/commit-level-up.dto.ts` | DTO | n/a | `server/src/modules/characters/dto/dying.dto.ts` (`RecoveryCheckDto`) | exact |
| `server/src/modules/leveling/dto/level-up-state.dto.ts` | DTO + TS shape | n/a | `server/src/modules/characters/dto/rest.dto.ts` (`RestPreviewDto`) | exact |

### Server — Pure-Function Lib (`server/src/modules/leveling/lib/`)

| New File | Role | Data Flow | Closest Analog | Match Quality |
|----------|------|-----------|----------------|---------------|
| `server/src/modules/leveling/lib/apply-attribute-boost.ts` | pure-function lib | transform | (no existing pure-function lib — first of its kind) | none |
| `server/src/modules/leveling/lib/apply-attribute-boost.spec.ts` | Jest unit test | n/a | (no existing `*.spec.ts` files in `server/src/`) | none |
| `server/src/modules/leveling/lib/skill-increase-cap.ts` | pure-function lib | transform | (same — first) | none |
| `server/src/modules/leveling/lib/skill-increase-cap.spec.ts` | Jest unit test | n/a | (same — first) | none |
| `server/src/modules/leveling/lib/prereq-evaluator.ts` | pure-function lib | transform | client-side `checkSkillPrerequisites` in `client/src/features/characters/components/add-feat-modal.tsx:27-74` | partial (server-side first) |
| `server/src/modules/leveling/lib/prereq-evaluator.spec.ts` | Jest unit test | n/a | (first) | none |
| `server/src/modules/leveling/lib/recompute-derived-stats.ts` | pure-function lib | transform | helper functions in `server/src/modules/characters/pathbuilder-import.service.ts:42-62` (`proficiencyFromValue`, `featSourceFromType`) | partial |
| `server/src/modules/leveling/lib/recompute-derived-stats.spec.ts` | Jest unit test | n/a | (first) | none |
| `server/src/modules/leveling/lib/compute-applicable-steps.ts` | pure-function lib | transform | (first) | none |
| `server/src/modules/leveling/lib/compute-applicable-steps.spec.ts` | Jest unit test | n/a | (first) | none |

### Server — Extended Files

| Modified File | Modification | Closest Analog (for the new code) | Match Quality |
|---------------|--------------|------------------------------------|---------------|
| `server/src/modules/characters/characters.gateway.ts` | Add `'level_up_committed'` to `CharacterUpdatePayload['type']` union (line 24) | already-present pattern in same file | exact (single-line union add) |
| `server/src/modules/characters/pathbuilder-import.service.ts` | Call `PrereqEvaluator` after import, persist violation list | service-orchestration pattern in same file | exact |
| `server/src/app.module.ts` | Register `LevelingModule` | existing `CharactersModule` registration on line 14 | exact |
| `client/src/shared/hooks/use-character-socket.ts` | Add `'level_up_committed'` to `CharacterUpdateType` union (line 15) + new `onLevelUpCommitted` callback | identical mirror of `characters.gateway.ts` | exact |

### Server — Prisma (Migration + Seed)

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `server/prisma/schema.prisma` | Schema models (add `LevelUpSession`, `ClassProgression`, `LevelUpHistory`; extend `Character` with `freeArchetype: Boolean`, `prereqViolations: Json?`) | n/a | existing `CharacterAlchemyState` model (line 167+) | exact |
| `server/prisma/migrations/YYYYMMDDHHMMSS_add_level_up_sessions_and_class_progression/migration.sql` | Prisma migration SQL | n/a | `server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql` | exact |
| `server/prisma/seed-class-progression.ts` | Seed script | batch / file-I/O | `server/prisma/seed-equipment.ts` | exact |

### Client — React (`client/src/features/characters/components/level-up/`)

| New File | Role | Data Flow | Closest Analog | Match Quality |
|----------|------|-----------|----------------|---------------|
| `client/src/features/characters/components/level-up/level-up-wizard.tsx` | React modal container | event-driven (useReducer) + REST | `client/src/features/characters/components/add-feat-modal.tsx` (modal chrome) + `client/src/features/characters/components/rest-modal.tsx` (REST + state) | exact |
| `client/src/features/characters/components/level-up/level-up-step-class-features.tsx` | React step component | display-only | `client/src/features/characters/components/actions-tab.tsx` (read-only list) | role-match |
| `client/src/features/characters/components/level-up/level-up-step-class-feature-choice.tsx` | React step (single-pick) | event-driven | `client/src/features/characters/components/add-condition-modal.tsx` (single-pick + value) | role-match |
| `client/src/features/characters/components/level-up/level-up-step-boost.tsx` | React step (multi-counter) | event-driven | `client/src/features/characters/components/hp-control.tsx` (`+/-` controls) | role-match |
| `client/src/features/characters/components/level-up/level-up-step-skill-increase.tsx` | React step (single-pick from list) | event-driven | `client/src/features/characters/components/add-condition-modal.tsx` | role-match |
| `client/src/features/characters/components/level-up/level-up-step-feat-class.tsx` | React step (filtered feat pick) | request-response + event | `client/src/features/characters/components/add-feat-modal.tsx` | exact |
| `client/src/features/characters/components/level-up/level-up-step-feat-skill.tsx` | React step | request-response + event | same | exact |
| `client/src/features/characters/components/level-up/level-up-step-feat-general.tsx` | React step | request-response + event | same | exact |
| `client/src/features/characters/components/level-up/level-up-step-feat-ancestry.tsx` | React step | request-response + event | same | exact |
| `client/src/features/characters/components/level-up/level-up-step-feat-archetype.tsx` | React step | request-response + event | same | exact |
| `client/src/features/characters/components/level-up/level-up-step-spellcaster.tsx` | React step | request-response + event | `client/src/features/characters/components/alchemy-tab.tsx` (formula/repertoire pick) | role-match |
| `client/src/features/characters/components/level-up/level-up-step-review.tsx` | React step (diff display) | display + commit-CTA | `client/src/features/characters/components/rest-modal.tsx` (preview/commit) | exact |
| `client/src/features/characters/components/level-up/level-up-choice-card.tsx` | React primitive | display | feat card in `add-feat-modal.tsx:272-330` | exact |
| `client/src/features/characters/components/level-up/level-up-prereq-confirm-dialog.tsx` | React layered modal | event-driven | modal chrome in `add-condition-modal.tsx:91-103` | exact |
| `client/src/features/characters/components/level-up/level-up-resume-banner.tsx` | React banner | display + CTA | (no existing banner pattern) | none |
| `client/src/features/characters/components/level-up/level-up-violations-banner.tsx` | React banner | display | (no existing banner pattern) | none |
| `client/src/features/characters/components/level-up/use-level-up-session.ts` | React-query hook | request-response | `client/src/shared/hooks/use-character-socket.ts` (hook structure) | partial |
| `client/src/features/characters/components/level-up/wizard-state-reducer.ts` | TS reducer + types | transform | (no existing useReducer in codebase) | none |

### Client — Extended Files

| Modified File | Modification | Closest Analog | Match Quality |
|---------------|--------------|----------------|---------------|
| `client/src/features/characters/components/character-sheet-page.tsx` | Add `Stufe steigen` button (header, lines 1607-1626), mount `<LevelUpWizard>`, mount `<LevelUpResumeBanner>`, mount `<LevelUpViolationsBanner>` | existing header buttons (Download/Edit/Delete) at lines 1607-1626 + existing modal mounts at lines 1652-1666 | exact |
| `client/src/shared/lib/api.ts` | Add `startLevelUp`, `patchLevelUp`, `commitLevelUp`, `discardLevelUp`, `getLevelUpPreview` methods | `getRestPreview` / `performRest` at lines 381-388 | exact |
| `client/src/shared/types/index.ts` (or wherever the `Character`/`CharacterUpdate` types live) | Add `LevelUpSession`, `LevelUpPreview`, extend `Character` with `freeArchetype` + `prereqViolations` | existing type definitions | exact |

---

## Pattern Assignments

### `server/src/modules/leveling/leveling.module.ts` (NestJS module)

**Analog:** `server/src/modules/characters/characters.module.ts:1-27`

**Module pattern (full file — copy this shape):**
```typescript
import { Module } 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';   // for CharactersGateway
import { TranslationsModule } from '../translations/translations.module';

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

**Registration in `app.module.ts`:** add the import next to `CharactersModule` (line 14) and include in `imports: []` (line 25). Mirror exact pattern.

---

### `server/src/modules/leveling/leveling.controller.ts` (REST controller, request-response)

**Analog:** `server/src/modules/characters/characters.controller.ts:1-110`

**Imports + decorators pattern (lines 1-37):**
```typescript
import {
  Controller, Get, Post, Patch, Delete, Body, Param,
} from '@nestjs/common';
import {
  ApiTags, ApiOperation, ApiResponse, ApiBearerAuth,
} from '@nestjs/swagger';
import { LevelingService } from './leveling.service';
import {
  StartLevelUpDto, PatchLevelUpDto, CommitLevelUpDto,
} from './dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';

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

**Endpoint pattern (lines 43-67) — apply to every endpoint:**
```typescript
@Post()
@ApiOperation({ summary: 'Start or resume a level-up session' })
@ApiResponse({ status: 201, description: 'Session created or resumed' })
async start(
  @Param('characterId') characterId: string,
  @Body() dto: StartLevelUpDto,
  @CurrentUser('id') userId: string,
) {
  return this.levelingService.startOrResume(characterId, dto, userId);
}
```

**Note:** auth is global via `JwtAuthGuard` in `app.module.ts:53-56` — no `@UseGuards()` on individual endpoints. `@CurrentUser('id')` decorator extracts `userId` from the JWT payload.

---

### `server/src/modules/leveling/leveling.service.ts` (NestJS service, transactional CRUD)

**Analog:** `server/src/modules/characters/characters.service.ts:1-86, 261-300`

**Imports + constructor pattern (lines 1-38):**
```typescript
import {
  Injectable, NotFoundException, ForbiddenException, Inject, forwardRef,
} from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CharactersGateway } from '../characters/characters.gateway';
import { TranslationsService } from '../translations/translations.service';

@Injectable()
export class LevelingService {
  constructor(
    private prisma: PrismaService,
    private translationsService: TranslationsService,
    @Inject(forwardRef(() => CharactersGateway))
    private charactersGateway: CharactersGateway,
  ) {}
```

**Permission helper pattern (lines 63-86) — copy `checkCharacterAccess` verbatim or import it from CharactersService (preferred — keep one source of truth). Owner-or-GM gate, `requireOwnership=true` for mutations:**
```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');
  }
  // ... member check ...
  return character;
}
```

**Atomic transaction pattern — analog:** `server/src/modules/battle/combatants.service.ts:82-118`
```typescript
return this.prisma.$transaction(async (tx) => {
  // 1. INSERT LevelUpHistory snapshot
  await tx.levelUpHistory.create({ data: { characterId, snapshot: snapshotJson } });
  // 2. UPDATE Character (level, hpMax)
  await tx.character.update({ where: { id: characterId }, data: { level, hpMax } });
  // 3. UPSERT CharacterAbility / Skill / Feat rows
  // ... etc ...
  // 4. Mark LevelUpSession committed (or DELETE)
  await tx.levelUpSession.update({ where: { id: sessionId }, data: { committedAt: new Date() } });
  return tx.character.findUnique({ where: { id: characterId }, include: { ... } });
});
```

**Broadcast-after-commit pattern (lines 294-299) — single emit AFTER `$transaction` returns:**
```typescript
this.charactersGateway.broadcastCharacterUpdate(characterId, {
  characterId,
  type: 'level_up_committed',
  data: { level: newLevel, derivedStats: { hpMax, ac, fortitude, reflex, will, perception, classDC } },
});
```

**Error pattern — German user messages, NestJS exceptions:**
```typescript
throw new NotFoundException('Stufenaufstiegs-Session nicht gefunden');
throw new ForbiddenException('Nur Besitzer oder GM dürfen Stufenaufstiege starten');
throw new BadRequestException('Charakter ist bereits auf maximaler Stufe');
```

---

### `server/src/modules/leveling/dto/*.dto.ts` (DTOs, class-validator)

**Analog:** `server/src/modules/characters/dto/dying.dto.ts:1-22` (small, single-purpose), `server/src/modules/characters/dto/alchemy.dto.ts:13-56` (request DTO + nested DTOs).

**Validation pattern — exact decorators (from `dying.dto.ts`):**
```typescript
import { IsString, IsInt, Min, Max, IsBoolean, IsOptional } from 'class-validator';

export class RecoveryCheckDto {
  @IsString()
  characterId: string;

  @IsInt()
  @Min(1)
  @Max(20)
  dieRoll: number;
}
```

**With Swagger annotations (from `alchemy.dto.ts:13-37`):**
```typescript
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsInt, Min, IsArray, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

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

export class PatchLevelUpDto {
  @ApiProperty({ description: 'Partial wizard state to merge', type: 'object' })
  @ValidateNested()
  @Type(() => Object)
  state: Record<string, unknown>;       // shape validated in service via TS guard
}
```

**Barrel export (analog: `server/src/modules/characters/dto/index.ts`):**
```typescript
// server/src/modules/leveling/dto/index.ts
export * from './start-level-up.dto';
export * from './patch-level-up.dto';
export * from './commit-level-up.dto';
export * from './level-up-state.dto';
```

**Response DTO pattern (analog: `server/src/modules/characters/dto/rest.dto.ts:16-37`) for `LevelUpPreviewDto`:**
```typescript
export class LevelUpPreviewDto {
  @ApiProperty({ description: 'Stats before commit' })
  before: { hpMax: number; ac: number; classDC: number; perception: number; saves: { fort: number; ref: number; will: number } };

  @ApiProperty({ description: 'Stats after commit' })
  after: { hpMax: number; ac: number; classDC: number; perception: number; saves: { fort: number; ref: number; will: number } };

  @ApiProperty({ description: 'Spellcaster increments (if applicable)' })
  spellcaster?: { slotIncrements: { level: number; delta: number }[]; repertoireDelta?: number };
}
```

---

### `server/src/modules/leveling/lib/*.ts` (pure-function modules)

**Analog:** the closest existing pure helper is `proficiencyFromValue` / `featSourceFromType` in `server/src/modules/characters/pathbuilder-import.service.ts:42-62`:

```typescript
function proficiencyFromValue(value: number): Proficiency {
  switch (value) {
    case 8: return Proficiency.LEGENDARY;
    case 6: return Proficiency.MASTER;
    case 4: return Proficiency.EXPERT;
    case 2: return Proficiency.TRAINED;
    default: return Proficiency.UNTRAINED;
  }
}
```

**Lib module conventions to apply:**
- **NO** `@Injectable()`, NO Prisma imports, NO NestJS imports.
- Named exports only: `export function applyAttributeBoost(...)`.
- Strict TypeScript types (no `any` — CLAUDE.md hard rule).
- Side-effect free: input → output.
- Sit alongside their `*.spec.ts` (matches `testRegex: ".*\\.spec\\.ts$"` from `server/package.json:88-104`).

**Prereq-evaluator partial analog — client-side pattern in `client/src/features/characters/components/add-feat-modal.tsx:27-74`:**
```typescript
function checkSkillPrerequisites(
  prerequisites: string | undefined,
  skills: CharacterSkill[],
): { met: boolean; unmetReason?: string } {
  if (!prerequisites) return { met: true };
  const prereqLower = prerequisites.toLowerCase();
  const patterns = [
    { regex: /legendary in (\w+(?:\s+\w+)?)/gi, required: 'LEGENDARY' as Proficiency },
    { regex: /master in (\w+(?:\s+\w+)?)/gi, required: 'MASTER' as Proficiency },
    { regex: /expert in (\w+(?:\s+\w+)?)/gi, required: 'EXPERT' as Proficiency },
    { regex: /trained in (\w+(?:\s+\w+)?)/gi, required: 'TRAINED' as Proficiency },
  ];
  // ... loop matches, look up character skill rank, compare ranks ...
}
```

The server-side `prereq-evaluator.ts` extends this to also handle: feat possession, level, class, ancestry, heritage, plus return discriminated union `{ ok: true } | { ok: false, reason: string } | { unknown: true, raw: string }` per Research §Architecture Patterns.

**Test file pattern — there are NO existing `*.spec.ts` files in `server/src/`** (this phase establishes the test discipline per `01-CONTEXT.md` line 50 and Research §Code Examples). Use the Jest config already present in `server/package.json:88-104`. Minimal first test:
```typescript
// server/src/modules/leveling/lib/apply-attribute-boost.spec.ts
import { applyAttributeBoost } from './apply-attribute-boost';

describe('applyAttributeBoost', () => {
  it('returns +2 for scores below 18', () => {
    expect(applyAttributeBoost(10)).toBe(12);
    expect(applyAttributeBoost(16)).toBe(18);
  });

  it('returns +1 for scores at or above 18 (PF2e cap rule)', () => {
    expect(applyAttributeBoost(18)).toBe(19);
    expect(applyAttributeBoost(20)).toBe(21);
  });
});
```

---

### `server/src/modules/characters/characters.gateway.ts` (EXTEND — single-line union add)

**Current code (line 22-26):**
```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;
}
```

**Required change — append `'level_up_committed'`:**
```typescript
type: 'hp' | 'conditions' | ... | 'dying' | 'level_up_committed';
```

**Mirror change in `client/src/shared/hooks/use-character-socket.ts:15`** — identical union add. Then plumb a new `onLevelUpCommitted` callback in the `UseCharacterSocketOptions` interface (analog: `onRestUpdate` at line 53).

**Broadcast call pattern** (analog: `characters.service.ts:294-299`) — emit ONCE after `$transaction` returns successfully (Pitfall #9).

---

### `server/src/modules/characters/pathbuilder-import.service.ts` (EXTEND)

**Analog:** the service itself — extend by injecting `PrereqEvaluator` (a pure-function module, so no DI; just `import` and call) at the end of `importCharacter()`. Persist the `{ featId, featName, prereqText }[]` array to the new `Character.prereqViolations: Json?` column.

**Service-orchestration pattern (lines 64-72):**
```typescript
@Injectable()
export class PathbuilderImportService {
  constructor(
    private prisma: PrismaService,
    private translationsService: TranslationsService,
  ) {}

  async importCharacter(campaignId: string, ownerId: string, pathbuilderJson: PathbuilderJson) {
    // existing import flow ...
    // NEW at the end:
    const violations = character.feats
      .map(f => ({ featId: f.id, featName: f.name, prereq: featDb[f.featId]?.prerequisites }))
      .filter(v => v.prereq)
      .map(v => ({ ...v, result: evaluatePrereq(v.prereq, characterContext) }))
      .filter(v => v.result.ok === false)
      .map(v => ({ featId: v.featId, featName: v.featName, prereqText: v.prereq }));

    if (violations.length > 0) {
      await this.prisma.character.update({
        where: { id: character.id },
        data: { prereqViolations: violations },
      });
    }
    return character;
  }
}
```

---

### `server/prisma/schema.prisma` (EXTEND — add 3 models + 2 columns on `Character`)

**Analog model — `CharacterAlchemyState` (lines 167+) shows the full pattern (table + unique index + cascade delete on Character):**

The new models follow the same shape:
```prisma
model LevelUpSession {
  id           String    @id @default(uuid())
  characterId  String
  targetLevel  Int
  state        Json      // wizard state (the WizardState shape)
  committedAt  DateTime?
  createdAt    DateTime  @default(now())
  updatedAt    DateTime  @updatedAt

  character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)

  // One open DRAFT per character (Decision D-14): partial unique index on characterId WHERE committedAt IS NULL
  // PRISMA NOTE: partial index requires raw SQL in the migration (see Migration pattern below).
  @@index([characterId])
}

model ClassProgression {
  id                  String  @id @default(uuid())
  className           String
  level               Int
  grants              String[]
  proficiencyChanges  Json
  spellSlotIncrement  Json?
  cantripIncrement    Int?
  repertoireIncrement Int?
  choiceType          String?
  choiceOptionsRef    String?

  @@unique([className, level])
}

model LevelUpHistory {
  id          String   @id @default(uuid())
  characterId String
  fromLevel   Int
  toLevel     Int
  snapshot    Json     // full character snapshot before the level-up
  committedAt DateTime @default(now())

  character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)

  @@index([characterId])
}

// EXTEND existing Character model:
model Character {
  // ... existing fields ...
  freeArchetype     Boolean @default(false)   // D-08
  prereqViolations  Json?                     // D-06

  // ... existing relations ...
  levelUpSessions LevelUpSession[]
  levelUpHistory  LevelUpHistory[]
}
```

---

### `server/prisma/migrations/YYYYMMDDHHMMSS_add_level_up_sessions_and_class_progression/migration.sql`

**Analog:** `server/prisma/migrations/20260120080237_add_alchemy_and_rest_system/migration.sql`

**Naming convention:** `YYYYMMDDHHMMSS_snake_case_description/migration.sql` — Prisma generates this via `npm run db:migrate:dev -- --name add_level_up_sessions_and_class_progression`.

**Pattern from analog (lines 4-71):**
```sql
-- CreateEnum (if applicable)
CREATE TYPE "ResearchField" AS ENUM ('BOMBER', 'CHIRURGEON', ...);

-- CreateTable
CREATE TABLE "CharacterAlchemyState" (
    "id" TEXT NOT NULL,
    "characterId" TEXT NOT NULL,
    -- ... fields ...
    CONSTRAINT "CharacterAlchemyState_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "CharacterAlchemyState_characterId_key" ON "CharacterAlchemyState"("characterId");

-- AddForeignKey
ALTER TABLE "CharacterAlchemyState" ADD CONSTRAINT "..._characterId_fkey"
  FOREIGN KEY ("characterId") REFERENCES "Character"("id") ON DELETE CASCADE ON UPDATE CASCADE;
```

**Phase 1 specific addition — partial unique index for "one open DRAFT per character" (D-14)** must be hand-added because Prisma 7 does not yet emit partial indexes from the schema:
```sql
-- After auto-generated indexes:
CREATE UNIQUE INDEX "LevelUpSession_one_open_per_character"
  ON "LevelUpSession"("characterId")
  WHERE "committedAt" IS NULL;
```

---

### `server/prisma/seed-class-progression.ts` (seed script, batch / file-I/O)

**Analog:** `server/prisma/seed-equipment.ts:1-120`

**Imports + Prisma client pattern (lines 1-8):**
```typescript
import 'dotenv/config';
import * as fs from 'fs';
import * as path from 'path';
import { PrismaClient } from '../src/generated/prisma/client.js';
import { PrismaPg } from '@prisma/adapter-pg';

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter });
```

**Idempotent seed pattern — find-or-update (lines 105-130):**
```typescript
for (const item of data) {
  try {
    const existing = await prisma.classProgression.findUnique({
      where: { className_level: { className: item.className, level: item.level } },
    });
    if (existing) {
      await prisma.classProgression.update({
        where: { id: existing.id },
        data: { grants: item.grants, proficiencyChanges: item.proficiencyChanges, /* ... */ },
      });
      updated++;
    } else {
      await prisma.classProgression.create({ data: { /* ... */ } });
      created++;
    }
  } catch (err) {
    errors++;
    console.error(`Failed to seed ${item.className} L${item.level}:`, err);
  }
}
```

**Console logging pattern (lines 99-104):**
```typescript
console.log(`📚 Importing ${data.length} class progressions...`);
// ... at end ...
console.log(`  ✓ ${created} created, ${updated} updated, ${errors} errors`);
```

**Add NPM script entry to `server/package.json`** mirroring the existing `db:seed:equipment` script:
```json
"db:seed:class-progression": "tsx prisma/seed-class-progression.ts"
```

---

### `client/src/features/characters/components/level-up/level-up-wizard.tsx` (modal container)

**Analogs:** `client/src/features/characters/components/add-feat-modal.tsx:246-260` (chrome) + `client/src/features/characters/components/rest-modal.tsx:1-49` (REST + state pattern).

**Modal chrome pattern (canonical — from add-feat-modal.tsx:246-260):**
```tsx
return (
  <div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
    {/* Backdrop */}
    <div className="absolute inset-0 bg-black/60" onClick={onClose} />

    {/* Modal */}
    <div className="relative w-full sm:max-w-2xl max-h-[90vh] bg-bg-secondary rounded-t-2xl sm:rounded-2xl flex flex-col overflow-hidden">
      {/* Header */}
      <div className="flex items-center justify-between p-4 border-b border-border">
        <h2 className="text-lg font-semibold text-text-primary">Stufenaufstieg — Stufe {N}</h2>
        <Button variant="ghost" size="icon" onClick={onClose}>
          <X className="h-5 w-5" />
        </Button>
      </div>
      {/* Stepper, Body, Footer ... per UI-SPEC */}
    </div>
  </div>
);
```

**REST + state lifecycle (analog — rest-modal.tsx:14-49):**
```tsx
export function LevelUpWizard({ campaignId, characterId, onClose, onCommitted }: Props) {
  const [session, setSession] = useState<LevelUpSession | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [isCommitting, setIsCommitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => { loadSession(); }, [characterId]);

  const loadSession = async () => {
    try {
      setIsLoading(true);
      const data = await api.startLevelUp(characterId);
      setSession(data);
    } catch (err) {
      setError('Fehler beim Laden der Sitzung');
      console.error('Failed to start level-up:', err);
    } finally {
      setIsLoading(false);
    }
  };

  const handleCommit = async () => {
    try {
      setIsCommitting(true);
      const result = await api.commitLevelUp(characterId, session!.id);
      onCommitted(result);
      onClose();
    } catch (err) {
      setError('Fehler beim Bestätigen');
      console.error('Failed to commit:', err);
    } finally {
      setIsCommitting(false);
    }
  };
  // ...
}
```

**Imports pattern — `@/` alias for client (analog: rest-modal.tsx:1-5):**
```tsx
import { useState, useEffect, useReducer } from 'react';
import { X, ChevronLeft, ChevronRight, Check, Sparkles } from 'lucide-react';
import { Button, Spinner } from '@/shared/components/ui';
import { api } from '@/shared/lib/api';
import type { LevelUpSession, LevelUpPreview } from '@/shared/types';
```

---

### `client/src/features/characters/components/level-up/level-up-step-feat-*.tsx` (filtered feat picks)

**Analog:** `client/src/features/characters/components/add-feat-modal.tsx` — entire file is the canonical filtered-feat-pick pattern.

**Loading state (analog rest-modal.tsx:72-76):**
```tsx
{isLoading ? (
  <div className="flex items-center justify-center py-8">
    <Loader2 className="h-6 w-6 animate-spin text-primary-500" />
  </div>
) : ...}
```

**Feat card pattern — analog `add-feat-modal.tsx:272-330`:** inner container `p-4 rounded-xl bg-bg-tertiary` with title (`font-semibold text-text-primary`), German subtitle (`text-sm text-text-muted`), action icons, type chip, level chip, rarity chip, prerequisite line, traits, summary. UI-SPEC Choice-Card section overrides exact selected-state styling (border-primary-500 + ring + Check icon).

**Source colors — already-existing map (must reuse):**
```tsx
// From feat-detail-modal.tsx:22-29 (UI-SPEC Color §Inherited Exceptions)
const featSourceColors = {
  Class: 'bg-red-500/20 text-red-400',
  Ancestry: 'bg-blue-500/20 text-blue-400',
  General: 'bg-yellow-500/20 text-yellow-400',
  Skill: 'bg-green-500/20 text-green-400',
  Archetype: 'bg-purple-500/20 text-purple-400',
  Bonus: 'bg-cyan-500/20 text-cyan-400',
};
```

---

### `client/src/features/characters/components/level-up/level-up-step-boost.tsx` (counter step)

**Analog:** `client/src/features/characters/components/hp-control.tsx` — `+/-` button counter pattern.

**Counter button pattern (touch-target floor `h-11 w-11`):**
The exact JSX is in `01-UI-SPEC.md` lines 369-414 (Boost-Set Step Component Contract). The role-match for the `+/-` interaction is the existing HpControl component.

---

### `client/src/features/characters/components/level-up/level-up-step-review.tsx` (preview/diff)

**Analog:** `client/src/features/characters/components/rest-modal.tsx` — entire file is preview-then-commit.

**Preview-section pattern (rest-modal.tsx:80-92):**
```tsx
<div className="flex items-center gap-3 p-3 rounded-lg bg-green-500/10 border border-green-500/20">
  <Heart className="h-5 w-5 text-green-400 flex-shrink-0" />
  <div>
    <p className="text-sm font-medium text-green-400">HP-Heilung</p>
    <p className="text-xs text-text-secondary">+{preview.hpToHeal} HP (auf {preview.hpAfterRest})</p>
  </div>
</div>
```

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
<div className="flex items-center gap-2">
  {/* NEW: Stufe steigen — only when isOwner-or-isGM AND level < 20 AND no foreign DRAFT */}
  {(isOwner || isGM) && character.level < 20 && (
    <Button variant="default" size="sm" onClick={() => setShowLevelUpWizard(true)}>
      {hasDraft ? <RotateCcw className="h-4 w-4" /> : <Sparkles className="h-4 w-4" />}
      {hasDraft ? 'Stufe fortsetzen' : 'Stufe steigen'}
    </Button>
  )}
  <Button variant="outline" size="sm" onClick={() => downloadCharacterHTML(character)} title="Als HTML exportieren">
    <Download className="h-4 w-4" />
  </Button>
  {isOwner && (<>...</>)}
</div>
```

**Modal mount pattern (analog: lines 1652-1666):**
```tsx
{showLevelUpWizard && (
  <LevelUpWizard
    campaignId={campaignId!}
    characterId={characterId!}
    onClose={() => 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<LevelUpSession> {
  const response = await this.client.post(`/characters/${characterId}/level-up`, { targetLevel });
  return response.data;
}

async patchLevelUp(characterId: string, sessionId: string, state: Partial<WizardState>): Promise<LevelUpSession> {
  const response = await this.client.patch(`/characters/${characterId}/level-up/${sessionId}`, { state });
  return response.data;
}

async getLevelUpPreview(characterId: string, sessionId: string): Promise<LevelUpPreview> {
  const response = await this.client.get(`/characters/${characterId}/level-up/${sessionId}/preview`);
  return response.data;
}

async commitLevelUp(characterId: string, sessionId: string): Promise<Character> {
  const response = await this.client.post(`/characters/${characterId}/level-up/${sessionId}/commit`);
  return response.data;
}

async discardLevelUp(characterId: string, sessionId: string): Promise<void> {
  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
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
  <div className="absolute inset-0 bg-black/60" onClick={onClose} />
  <div className="relative w-full sm:max-w-2xl max-h-[90vh] bg-bg-secondary rounded-t-2xl sm:rounded-2xl flex flex-col overflow-hidden">
    {/* Header / Body / Footer */}
  </div>
</div>
```

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
<div className="flex items-center justify-between p-4 border-b border-border">
  <h2 className="text-lg font-semibold text-text-primary">{title}</h2>
  <Button variant="ghost" size="icon" onClick={onClose}>
    <X className="h-5 w-5" />
  </Button>
</div>
```

---

### Loading State

**Source:** `rest-modal.tsx:72-76`.

**Apply to:** All wizard step bodies + the wizard root while session loads.

**Pattern:**
```tsx
{isLoading ? (
  <div className="flex items-center justify-center py-8">
    <Loader2 className="h-6 w-6 animate-spin text-primary-500" />
  </div>
) : ...}
```

(or use the existing `<Spinner />` 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 `<Button variant="ghost" size="icon">`.

9. **Client Imports Use `@/` Alias, Server Uses Relative Paths.**
   `vite.config.ts` defines `@/* → ./src/*`. Server has no path alias (per CONVENTIONS.md).

10. **Pure-Function Lib Pattern Is New — This Phase Establishes It.**
    `server/src/modules/leveling/lib/*.ts` will be the first set of NestJS-free, Prisma-free, side-effect-free TS modules with `*.spec.ts` siblings. Pattern: named export, strict types, no `any`.

11. **Header Button Cluster Lives at `character-sheet-page.tsx:1607-1626`.**
    "Stufe steigen" goes first in the cluster (left of Download per UI-SPEC line 210).

12. **Source-Color Map for Feat Badges: REUSE `featSourceColors` from `feat-detail-modal.tsx:22-29`** — UI-SPEC §"Inherited Exceptions" explicitly says do not invent new colors.

---

## Metadata

**Analog search scope:**
- `server/src/modules/characters/**` (primary analog source — feature parity is highest here)
- `server/src/modules/battle/**` (secondary — for `$transaction` pattern)
- `server/src/modules/equipment/**`, `server/src/modules/feats/**` (REST controller patterns)
- `server/prisma/migrations/**`, `server/prisma/seed*.ts`
- `client/src/features/characters/components/**` (modal + tab + control patterns)
- `client/src/shared/components/ui/**` (primitives — `Button`, `Card`, `Spinner`)
- `client/src/shared/hooks/use-character-socket.ts`
- `client/src/shared/lib/api.ts`

**Files scanned:** ~45 source files across server and client.

**Pattern extraction date:** 2026-04-27.

**Pattern density:** server-side analogs are very strong (94% exact-or-role match). Client-side `*.tsx` analogs are strong for modals, choice cards, REST flows; weak for banners (no precedent) and useReducer/react-query (no precedent — first introduction in Phase 1).
