- Unarmored Defense (barbarian, monk), Draconic Resilience, Fast Movement, Unarmored Movement, Aura of Protection, Diamond Soul, Slippery Mind and Jack of All Trades are counted, by level in the class that gives them - Armor and shields are items marked with what kind they are, so the sheet knows when a character is unarmored or in heavy armor; the library's and the seeds' are marked - The sheet lists the class features in force; a speed the DM set is final - A cantrip can be cast with every spell slot spent (tested end to end) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
981 lines
34 KiB
Plaintext
981 lines
34 KiB
Plaintext
// D&D Campaign Manager — Prisma schema
|
||
// Postgres. The connection (DATABASE_URL) is set in prisma.config.js, not here.
|
||
// Run `npx prisma migrate dev` then `npx prisma generate`; `npx prisma db seed`
|
||
// adds the admin account (prisma/seed.js). Worlds are created in the app from
|
||
// the seeds in /backend/seeds.
|
||
|
||
generator client {
|
||
provider = "prisma-client-js"
|
||
}
|
||
|
||
datasource db {
|
||
provider = "postgresql"
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Enums
|
||
// ---------------------------------------------------------------------------
|
||
|
||
enum CampaignRole {
|
||
DM
|
||
PLAYER
|
||
}
|
||
|
||
enum Species {
|
||
HUMAN
|
||
HALF_ELF
|
||
ELF
|
||
GNOME
|
||
DWARF
|
||
HALFLING
|
||
DRAGONBORN
|
||
TIEFLING
|
||
HALF_ORC
|
||
ORC
|
||
AASIMAR
|
||
GOLIATH
|
||
AARAKOCRA
|
||
BUGBEAR
|
||
CENTAUR
|
||
CHANGELING
|
||
DEEP_GNOME
|
||
DUERGAR
|
||
ELADRIN
|
||
FAIRY
|
||
FIRBOLG
|
||
AIR_GENASI
|
||
EARTH_GENASI
|
||
FIRE_GENASI
|
||
WATER_GENASI
|
||
GITHYANKI
|
||
GITHZERAI
|
||
GOBLIN
|
||
HARENGON
|
||
HOBGOBLIN
|
||
KENKU
|
||
KOBOLD
|
||
LIZARDFOLK
|
||
MINOTAUR
|
||
SEA_ELF
|
||
SHADAR_KAI
|
||
SHIFTER
|
||
TABAXI
|
||
TORTLE
|
||
TRITON
|
||
YUAN_TI
|
||
OWLIN
|
||
SATYR
|
||
}
|
||
|
||
enum CharacterClass {
|
||
FIGHTER
|
||
BARBARIAN
|
||
ROGUE
|
||
RANGER
|
||
WIZARD
|
||
SORCERER
|
||
CLERIC
|
||
PALADIN
|
||
BARD
|
||
WARLOCK
|
||
DRUID
|
||
MONK
|
||
ARTIFICER
|
||
}
|
||
|
||
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
|
||
CHOOSING_SUBCLASS
|
||
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, a
|
||
// quest scene built around NPCs and an objective, or free roam, where the
|
||
// party wanders between places the DM has laid out (to shop, to meet people).
|
||
enum EncounterMode {
|
||
BATTLE
|
||
QUEST
|
||
FREE_ROAM
|
||
}
|
||
|
||
enum NpcDisposition {
|
||
FRIENDLY
|
||
NEUTRAL
|
||
HOSTILE
|
||
}
|
||
|
||
enum RewardType {
|
||
GOLD
|
||
WEAPON
|
||
ITEM
|
||
}
|
||
|
||
// The money a character carries. The coins' worth is fixed in utils/grants.js:
|
||
// 1 gold = 10 silver = 100 copper. Cash ($) is modern money for crossover
|
||
// worlds; it has no worth in coins and is never exchanged for them.
|
||
enum Currency {
|
||
GOLD
|
||
SILVER
|
||
COPPER
|
||
CASH
|
||
}
|
||
|
||
enum NoteVisibility {
|
||
DM_ONLY
|
||
SHARED
|
||
}
|
||
|
||
enum DiceMode {
|
||
DIGITAL
|
||
PHYSICAL
|
||
}
|
||
|
||
// Which dice a campaign plays with. BOTH leaves it to each character (and
|
||
// every roll can still be typed in from a real die).
|
||
enum CampaignDiceMode {
|
||
DIGITAL
|
||
PHYSICAL
|
||
BOTH
|
||
}
|
||
|
||
// How a character's maximum HP grows on a level up: the fixed average of the
|
||
// hit die (rounded up), or a roll of it.
|
||
enum HpMethod {
|
||
AVERAGE
|
||
ROLL
|
||
}
|
||
|
||
// How the builder arrives at the six ability scores.
|
||
enum ScoreMethod {
|
||
ROLL_4D6
|
||
STANDARD_ARRAY
|
||
POINT_BUY
|
||
}
|
||
|
||
// Who sees a roll. ALL: everyone at the table. OWN: a player sees their own;
|
||
// the DM always sees every roll.
|
||
enum RollVisibility {
|
||
ALL
|
||
OWN
|
||
}
|
||
|
||
// What a critical hit does to the damage: roll the dice twice (the rule), or
|
||
// take the dice at their maximum and roll them once more on top.
|
||
enum CritRule {
|
||
DOUBLE_DICE
|
||
MAX_PLUS_ROLL
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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)
|
||
// An attack on several targets is harder to land: each target's AC counts
|
||
// as this much higher for every target beyond the first. 0 turns it off.
|
||
areaAttackAcPenalty Int @default(2)
|
||
// How much a character can have equipped at once: weapons and items
|
||
// together, and weapons alone. Only equipped gear can be used in an
|
||
// encounter or gives its effects, so this is what a character can bring to
|
||
// bear at one time. A pair of daggers is one weapon, made as such.
|
||
maxEquipped Int @default(2)
|
||
maxEquippedWeapons Int @default(1)
|
||
// Off: nobody chooses a subclass. Each class follows its first one without
|
||
// being asked, and subclasses are not named anywhere.
|
||
subclassesEnabled Boolean @default(true)
|
||
// On: a character can take levels in more than one class (game/multiclass.js).
|
||
multiclassEnabled Boolean @default(false)
|
||
// Which dice are rolled at this table; see CampaignDiceMode.
|
||
diceMode CampaignDiceMode @default(BOTH)
|
||
// On: when a monster's (or an NPC's) turn comes up in a battle, the server
|
||
// picks a target, rolls the attack and the damage, and puts it to the DM to
|
||
// accept, change or throw out.
|
||
autoRollMonsters Boolean @default(false)
|
||
autoRollNpcs Boolean @default(false)
|
||
// Off: experience is not kept. No XP on the sheets, nothing to award after
|
||
// an encounter, and nobody is shown as due a level.
|
||
trackXp Boolean @default(true)
|
||
hpOnLevelUp HpMethod @default(AVERAGE)
|
||
abilityScoreMethod ScoreMethod @default(ROLL_4D6)
|
||
rollVisibility RollVisibility @default(ALL)
|
||
// On: the DM's rolls for monsters and NPCs are seen by the DM alone until
|
||
// they are put into play.
|
||
hideMonsterRolls Boolean @default(true)
|
||
critRule CritRule @default(DOUBLE_DICE)
|
||
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[]
|
||
saleOffers SaleOffer[]
|
||
notes Note[]
|
||
locations Location[]
|
||
diceRolls DiceRoll[]
|
||
|
||
@@map("campaigns")
|
||
}
|
||
|
||
// A roll made at the table's dice roller, kept so the table (and above all
|
||
// the DM) can see it. Digital rolls are made by the server; physical ones are
|
||
// typed in and checked against their dice. Who sees a roll follows the
|
||
// campaign's rollVisibility; a `hidden` one is the DM's alone.
|
||
model DiceRoll {
|
||
id String @id @default(cuid())
|
||
campaignId String
|
||
userId String
|
||
rollerName String // who rolled, as shown: kept so a roll outlives a renamed account
|
||
label String? // what it was for: "Aria: Stealth"
|
||
notation String // "1d20+3", "2d6+1d8+2"
|
||
// Every die, in order: [{ sides, value, kept, sign }]. `kept` is false for
|
||
// the d20 that advantage or disadvantage dropped.
|
||
dice Json
|
||
modifier Int @default(0)
|
||
total Int
|
||
natural Int? // the d20 that counted, for a roll of a single d20
|
||
mode String @default("NORMAL") // NORMAL | ADVANTAGE | DISADVANTAGE
|
||
physical Boolean @default(false) // rolled at the table and typed in
|
||
hidden Boolean @default(false) // the DM's alone
|
||
encounterId String?
|
||
createdAt DateTime @default(now())
|
||
|
||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([campaignId, createdAt])
|
||
@@map("dice_rolls")
|
||
}
|
||
|
||
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[]
|
||
// Who is found here, what is for sale here, and the free-roam scenes it is a stop in.
|
||
npcs Npc[]
|
||
wares SaleOffer[]
|
||
freeRoams EncounterLocation[]
|
||
|
||
@@index([campaignId])
|
||
@@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])
|
||
@@index([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?
|
||
// The path taken within the class: a key from game/data/subclasses.js. Null
|
||
// until the class reaches the level it chooses at.
|
||
subclass String?
|
||
level Int @default(1)
|
||
// Levels the DM has approved that the character has not yet taken: each is
|
||
// one level-up checklist waiting for its picks. The player resolves them
|
||
// (or the DM does), one at a time.
|
||
levelUpsPending Int @default(0)
|
||
// Where the campaign rolls HP on a level up: how far the rolls so far are
|
||
// from the average they replace, in all (a 2 on a d8 is -3). Added to max HP.
|
||
hpRollAdjust Int @default(0)
|
||
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)
|
||
|
||
// The ability bonuses the player chose for their species, on top of its
|
||
// fixed ones, as { ability: amount }: a Half-Elf's +1/+1, a Goblin's +2/+1...
|
||
// Empty for a species with nothing to choose (game/data/species.js).
|
||
speciesBonusChoices Json @default("{}")
|
||
|
||
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
|
||
// What has been spent since the last rest. Spell slots by slot level (the
|
||
// first entry is 1st-level slots); how many there are comes from class and
|
||
// level (game/calculations.js, spellSlotsFor). A character has one Hit Die
|
||
// per level, spent on short rests.
|
||
spellSlotsUsed Int[] @default([])
|
||
hitDiceUsed Int @default(0)
|
||
// Class counters spent since the rest that gives them back, as
|
||
// { key: used } (game/classResources.js says which a character has).
|
||
resourcesUsed Json @default("{}")
|
||
// How the character is doing beyond HP. Reminders for the table: none of
|
||
// them changes a number on the sheet. Conditions are the character's own,
|
||
// so they outlast the encounter they were given in.
|
||
inspiration Boolean @default(false)
|
||
exhaustion Int @default(0) // 0 to 6
|
||
conditions String[] @default([])
|
||
armorBonus Int @default(0) // a flat AC adjustment the DM sets by hand, on top of what equipped gear gives
|
||
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 (and cash) 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)
|
||
cash Int @default(0) // whole dollars
|
||
|
||
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[]
|
||
// Levels in classes other than characterClass, for a multiclassed character
|
||
// (game/multiclass.js). The first class's own level is `level` less these.
|
||
classLevels CharacterClassLevel[]
|
||
|
||
@@index([campaignId])
|
||
@@index([userId])
|
||
@@map("characters")
|
||
}
|
||
|
||
// A class a character has taken levels in besides their first (characterClass),
|
||
// with its own level and subclass. A single-class character has none.
|
||
model CharacterClassLevel {
|
||
id String @id @default(cuid())
|
||
characterId String
|
||
characterClass CharacterClass
|
||
level Int @default(1)
|
||
subclass String?
|
||
createdAt DateTime @default(now())
|
||
|
||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([characterId, characterClass])
|
||
@@map("character_class_levels")
|
||
}
|
||
|
||
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)
|
||
// What it does to the sheet of whoever has it equipped: a list of
|
||
// { stat, amount } (game/calculations.js, GEAR_STATS), and a note for
|
||
// anything that is not a number.
|
||
effects Json @default("[]")
|
||
effectNote String?
|
||
createdAt DateTime @default(now())
|
||
|
||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||
assigned CharacterWeapon[]
|
||
rewards EncounterReward[]
|
||
offers SaleOffer[]
|
||
|
||
@@index([campaignId])
|
||
@@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)
|
||
// As on a weapon: what it does to whoever has it equipped. Armor is an
|
||
// item with an AC effect.
|
||
effects Json @default("[]")
|
||
effectNote String?
|
||
// Set on armor and shields. Its AC is still an effect; this says what kind
|
||
// it is, so the sheet knows when a character is unarmored (Unarmored
|
||
// Defense, a monk's speed) or in heavy armor (a barbarian's Fast Movement).
|
||
armorType ArmorType?
|
||
createdAt DateTime @default(now())
|
||
|
||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||
held InventoryItem[]
|
||
rewards EncounterReward[]
|
||
offers SaleOffer[]
|
||
|
||
@@index([campaignId])
|
||
@@map("items")
|
||
}
|
||
|
||
enum ArmorType {
|
||
LIGHT
|
||
MEDIUM
|
||
HEAVY
|
||
SHIELD
|
||
}
|
||
|
||
// 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)
|
||
// Brings back the dead: a character who can cast it may revive a dead ally
|
||
// on their turn in a battle (encounters.routes.js).
|
||
revives Boolean @default(false)
|
||
// How it lands in a battle (game/combat.js). attackRoll: the caster rolls a
|
||
// spell attack against AC. saveAbility: each target saves against the
|
||
// caster's spell DC with this ability, taking half on a success when
|
||
// halfOnSave. Neither: it simply takes effect.
|
||
attackRoll Boolean @default(false)
|
||
saveAbility Ability?
|
||
halfOnSave Boolean @default(false)
|
||
|
||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||
known CharacterSpell[]
|
||
|
||
@@index([campaignId])
|
||
@@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?
|
||
// Named attacks, for a monster with more than the one above: a list of
|
||
// { name, attackBonus, damageDice, damageType?, count? }. `count` is how
|
||
// many times it is made each turn (a multiattack). Empty: the single
|
||
// attackBonus/damageDice above is its attack.
|
||
attacks Json @default("[]")
|
||
|
||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||
encounterAppearances EncounterParticipant[]
|
||
|
||
@@index([campaignId])
|
||
@@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?
|
||
// A money changer swaps gold and cash at their own rate: exchangeGold gold
|
||
// for exchangeCash cash, either way. Both null: this NPC does not exchange.
|
||
exchangeGold Int?
|
||
exchangeCash Int?
|
||
// Where this NPC is usually found. In a free-roam scene they are met (and
|
||
// traded with) by whoever goes there.
|
||
locationId String?
|
||
createdAt DateTime @default(now())
|
||
|
||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||
encounterAppearances EncounterParticipant[]
|
||
wares SaleOffer[]
|
||
|
||
@@index([campaignId])
|
||
@@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)
|
||
// The experience points handed out for this encounter, in all; null until
|
||
// the DM awards them. They are awarded once.
|
||
xpAwarded Int?
|
||
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[]
|
||
wares SaleOffer[]
|
||
actions EncounterAction[]
|
||
// Free roam: the places the party can go to in this scene.
|
||
places EncounterLocation[]
|
||
|
||
@@index([campaignId])
|
||
@@map("encounters")
|
||
}
|
||
|
||
// A place that is a stop in a free-roam scene: one of the campaign's
|
||
// locations, picked by the DM as somewhere the party can go right now.
|
||
model EncounterLocation {
|
||
id String @id @default(cuid())
|
||
encounterId String
|
||
locationId String
|
||
createdAt DateTime @default(now())
|
||
|
||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||
|
||
@@unique([encounterId, locationId])
|
||
@@map("encounter_locations")
|
||
}
|
||
|
||
// An attack or a spell in a battle, from the moment it is declared to the
|
||
// moment its damage lands (game/combat.js). A player declares their own on
|
||
// their turn; the DM declares anyone's; and with the campaign's auto-roll
|
||
// settings the server declares a monster's or NPC's, already rolled. While
|
||
// PENDING it gathers what it needs (a d20 against each target's AC, or each
|
||
// target's saving throw, and the damage); the DM then applies it, changed as
|
||
// they see fit, or discards it. `hidden` keeps it from the players until it
|
||
// is applied.
|
||
model EncounterAction {
|
||
id String @id @default(cuid())
|
||
encounterId String
|
||
round Int
|
||
actorParticipantId String?
|
||
actorName String
|
||
kind String // ATTACK (a d20 against AC) | SAVE (targets save against a DC) | AUTO (it just lands)
|
||
name String // "Longsword", "Fireball", "Bite"
|
||
// What it rolls with: { attackBonus?, saveAbility?, saveDC?, halfOnSave?,
|
||
// damageDice?, damageBonus, damageType?, characterId?, slotLevel? }.
|
||
detail Json
|
||
// One entry per target: { participantId, name, kind, ac, penalty, saveBonus,
|
||
// d20, outcome (set by the DM to overrule the dice), damage (likewise) }.
|
||
targets Json
|
||
// The damage once rolled or entered: { total, dice?, modifier?, crit? }.
|
||
damage Json?
|
||
status String @default("PENDING") // PENDING | APPLIED | DISCARDED
|
||
auto Boolean @default(false) // declared and rolled by the server
|
||
hidden Boolean @default(false)
|
||
createdById String?
|
||
createdAt DateTime @default(now())
|
||
appliedAt DateTime?
|
||
|
||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([encounterId, status])
|
||
@@map("encounter_actions")
|
||
}
|
||
|
||
// 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)
|
||
|
||
@@index([encounterId])
|
||
@@map("encounter_rewards")
|
||
}
|
||
|
||
// Something for sale: a weapon or item from the campaign pools, at a price.
|
||
// It belongs to an NPC (and is on offer wherever that NPC is met), to an
|
||
// encounter itself (a stall, a vending machine), or to a location (a shop
|
||
// that is always there, open to whoever goes to it in a free-roam scene).
|
||
// Only the DM sets these up; players see them, and buy, in a quest or
|
||
// free-roam encounter in progress (encounters.routes.js).
|
||
model SaleOffer {
|
||
id String @id @default(cuid())
|
||
campaignId String
|
||
npcId String? // exactly one of npcId / encounterId / locationId is set
|
||
encounterId String?
|
||
locationId String?
|
||
weaponId String? // exactly one of weaponId / itemId is set
|
||
itemId String?
|
||
price Int // for one, in `currency`
|
||
currency Currency @default(GOLD)
|
||
level Int @default(1) // gear level it is handed over at
|
||
stock Int? // how many are left; null = no limit
|
||
createdAt DateTime @default(now())
|
||
|
||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||
npc Npc? @relation(fields: [npcId], references: [id], onDelete: Cascade)
|
||
encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
|
||
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||
|
||
@@index([campaignId])
|
||
@@index([npcId])
|
||
@@index([encounterId])
|
||
@@index([locationId])
|
||
@@map("sale_offers")
|
||
}
|
||
|
||
// 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"]; a character's own are on the Character row
|
||
// Conditions that wear off by themselves, as { condition: rounds left }.
|
||
// Counted down each time the round goes up; at 0 the condition is removed.
|
||
conditionRounds Json @default("{}")
|
||
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)
|
||
|
||
@@index([encounterId])
|
||
@@index([characterId])
|
||
@@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])
|
||
|
||
@@index([campaignId])
|
||
@@index([characterId])
|
||
@@map("notes")
|
||
}
|