Files
the-sunken-reach/backend/prisma/schema.prisma
T
joshuaandClaude Opus 5.5 f8cee43ca2 V1.7.0: area attacks on several targets
- More than one combatant can be marked as a target; the Target button now
  adds to the targets instead of moving the mark.
- Several targets make an area attack, which is harder to land: every
  target's AC counts as 2 higher for each target beyond the first. The
  attack bar shows the AC to beat, and each target's card names the penalty.
- The DM unticks any target the roll missed, then applies one total: split
  evenly between those hit (remainder to the first marked), or the full
  amount to each. Each share is shown before it is applied.
- The single target column from V1.6.0 becomes a list, carried over by the
  migration.
- All packages at version 1.7.0.

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

637 lines
20 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
}
// The coins a character carries. Their worth is fixed in utils/grants.js:
// 1 gold = 10 silver = 100 copper.
enum Currency {
GOLD
SILVER
COPPER
}
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)
// Death saves, counted while the character is at 0 HP (utils/hp.js). Three
// failures is dead; three successes sets `stable` and clears the tallies.
deathSaveSuccesses Int @default(0)
deathSaveFailures Int @default(0)
stable Boolean @default(false) // at 0 HP but no longer rolling
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
// The purse: each coin is counted on its own and only changes through the
// money ledger (GoldTransaction).
gold Int @default(0)
silver Int @default(0)
copper 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 a character's purse: every change to a coin balance is one
// row here. (The name predates silver and copper.)
model GoldTransaction {
id String @id @default(cuid())
characterId String
currency Currency @default(GOLD) // which coin this entry is in
amount Int // signed: positive = gained, negative = spent
balanceAfter Int // that coin's balance afterwards
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?
// Who is being attacked (or healed) right now, shown to everyone, in the
// order they were picked. More than one makes it an area attack. Picked by
// the DM or by the player on turn, and cleared when the turn passes.
targetParticipantIds String[] @default([])
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: how many coins
currency Currency @default(GOLD) // GOLD: which coin they are
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")
}