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
This commit is contained in:
2026-04-27 14:25:10 +02:00
parent 096edbf950
commit de2ec38bf6

View File

@@ -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])
}