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>
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(matchestestRegex: ".*\\.spec\\.ts$"fromserver/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.
PrismaServicefrom'../../prisma/prisma.service'.- Sibling-module services from
'./xxx.service'(relative). - DTOs from
'./dto'barrel. - Generated Prisma types from
'../../generated/prisma/client.js'(note.jsextension — 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
-
NestJS Service Pattern:
@Injectable() + constructor(private prisma, private translationsService, @Inject(forwardRef()) gateway)Every service follows the same constructor shape with the gateway viaforwardRefto break the circular dependency. -
checkCharacterAccessis the One Permission Helper. Owner-or-GM gate. NewLevelingServiceshould reuse it (import from CharactersService or duplicate). Three-tier check: NotFoundException → owner/GM check (if mutation) → member check. -
REST Endpoints Use
prisma.$transaction(async tx => ...)for Atomic Multi-Table Writes. Already in use incombatants.service.ts:82-118. Apply toLevelingService.commit()for snapshot + character mutation + history insert + session-mark-committed. -
Single WebSocket Broadcast After Transaction Commit. Existing pattern:
this.charactersGateway.broadcastCharacterUpdate(id, { type: 'xxx', data: ... })called once afterawait prisma.x.update(). New'level_up_committed'type added to the union — same call site pattern. -
DTO + class-validator + Swagger.
@ApiProperty({ description })paired with@IsInt() @Min() @Max()etc. Barrelindex.tsre-exports all DTOs. NestJS globalValidationPipeauto-rejects bad shapes. -
Prisma Migration Naming:
YYYYMMDDHHMMSS_snake_case_description/migration.sqlgenerated vianpm run db:migrate:dev -- --name xxx. Hand-add raw SQL for partial unique indexes (Prisma 7 limitation). -
Seed Scripts: idempotent find-then-update-or-create with
'dotenv/config'+PrismaPg adapter.seed-equipment.tsis the canonical example. Newseed-class-progression.tsmirrors imports and idempotency exactly. -
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. Backdropbg-black/60. Close button in header<Button variant="ghost" size="icon">. -
Client Imports Use
@/Alias, Server Uses Relative Paths.vite.config.tsdefines@/* → ./src/*. Server has no path alias (per CONVENTIONS.md). -
Pure-Function Lib Pattern Is New — This Phase Establishes It.
server/src/modules/leveling/lib/*.tswill be the first set of NestJS-free, Prisma-free, side-effect-free TS modules with*.spec.tssiblings. Pattern: named export, strict types, noany. -
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). -
Source-Color Map for Feat Badges: REUSE
featSourceColorsfromfeat-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$transactionpattern)server/src/modules/equipment/**,server/src/modules/feats/**(REST controller patterns)server/prisma/migrations/**,server/prisma/seed*.tsclient/src/features/characters/components/**(modal + tab + control patterns)client/src/shared/components/ui/**(primitives —Button,Card,Spinner)client/src/shared/hooks/use-character-socket.tsclient/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).