Files
the-sunken-reach/backend/prisma/schema.prisma
T
joshuaandClaude Opus 5.5 932e755895 V1.3.0: Hollowcrown seed, player visibility rules, initiative rolls, number limits, temp HP
- Hollowcrown: The Rift Between Worlds as its own seed (npm run
  seed:hollowcrown), built from the campaign book: pools, 58 monsters,
  57 NPCs, 28 locations, 72 encounters with rewards, and DM notes. Shared
  seeding logic moved to prisma/seedCampaign.js.
- What players can see: only encounters their own character is in; only
  the pool entries their character holds (plus starting options while
  building); only locations their character has been to; no invite code;
  no gold, inventory or money log of other players' characters. Socket
  events carry ids only and campaign rooms are members-only.
- Encounters have a location; while one is in progress its characters are
  there, and visits are remembered.
- Campaign setting to show players a health bar only for monsters and NPCs.
- Initiative is entered as the d20 roll (1-20) and the bonus is added;
  players can enter their own character's roll.
- Number fields enforce their limits when typing, and the API enforces
  the same limits.
- Temp HP shows on HP bars and absorbs damage before real HP.
- All packages at version 1.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:53:48 +02:00

613 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// D&D Campaign Manager — Prisma schema
// Postgres. Run `npx prisma migrate dev --name init` after `DATABASE_URL` is set,
// then `npx prisma db seed` using the seed script built from /seed-data/*.json
// (see CLAUDE_BUILD_PROMPT.md, "Database" section).
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ---------------------------------------------------------------------------
// Enums
// ---------------------------------------------------------------------------
enum CampaignRole {
DM
PLAYER
}
enum Species {
HUMAN
HALF_ELF
ELF
GNOME
DWARF
HALFLING
DRAGONBORN
TIEFLING
HALF_ORC
ORC
}
enum CharacterClass {
FIGHTER
BARBARIAN
ROGUE
RANGER
WIZARD
SORCERER
CLERIC
PALADIN
BARD
WARLOCK
DRUID
MONK
}
enum Ability {
STR
DEX
CON
INT
WIS
CHA
}
enum Skill {
ACROBATICS
ANIMAL_HANDLING
ARCANA
ATHLETICS
DECEPTION
HISTORY
INSIGHT
INTIMIDATION
INVESTIGATION
MEDICINE
NATURE
PERCEPTION
PERFORMANCE
PERSUASION
RELIGION
SLEIGHT_OF_HAND
STEALTH
SURVIVAL
}
enum BuildStage {
NOT_STARTED
ROLLING_SPECIES
ROLLING_CLASS
ROLLING_MOTIVATION
ASSIGNING_SCORES
ASSIGNING_PROFICIENCIES
CHOOSING_ABILITIES
CHOOSING_BACKGROUND
CHOOSING_APPEARANCE
CHOOSING_EQUIPMENT
COMPLETE
}
enum EncounterStatus {
PLANNING
ACTIVE
COMPLETE
}
// What kind of scene an encounter is: a fight run in initiative order, or a
// quest scene built around NPCs and an objective.
enum EncounterMode {
BATTLE
QUEST
}
enum NpcDisposition {
FRIENDLY
NEUTRAL
HOSTILE
}
enum RewardType {
GOLD
WEAPON
ITEM
}
enum NoteVisibility {
DM_ONLY
SHARED
}
enum DiceMode {
DIGITAL
PHYSICAL
}
// ---------------------------------------------------------------------------
// Identity & campaigns
// ---------------------------------------------------------------------------
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
displayName String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships CampaignMember[]
charactersOwned Character[] @relation("PlayerCharacters")
charactersCreated Character[] @relation("CreatedBy")
notesAuthored Note[]
goldTransactions GoldTransaction[]
@@map("users")
}
model Campaign {
id String @id @default(cuid())
name String
description String?
inviteCode String @unique
startingGold Int @default(0)
startingItemCount Int @default(0)
startingSpellCount Int @default(0)
skipMotivationRoll Boolean @default(false)
// Off: players see only a health bar for monsters and NPCs, not the numbers.
showMonsterHp Boolean @default(true)
ownerId String
// Set while the campaign is archived: kept in full, but moved off the main
// list and closed to new members until the DM restores it.
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members CampaignMember[]
characters Character[]
weapons Weapon[]
spells Spell[]
items Item[]
monsters Monster[]
npcs Npc[]
encounters Encounter[]
notes Note[]
locations Location[]
@@map("campaigns")
}
model Location {
id String @id @default(cuid())
campaignId String
name String
description String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
characters Character[]
encounters Encounter[]
visits CharacterLocationVisit[]
@@map("locations")
}
// A location a character has been to. Written whenever a character is put
// somewhere (by the DM, or by an encounter held there), and never removed by
// moving on: it is what decides which locations a player may see.
model CharacterLocationVisit {
id String @id @default(cuid())
characterId String
locationId String
firstVisitedAt DateTime @default(now())
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
@@unique([characterId, locationId])
@@map("character_location_visits")
}
model CampaignMember {
id String @id @default(cuid())
campaignId String
userId String
role CampaignRole @default(PLAYER)
joinedAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([campaignId, userId])
@@map("campaign_members")
}
// ---------------------------------------------------------------------------
// Characters
// ---------------------------------------------------------------------------
model Character {
id String @id @default(cuid())
campaignId String
userId String? // owning player; null for a DM-controlled NPC/ally
createdById String
name String
species Species?
characterClass CharacterClass?
level Int @default(1)
xp Int @default(0)
motivation String?
// Rolled ability scores (before species bonus)
strScore Int @default(10)
dexScore Int @default(10)
conScore Int @default(10)
intScore Int @default(10)
wisScore Int @default(10)
chaScore Int @default(10)
// Half-Elf's flexible +1/+1 choice; null unless species = HALF_ELF
halfElfBonusAbility1 Ability?
halfElfBonusAbility2 Ability?
currentHp Int @default(0)
tempHp Int @default(0)
armorBonus Int @default(0) // flat AC bonus from equipped armor/shield
speedOverride Int?
// The next three are composed by the builder from the player's picks
// (game/data/characterOptions.js). After that only the DM can edit them.
specialAbilities String? // shown in the quick-stats panel
gold Int @default(0)
background String?
appearance String?
privateNotes String? // visible to the owning player only — not even the DM
diceMode DiceMode @default(DIGITAL) // chosen once, before the first build roll
currentLocationId String?
buildStage BuildStage @default(NOT_STARTED)
buildState Json? // in-progress dice rolls / pending choices while building
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
user User? @relation("PlayerCharacters", fields: [userId], references: [id])
createdBy User @relation("CreatedBy", fields: [createdById], references: [id])
currentLocation Location? @relation(fields: [currentLocationId], references: [id], onDelete: SetNull)
skillProficiencies CharacterSkillProficiency[]
saveProficiencies CharacterSaveProficiency[]
inventoryItems InventoryItem[]
goldTransactions GoldTransaction[]
weapons CharacterWeapon[]
spells CharacterSpell[]
notes Note[]
encounterEntries EncounterParticipant[]
locationVisits CharacterLocationVisit[]
@@map("characters")
}
model CharacterSkillProficiency {
id String @id @default(cuid())
characterId String
skill Skill
proficient Boolean @default(true)
expertise Boolean @default(false) // double proficiency bonus, e.g. Rogue
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
@@unique([characterId, skill])
@@map("character_skill_proficiencies")
}
model CharacterSaveProficiency {
id String @id @default(cuid())
characterId String
ability Ability
proficient Boolean @default(true)
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
@@unique([characterId, ability])
@@map("character_save_proficiencies")
}
// ---------------------------------------------------------------------------
// Weapons & inventory
// ---------------------------------------------------------------------------
model Weapon {
id String @id @default(cuid())
campaignId String
name String
damageDice String // e.g. "1d8"
damageType String // e.g. "slashing"
attackAbility String // "STR" | "DEX" | "FINESSE" (finesse = better of STR/DEX)
properties String[] // e.g. ["finesse", "light", "thrown"]
rangeNormal Int?
rangeLong Int?
// damageDice is the level-1 damage; a held copy's dice climb the damage
// ladder (game/calculations.js) by this many rungs per level above 1.
damageScaling Int @default(1)
// The level this weapon starts at: a held copy is never below it, and
// damageDice is the damage at this level.
minLevel Int @default(1)
// Offered in the character builder's starting-equipment step. Off by default:
// the DM opts entries in, so the pool can hold gear that must be earned.
isStartingOption Boolean @default(false)
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
assigned CharacterWeapon[]
rewards EncounterReward[]
@@map("weapons")
}
model CharacterWeapon {
id String @id @default(cuid())
characterId String
weaponId String
quantity Int @default(1)
equipped Boolean @default(false)
level Int @default(1) // this copy's level; the character must be at least this level to use it
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
weapon Weapon @relation(fields: [weaponId], references: [id], onDelete: Cascade)
@@unique([characterId, weaponId])
@@map("character_weapons")
}
// Campaign-level item pool, DM-managed like weapons and spells.
model Item {
id String @id @default(cuid())
campaignId String
name String
description String?
damageDice String? // optional dice (damage, healing...) at its starting level; scales like a weapon's
damageScaling Int @default(1)
minLevel Int @default(1)
isStartingOption Boolean @default(false)
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
held InventoryItem[]
rewards EncounterReward[]
@@map("items")
}
// An Item from the campaign pool held by a character.
model InventoryItem {
id String @id @default(cuid())
characterId String
itemId String
quantity Int @default(1)
equipped Boolean @default(false)
level Int @default(1)
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@unique([characterId, itemId])
@@map("inventory_items")
}
// Ledger behind Character.gold: every change to the balance is one row here.
model GoldTransaction {
id String @id @default(cuid())
characterId String
amount Int // signed: positive = gained, negative = spent
balanceAfter Int
reason String?
createdById String?
createdAt DateTime @default(now())
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([characterId, createdAt])
@@map("gold_transactions")
}
// ---------------------------------------------------------------------------
// Spells
// ---------------------------------------------------------------------------
model Spell {
id String @id @default(cuid())
campaignId String
name String
level Int // 0 = cantrip. Also the character level needed to use the spell.
damageDice String? // fixed; spells don't level up
school String?
castingTime String?
range String?
duration String?
description String?
isStartingOption Boolean @default(false)
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
known CharacterSpell[]
@@map("spells")
}
model CharacterSpell {
id String @id @default(cuid())
characterId String
spellId String
prepared Boolean @default(false)
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
spell Spell @relation(fields: [spellId], references: [id], onDelete: Cascade)
@@unique([characterId, spellId])
@@map("character_spells")
}
// ---------------------------------------------------------------------------
// Monsters & encounters
// ---------------------------------------------------------------------------
model Monster {
id String @id @default(cuid())
campaignId String
name String
ac Int
maxHp Int
speed Int @default(30)
strScore Int @default(10)
dexScore Int @default(10)
conScore Int @default(10)
intScore Int @default(10)
wisScore Int @default(10)
chaScore Int @default(10)
attackBonus Int @default(0)
damageDice String?
challengeRating String?
specialTrait String?
notes String?
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
encounterAppearances EncounterParticipant[]
@@map("monsters")
}
// A non-player character the DM runs: a quest-giver, shopkeeper, rival...
// Combat stats are optional — most NPCs are there to be talked to.
model Npc {
id String @id @default(cuid())
campaignId String
name String
role String? // e.g. "Innkeeper", "Captain of the Watch"
disposition NpcDisposition @default(NEUTRAL)
description String? // what the party can see and knows
dmNotes String? // secrets, motives — never sent to players
ac Int?
maxHp Int?
attackBonus Int?
damageDice String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
encounterAppearances EncounterParticipant[]
@@map("npcs")
}
model Encounter {
id String @id @default(cuid())
campaignId String
name String
mode EncounterMode @default(BATTLE)
status EncounterStatus @default(PLANNING)
currentRound Int @default(1)
// Whose turn it is in a battle; null until the fight starts.
activeParticipantId String?
description String? // the scene / objective, shown to everyone
dmNotes String? // never sent to players
// Where the scene takes place. While the encounter is in progress, the
// characters in it are there too.
locationId String?
// Set while archived: kept in full, but off the campaign's list — players
// no longer see it there at all.
archivedAt DateTime?
// Place in the campaign's own order, lowest first. The DM rearranges it.
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
participants EncounterParticipant[]
rewards EncounterReward[]
@@map("encounters")
}
// Loot the DM lines up for an encounter and later hands to a character. Until
// it is awarded it is the DM's secret; awarding it is what actually changes
// the character (gold ledger entry, or the weapon/item added to what they hold).
model EncounterReward {
id String @id @default(cuid())
encounterId String
type RewardType
gold Int? // GOLD
weaponId String? // WEAPON
itemId String? // ITEM
quantity Int @default(1)
level Int @default(1) // gear level the weapon/item is handed over at
note String?
awardedTo String? // who received it, as display text ("Aria", "Aria, Bo")
awardedAt DateTime?
createdAt DateTime @default(now())
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@map("encounter_rewards")
}
// A row per combatant in an encounter — a character, a monster or an NPC.
// A monster's currentHp/maxHp live here, per instance. A character's HP has
// one source of truth, the Character row: encounters read and write that, and
// the columns here only hold the last values seen (used if the character is
// later deleted and the row loses its link).
model EncounterParticipant {
id String @id @default(cuid())
encounterId String
characterId String?
monsterId String?
npcId String?
displayName String // denormalized so a monster can be renamed per-instance, e.g. "Goblin #2"
// The d20 rolled for initiative (1–20); null until it is rolled.
initiativeRoll Int?
// The roll plus the combatant's initiative bonus, worked out when the roll
// is entered. This is what the turn order sorts on.
initiative Int @default(0)
currentHp Int
maxHp Int
conditions String[] @default([]) // e.g. ["poisoned", "prone"]
isMonster Boolean
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
character Character? @relation(fields: [characterId], references: [id], onDelete: SetNull)
monster Monster? @relation(fields: [monsterId], references: [id], onDelete: SetNull)
npc Npc? @relation(fields: [npcId], references: [id], onDelete: SetNull)
@@map("encounter_participants")
}
// ---------------------------------------------------------------------------
// Notes
// ---------------------------------------------------------------------------
model Note {
id String @id @default(cuid())
campaignId String?
characterId String?
authorId String
body String
visibility NoteVisibility @default(SHARED)
createdAt DateTime @default(now())
campaign Campaign? @relation(fields: [campaignId], references: [id], onDelete: Cascade)
character Character? @relation(fields: [characterId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id])
@@map("notes")
}