- A character at 0 HP is Dying, Stable or Dead. The tallies live on the character, so the sheet and the encounter card always agree. - Death saves are typed in as the d20 showed: 10 or more succeeds, less fails, a 1 fails twice, a 20 puts the character back up at 1 HP. Three successes make them stable; three failures kill. - Damage taken at 0 HP is a failed save and ends stable. A blow that leaves as much as max HP still to take after reaching 0 kills outright. - Healing from 0 clears the tallies. The dead cannot be healed; the DM revives them at 1 HP. - A player rolls and stabilises for their own character; the DM for anyone, and only the DM sets tallies by hand or revives. - The party overview, campaign list and quick stats show the state. - All packages at version 1.5.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
633 lines
19 KiB
Plaintext
633 lines
19 KiB
Plaintext
// 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?
|
||
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")
|
||
}
|