From de2ec38bf61d27f97859890d02cc80d1830ce887 Mon Sep 17 00:00:00 2001 From: Alexander Zielonka Date: Mon, 27 Apr 2026 14:25:10 +0200 Subject: [PATCH] feat(01-01): extend Prisma schema with level-up tables and Character columns - Add LevelUpSession model (DRAFT state, soft-archive on commit via committedAt) - Add LevelUpHistory model (append-only audit log of committed level-ups) - Add ClassProgression model (per-class, per-level grants and prof changes) - Add ClassFeatureOption model (sub-choices like doctrines/schools/instincts) - Extend Character with freeArchetype Boolean (D-08) and prereqViolations Json (D-06) - Add reverse relations levelUpSessions and levelUpHistories on Character - Schema formatted via prisma format and validated via prisma validate --- server/prisma/schema.prisma | 394 +++++++++++++++++++++--------------- 1 file changed, 232 insertions(+), 162 deletions(-) diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 7c6472e..33ed2a1 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -109,19 +109,19 @@ enum ResearchField { // ========================================== model User { - id String @id @default(uuid()) - username String @unique - email String @unique - passwordHash String - role UserRole @default(PLAYER) - avatarUrl String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + username String @unique + email String @unique + passwordHash String + role UserRole @default(PLAYER) + avatarUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations - gmCampaigns Campaign[] @relation("CampaignGM") + gmCampaigns Campaign[] @relation("CampaignGM") campaignMembers CampaignMember[] - characters Character[] @relation("CharacterOwner") + characters Character[] @relation("CharacterOwner") uploadedDocuments Document[] documentAccess DocumentAccess[] highlights Highlight[] @@ -143,14 +143,14 @@ model Campaign { updatedAt DateTime @updatedAt // Relations - gm User @relation("CampaignGM", fields: [gmId], references: [id]) - members CampaignMember[] - characters Character[] - battleMaps BattleMap[] - combatants Combatant[] - battleSessions BattleSession[] - documents Document[] - notes Note[] + gm User @relation("CampaignGM", fields: [gmId], references: [id]) + members CampaignMember[] + characters Character[] + battleMaps BattleMap[] + combatants Combatant[] + battleSessions BattleSession[] + documents Document[] + notes Note[] } model CampaignMember { @@ -169,24 +169,24 @@ model CampaignMember { // ========================================== model Character { - id String @id @default(uuid()) - campaignId String - ownerId String? - name String - type CharacterType @default(PC) - level Int @default(1) - avatarUrl String? + id String @id @default(uuid()) + campaignId String + ownerId String? + name String + type CharacterType @default(PC) + level Int @default(1) + avatarUrl String? // Core Stats - hpCurrent Int - hpMax Int - hpTemp Int @default(0) + hpCurrent Int + hpMax Int + hpTemp Int @default(0) // Ancestry/Class/Background (References) - ancestryId String? - heritageId String? - classId String? - backgroundId String? + ancestryId String? + heritageId String? + classId String? + backgroundId String? // Experience experiencePoints Int @default(0) @@ -197,26 +197,32 @@ model Character { // Pathbuilder Import Data (JSON blob for original import) pathbuilderData Json? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Relations - campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) - owner User? @relation("CharacterOwner", fields: [ownerId], references: [id]) - abilities CharacterAbility[] - feats CharacterFeat[] - skills CharacterSkill[] - spells CharacterSpell[] - items CharacterItem[] - conditions CharacterCondition[] - resources CharacterResource[] - battleTokens BattleToken[] + campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) + owner User? @relation("CharacterOwner", fields: [ownerId], references: [id]) + abilities CharacterAbility[] + feats CharacterFeat[] + skills CharacterSkill[] + spells CharacterSpell[] + items CharacterItem[] + conditions CharacterCondition[] + resources CharacterResource[] + battleTokens BattleToken[] documentAccess DocumentAccess[] // Alchemy - alchemyState CharacterAlchemyState? - formulas CharacterFormula[] - preparedItems CharacterPreparedItem[] + alchemyState CharacterAlchemyState? + formulas CharacterFormula[] + preparedItems CharacterPreparedItem[] + + // === Phase 1 additions === + freeArchetype Boolean @default(false) // D-08 + prereqViolations Json? // D-06 + levelUpSessions LevelUpSession[] + levelUpHistories LevelUpHistory[] } model CharacterAbility { @@ -233,10 +239,10 @@ model CharacterAbility { model CharacterFeat { id String @id @default(uuid()) characterId String - featId String? // Reference to Feat table - name String // English name - nameGerman String? // German translation - level Int // Level obtained + featId String? // Reference to Feat table + name String // English name + nameGerman String? // German translation + level Int // Level obtained source FeatSource character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) @@ -282,26 +288,26 @@ model CharacterItem { notes String? // Player-editable: Alias/Nickname for the item - alias String? + alias String? // GM-editable: Custom overrides for item properties - customName String? // Override display name - customDamage String? // Override damage dice (e.g. "2d6") - customDamageType String? // Override damage type (e.g. "fire") - customTraits String[] // Override/add traits - customRange String? // Override range - customHands String? // Override hands requirement + customName String? // Override display name + customDamage String? // Override damage dice (e.g. "2d6") + customDamageType String? // Override damage type (e.g. "fire") + customTraits String[] // Override/add traits + customRange String? // Override range + customHands String? // Override hands requirement character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) equipment Equipment? @relation(fields: [equipmentId], references: [id]) } model CharacterCondition { - id String @id @default(uuid()) + id String @id @default(uuid()) characterId String name String nameGerman String? - value Int? // For valued conditions like Frightened 2 + value Int? // For valued conditions like Frightened 2 duration String? source String? @@ -325,14 +331,14 @@ model CharacterResource { // ========================================== model CharacterAlchemyState { - id String @id @default(uuid()) - characterId String @unique - researchField ResearchField? - versatileVialsCurrent Int @default(0) - versatileVialsMax Int @default(0) - advancedAlchemyBatch Int @default(0) - advancedAlchemyMax Int @default(0) - lastRestAt DateTime? + id String @id @default(uuid()) + characterId String @unique + researchField ResearchField? + versatileVialsCurrent Int @default(0) + versatileVialsMax Int @default(0) + advancedAlchemyBatch Int @default(0) + advancedAlchemyMax Int @default(0) + lastRestAt DateTime? character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) } @@ -354,15 +360,15 @@ model CharacterFormula { } model CharacterPreparedItem { - id String @id @default(uuid()) - characterId String - equipmentId String - name String - nameGerman String? - quantity Int @default(1) - isQuickAlchemy Boolean @default(false) - isInfused Boolean @default(true) // Infused items expire on rest, permanent items don't - createdAt DateTime @default(now()) + id String @id @default(uuid()) + characterId String + equipmentId String + name String + nameGerman String? + quantity Int @default(1) + isQuickAlchemy Boolean @default(false) + isInfused Boolean @default(true) // Infused items expire on rest, permanent items don't + createdAt DateTime @default(now()) character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) equipment Equipment @relation(fields: [equipmentId], references: [id]) @@ -390,40 +396,40 @@ model BattleMap { } model Combatant { - id String @id @default(uuid()) - campaignId String - name String - type CombatantType - level Int - hpMax Int - ac Int + id String @id @default(uuid()) + campaignId String + name String + type CombatantType + level Int + hpMax Int + ac Int // Saves - fortitude Int - reflex Int - will Int - perception Int + fortitude Int + reflex Int + will Int + perception Int // Speed - speed Int @default(25) + speed Int @default(25) avatarUrl String? description String? createdAt DateTime @default(now()) - campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) - abilities CombatantAbility[] - battleTokens BattleToken[] + campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) + abilities CombatantAbility[] + battleTokens BattleToken[] } model CombatantAbility { id String @id @default(uuid()) combatantId String name String - actionCost Int // 1, 2, 3, or 0 for free/reaction + actionCost Int // 1, 2, 3, or 0 for free/reaction actionType ActionType description String - damage String? // e.g. "2d6+4 slashing" + damage String? // e.g. "2d6+4 slashing" traits String[] combatant Combatant @relation(fields: [combatantId], references: [id], onDelete: Cascade) @@ -438,8 +444,8 @@ model BattleSession { roundNumber Int @default(0) createdAt DateTime @default(now()) - campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) - map BattleMap? @relation(fields: [mapId], references: [id]) + campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) + map BattleMap? @relation(fields: [mapId], references: [id]) tokens BattleToken[] } @@ -474,7 +480,7 @@ model Document { category String? tags String[] filePath String - fileType String // html, pdf + fileType String // html, pdf uploadedBy String createdAt DateTime @default(now()) @@ -487,8 +493,8 @@ model Document { model DocumentAccess { id String @id @default(uuid()) documentId String - userId String? // NULL = all campaign members - characterId String? // Access per character + userId String? // NULL = all campaign members + characterId String? // Access per character document Document @relation(fields: [documentId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id]) @@ -513,14 +519,14 @@ model Highlight { } model Note { - id String @id @default(uuid()) - userId String - campaignId String - title String - content String // Markdown - isShared Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + userId String + campaignId String + title String + content String // Markdown + isShared Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade) @@ -542,32 +548,32 @@ model NoteShare { // ========================================== model Feat { - id String @id @default(uuid()) - name String @unique - traits String[] - summary String? - description String? // Full feat description/benefit text - actions String? // "1", "2", "3", "free", "reaction", null for passive - url String? - level Int? // Minimum level requirement - sourceBook String? + id String @id @default(uuid()) + name String @unique + traits String[] + summary String? + description String? // Full feat description/benefit text + actions String? // "1", "2", "3", "free", "reaction", null for passive + url String? + level Int? // Minimum level requirement + sourceBook String? // Feat classification - featType String? // "General", "Skill", "Class", "Ancestry", "Archetype", "Heritage" - rarity String? // "Common", "Uncommon", "Rare", "Unique" + featType String? // "General", "Skill", "Class", "Ancestry", "Archetype", "Heritage" + rarity String? // "Common", "Uncommon", "Rare", "Unique" // Prerequisites - prerequisites String? // Text description of prerequisites + prerequisites String? // Text description of prerequisites // For class/archetype feats - className String? // "Fighter", "Wizard", etc. - archetypeName String? // "Sentinel", "Medic", etc. + className String? // "Fighter", "Wizard", etc. + archetypeName String? // "Sentinel", "Medic", etc. // For ancestry feats - ancestryName String? // "Human", "Elf", etc. + ancestryName String? // "Human", "Elf", etc. // For skill feats - skillName String? // "Acrobatics", "Athletics", etc. + skillName String? // "Acrobatics", "Athletics", etc. // Cached German translation nameGerman String? @@ -582,49 +588,49 @@ model Feat { } model Equipment { - id String @id @default(uuid()) - name String @unique + id String @id @default(uuid()) + name String @unique traits String[] - itemCategory String // "Weapons", "Armor", "Consumables", "Shields", etc. + itemCategory String // "Weapons", "Armor", "Consumables", "Shields", etc. itemSubcategory String? bulk String? // "L" for light, "1", "2", etc. url String? summary String? level Int? - price Int? // In CP + price Int? // In CP // Weapon-specific fields - hands String? - damage String? // "1d8 S", "1d6 P", etc. - damageType String? // "S", "P", "B" (Slashing, Piercing, Bludgeoning) - range String? - reload String? - weaponCategory String? // "Simple", "Martial", "Advanced", "Ammunition" - weaponGroup String? // "Sword", "Axe", "Bow", etc. + hands String? + damage String? // "1d8 S", "1d6 P", etc. + damageType String? // "S", "P", "B" (Slashing, Piercing, Bludgeoning) + range String? + reload String? + weaponCategory String? // "Simple", "Martial", "Advanced", "Ammunition" + weaponGroup String? // "Sword", "Axe", "Bow", etc. // Armor-specific fields - ac Int? - dexCap Int? - checkPenalty Int? - speedPenalty Int? - strength Int? // Strength requirement - armorCategory String? // "Unarmored", "Light", "Medium", "Heavy" - armorGroup String? // "Leather", "Chain", "Plate", etc. + ac Int? + dexCap Int? + checkPenalty Int? + speedPenalty Int? + strength Int? // Strength requirement + armorCategory String? // "Unarmored", "Light", "Medium", "Heavy" + armorGroup String? // "Leather", "Chain", "Plate", etc. // Shield-specific fields - shieldHp Int? - shieldHardness Int? - shieldBt Int? // Broken Threshold + shieldHp Int? + shieldHardness Int? + shieldBt Int? // Broken Threshold // Consumable/Equipment-specific fields - activation String? // "Cast A Spell", "[one-action]", etc. - duration String? - usage String? - effect String? // Specific effect text for item variants (Lesser, Moderate, Greater, Major) + activation String? // "Cast A Spell", "[one-action]", etc. + duration String? + usage String? + effect String? // Specific effect text for item variants (Lesser, Moderate, Greater, Major) - characterItems CharacterItem[] - formulas CharacterFormula[] - preparedItems CharacterPreparedItem[] + characterItems CharacterItem[] + formulas CharacterFormula[] + preparedItems CharacterPreparedItem[] } model Spell { @@ -655,19 +661,83 @@ model Trait { // ========================================== model Translation { - id String @id @default(uuid()) - type TranslationType - englishName String - germanName String - germanSummary String? - germanDescription String? - germanEffect String? // Translated effect text for alchemical items - quality TranslationQuality @default(MEDIUM) - translatedBy String @default("claude-api") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + type TranslationType + englishName String + germanName String + germanSummary String? + germanDescription String? + germanEffect String? // Translated effect text for alchemical items + quality TranslationQuality @default(MEDIUM) + translatedBy String @default("claude-api") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([type, englishName]) @@index([type]) @@index([englishName]) } + +// ========================================== +// LEVEL-UP SYSTEM (Phase 1) +// ========================================== + +model LevelUpSession { + id String @id @default(uuid()) + characterId String + targetLevel Int + state Json @default("{}") + committedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) + // Partial unique index "one open DRAFT per character" added in raw migration SQL — + // Prisma 7 cannot emit partial indexes from schema (per RESEARCH.md §Don't Hand-Roll). + + @@index([characterId]) +} + +model LevelUpHistory { + id String @id @default(uuid()) + characterId String + levelFrom Int + levelTo Int + snapshotBefore Json + choices Json + committedAt DateTime @default(now()) + + character Character @relation(fields: [characterId], references: [id], onDelete: Cascade) + + @@index([characterId, committedAt(sort: Desc)]) +} + +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]) + @@index([className]) +} + +model ClassFeatureOption { + id String @id @default(uuid()) + optionsRef String + optionKey String + name String + nameGerman String? + description String + grants String[] + proficiencyChanges Json? + + @@unique([optionsRef, optionKey]) + @@index([optionsRef]) +}