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

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

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

50 KiB

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):

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):

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:

@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):

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:

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

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:

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:

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):

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):

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):

// 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:

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:

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:

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:

// 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):

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':

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):

@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:

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):

-- 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:

-- 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):

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):

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):

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:

"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):

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):

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):

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):

{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):

// 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):

<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):

<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):

{showLevelUpWizard && (
  <LevelUpWizard
    campaignId={campaignId!}
    characterId={characterId!}
    onClose={() => setShowLevelUpWizard(false)}
    onCommitted={() => { setShowLevelUpWizard(false); fetchCharacter(); }}
  />
)}

State variable pattern (analog: lines 122-132):

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):

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):

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:

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:

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:

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:

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:

<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:

<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:

{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).