Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c852ecb2e4 | ||
|
|
bdce3399ee | ||
|
|
f764878960 | ||
|
|
6b466d497e | ||
|
|
f8462a6e31 | ||
|
|
754eb0ee68 | ||
|
|
f8cee43ca2 | ||
|
|
e4456b0df2 | ||
|
|
ec48b65481 | ||
|
|
4362b7423e | ||
|
|
98a89185c1 | ||
|
|
932e755895 | ||
|
|
2637a510df |
@@ -6,3 +6,4 @@ build/
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
temp/
|
||||
@@ -14,10 +14,10 @@ monsters, and a real-time encounter/combat tracker.
|
||||
| `docs/CHARACTERS.md` | Building a character, every number on the sheet, levels, money, permissions |
|
||||
| `docs/WEAPONS.md` | The weapon pool, weapon levels and how damage grows |
|
||||
| `docs/SPELLS.md` | The spell list, who can use which spells, the spell library |
|
||||
| `docs/ITEMS.md` | The item pool and character inventory |
|
||||
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, what players see |
|
||||
| `docs/ITEMS.md` | The item pool, character inventory, items for sale |
|
||||
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, trade, what players see |
|
||||
| `docs/MONSTERS.md` | The monster list and built-in bestiary |
|
||||
| `docs/NPCS.md` | Non-player characters |
|
||||
| `docs/NPCS.md` | Non-player characters, what they sell and the rates they change money at |
|
||||
|
||||
`CLAUDE_BUILD_PROMPT.md`, `docs/GAME_RULES_REFERENCE.md` and
|
||||
`docs/ROLL_YOUR_CHARACTER.md` are the original specifications the first
|
||||
@@ -71,8 +71,21 @@ already has an active "Aldenfell"; archive or delete that one in the app
|
||||
`SEED_ADMIN_PASSWORD` and `SEED_ADMIN_NAME` set the account it creates.
|
||||
`npx prisma db seed -- --reset` wipes every user and campaign first.
|
||||
|
||||
On a server, apply new migrations with `npx prisma migrate deploy`. Change
|
||||
your password from the account page (click your name in the top bar).
|
||||
A second campaign, Hollowcrown: The Rift Between Worlds, has its own seed and
|
||||
is not part of the default one. It follows the same rules (it only adds, and
|
||||
skips if the admin already has an active Hollowcrown):
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run seed:hollowcrown
|
||||
```
|
||||
|
||||
On a server, after pulling a new version, run `npm run deploy` in `backend`
|
||||
and then restart the backend. It applies new migrations **and** regenerates
|
||||
the Prisma client; without the second half the server keeps running with the
|
||||
old data model and pages that use anything new fail to load (for example
|
||||
"Could not load this encounter"). Change your password from the account page
|
||||
(click your name in the top bar).
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
@@ -10,11 +10,13 @@
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
||||
"test": "jest",
|
||||
"docs:lists": "node scripts/generate-doc-lists.js",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:seed": "prisma db seed",
|
||||
"deploy": "prisma migrate deploy && prisma generate",
|
||||
"seed:hollowcrown": "node prisma/seed-hollowcrown.js",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- AlterTable: whether players see a monster's exact hit points or only its health bar
|
||||
ALTER TABLE "campaigns" ADD COLUMN "showMonsterHp" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- AlterTable: where an encounter takes place
|
||||
ALTER TABLE "encounters" ADD COLUMN "locationId" TEXT;
|
||||
ALTER TABLE "encounters" ADD CONSTRAINT "encounters_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- CreateTable: every location a character has been to
|
||||
CREATE TABLE "character_location_visits" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"locationId" TEXT NOT NULL,
|
||||
"firstVisitedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "character_location_visits_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE UNIQUE INDEX "character_location_visits_characterId_locationId_key" ON "character_location_visits"("characterId", "locationId");
|
||||
ALTER TABLE "character_location_visits" ADD CONSTRAINT "character_location_visits_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "character_location_visits" ADD CONSTRAINT "character_location_visits_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Characters have been to wherever they are now.
|
||||
INSERT INTO "character_location_visits" ("id", "characterId", "locationId")
|
||||
SELECT 'visit_' || "id", "id", "currentLocationId" FROM "characters" WHERE "currentLocationId" IS NOT NULL;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- AlterTable: the d20 a combatant rolled for initiative; "initiative" is now roll + bonus
|
||||
ALTER TABLE "encounter_participants" ADD COLUMN "initiativeRoll" INTEGER;
|
||||
|
||||
-- Initiatives typed in before this were totals. Keep them as they are, and
|
||||
-- treat the nearest possible die face as the roll so those combatants still
|
||||
-- count as having rolled.
|
||||
UPDATE "encounter_participants"
|
||||
SET "initiativeRoll" = LEAST(GREATEST("initiative", 1), 20)
|
||||
WHERE "initiative" <> 0;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- CreateEnum: the three coins. Silver is worth 0.1 gold, copper 0.01 gold.
|
||||
CREATE TYPE "Currency" AS ENUM ('GOLD', 'SILVER', 'COPPER');
|
||||
|
||||
-- AlterTable: a character's purse holds each coin separately
|
||||
ALTER TABLE "characters" ADD COLUMN "silver" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "characters" ADD COLUMN "copper" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- AlterTable: every ledger entry and coin reward is in one coin; all existing ones are gold
|
||||
ALTER TABLE "gold_transactions" ADD COLUMN "currency" "Currency" NOT NULL DEFAULT 'GOLD';
|
||||
ALTER TABLE "encounter_rewards" ADD COLUMN "currency" "Currency" NOT NULL DEFAULT 'GOLD';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable: death saves, counted while a character is at 0 HP
|
||||
ALTER TABLE "characters" ADD COLUMN "deathSaveSuccesses" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "deathSaveFailures" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "stable" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: the combatant currently marked as the target
|
||||
ALTER TABLE "encounters" ADD COLUMN "targetParticipantId" TEXT;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- AlterTable: several combatants can be marked as targets at once (an area attack)
|
||||
ALTER TABLE "encounters" ADD COLUMN "targetParticipantIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- Carry over a single target marked before this.
|
||||
UPDATE "encounters"
|
||||
SET "targetParticipantIds" = ARRAY["targetParticipantId"]
|
||||
WHERE "targetParticipantId" IS NOT NULL;
|
||||
|
||||
ALTER TABLE "encounters" DROP COLUMN "targetParticipantId";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: how much an area attack adds to each AC per extra target
|
||||
ALTER TABLE "campaigns" ADD COLUMN "areaAttackAcPenalty" INTEGER NOT NULL DEFAULT 2;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable: spells that bring back the dead
|
||||
ALTER TABLE "spells" ADD COLUMN "revives" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- The library's two revival spells, wherever a campaign already has them.
|
||||
UPDATE "spells" SET "revives" = true WHERE "name" IN ('Revivify', 'Raise Dead');
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterEnum: cash ($), a modern currency for crossover worlds. It has no fixed
|
||||
-- worth in gold, so it is never exchanged or used to make change.
|
||||
ALTER TYPE "Currency" ADD VALUE 'CASH';
|
||||
|
||||
-- AlterTable: cash is counted on its own, like each coin
|
||||
ALTER TABLE "characters" ADD COLUMN "cash" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- AlterTable: an NPC who changes money does it at their own rate, this many
|
||||
-- gold for that much cash. Both are null for an NPC who does not.
|
||||
ALTER TABLE "npcs" ADD COLUMN "exchangeGold" INTEGER;
|
||||
ALTER TABLE "npcs" ADD COLUMN "exchangeCash" INTEGER;
|
||||
|
||||
-- CreateTable: something for sale, by an NPC or at an encounter itself
|
||||
CREATE TABLE "sale_offers" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"npcId" TEXT,
|
||||
"encounterId" TEXT,
|
||||
"weaponId" TEXT,
|
||||
"itemId" TEXT,
|
||||
"price" INTEGER NOT NULL,
|
||||
"currency" "Currency" NOT NULL DEFAULT 'GOLD',
|
||||
"level" INTEGER NOT NULL DEFAULT 1,
|
||||
"stock" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "sale_offers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_npcId_fkey" FOREIGN KEY ("npcId") REFERENCES "npcs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_weaponId_fkey" FOREIGN KEY ("weaponId") REFERENCES "weapons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -118,6 +118,16 @@ enum RewardType {
|
||||
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
|
||||
@@ -158,6 +168,11 @@ model Campaign {
|
||||
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)
|
||||
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.
|
||||
@@ -173,6 +188,7 @@ model Campaign {
|
||||
monsters Monster[]
|
||||
npcs Npc[]
|
||||
encounters Encounter[]
|
||||
saleOffers SaleOffer[]
|
||||
notes Note[]
|
||||
locations Location[]
|
||||
|
||||
@@ -188,10 +204,28 @@ model Location {
|
||||
|
||||
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
|
||||
@@ -236,13 +270,23 @@ model Character {
|
||||
|
||||
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 (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?
|
||||
@@ -271,6 +315,7 @@ model Character {
|
||||
spells CharacterSpell[]
|
||||
notes Note[]
|
||||
encounterEntries EncounterParticipant[]
|
||||
locationVisits CharacterLocationVisit[]
|
||||
|
||||
@@map("characters")
|
||||
}
|
||||
@@ -328,6 +373,7 @@ model Weapon {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
assigned CharacterWeapon[]
|
||||
rewards EncounterReward[]
|
||||
offers SaleOffer[]
|
||||
|
||||
@@map("weapons")
|
||||
}
|
||||
@@ -362,6 +408,7 @@ model Item {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
held InventoryItem[]
|
||||
rewards EncounterReward[]
|
||||
offers SaleOffer[]
|
||||
|
||||
@@map("items")
|
||||
}
|
||||
@@ -382,12 +429,14 @@ model InventoryItem {
|
||||
@@map("inventory_items")
|
||||
}
|
||||
|
||||
// Ledger behind Character.gold: every change to the balance is one row here.
|
||||
// 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
|
||||
balanceAfter Int // that coin's balance afterwards
|
||||
reason String?
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -415,6 +464,9 @@ model Spell {
|
||||
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)
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
known CharacterSpell[]
|
||||
@@ -478,10 +530,15 @@ model Npc {
|
||||
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?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
encounterAppearances EncounterParticipant[]
|
||||
wares SaleOffer[]
|
||||
|
||||
@@map("npcs")
|
||||
}
|
||||
@@ -495,8 +552,15 @@ model Encounter {
|
||||
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?
|
||||
@@ -506,8 +570,10 @@ model Encounter {
|
||||
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[]
|
||||
|
||||
@@map("encounters")
|
||||
}
|
||||
@@ -519,7 +585,8 @@ model EncounterReward {
|
||||
id String @id @default(cuid())
|
||||
encounterId String
|
||||
type RewardType
|
||||
gold Int? // GOLD
|
||||
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)
|
||||
@@ -536,6 +603,33 @@ model EncounterReward {
|
||||
@@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) or to an
|
||||
// encounter itself (a stall, a vending machine). Only the DM sets these up;
|
||||
// players see them, and buy, in a quest encounter in progress
|
||||
// (encounters.routes.js).
|
||||
model SaleOffer {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
npcId String? // exactly one of npcId / encounterId is set
|
||||
encounterId 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)
|
||||
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
|
||||
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@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
|
||||
@@ -548,6 +642,10 @@ model EncounterParticipant {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Adds the Hollowcrown: The Rift Between Worlds campaign
|
||||
// (seed-data/hollowcrown.js) for the admin account. Not part of the default
|
||||
// seed — run it on its own:
|
||||
//
|
||||
// npm run seed:hollowcrown
|
||||
//
|
||||
// Like every seed it only adds (see seedCampaign.js): it never touches other
|
||||
// campaigns, and does nothing if the admin already has an active Hollowcrown.
|
||||
const { run, fromLibrary } = require('./seedCampaign');
|
||||
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../src/game/data/library');
|
||||
const hollowcrown = require('../seed-data/hollowcrown.js');
|
||||
|
||||
const startingSpells = new Set(hollowcrown.startingSpells);
|
||||
for (const key of startingSpells) fromLibrary(SPELL_LIBRARY, key); // fail early on a misspelt key
|
||||
|
||||
run({
|
||||
...hollowcrown,
|
||||
// The book's starting picks come from the site library; its modern relics
|
||||
// and magic weapons are never starting options.
|
||||
weapons: [
|
||||
...hollowcrown.libraryWeapons.map((key) => fromLibrary(WEAPON_LIBRARY, key, { isStartingOption: true })),
|
||||
...hollowcrown.weapons,
|
||||
],
|
||||
// The whole spell library goes in, so a spell is there to hand out the
|
||||
// moment a character unlocks it; only the book's Session 0 list is offered
|
||||
// in the builder.
|
||||
spells: [
|
||||
...SPELL_LIBRARY.map(({ key }) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: startingSpells.has(key) })),
|
||||
...hollowcrown.spells,
|
||||
],
|
||||
monsters: [...hollowcrown.libraryMonsters.map((key) => fromLibrary(MONSTER_LIBRARY, key)), ...hollowcrown.monsters],
|
||||
});
|
||||
+17
-192
@@ -1,207 +1,32 @@
|
||||
// Seeds the Aldenfell campaign (seed-data/aldenfell.js) for the admin account.
|
||||
//
|
||||
// Safe to run against a live database: it only ever ADDS. The admin account is
|
||||
// created if it is missing (an existing one, and its password, is left alone),
|
||||
// and the campaign is added unless that account already owns an active
|
||||
// campaign of the same name. To get a fresh copy, archive or delete the old
|
||||
// one in the app and run the seed again.
|
||||
// The default seed: the Aldenfell campaign (seed-data/aldenfell.js) for the
|
||||
// admin account. See seedCampaign.js for what a seed does and why it is safe
|
||||
// to run against a live database.
|
||||
//
|
||||
// npx prisma db seed add what is missing
|
||||
// npx prisma db seed -- --reset DESTRUCTIVE: delete every user, campaign,
|
||||
// character, note, etc. first
|
||||
//
|
||||
// SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD / SEED_ADMIN_NAME override the
|
||||
// account that is created.
|
||||
// Other campaigns have their own command (npm run seed:hollowcrown).
|
||||
//
|
||||
// Species/classes/skills are NOT seeded as rows — they're fixed game-rule
|
||||
// tables ported into src/game/data/*.js, as is the default weapon/spell/monster
|
||||
// library DMs pull from. The default weapons (seed-data/weapons.json) ARE
|
||||
// seeded, into the campaign, since weapons are per-campaign in the schema.
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { generateInviteCode } = require('../src/utils/inviteCode');
|
||||
const { run, fromLibrary } = require('./seedCampaign');
|
||||
const { SPELL_LIBRARY } = require('../src/game/data/library');
|
||||
const defaultWeapons = require('../seed-data/weapons.json');
|
||||
const aldenfell = require('../seed-data/aldenfell.js');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const defaultWeapons = require(path.join(__dirname, '..', 'seed-data', 'weapons.json'));
|
||||
const aldenfell = require(path.join(__dirname, '..', 'seed-data', 'aldenfell.js'));
|
||||
|
||||
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL || 'joshua@crosscode.co.za';
|
||||
const ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD || '12345678';
|
||||
const ADMIN_NAME = process.env.SEED_ADMIN_NAME || 'Joshua';
|
||||
const RESET = process.argv.includes('--reset');
|
||||
|
||||
async function resetDatabase() {
|
||||
// Truncating the two roots cascades through every table that references them.
|
||||
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
|
||||
}
|
||||
|
||||
// Notes list newest-first on screen, so they are stamped a second apart,
|
||||
// backwards: the first entry in the seed data is the one at the top.
|
||||
function bookOrder(index) {
|
||||
return new Date(Date.now() - index * 1000);
|
||||
}
|
||||
|
||||
function byName(rows) {
|
||||
return new Map(rows.map((row) => [row.name, row]));
|
||||
}
|
||||
|
||||
function lookup(map, name, what, encounterName) {
|
||||
const row = map.get(name);
|
||||
if (!row) throw new Error(`Encounter "${encounterName}" references unknown ${what} "${name}"`);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function seedCampaign(db, admin) {
|
||||
const campaign = await db.campaign.create({
|
||||
data: {
|
||||
...aldenfell.campaign,
|
||||
inviteCode: generateInviteCode(),
|
||||
ownerId: admin.id,
|
||||
members: { create: { userId: admin.id, role: 'DM' } },
|
||||
},
|
||||
});
|
||||
const campaignId = campaign.id;
|
||||
|
||||
await db.location.createMany({ data: aldenfell.locations.map((l) => ({ ...l, campaignId })) });
|
||||
|
||||
run(
|
||||
{
|
||||
...aldenfell,
|
||||
// Everyday weapons are what a new character picks from; the rest are earned.
|
||||
await db.weapon.createMany({
|
||||
data: [
|
||||
...defaultWeapons.map((w) => ({ ...w, isStartingOption: true, campaignId })),
|
||||
...aldenfell.weapons.map((w) => ({ ...w, campaignId })),
|
||||
weapons: [...defaultWeapons.map((w) => ({ ...w, isStartingOption: true })), ...aldenfell.weapons],
|
||||
items: aldenfell.items.map(({ starting, ...item }) => ({ ...item, isStartingOption: Boolean(starting) })),
|
||||
spells: [
|
||||
...aldenfell.spells.starting.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: true })),
|
||||
...aldenfell.spells.earned.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: false })),
|
||||
],
|
||||
});
|
||||
await db.item.createMany({
|
||||
data: aldenfell.items.map(({ starting, ...item }) => ({ ...item, isStartingOption: Boolean(starting), campaignId })),
|
||||
});
|
||||
|
||||
const spellRow = (isStartingOption) => (spellKey) => {
|
||||
const template = SPELL_LIBRARY.find((s) => s.key === spellKey);
|
||||
if (!template) throw new Error(`Unknown library spell "${spellKey}"`);
|
||||
const { key, classes, ...spell } = template;
|
||||
return { ...spell, isStartingOption, campaignId };
|
||||
};
|
||||
const spells = [...aldenfell.spells.starting.map(spellRow(true)), ...aldenfell.spells.earned.map(spellRow(false))];
|
||||
await db.spell.createMany({ data: spells });
|
||||
|
||||
await db.monster.createMany({ data: aldenfell.monsters.map((m) => ({ ...m, campaignId })) });
|
||||
await db.npc.createMany({ data: aldenfell.npcs.map((n) => ({ ...n, campaignId })) });
|
||||
|
||||
const monsters = byName(await db.monster.findMany({ where: { campaignId } }));
|
||||
const npcs = byName(await db.npc.findMany({ where: { campaignId } }));
|
||||
const weapons = byName(await db.weapon.findMany({ where: { campaignId } }));
|
||||
const items = byName(await db.item.findMany({ where: { campaignId } }));
|
||||
|
||||
let rewardCount = 0;
|
||||
for (const [index, enc] of aldenfell.encounters.entries()) {
|
||||
const participants = [];
|
||||
for (const [name, count, displayName] of enc.monsters || []) {
|
||||
const monster = lookup(monsters, name, 'monster', enc.name);
|
||||
const label = displayName || monster.name;
|
||||
for (let i = 1; i <= count; i++) {
|
||||
participants.push({
|
||||
monsterId: monster.id,
|
||||
displayName: count > 1 ? `${label} #${i}` : label,
|
||||
currentHp: monster.maxHp,
|
||||
maxHp: monster.maxHp,
|
||||
isMonster: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const name of enc.npcs || []) {
|
||||
const npc = lookup(npcs, name, 'NPC', enc.name);
|
||||
// An NPC with no combat stats takes part with no hit points to track.
|
||||
participants.push({ npcId: npc.id, displayName: npc.name, currentHp: npc.maxHp ?? 0, maxHp: npc.maxHp ?? 0, isMonster: false });
|
||||
}
|
||||
|
||||
// Rewards list oldest-first, so these are stamped forwards.
|
||||
const rewards = (enc.rewards || []).map(({ gold, weapon, item, quantity = 1, level, note }, i) => {
|
||||
const createdAt = new Date(Date.now() + i);
|
||||
if (gold) return { type: 'GOLD', gold, note, createdAt };
|
||||
const entry = weapon ? lookup(weapons, weapon, 'weapon', enc.name) : lookup(items, item, 'item', enc.name);
|
||||
return {
|
||||
type: weapon ? 'WEAPON' : 'ITEM',
|
||||
[weapon ? 'weaponId' : 'itemId']: entry.id,
|
||||
quantity,
|
||||
level: Math.max(level ?? entry.minLevel, entry.minLevel),
|
||||
note,
|
||||
createdAt,
|
||||
};
|
||||
});
|
||||
rewardCount += rewards.length;
|
||||
|
||||
await db.encounter.create({
|
||||
data: {
|
||||
campaignId,
|
||||
name: enc.name,
|
||||
mode: enc.mode,
|
||||
description: enc.description,
|
||||
dmNotes: enc.dmNotes,
|
||||
sortOrder: index,
|
||||
participants: { create: participants },
|
||||
rewards: { create: rewards },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const notes = [
|
||||
...aldenfell.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
|
||||
...aldenfell.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
|
||||
];
|
||||
await db.note.createMany({
|
||||
data: notes.map((n, i) => ({ ...n, campaignId, authorId: admin.id, createdAt: bookOrder(i) })),
|
||||
});
|
||||
|
||||
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
|
||||
console.log(
|
||||
` ${defaultWeapons.length} starting + ${aldenfell.weapons.length} earned weapons, ${aldenfell.items.length} items, ` +
|
||||
`${spells.length} spells, ${aldenfell.locations.length} locations`,
|
||||
);
|
||||
console.log(
|
||||
` ${aldenfell.monsters.length} monsters, ${aldenfell.npcs.length} NPCs, ${aldenfell.encounters.length} encounters ` +
|
||||
`(${rewardCount} rewards lined up), ${notes.length} notes`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (RESET) {
|
||||
await resetDatabase();
|
||||
console.log('Database reset: every user and campaign was deleted.');
|
||||
}
|
||||
|
||||
let admin = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } });
|
||||
if (admin) {
|
||||
console.log(`Admin account ${ADMIN_EMAIL} already exists — left as it is.`);
|
||||
} else {
|
||||
admin = await prisma.user.create({
|
||||
data: { email: ADMIN_EMAIL, passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12), displayName: ADMIN_NAME },
|
||||
});
|
||||
console.log(`Admin/DM login created: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
|
||||
console.log(' Change that password from the account page (click your name, top right) once you are logged in.');
|
||||
}
|
||||
|
||||
const existing = await prisma.campaign.findFirst({
|
||||
where: { ownerId: admin.id, name: aldenfell.campaign.name, archivedAt: null },
|
||||
});
|
||||
if (existing) {
|
||||
console.log(`${ADMIN_EMAIL} already has an active "${existing.name}" campaign — nothing added.`);
|
||||
console.log(' For a fresh copy, archive or delete it under Campaign Settings and run the seed again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// One transaction, so a failed seed doesn't leave half a campaign behind.
|
||||
console.log('Seeding campaign...');
|
||||
await prisma.$transaction((tx) => seedCampaign(tx, admin), { timeout: 120000, maxWait: 20000 });
|
||||
console.log('Seed complete.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
{ reset: process.argv.includes('--reset') },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Shared by the campaign seeds (seed.js, seed-hollowcrown.js): makes sure the
|
||||
// admin account exists, then adds one fully-built campaign for it.
|
||||
//
|
||||
// A seed only ever ADDS, so it is safe against a live database. The admin
|
||||
// account is created if it is missing (an existing one, and its password, is
|
||||
// left alone), and the campaign is added unless that account already owns an
|
||||
// active campaign of the same name. To get a fresh copy, archive or delete the
|
||||
// old one in the app and run the seed again.
|
||||
//
|
||||
// SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD / SEED_ADMIN_NAME override the
|
||||
// account that is created.
|
||||
//
|
||||
// `content` is one campaign, with every pool as rows shaped like the Prisma
|
||||
// models minus ids:
|
||||
// campaign, locations, weapons, items, spells, monsters, npcs,
|
||||
// sharedNotes, dmNotes (note bodies, in reading order), and
|
||||
// encounters, in the order they should list:
|
||||
// { name, mode, description, dmNotes,
|
||||
// location: a location's name (optional) — where the scene is,
|
||||
// monsters: [[monster name, count, display name?, max HP?], ...],
|
||||
// npcs: [NPC name | { name, hp }, ...],
|
||||
// rewards: [{ gold } | { weapon } | { item }, each with optional
|
||||
// quantity, level (defaults to the entry's starting level)
|
||||
// and note] }
|
||||
const bcrypt = require('bcrypt');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { generateInviteCode } = require('../src/utils/inviteCode');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL || 'joshua@crosscode.co.za';
|
||||
const ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD || '12345678';
|
||||
const ADMIN_NAME = process.env.SEED_ADMIN_NAME || 'Joshua';
|
||||
|
||||
// Notes list newest-first on screen, so they are stamped a second apart,
|
||||
// backwards: the first entry in the seed data is the one at the top.
|
||||
function bookOrder(index) {
|
||||
return new Date(Date.now() - index * 1000);
|
||||
}
|
||||
|
||||
function byName(rows) {
|
||||
return new Map(rows.map((row) => [row.name, row]));
|
||||
}
|
||||
|
||||
function lookup(map, name, what, encounterName) {
|
||||
const row = map.get(name);
|
||||
if (!row) throw new Error(`Encounter "${encounterName}" references unknown ${what} "${name}"`);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function createCampaign(db, admin, content) {
|
||||
const campaign = await db.campaign.create({
|
||||
data: {
|
||||
...content.campaign,
|
||||
inviteCode: generateInviteCode(),
|
||||
ownerId: admin.id,
|
||||
members: { create: { userId: admin.id, role: 'DM' } },
|
||||
},
|
||||
});
|
||||
const campaignId = campaign.id;
|
||||
const inCampaign = (rows) => rows.map((row) => ({ ...row, campaignId }));
|
||||
|
||||
await db.location.createMany({ data: inCampaign(content.locations) });
|
||||
await db.weapon.createMany({ data: inCampaign(content.weapons) });
|
||||
await db.item.createMany({ data: inCampaign(content.items) });
|
||||
await db.spell.createMany({ data: inCampaign(content.spells) });
|
||||
await db.monster.createMany({ data: inCampaign(content.monsters) });
|
||||
await db.npc.createMany({ data: inCampaign(content.npcs) });
|
||||
|
||||
const monsters = byName(await db.monster.findMany({ where: { campaignId } }));
|
||||
const npcs = byName(await db.npc.findMany({ where: { campaignId } }));
|
||||
const weapons = byName(await db.weapon.findMany({ where: { campaignId } }));
|
||||
const items = byName(await db.item.findMany({ where: { campaignId } }));
|
||||
const locations = byName(await db.location.findMany({ where: { campaignId } }));
|
||||
|
||||
let rewardCount = 0;
|
||||
for (const [index, enc] of content.encounters.entries()) {
|
||||
const participants = [];
|
||||
for (const [name, count, displayName, hp] of enc.monsters || []) {
|
||||
const monster = lookup(monsters, name, 'monster', enc.name);
|
||||
const label = displayName || monster.name;
|
||||
const maxHp = hp ?? monster.maxHp;
|
||||
for (let i = 1; i <= count; i++) {
|
||||
participants.push({
|
||||
monsterId: monster.id,
|
||||
displayName: count > 1 ? `${label} #${i}` : label,
|
||||
currentHp: maxHp,
|
||||
maxHp,
|
||||
isMonster: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const entry of enc.npcs || []) {
|
||||
const { name, hp } = typeof entry === 'string' ? { name: entry } : entry;
|
||||
const npc = lookup(npcs, name, 'NPC', enc.name);
|
||||
// An NPC with no combat stats takes part with no hit points to track.
|
||||
const maxHp = hp ?? npc.maxHp ?? 0;
|
||||
participants.push({ npcId: npc.id, displayName: npc.name, currentHp: maxHp, maxHp, isMonster: false });
|
||||
}
|
||||
|
||||
// Rewards list oldest-first, so these are stamped forwards.
|
||||
const rewards = (enc.rewards || []).map(({ gold, weapon, item, quantity, level, note }, i) => {
|
||||
const createdAt = new Date(Date.now() + i);
|
||||
if (gold) return { type: 'GOLD', gold, note, createdAt };
|
||||
const entry = weapon ? lookup(weapons, weapon, 'weapon', enc.name) : lookup(items, item, 'item', enc.name);
|
||||
return {
|
||||
type: weapon ? 'WEAPON' : 'ITEM',
|
||||
[weapon ? 'weaponId' : 'itemId']: entry.id,
|
||||
quantity: quantity ?? 1,
|
||||
level: Math.max(level ?? entry.minLevel, entry.minLevel),
|
||||
note,
|
||||
createdAt,
|
||||
};
|
||||
});
|
||||
rewardCount += rewards.length;
|
||||
|
||||
await db.encounter.create({
|
||||
data: {
|
||||
campaignId,
|
||||
name: enc.name,
|
||||
mode: enc.mode,
|
||||
description: enc.description,
|
||||
dmNotes: enc.dmNotes,
|
||||
locationId: enc.location ? lookup(locations, enc.location, 'location', enc.name).id : null,
|
||||
sortOrder: index,
|
||||
participants: { create: participants },
|
||||
rewards: { create: rewards },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const notes = [
|
||||
...content.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
|
||||
...content.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
|
||||
];
|
||||
await db.note.createMany({
|
||||
data: notes.map((n, i) => ({ ...n, campaignId, authorId: admin.id, createdAt: bookOrder(i) })),
|
||||
});
|
||||
|
||||
const starting = (rows) => rows.filter((row) => row.isStartingOption).length;
|
||||
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
|
||||
console.log(
|
||||
` ${content.weapons.length} weapons (${starting(content.weapons)} starting), ${content.items.length} items ` +
|
||||
`(${starting(content.items)} starting), ${content.spells.length} spells (${starting(content.spells)} starting), ` +
|
||||
`${content.locations.length} locations`,
|
||||
);
|
||||
console.log(
|
||||
` ${content.monsters.length} monsters, ${content.npcs.length} NPCs, ${content.encounters.length} encounters ` +
|
||||
`(${rewardCount} rewards lined up), ${notes.length} notes`,
|
||||
);
|
||||
}
|
||||
|
||||
async function resetDatabase() {
|
||||
// Truncating the two roots cascades through every table that references them.
|
||||
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
|
||||
}
|
||||
|
||||
// `reset` is DESTRUCTIVE: every user, campaign, character, note, etc. is
|
||||
// deleted before anything is added.
|
||||
async function seed(content, { reset = false } = {}) {
|
||||
if (reset) {
|
||||
await resetDatabase();
|
||||
console.log('Database reset: every user and campaign was deleted.');
|
||||
}
|
||||
|
||||
let admin = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } });
|
||||
if (admin) {
|
||||
console.log(`Admin account ${ADMIN_EMAIL} already exists — left as it is.`);
|
||||
} else {
|
||||
admin = await prisma.user.create({
|
||||
data: { email: ADMIN_EMAIL, passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12), displayName: ADMIN_NAME },
|
||||
});
|
||||
console.log(`Admin/DM login created: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
|
||||
console.log(' Change that password from the account page (click your name, top right) once you are logged in.');
|
||||
}
|
||||
|
||||
const existing = await prisma.campaign.findFirst({
|
||||
where: { ownerId: admin.id, name: content.campaign.name, archivedAt: null },
|
||||
});
|
||||
if (existing) {
|
||||
console.log(`${ADMIN_EMAIL} already has an active "${existing.name}" campaign — nothing added.`);
|
||||
console.log(' For a fresh copy, archive or delete it under Campaign Settings and run the seed again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// One transaction, so a failed seed doesn't leave half a campaign behind.
|
||||
console.log('Seeding campaign...');
|
||||
await prisma.$transaction((tx) => createCampaign(tx, admin, content), { timeout: 120000, maxWait: 20000 });
|
||||
console.log('Seed complete.');
|
||||
}
|
||||
|
||||
// Runs a seed script to completion: exit code 1 on failure, connection closed either way.
|
||||
function run(content, options) {
|
||||
return seed(content, options)
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
}
|
||||
|
||||
// A copy of a library entry as a pool row: its `key` and picker-only tags dropped.
|
||||
function fromLibrary(library, key, extra = {}) {
|
||||
const template = library.find((entry) => entry.key === key);
|
||||
if (!template) throw new Error(`Unknown library entry "${key}"`);
|
||||
const { key: _key, category, classes, ...row } = template;
|
||||
return { ...row, ...extra };
|
||||
}
|
||||
|
||||
module.exports = { run, fromLibrary };
|
||||
@@ -300,7 +300,9 @@ const npcs = [
|
||||
// Encounters — pre-built (PLANNING) scenes for the beats of the Campaign Book,
|
||||
// in book order. BATTLE scenes run in initiative order; QUEST scenes are built
|
||||
// around NPCs and an objective.
|
||||
// description — the scene, shown to every player who opens it: no spoilers.
|
||||
// location — a location's name: where the scene is. Its characters move
|
||||
// there when it begins.
|
||||
// description — the scene, shown to the players in it: no spoilers.
|
||||
// dmNotes — DCs, branches and secrets, DM only.
|
||||
// monsters — [monster name, count, optional display name]; a count above
|
||||
// one gets "#n" suffixes.
|
||||
@@ -313,33 +315,33 @@ const npcs = [
|
||||
|
||||
const encounters = [
|
||||
{
|
||||
name: 'E1 — The Founding Fair', mode: 'QUEST',
|
||||
name: 'E1 — The Founding Fair', mode: 'QUEST', location: 'Highcrown — The Lantern Market',
|
||||
description: 'Highcrown’s Lantern Market at the height of the Founding Fair: lantern-strung stalls, contests of strength and skill, minstrels, and strangers from every corner of the kingdom. This is where you meet.',
|
||||
dmNotes: 'Character creation happens here (Roll-Your-Character System) — it can be the whole first half of session one. No combat. Let each PC win or lose something small at a stall, and introduce Mireille and Ser Kavan in passing so they are familiar faces by E4. Ends when the chapel bell fails to ring → E2.',
|
||||
npcs: ['Mireille Costa', 'Ser Kavan Thorne'],
|
||||
},
|
||||
{
|
||||
name: 'E2 — The Bell That Wasn’t Ringing', mode: 'QUEST',
|
||||
name: 'E2 — The Bell That Wasn’t Ringing', mode: 'QUEST', location: 'Highcrown — The Founding Chapel',
|
||||
description: 'The Founding Chapel’s bell has rung every hour for as long as anyone remembers. It has stopped. A crowd is gathering outside, and someone is asking for volunteers to go in and look.',
|
||||
dmNotes: 'No break-in. The bell rope is neatly cut and left coiled; the bell-ringer is missing, not dead; a line of unfamiliar script is scratched inside the bell (Hollow King iconography). DC 13 Investigation finds the rope and script; DC 12 Persuasion/Insight gets the “woman who wasn’t blinking”.\n• E2A investigate immediately: full clues; skip Vault Room 1.\n• E2B distracted first: the guard missed the script; DC 13 Investigation to find it; start at Room 1.\n• E2C talk to the crowd first: three witness accounts, no script lead; E10 Insight later at advantage.\nThe trail leads to the cellar → E3.',
|
||||
npcs: ['Ser Kavan Thorne', 'A Frightened Local'],
|
||||
},
|
||||
{
|
||||
name: 'E3 — Sunken Vaults: The Old Crypt Hall', mode: 'BATTLE',
|
||||
name: 'E3 — Sunken Vaults: The Old Crypt Hall', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
|
||||
description: 'A long hall of burial niches beneath the chapel, some sealed, some broken open. Something is growling in the dark between the pillars.',
|
||||
dmNotes: 'Vault Room 2, the hub (→ Rooms 3, 4, stair to 6). The dog is starving, not vicious: DC 12 Animal Handling or a thrown ration ends it. DC 13 Investigation: the open niches are just old theft. DC 12 Perception finds the locket.',
|
||||
monsters: [['Feral Guard Dog', 1]],
|
||||
rewards: [{ item: 'Silver Locket', note: 'Room 2 — DC 12 Perception' }],
|
||||
},
|
||||
{
|
||||
name: 'E3 — Sunken Vaults: The Restless Ossuary', mode: 'BATTLE',
|
||||
name: 'E3 — Sunken Vaults: The Restless Ossuary', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
|
||||
description: 'Bones stacked floor to ceiling, and an offering box furred with dust. The air is still. Then it isn’t.',
|
||||
dmNotes: 'Vault Room 4, a dead end. One or two skeletons rise halfway through the room — ordinary restless dead, not tied to the Hollow King. DC 12 Religion: proper deconsecration would settle them without a fight. DC 13 to open the offering box.',
|
||||
monsters: [['Skeleton', 2]],
|
||||
rewards: [{ gold: 25, note: 'Offering box — DC 13 to open' }],
|
||||
},
|
||||
{
|
||||
name: 'E3 — Sunken Vaults: The Bell-Ringer’s Refuge', mode: 'QUEST',
|
||||
name: 'E3 — Sunken Vaults: The Bell-Ringer’s Refuge', mode: 'QUEST', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
|
||||
description: 'A side chamber with a bedroll, a cold lantern, and a frightened old man who very much hopes you are who you look like.',
|
||||
dmNotes: 'Vault Room 5. The bell-ringer is alive but shaken: he saw “someone who looked like his neighbor, except wrong”. Getting him out is the win — the bell rings again, the fair goes on, nothing is solved.\nRoom 3 (Smugglers’ Cache) is on the way: DC 14 Investigation ties the crates’ sigil to a lender linked to the corrupted house.\nRoom 6 (The Descent) leads to the Root: wrong, cold air. Signal the tone shift; the party can always turn back. → E4.',
|
||||
npcs: ['The Bell-Ringer'],
|
||||
@@ -370,14 +372,14 @@ const encounters = [
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — The Smugglers’ Tithe: Tidewatch', mode: 'QUEST',
|
||||
name: 'Side Quest — The Smugglers’ Tithe: Tidewatch', mode: 'QUEST', location: 'Salt Coast — Tidewatch',
|
||||
description: 'Tidewatch, the Salt Coast’s largest port: proud, salt-stained, and suspicious of anyone asking questions. Someone is skimming the town’s quiet trade, and the Concord wants to know who.',
|
||||
dmNotes: 'Lv 1–3. Mireille hires the party to learn why a cut of Tidewatch’s smuggled goods vanishes before reaching buyers. TRUTH: a Bandit Captain is extorting the smugglers and funnelling the “tithe” to arm one side of the Bramble Marches conflict. Smugglers distrust outsiders. Track a shipment to Smuggler’s Cove → next scene.\nOutcome decides whether Greyford is supplied at E11/E16.',
|
||||
npcs: ['Mireille Costa'],
|
||||
rewards: [{ gold: 100, note: 'Mireille’s fee — paid in full if it ends quietly' }],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', mode: 'BATTLE',
|
||||
name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', mode: 'BATTLE', location: 'Salt Coast — Smuggler’s Cove',
|
||||
description: 'A hidden inlet south of Tidewatch. Lanterns hooded, a longboat drawn up on the shingle, and a crew that was not expecting company.',
|
||||
dmNotes: 'Fight, negotiate, or both. The Captain leads from the front (multiattack) and bolts for the boat below half HP. End it quietly (Mireille pays well) or expose where the tithe was headed (pulls the Marches plot forward).',
|
||||
monsters: [['Bandit Captain', 1], ['Highcrown Cutthroat', 4]],
|
||||
@@ -387,13 +389,13 @@ const encounters = [
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — The Warden’s Silence: Direwolf Pack', mode: 'BATTLE',
|
||||
name: 'Side Quest — The Warden’s Silence: Direwolf Pack', mode: 'BATTLE', location: 'Greywood — The Old Shrine Sites',
|
||||
description: 'The trail to the shrine runs through old growth where the light barely reaches the ground. The forest has gone quiet, and there are eyes between the trees.',
|
||||
dmNotes: 'Lv 1–2. The pack is agitated by the shrine, not evil — DC 13 Animal Handling or Nature to back away without a fight. Pack tactics make three wolves the clearest action-economy lesson for a new table.',
|
||||
monsters: [['Greywood Direwolf', 3]],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — The Warden’s Silence: The Shrine', mode: 'BATTLE',
|
||||
name: 'Side Quest — The Warden’s Silence: The Shrine', mode: 'BATTLE', location: 'Greywood — The Old Shrine Sites',
|
||||
description: 'A mossed-over shrine older than the kingdom, split by a crack you could lose an arm in. The missing Warden is standing in front of it. She turns and greets you by name.',
|
||||
dmNotes: 'HORROR register — signal it. The Warden was Hollowed after investigating the crack alone: the party’s first, sympathetic Hollowed. DC 15 Insight notices the wrongness (no blink, a repeated phrase) before it acts. Fight, restrain and bring back to Ashwen (hard questions), or it escapes to report back. Either way Ashwen is convinced.',
|
||||
monsters: [['Hollowed', 1, 'The Missing Warden']],
|
||||
@@ -403,7 +405,7 @@ const encounters = [
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — Trial of Stone', mode: 'QUEST',
|
||||
name: 'Side Quest — Trial of Stone', mode: 'QUEST', location: 'The Spine — Stonejaw Hold',
|
||||
description: 'Stonejaw Hold, a giant clan’s valley in the Spine foothills. The chieftain has heard you out. He does not offer agreement. He offers a trial.',
|
||||
dmNotes: 'Lv 2–4. A genuine test of character, nothing sinister. Trials (per PC or shared): non-lethal wrestling with a giant (the Hill Giant here — HP is a “pin” clock, nobody dies); a test of honesty; proving their word is good (retrieve something, deliver a message honestly). Beats: arrive → formal challenge → trial → feast.\nSuccess = standing alliance (Spine Clans track, unlocks E9D and E11C). Failure is not fatal: Korgath offers a second, harder trial.',
|
||||
monsters: [['Stonejaw Hill Giant', 1, 'Stonejaw Champion']],
|
||||
@@ -414,7 +416,7 @@ const encounters = [
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — Refugees of the Marches', mode: 'QUEST',
|
||||
name: 'Side Quest — Refugees of the Marches', mode: 'QUEST', location: 'Bramble Marches — The Sorrow Camp',
|
||||
description: 'The Sorrow Camp: hundreds of people under canvas just inside the border, and a garrison at Greyford Keep that wants them gone. Food sent for the camp is not all arriving.',
|
||||
dmNotes: 'Lv 2–4, the clearest moral-choice quest. No clean villain: the commander is not wrong about the strategic liability, and the refugees have nowhere else to go. Beats: tension between camp and garrison → find who is skimming aid (the Captain’s crew) → confront them → broker, or fail to broker, an arrangement.\nIntroduce a named refugee NPC here — they are at risk at E16B.',
|
||||
monsters: [['Bandit Captain', 1, 'Aid-Skimmer Boss'], ['Highcrown Cutthroat', 2]],
|
||||
@@ -422,7 +424,7 @@ const encounters = [
|
||||
rewards: [{ gold: 50, note: 'Recovered aid money — keeping it is a choice the camp will notice' }],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — What the Tide Brought In: The Reef', mode: 'BATTLE',
|
||||
name: 'Side Quest — What the Tide Brought In: The Reef', mode: 'BATTLE', location: 'Salt Coast — Tidewatch',
|
||||
description: 'A fishing boat washed up empty this morning: sails intact, catch in the hold, crew gone. The fishers will point out the reef. None of them will row you there.',
|
||||
dmNotes: 'Lv 2–3, horror-lite. NOT connected to the Hollow King — a deliberate palate-cleanser. DC 13 Investigation on the boat finds claw marks, not violence. Confront it at sea (it swims, the party does not) or lure it to the shallows. Outcome: safe fishing reopens and real goodwill in Tidewatch.',
|
||||
monsters: [['Reef Terror', 1]],
|
||||
@@ -432,21 +434,21 @@ const encounters = [
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Audience — Threndir’s Eyrie', mode: 'QUEST',
|
||||
name: 'Audience — Threndir’s Eyrie', mode: 'QUEST', location: 'The Spine — Threndir’s Eyrie',
|
||||
description: 'A hard climb above the snow line, and a cave mouth wide enough to sail a ship into. The dragon inside already knows you are here. He has not moved, and he has not told you to leave.',
|
||||
dmNotes: 'A negotiation, not a fight: Threndir wants proof the crown can still be trusted (Persuasion DC 15–18; real evidence from E10 lowers it). He talks first unless attacked. If it goes wrong, add the “Threndir Ashclaw (old copper dragon)” monster to this scene — and remember he is far beyond a low-level party.',
|
||||
npcs: ['Threndir Ashclaw'],
|
||||
rewards: [{ weapon: 'Threndir’s Fang', note: 'A shed tooth, set in a hilt — given only if he comes to respect them' }],
|
||||
},
|
||||
{
|
||||
name: 'E9 — The Root: Weeping Passage', mode: 'BATTLE',
|
||||
name: 'E9 — The Root: Weeping Passage', mode: 'BATTLE', location: 'Hollow Below — The Weeping Passage',
|
||||
description: 'A long tunnel beneath the Greywood where the walls weep, and the sound they make is almost a voice. Somewhere ahead, very faintly, a bell tolls.',
|
||||
dmNotes: 'HORROR register — signal the tone shift. Root Room 2. DC 14 Survival: the dripping is a Hollowed’s footsteps. The Bellcrawler uses Stillness (DC 17 Perception to spot) and mimics the bell to lure the curious.\n• E9A retreat: keep what they learned, full rest before re-entry.\n• E9B push through: the marking here matches the bell script (DC 16 Arcana/Religion).\n• E9C (took E4A): Ressa Vane escorts and reports back — remove her from the scene otherwise.\n• E9D (won Trial of Stone): a Spine guide enters via Room 4.',
|
||||
monsters: [['Bellcrawler', 1]],
|
||||
npcs: ['Corporal Ressa Vane'],
|
||||
},
|
||||
{
|
||||
name: 'E9 — The Root: Hollowed Nest', mode: 'BATTLE',
|
||||
name: 'E9 — The Root: Hollowed Nest', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
|
||||
description: 'A cavern kept unnervingly tidy: bedrolls squared away, boots in a row. Two people sit facing the wall. They stand when you enter, and smile at exactly the same moment.',
|
||||
dmNotes: 'Root Room 3. DC 15 Insight before combat. Consider making one a named missing person. Slam hits force DC 12 Wis or frightened for a round; each can whisper one sentence in Vessa’s voice. The letter (DC 14) hints at the corrupted house.',
|
||||
monsters: [['Hollowed', 2]],
|
||||
@@ -456,7 +458,7 @@ const encounters = [
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E9 — The Root: Root Stalker’s Warren', mode: 'BATTLE',
|
||||
name: 'E9 — The Root: Root Stalker’s Warren', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
|
||||
description: 'The roots in this chamber are as thick as a man’s waist, and they are not growing from anything. One of them shifts when your light touches it.',
|
||||
dmNotes: 'Root Room 4, a dead end (optional secret passage to Room 6). DC 14 Perception or its first attack has advantage. Run it as a slow, dreadful stalk: tremorsense, burrow, grasping roots (restrained, DC 14 Str). E9D: DC 13 Survival or it wakes at the entrance.',
|
||||
monsters: [['Root Stalker', 1]],
|
||||
@@ -466,7 +468,7 @@ const encounters = [
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E9 — The Root: Bellcrawler Gallery', mode: 'BATTLE',
|
||||
name: 'E9 — The Root: Bellcrawler Gallery', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
|
||||
description: 'Narrow ledges over a drop your torch cannot find the bottom of. From three different directions at once, a bell begins to toll.',
|
||||
dmNotes: 'Root Room 6. DC 15 Perception to place them; DC 13 Dex on the ledges or fall 2d6. They skitter (bonus-action disengage) and freeze when watched — stealth and dread, not a straight fight. → Room 7, the stair to the Hollow Court: let them see it and turn back.',
|
||||
monsters: [['Bellcrawler', 3]],
|
||||
@@ -476,14 +478,14 @@ const encounters = [
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E10 — The House Unmasked', mode: 'QUEST',
|
||||
name: 'E10 — The House Unmasked', mode: 'QUEST', location: 'Highcrown — The Palace of Aldenfell',
|
||||
description: 'Back in Highcrown with a name, a ledger, and a question nobody at court wants asked out loud: what do you do with it?',
|
||||
dmNotes: 'The ledger: every shrine “donation” falls three days after a livestock death.\n• E10A confront the house directly: DC 16 Persuasion/Intimidation with proof, DC 20 without.\n• E10B report to the crown, Isolde or Kavan: sets up Act 3 politics; if E9C, Kavan already suspected (trust bump).\n• E10C (E2C + the servant thread): private DC 13 check at advantage → confession and the sealed letter; the house never learns it is suspected. Best route into Act 3.',
|
||||
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne'],
|
||||
rewards: [{ item: 'Sealed Letter', note: 'E10C only' }],
|
||||
},
|
||||
{
|
||||
name: 'E11 — Silence on the Border', mode: 'QUEST',
|
||||
name: 'E11 — Silence on the Border', mode: 'QUEST', location: 'Bramble Marches — Greyford Keep',
|
||||
description: 'Greyford Keep. The gate stands open. No smoke from the chimneys, no one on the walls, no bodies. A kettle is still warm on a guardroom stove.',
|
||||
dmNotes: 'DC 13 Perception: drag marks — the defenders left. The Smugglers’ Tithe outcome decides rescue vs recovery. E11C (Spine allied): the clans are already here and share Root Stalker migration intel. Remove the Commander from the scene if this is a recovery.',
|
||||
npcs: ['Greyford’s Commander'],
|
||||
@@ -513,7 +515,7 @@ const encounters = [
|
||||
monsters: [['The Hollow King’s Shadow', 1]],
|
||||
},
|
||||
{
|
||||
name: 'E19 — Vessa’s Seat', mode: 'QUEST',
|
||||
name: 'E19 — Vessa’s Seat', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
|
||||
description: 'A single worn stone seat at the heart of the Hollow Court, and a woman who has been expecting you. She asks you to sit. She says she would like to explain.',
|
||||
dmNotes: 'Court Room 4. The party learns the Hollow King’s true nature from Vessa: the imprisoned will of everyone the founders sacrificed — a suppression of many, not a prison for one. DC 16 Insight: she believes what she says. A strong candidate for a negotiated outcome.\nRoot-fiber + seal-stone (DC 15 Arcana, automatic with a Religion-proficient caster) → advantage on the first check at E22. Then E20: the clock turns to Stage 4.',
|
||||
npcs: ['Vessa Hollow'],
|
||||
@@ -526,14 +528,14 @@ const encounters = [
|
||||
rewards: [{ item: 'Potion of Greater Healing', quantity: 2, note: 'Going-away gift from whoever stands with them' }],
|
||||
},
|
||||
{
|
||||
name: 'E22 — The Seal Chamber', mode: 'QUEST',
|
||||
name: 'E22 — The Seal Chamber', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
|
||||
description: 'A wound in the world, stitched shut long ago, and coming undone one thread at a time. Whatever you came here to decide, this is where you decide it.',
|
||||
dmNotes: 'Court Room 5 — the key branch. DC 18 Arcana/Religion reveals the seal is a suppression of many. Optional DC 14 Wis saves vs disorientation.\n• E22A reinforce (DC 18 Arcana/Religion): the Sealed Ending — time bought.\n• E22B break it deliberately: E23 on the party’s terms — the only path to true victory.\n• E22C do nothing: the seal fails anyway; E24–E25 are harder.\nHollowbane is the campaign’s single most significant magic item: legendary growth, starts at level 7.',
|
||||
npcs: ['Vessa Hollow'],
|
||||
rewards: [{ weapon: 'Hollowbane', note: 'Drawn from the seal itself' }],
|
||||
},
|
||||
{
|
||||
name: 'E25 — The Last Approach', mode: 'BATTLE',
|
||||
name: 'E25 — The Last Approach', mode: 'BATTLE', location: 'Hollow Below — The Hollow Court (Layer 3)',
|
||||
description: 'The final stretch of the Hollow Court, where gravity, light and memory have all stopped agreeing with one another. The things that live here know you are coming.',
|
||||
dmNotes: 'A gauntlet of horror-register creatures; whichever faction the party rallied fights alongside them (add those NPCs). Grey Dawn = a disciplined line; Spine Clans = terrain and ritual (collapse passages); Independent = hardest, and entirely theirs. → E26, the Hollow King: build that stat block from DM Secrets, scaled to the party.',
|
||||
monsters: [['Root Stalker', 1], ['Bellcrawler', 2]],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -115,7 +115,7 @@ const SPELL_LIBRARY = [
|
||||
{ key: 'nondetection', name: 'Nondetection', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: '8 hours', description: 'Hide a target from divination magic.', classes: ['BARD', 'RANGER', 'WIZARD'] },
|
||||
{ key: 'protection-from-energy', name: 'Protection from Energy', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'Resistance to one damage type of your choice.', classes: ['CLERIC', 'DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'remove-curse', name: 'Remove Curse', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Lift a curse from a creature or object.', classes: ['CLERIC', 'PALADIN', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'revivify', name: 'Revivify', level: 3, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature that died within the last minute returns to life with 1 hit point.', classes: ['CLERIC', 'PALADIN'] },
|
||||
{ key: 'revivify', name: 'Revivify', level: 3, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature that died within the last minute returns to life with 1 hit point.', revives: true, classes: ['CLERIC', 'PALADIN'] },
|
||||
{ key: 'sleet-storm', name: 'Sleet Storm', level: 3, school: 'Conjuration', castingTime: '1 action', range: '150 feet', duration: 'Concentration, up to 1 minute', description: 'Freezing rain falls in a 40-foot-radius cylinder, icing the ground.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'slow', name: 'Slow', level: 3, school: 'Transmutation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Up to six creatures are hindered, halving their speed and worse.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'spirit-guardians', name: 'Spirit Guardians', level: 3, school: 'Conjuration', castingTime: '1 action', range: 'Self (15-foot radius)', duration: 'Concentration, up to 10 minutes', description: 'Protective spirits swirl around you, damaging enemies that enter.', damageDice: '3d8', classes: ['CLERIC'] },
|
||||
@@ -150,7 +150,7 @@ const SPELL_LIBRARY = [
|
||||
{ key: 'insect-plague', name: 'Insect Plague', level: 5, school: 'Conjuration', castingTime: '1 action', range: '300 feet', duration: 'Concentration, up to 10 minutes', description: 'A swarm of biting, stinging insects fills a 20-foot-radius sphere.', damageDice: '4d10', classes: ['CLERIC', 'DRUID', 'SORCERER'] },
|
||||
{ key: 'legend-lore', name: 'Legend Lore', level: 5, school: 'Divination', castingTime: '10 minutes', range: 'Self', duration: 'Instantaneous', description: 'Learn what you know, or have heard, about a name, place, or object.', classes: ['BARD', 'CLERIC', 'WIZARD'] },
|
||||
{ key: 'mass-cure-wounds', name: 'Mass Cure Wounds', level: 5, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'Up to six creatures in a 30-foot-radius sphere regain hit points.', classes: ['BARD', 'CLERIC', 'DRUID'] },
|
||||
{ key: 'raise-dead', name: 'Raise Dead', level: 5, school: 'Necromancy', castingTime: '1 hour', range: 'Touch', duration: 'Instantaneous', description: 'Return a dead creature to life, as long as its body is mostly intact.', classes: ['BARD', 'CLERIC', 'PALADIN'] },
|
||||
{ key: 'raise-dead', name: 'Raise Dead', level: 5, school: 'Necromancy', castingTime: '1 hour', range: 'Touch', duration: 'Instantaneous', description: 'Return a dead creature to life, as long as its body is mostly intact.', revives: true, classes: ['BARD', 'CLERIC', 'PALADIN'] },
|
||||
{ key: 'wall-of-force', name: 'Wall of Force', level: 5, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'An invisible wall of force springs into existence, impenetrable to physical force.', classes: ['WIZARD'] },
|
||||
{ key: 'wall-of-stone', name: 'Wall of Stone', level: 5, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A nonmagical wall of solid stone springs into existence.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
];
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,9 @@ const { requireCampaignMember, requireDM } = require('../middleware/campaignAcce
|
||||
const { generateInviteCode } = require('../utils/inviteCode');
|
||||
const { computeCharacterSheet, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
|
||||
const { visitedLocationIds } = require('../utils/locations');
|
||||
const { deathSaveInfo } = require('../utils/hp');
|
||||
const { CURRENCIES } = require('../utils/grants');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -16,11 +19,14 @@ function campaignSummary(campaign, role) {
|
||||
id: campaign.id,
|
||||
name: campaign.name,
|
||||
description: campaign.description,
|
||||
inviteCode: campaign.inviteCode,
|
||||
// The invite code is the DM's to hand out.
|
||||
inviteCode: role === 'DM' ? campaign.inviteCode : undefined,
|
||||
startingGold: campaign.startingGold,
|
||||
startingItemCount: campaign.startingItemCount,
|
||||
startingSpellCount: campaign.startingSpellCount,
|
||||
skipMotivationRoll: campaign.skipMotivationRoll,
|
||||
showMonsterHp: campaign.showMonsterHp,
|
||||
areaAttackAcPenalty: campaign.areaAttackAcPenalty,
|
||||
ownerId: campaign.ownerId,
|
||||
archivedAt: campaign.archivedAt,
|
||||
role,
|
||||
@@ -45,10 +51,12 @@ router.get('/', async (req, res, next) => {
|
||||
const createCampaignSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
startingGold: z.number().int().min(0).optional(),
|
||||
startingItemCount: z.number().int().min(0).optional(),
|
||||
startingSpellCount: z.number().int().min(0).optional(),
|
||||
startingGold: z.number().int().min(0).max(1000000).optional(),
|
||||
startingItemCount: z.number().int().min(0).max(50).optional(),
|
||||
startingSpellCount: z.number().int().min(0).max(50).optional(),
|
||||
skipMotivationRoll: z.boolean().optional(),
|
||||
showMonsterHp: z.boolean().optional(),
|
||||
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
|
||||
});
|
||||
|
||||
// POST /campaigns — create, creator becomes DM/owner
|
||||
@@ -190,7 +198,11 @@ function characterSummary(character) {
|
||||
buildStage: character.buildStage,
|
||||
currentHp: character.currentHp,
|
||||
tempHp: character.tempHp,
|
||||
deathSaves: deathSaveInfo(character),
|
||||
gold: character.gold,
|
||||
silver: character.silver,
|
||||
copper: character.copper,
|
||||
cash: character.cash,
|
||||
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
|
||||
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
|
||||
currentLocationId: character.currentLocationId,
|
||||
@@ -205,7 +217,19 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
|
||||
include: { skillProficiencies: true, saveProficiencies: true, currentLocation: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
res.json({ characters: characters.map(characterSummary) });
|
||||
const summaries = characters.map(characterSummary);
|
||||
if (req.membership.role === 'DM') return res.json({ characters: summaries });
|
||||
|
||||
// A player sees the party's headline numbers, but another character's
|
||||
// purse is their own business, and so is a place this player has not been.
|
||||
const visited = await visitedLocationIds(prisma, req.user.id, req.campaign.id);
|
||||
res.json({
|
||||
characters: summaries.map((c) => {
|
||||
if (c.userId === req.user.id) return c;
|
||||
const knowsPlace = c.currentLocationId && visited.has(c.currentLocationId);
|
||||
return { ...c, gold: null, silver: null, copper: null, cash: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -252,7 +276,7 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
|
||||
},
|
||||
});
|
||||
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:created', { character: characterSummary(character) });
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:created', { characterId: character.id });
|
||||
res.status(201).json({ character: characterSummary(character) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -260,12 +284,28 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weapons (campaign-level pool; DM manages, anyone in campaign can read)
|
||||
// Weapons (campaign-level pool; the DM manages it and sees all of it)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// What a player may see of a pool: whatever one of their own characters holds,
|
||||
// plus the starting options while they still have a character to build. The
|
||||
// rest of the pool — gear yet to be found, spells yet to be learned — is the
|
||||
// DM's. `held` is the pool model's relation to the per-character rows.
|
||||
async function poolScope(req, held) {
|
||||
const where = { campaignId: req.campaign.id };
|
||||
if (req.membership.role === 'DM') return where;
|
||||
|
||||
const mine = { userId: req.user.id, campaignId: req.campaign.id };
|
||||
const building = await prisma.character.count({ where: { ...mine, buildStage: { not: 'COMPLETE' } } });
|
||||
return {
|
||||
...where,
|
||||
OR: [{ [held]: { some: { character: mine } } }, ...(building > 0 ? [{ isStartingOption: true }] : [])],
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/:id/weapons', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const weapons = await prisma.weapon.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
const weapons = await prisma.weapon.findMany({ where: await poolScope(req, 'assigned'), orderBy: { name: 'asc' } });
|
||||
res.json({ weapons });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -350,7 +390,7 @@ router.delete('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), as
|
||||
|
||||
router.get('/:id/spells', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const spells = await prisma.spell.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
const spells = await prisma.spell.findMany({ where: await poolScope(req, 'known'), orderBy: { name: 'asc' } });
|
||||
res.json({ spells });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -367,6 +407,7 @@ const spellSchema = z.object({
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
revives: z.boolean().optional(), // brings back the dead
|
||||
});
|
||||
|
||||
router.post('/:id/spells', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
@@ -409,7 +450,7 @@ router.delete('/:id/spells/:spellId', requireCampaignMember(), requireDM(), asyn
|
||||
|
||||
router.get('/:id/items', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const items = await prisma.item.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
const items = await prisma.item.findMany({ where: await poolScope(req, 'held'), orderBy: { name: 'asc' } });
|
||||
res.json({ items });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -476,8 +517,8 @@ router.get('/:id/monsters', requireCampaignMember(), requireDM(), async (req, re
|
||||
|
||||
const monsterSchema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
ac: z.number().int().min(0),
|
||||
maxHp: z.number().int().min(1),
|
||||
ac: z.number().int().min(0).max(50),
|
||||
maxHp: z.number().int().min(1).max(100000),
|
||||
speed: z.number().int().min(0).optional(),
|
||||
strScore: z.number().int().min(1).max(30).optional(),
|
||||
dexScore: z.number().int().min(1).max(30).optional(),
|
||||
@@ -485,8 +526,8 @@ const monsterSchema = z.object({
|
||||
intScore: z.number().int().min(1).max(30).optional(),
|
||||
wisScore: z.number().int().min(1).max(30).optional(),
|
||||
chaScore: z.number().int().min(1).max(30).optional(),
|
||||
attackBonus: z.number().int().optional(),
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
attackBonus: z.number().int().min(-20).max(50).optional(),
|
||||
damageDice: z.string().max(60).optional().nullable(), // may name its type: "1d6+2 necrotic"
|
||||
challengeRating: z.string().max(10).optional().nullable(),
|
||||
specialTrait: z.string().max(500).optional().nullable(),
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
@@ -533,9 +574,11 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(),
|
||||
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const isDM = req.membership.role === 'DM';
|
||||
// Archived encounters are the DM's to see; players only get the live list.
|
||||
// The DM sees every encounter. A player sees the live ones that one of
|
||||
// their own characters is in, and nothing of the rest.
|
||||
const mine = { archivedAt: null, participants: { some: { character: { userId: req.user.id } } } };
|
||||
const encounters = await prisma.encounter.findMany({
|
||||
where: { campaignId: req.campaign.id, ...(isDM ? {} : { archivedAt: null }) },
|
||||
where: { campaignId: req.campaign.id, ...(isDM ? {} : mine) },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
// dmNotes never leaves the server for a player.
|
||||
@@ -549,12 +592,18 @@ const createEncounterSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
locationId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = createEncounterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (parsed.data.locationId) {
|
||||
const location = await prisma.location.findFirst({ where: { id: parsed.data.locationId, campaignId: req.campaign.id } });
|
||||
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
|
||||
}
|
||||
|
||||
// A new encounter goes to the top of the campaign's order.
|
||||
const first = await prisma.encounter.aggregate({ where: { campaignId: req.campaign.id }, _min: { sortOrder: true } });
|
||||
const encounter = await prisma.encounter.create({
|
||||
@@ -597,7 +646,11 @@ router.put('/:id/encounters/order', requireCampaignMember(), requireDM(), async
|
||||
|
||||
router.get('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const npcs = await prisma.npc.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
const npcs = await prisma.npc.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
include: { wares: { include: WARE_INCLUDE, orderBy: { createdAt: 'asc' } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
res.json({ npcs });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -610,16 +663,35 @@ const npcSchema = z.object({
|
||||
disposition: z.enum(['FRIENDLY', 'NEUTRAL', 'HOSTILE']).optional(),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
dmNotes: z.string().max(4000).optional().nullable(),
|
||||
ac: z.number().int().min(0).optional().nullable(),
|
||||
maxHp: z.number().int().min(1).optional().nullable(),
|
||||
attackBonus: z.number().int().optional().nullable(),
|
||||
ac: z.number().int().min(0).max(50).optional().nullable(),
|
||||
maxHp: z.number().int().min(1).max(100000).optional().nullable(),
|
||||
attackBonus: z.number().int().min(-20).max(50).optional().nullable(),
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
// The money changer's rate: exchangeGold gold for exchangeCash cash. Both
|
||||
// null for an NPC who does not change money.
|
||||
exchangeGold: z.number().int().min(1).max(1000000).optional().nullable(),
|
||||
exchangeCash: z.number().int().min(1).max(1000000).optional().nullable(),
|
||||
});
|
||||
|
||||
// A rate has two sides: both are given, or both are cleared.
|
||||
function halfARate(data) {
|
||||
const sides = [data.exchangeGold, data.exchangeCash];
|
||||
return sides.some((side) => side === undefined) !== sides.every((side) => side === undefined) || (sides[0] === null) !== (sides[1] === null);
|
||||
}
|
||||
const HALF_A_RATE = 'An exchange rate needs both sides: so much gold for so much cash.';
|
||||
|
||||
const WARE_INCLUDE = { weapon: true, item: true };
|
||||
|
||||
// Open encounter pages refetch: what is for sale in a scene just changed.
|
||||
function emitTradeChanged(req) {
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('encounter:participant-updated', {});
|
||||
}
|
||||
|
||||
router.post('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = npcSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
|
||||
const npc = await prisma.npc.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ npc });
|
||||
} catch (err) {
|
||||
@@ -631,8 +703,10 @@ router.patch('/:id/npcs/:npcId', requireCampaignMember(), requireDM(), async (re
|
||||
try {
|
||||
const parsed = npcSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
|
||||
const npc = await updatePoolEntry('npc', req.params.npcId, req.campaign.id, parsed.data);
|
||||
if (!npc) return res.status(404).json({ error: 'NPC not found in this campaign' });
|
||||
emitTradeChanged(req);
|
||||
res.json({ npc });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -676,12 +750,17 @@ router.get('/:id/notes', requireCampaignMember(), async (req, res, next) => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Locations — DM-managed, used to track where each character currently is
|
||||
// Locations — DM-managed, used to track where each character currently is.
|
||||
// A player only sees the places one of their own characters has been to.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/locations', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const locations = await prisma.location.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
const where = { campaignId: req.campaign.id };
|
||||
if (req.membership.role !== 'DM') {
|
||||
where.id = { in: [...(await visitedLocationIds(prisma, req.user.id, req.campaign.id))] };
|
||||
}
|
||||
const locations = await prisma.location.findMany({ where, orderBy: { name: 'asc' } });
|
||||
res.json({ locations });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -727,13 +806,90 @@ router.delete('/:id/locations/:locationId', requireCampaignMember(), requireDM()
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wares — weapons and items for sale, by an NPC or at an encounter itself.
|
||||
// Only the DM sets them up. Players see them, and buy, on the encounter page
|
||||
// (encounters.routes.js); an NPC's wares go wherever the NPC does.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const wareSchema = z
|
||||
.object({
|
||||
npcId: z.string().optional(),
|
||||
encounterId: z.string().optional(),
|
||||
weaponId: z.string().optional(),
|
||||
itemId: z.string().optional(),
|
||||
price: z.number().int().min(1).max(1000000),
|
||||
currency: z.enum(CURRENCIES).default('GOLD'),
|
||||
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||
stock: z.number().int().min(0).max(9999).optional().nullable(), // null: no limit
|
||||
})
|
||||
.refine((data) => Boolean(data.npcId) !== Boolean(data.encounterId), { message: 'Say who sells it: an NPC or an encounter, not both' })
|
||||
.refine((data) => Boolean(data.weaponId) !== Boolean(data.itemId), { message: 'Pick one weapon or one item to sell' });
|
||||
|
||||
router.post('/:id/wares', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = wareSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const data = parsed.data;
|
||||
const inCampaign = async (model, id) => (id ? prisma[model].findFirst({ where: { id, campaignId: req.campaign.id } }) : null);
|
||||
|
||||
const seller = (await inCampaign('npc', data.npcId)) || (await inCampaign('encounter', data.encounterId));
|
||||
if (!seller) return res.status(400).json({ error: 'That seller is not part of this campaign.' });
|
||||
const entry = (await inCampaign('weapon', data.weaponId)) || (await inCampaign('item', data.itemId));
|
||||
if (!entry) return res.status(400).json({ error: `That ${data.weaponId ? 'weapon' : 'item'} is not part of this campaign.` });
|
||||
const level = data.level ?? entry.minLevel;
|
||||
if (level < entry.minLevel) {
|
||||
return res.status(400).json({ error: `${entry.name} starts at level ${entry.minLevel} and can't go below it.` });
|
||||
}
|
||||
|
||||
const ware = await prisma.saleOffer.create({ data: { ...data, level, campaignId: req.campaign.id }, include: WARE_INCLUDE });
|
||||
emitTradeChanged(req);
|
||||
res.status(201).json({ ware });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// The price and what is left can change; what is sold, and by whom, cannot.
|
||||
const patchWareSchema = z.object({
|
||||
price: z.number().int().min(1).max(1000000).optional(),
|
||||
currency: z.enum(CURRENCIES).optional(),
|
||||
stock: z.number().int().min(0).max(9999).nullable().optional(),
|
||||
});
|
||||
|
||||
router.patch('/:id/wares/:wareId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = patchWareSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (!(await updatePoolEntry('saleOffer', req.params.wareId, req.campaign.id, parsed.data))) {
|
||||
return res.status(404).json({ error: 'Nothing like that is for sale in this campaign' });
|
||||
}
|
||||
emitTradeChanged(req);
|
||||
res.json({ ware: await prisma.saleOffer.findUnique({ where: { id: req.params.wareId }, include: WARE_INCLUDE }) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id/wares/:wareId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
if (!(await deletePoolEntry('saleOffer', req.params.wareId, req.campaign.id))) {
|
||||
return res.status(404).json({ error: 'Nothing like that is for sale in this campaign' });
|
||||
}
|
||||
emitTradeChanged(req);
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default content library — static SRD-style weapons/spells/monsters any
|
||||
// campaign can pull from, instead of the DM hand-typing everything. See
|
||||
// backend/src/game/data/{weapons,spells,monsters}Library.js for the sets.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/library', requireCampaignMember(), async (req, res, next) => {
|
||||
router.get('/:id/library', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
res.json({ weapons: WEAPON_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY });
|
||||
} catch (err) {
|
||||
|
||||
@@ -29,7 +29,9 @@ const {
|
||||
} = require('../game/buildFlow');
|
||||
const characterOptions = require('../game/data/characterOptions');
|
||||
const { SKILLS } = require('../game/data/skills');
|
||||
const { InsufficientGoldError, adjustGold, giveGear } = require('../utils/grants');
|
||||
const { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
|
||||
const { recordVisits } = require('../utils/locations');
|
||||
const { absorbDamage, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -51,7 +53,14 @@ async function loadFullCharacter(id) {
|
||||
|
||||
// privateNotes is a real privacy boundary, not a UI hide — it's only ever
|
||||
// included in the payload for the character's own owning player.
|
||||
function serializeCharacter(character, isOwner) {
|
||||
//
|
||||
// Another player (neither the owner nor the DM) gets the sheet the party can
|
||||
// see across the table — who the character is, HP, AC, abilities, what they
|
||||
// fight with — but not what is in their pack, their purse, or where they are.
|
||||
// `isDM` defaults to true because every caller but the plain read is a write
|
||||
// route, which only the owner or the DM can reach.
|
||||
function serializeCharacter(character, isOwner, isDM = true) {
|
||||
const restricted = !isOwner && !isDM;
|
||||
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
|
||||
return {
|
||||
id: character.id,
|
||||
@@ -76,22 +85,27 @@ function serializeCharacter(character, isOwner) {
|
||||
},
|
||||
currentHp: character.currentHp,
|
||||
tempHp: character.tempHp,
|
||||
deathSaves: deathSaveInfo(character),
|
||||
armorBonus: character.armorBonus,
|
||||
specialAbilities: character.specialAbilities,
|
||||
background: character.background,
|
||||
appearance: character.appearance,
|
||||
privateNotes: isOwner ? character.privateNotes : undefined,
|
||||
diceMode: character.diceMode,
|
||||
currentLocationId: character.currentLocationId,
|
||||
currentLocation: character.currentLocation || null,
|
||||
gold: character.gold,
|
||||
currentLocationId: restricted ? null : character.currentLocationId,
|
||||
currentLocation: restricted ? null : character.currentLocation || null,
|
||||
gold: restricted ? null : character.gold,
|
||||
silver: restricted ? null : character.silver,
|
||||
copper: restricted ? null : character.copper,
|
||||
cash: restricted ? null : character.cash,
|
||||
restricted,
|
||||
buildStage: character.buildStage,
|
||||
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
|
||||
skillProficiencies: character.skillProficiencies,
|
||||
saveProficiencies: character.saveProficiencies,
|
||||
// Each held entry carries what its level works out to: the dice at that
|
||||
// level, and whether this character is high enough level to use it.
|
||||
inventoryItems: character.inventoryItems.map((ii) => ({
|
||||
inventoryItems: (restricted ? [] : character.inventoryItems).map((ii) => ({
|
||||
...ii,
|
||||
scaledDamageDice: ii.item.damageDice
|
||||
? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel)
|
||||
@@ -126,7 +140,7 @@ router.get('/build/options', (req, res) => {
|
||||
router.get('/:id', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
const character = await loadFullCharacter(req.character.id);
|
||||
res.json({ character: serializeCharacter(character, req.isOwner) });
|
||||
res.json({ character: serializeCharacter(character, req.isOwner, req.isDM) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -135,8 +149,8 @@ router.get('/:id', requireCharacterAccess(), async (req, res, next) => {
|
||||
const patchCharacterSchema = z.object({
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
currentHp: z.number().int().optional(),
|
||||
tempHp: z.number().int().min(0).optional(),
|
||||
armorBonus: z.number().int().min(0).optional(),
|
||||
tempHp: z.number().int().min(0).max(9999).optional(),
|
||||
armorBonus: z.number().int().min(0).max(30).optional(),
|
||||
speedOverride: z.number().int().min(0).nullable().optional(),
|
||||
specialAbilities: z.string().max(4000).nullable().optional(),
|
||||
background: z.string().max(4000).nullable().optional(),
|
||||
@@ -160,8 +174,8 @@ function clamp(value, min, max) {
|
||||
|
||||
router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
if (req.body && req.body.gold !== undefined) {
|
||||
return res.status(400).json({ error: 'Gold changes go through the money tracker.' });
|
||||
if (req.body && Object.values(COIN_COLUMN).some((coin) => req.body[coin] !== undefined)) {
|
||||
return res.status(400).json({ error: 'Gold, silver, copper and cash changes go through the money tracker.' });
|
||||
}
|
||||
const parsed = patchCharacterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
@@ -170,10 +184,28 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
|
||||
return res.status(403).json({ error: 'Only the DM can change level, XP, speed, location, special abilities, background or appearance.' });
|
||||
}
|
||||
|
||||
// Damage is soaked by temp HP before it reaches real HP — unless this
|
||||
// same request sets temp HP outright.
|
||||
const settingHp = parsed.data.currentHp !== undefined;
|
||||
const settingTempHp = parsed.data.tempHp !== undefined;
|
||||
if (req.character.buildStage === 'COMPLETE' && (settingHp || parsed.data.level !== undefined)) {
|
||||
// Current HP stays within 0..max, including when a level change moves max.
|
||||
if (req.character.buildStage === 'COMPLETE' && (parsed.data.currentHp !== undefined || parsed.data.level !== undefined)) {
|
||||
const maxHp = computeCharacterSheet({ ...req.character, ...parsed.data }).hp.max;
|
||||
parsed.data.currentHp = clamp(parsed.data.currentHp ?? req.character.currentHp, 0, maxHp);
|
||||
if (!settingHp) {
|
||||
parsed.data.currentHp = clamp(req.character.currentHp, 0, maxHp);
|
||||
} else {
|
||||
if (lifeState(req.character) === 'DEAD' && parsed.data.currentHp > 0) {
|
||||
return res.status(400).json({ error: `${req.character.name} is dead. The DM has to revive them before they can be healed.` });
|
||||
}
|
||||
// Reaching 0, being hit while there, and being healed back up all
|
||||
// move the death saves along with the HP.
|
||||
const { tempHp, ...after } = changeHp(settingTempHp ? { ...req.character, tempHp: 0 } : req.character, parsed.data.currentHp, maxHp);
|
||||
Object.assign(parsed.data, after, settingTempHp ? {} : { tempHp });
|
||||
}
|
||||
} else if (settingHp && !settingTempHp) {
|
||||
const after = absorbDamage(req.character.currentHp, req.character.tempHp, parsed.data.currentHp);
|
||||
parsed.data.currentHp = after.currentHp;
|
||||
if (after.tempHp !== req.character.tempHp) parsed.data.tempHp = after.tempHp;
|
||||
}
|
||||
|
||||
// privateNotes is for the owning player only — a DM editing someone
|
||||
@@ -191,6 +223,7 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
|
||||
}
|
||||
|
||||
const updated = await prisma.character.update({ where: { id: req.character.id }, data: parsed.data });
|
||||
if (parsed.data.currentLocationId) await recordVisits(prisma, [updated.id], parsed.data.currentLocationId);
|
||||
if (updated.level < req.character.level) {
|
||||
// Dropping a level can put equipped gear and prepared spells out of reach.
|
||||
const tooHigh = { characterId: updated.id, level: { gt: updated.level } };
|
||||
@@ -206,7 +239,62 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
|
||||
// Broadcast payload never includes privateNotes — a socket room has
|
||||
// every campaign member in it, not just the owner. Only the direct HTTP
|
||||
// response (below) is allowed to include it.
|
||||
req.app.get('io').to(`campaign:${updated.campaignId}`).emit('character:updated', { character: serializeCharacter(full, false) });
|
||||
req.app.get('io').to(`campaign:${updated.campaignId}`).emit('character:updated', { characterId: full.id });
|
||||
res.json({ character: serializeCharacter(full, req.isOwner) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Death saves — what happens to a character at 0 HP (utils/hp.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const deathSaveTally = z.number().int().min(0).max(3);
|
||||
const deathSaveRoll = z.number().int().min(1, 'A death save is a d20: 1 to 20').max(20, 'A death save is a d20: 1 to 20');
|
||||
|
||||
const deathSaveSchema = z.discriminatedUnion('action', [
|
||||
// What the d20 showed; the server works out what it means.
|
||||
z.object({ action: z.literal('roll'), roll: deathSaveRoll }),
|
||||
// Steadied by someone else: a Medicine check, a healer's kit, a spell.
|
||||
z.object({ action: z.literal('stabilize') }),
|
||||
// The DM setting the tallies by hand: a critical hit's second failure, or
|
||||
// undoing a mistake.
|
||||
z.object({ action: z.literal('set'), successes: deathSaveTally, failures: deathSaveTally }),
|
||||
// The DM bringing back the dead, at 1 HP.
|
||||
z.object({ action: z.literal('revive') }),
|
||||
]);
|
||||
|
||||
// The owning player rolls their own death saves and can be steadied; setting
|
||||
// the tallies by hand and raising the dead are the DM's.
|
||||
router.post('/:id/death-saves', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = deathSaveSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { character } = req;
|
||||
if (character.buildStage !== 'COMPLETE') return res.status(400).json({ error: 'Character build is not finished yet.' });
|
||||
|
||||
const input = parsed.data;
|
||||
const state = lifeState(character);
|
||||
if ((input.action === 'set' || input.action === 'revive') && !req.isDM) {
|
||||
return res.status(403).json({ error: 'Only the DM can do that.' });
|
||||
}
|
||||
|
||||
let data;
|
||||
if (input.action === 'roll' || input.action === 'stabilize') {
|
||||
if (state !== 'DYING') return res.status(400).json({ error: `${character.name} is not dying, so there is no death save to make.` });
|
||||
data = input.action === 'roll' ? rollDeathSave(character, input.roll) : { ...NO_DEATH_SAVES, stable: true };
|
||||
} else if (input.action === 'set') {
|
||||
if (state === 'UP') return res.status(400).json({ error: `${character.name} is not at 0 HP.` });
|
||||
data = settleDeathSaves(input.successes, input.failures);
|
||||
} else {
|
||||
if (state !== 'DEAD') return res.status(400).json({ error: `${character.name} is not dead.` });
|
||||
data = { currentHp: 1, ...NO_DEATH_SAVES };
|
||||
}
|
||||
|
||||
await prisma.character.update({ where: { id: character.id }, data });
|
||||
const full = await loadFullCharacter(character.id);
|
||||
req.app.get('io').to(`campaign:${full.campaignId}`).emit('character:updated', { characterId: full.id });
|
||||
res.json({ character: serializeCharacter(full, req.isOwner) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -336,6 +424,9 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
|
||||
speedOverride: source.speedOverride,
|
||||
specialAbilities: source.specialAbilities,
|
||||
gold: source.gold,
|
||||
silver: source.silver,
|
||||
copper: source.copper,
|
||||
cash: source.cash,
|
||||
background: source.background,
|
||||
appearance: source.appearance,
|
||||
// Same privacy boundary as serializeCharacter: a DM copying a
|
||||
@@ -351,10 +442,13 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
|
||||
},
|
||||
inventoryItems: { create: [...items.values()] },
|
||||
goldTransactions: {
|
||||
create:
|
||||
source.gold > 0
|
||||
? [{ amount: source.gold, balanceAfter: source.gold, reason: 'Carried over from another campaign', createdById: req.user.id }]
|
||||
: [],
|
||||
create: CURRENCIES.filter((currency) => source[COIN_COLUMN[currency]] > 0).map((currency) => ({
|
||||
currency,
|
||||
amount: source[COIN_COLUMN[currency]],
|
||||
balanceAfter: source[COIN_COLUMN[currency]],
|
||||
reason: 'Carried over from another campaign',
|
||||
createdById: req.user.id,
|
||||
})),
|
||||
},
|
||||
weapons: { create: [...weapons.values()] },
|
||||
spells: { create: [...spells.values()] },
|
||||
@@ -364,7 +458,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
|
||||
});
|
||||
|
||||
const full = await loadFullCharacter(copyId);
|
||||
req.app.get('io').to(`campaign:${targetCampaignId}`).emit('character:created', { character: serializeCharacter(full, false) });
|
||||
req.app.get('io').to(`campaign:${targetCampaignId}`).emit('character:created', { characterId: full.id });
|
||||
res.status(201).json({ character: serializeCharacter(full, true) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -781,7 +875,7 @@ router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), asyn
|
||||
});
|
||||
|
||||
const full = await loadFullCharacter(result.id);
|
||||
req.app.get('io').to(`campaign:${result.campaignId}`).emit('character:created', { character: serializeCharacter(full, false) });
|
||||
req.app.get('io').to(`campaign:${result.campaignId}`).emit('character:created', { characterId: full.id });
|
||||
res.json({ character: serializeCharacter(full, req.isOwner) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -818,6 +912,7 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
|
||||
currentHp: character.currentHp,
|
||||
maxHp: sheet.hp.max,
|
||||
tempHp: character.tempHp,
|
||||
deathSaves: deathSaveInfo(character),
|
||||
armorClass: sheet.armorClass,
|
||||
initiative: sheet.initiative,
|
||||
speed: sheet.speed,
|
||||
@@ -838,14 +933,30 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
|
||||
|
||||
const GOLD_TRANSACTION_INCLUDE = { createdBy: { select: { displayName: true } } };
|
||||
|
||||
// Which coin an amount is in; gold when none is given.
|
||||
const currencySchema = z.enum(CURRENCIES).default('GOLD');
|
||||
|
||||
function coinName(currency) {
|
||||
return currency.toLowerCase();
|
||||
}
|
||||
|
||||
const PURSE_SELECT = { gold: true, silver: true, copper: true, cash: true };
|
||||
|
||||
// For a payment the whole purse can't cover. Cash stands apart from the coins.
|
||||
function notEnoughMoney(character, amount, currency) {
|
||||
if (currency === 'CASH') return `Not enough cash — ${character.name} has $${character.cash}, which doesn't cover $${amount}.`;
|
||||
return `Not enough money — ${character.name} has ${character.gold} gold, ${character.silver} silver and ${character.copper} copper (worth ${worthInGold(character)} gold), which doesn't cover ${amount} ${coinName(currency)}.`;
|
||||
}
|
||||
|
||||
router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isDM && !req.isOwner) return res.status(403).json({ error: "Only the character's player and the DM can see its money" });
|
||||
const transactions = await prisma.goldTransaction.findMany({
|
||||
where: { characterId: req.character.id },
|
||||
include: GOLD_TRANSACTION_INCLUDE,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
res.json({ gold: req.character.gold, transactions });
|
||||
res.json({ gold: req.character.gold, silver: req.character.silver, copper: req.character.copper, cash: req.character.cash, transactions });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -853,30 +964,76 @@ router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
|
||||
|
||||
// amount is signed: positive = gained, negative = spent. The owning player
|
||||
// and the DM can both record entries; each one is stamped with who made it.
|
||||
// A spend is paid from the whole purse: if there aren't enough of the coin
|
||||
// named, bigger coins are broken (or smaller ones counted out) to cover it.
|
||||
// Cash is only ever paid in cash.
|
||||
router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z
|
||||
.object({
|
||||
amount: z.number().int().refine((n) => n !== 0, 'Amount cannot be zero'),
|
||||
amount: z.number().int().min(-1000000).max(1000000).refine((n) => n !== 0, 'Amount cannot be zero'),
|
||||
currency: currencySchema,
|
||||
reason: z.string().trim().max(200).optional().nullable(),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { amount, reason } = parsed.data;
|
||||
const { amount, currency, reason } = parsed.data;
|
||||
|
||||
let transaction;
|
||||
let transactions;
|
||||
try {
|
||||
// Throwing inside the transaction rolls the balance change back.
|
||||
transaction = await prisma.$transaction((tx) =>
|
||||
adjustGold(tx, { characterId: req.character.id, amount, reason, userId: req.user.id, include: GOLD_TRANSACTION_INCLUDE })
|
||||
);
|
||||
transactions = await prisma.$transaction(async (tx) => {
|
||||
const entry = { characterId: req.character.id, currency, reason, userId: req.user.id, include: GOLD_TRANSACTION_INCLUDE };
|
||||
return amount > 0 ? [await adjustGold(tx, { ...entry, amount })] : spendCoins(tx, { ...entry, amount: -amount });
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof InsufficientGoldError)) throw err;
|
||||
return res.status(400).json({ error: `Not enough gold — the balance is ${req.character.gold}.` });
|
||||
return res.status(400).json({ error: notEnoughMoney(req.character, -amount, currency) });
|
||||
}
|
||||
|
||||
emitCharacterUpdated(req);
|
||||
res.status(201).json({ gold: transaction.balanceAfter, transaction });
|
||||
const purse = await prisma.character.findUnique({ where: { id: req.character.id }, select: PURSE_SELECT });
|
||||
res.status(201).json({ ...purse, transaction: transactions[0], transactions });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Changes coins of one kind into another at their fixed worth (1 gold = 10
|
||||
// silver = 100 copper). `amount` is how many `from` coins to hand over; it has
|
||||
// to come to a whole number of `to` coins. Both sides go on the ledger. Unlike
|
||||
// a spend, this moves exactly the coins named: no change is made. Cash has no
|
||||
// worth in coins, so it can't be exchanged.
|
||||
router.post('/:id/gold/exchange', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z
|
||||
.object({ from: z.enum(COINS), to: z.enum(COINS), amount: z.number().int().min(1).max(1000000) })
|
||||
.refine((data) => data.from !== data.to, { message: 'Pick two different coins' })
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { from, to, amount } = parsed.data;
|
||||
|
||||
const worth = amount * COIN_VALUE[from];
|
||||
if (worth % COIN_VALUE[to] !== 0) {
|
||||
const step = COIN_VALUE[to] / COIN_VALUE[from];
|
||||
return res.status(400).json({ error: `${amount} ${coinName(from)} doesn't make a whole number of ${coinName(to)} — exchange in multiples of ${step}.` });
|
||||
}
|
||||
const received = worth / COIN_VALUE[to];
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const base = { characterId: req.character.id, userId: req.user.id };
|
||||
await adjustGold(tx, { ...base, currency: from, amount: -amount, reason: `Exchanged for ${received} ${coinName(to)}` });
|
||||
await adjustGold(tx, { ...base, currency: to, amount: received, reason: `Exchanged from ${amount} ${coinName(from)}` });
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof InsufficientGoldError)) throw err;
|
||||
return res.status(400).json({ error: `Not enough ${coinName(from)} — the balance is ${req.character[COIN_COLUMN[from]]}.` });
|
||||
}
|
||||
|
||||
emitCharacterUpdated(req);
|
||||
const character = await loadFullCharacter(req.character.id);
|
||||
res.status(201).json({ character: serializeCharacter(character, req.isOwner, req.isDM), received });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -1020,7 +1177,7 @@ for (const gear of GEAR) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const giveSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('gold'), toCharacterId: z.string(), amount: z.number().int().min(1) }),
|
||||
z.object({ kind: z.literal('gold'), toCharacterId: z.string(), amount: z.number().int().min(1), currency: z.enum(CURRENCIES).default('GOLD') }),
|
||||
z.object({ kind: z.literal('weapon'), toCharacterId: z.string(), id: z.string(), quantity: z.number().int().min(1).default(1) }),
|
||||
z.object({ kind: z.literal('item'), toCharacterId: z.string(), id: z.string(), quantity: z.number().int().min(1).default(1) }),
|
||||
z.object({ kind: z.literal('spell'), toCharacterId: z.string(), id: z.string() }),
|
||||
@@ -1047,8 +1204,10 @@ router.post('/:id/give', requireCharacterAccess({ write: true }), async (req, re
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (give.kind === 'gold') {
|
||||
await adjustGold(tx, { characterId: giver.id, amount: -give.amount, reason: `Gave to ${recipient.name}`, userId: req.user.id });
|
||||
await adjustGold(tx, { characterId: recipient.id, amount: give.amount, reason: `Received from ${giver.name}`, userId: req.user.id });
|
||||
// The giver pays from their whole purse, with change; the recipient gets the coins named.
|
||||
const coins = { amount: give.amount, currency: give.currency, userId: req.user.id };
|
||||
await spendCoins(tx, { ...coins, characterId: giver.id, reason: `Gave to ${recipient.name}` });
|
||||
await adjustGold(tx, { ...coins, characterId: recipient.id, reason: `Received from ${giver.name}` });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1082,7 +1241,7 @@ router.post('/:id/give', requireCharacterAccess({ write: true }), async (req, re
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InsufficientGoldError) {
|
||||
return res.status(400).json({ error: `Not enough gold — ${giver.name} has ${giver.gold}.` });
|
||||
return res.status(400).json({ error: notEnoughMoney(giver, give.amount, give.currency || 'GOLD') });
|
||||
}
|
||||
if (err instanceof GiveError) return res.status(400).json({ error: err.message });
|
||||
throw err;
|
||||
|
||||
@@ -3,8 +3,10 @@ const { z } = require('zod');
|
||||
const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
const { computeCharacterSheet, computeWeaponStats, canUseGear, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
const { adjustGold, giveGear } = require('../utils/grants');
|
||||
const { computeCharacterSheet, computeWeaponStats, canUseGear, abilityModifier, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
const { CURRENCIES, InsufficientGoldError, adjustGold, spendCoins, giveGear } = require('../utils/grants');
|
||||
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
|
||||
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -18,9 +20,20 @@ async function requireEncounterAccess(req, res, next) {
|
||||
const membership = await loadCampaignMembership(req.user.id, encounter.campaignId);
|
||||
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
|
||||
|
||||
// A player only gets into a scene one of their own characters is in. To
|
||||
// anyone else it doesn't exist — the name alone can be a spoiler.
|
||||
const isDM = membership.role === 'DM';
|
||||
if (!isDM) {
|
||||
const present = await prisma.encounterParticipant.findFirst({
|
||||
where: { encounterId: encounter.id, character: { userId: req.user.id } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!present) return res.status(404).json({ error: 'Encounter not found' });
|
||||
}
|
||||
|
||||
req.encounter = encounter;
|
||||
req.membership = membership;
|
||||
req.isDM = membership.role === 'DM';
|
||||
req.isDM = isDM;
|
||||
return next();
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
@@ -37,10 +50,61 @@ function clamp(value, min, max) {
|
||||
}
|
||||
|
||||
// Highest initiative first; ties keep the order combatants were added in.
|
||||
// Anyone who has not rolled yet waits at the bottom.
|
||||
function inTurnOrder(participants) {
|
||||
return [...participants].sort((a, b) => b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
|
||||
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
|
||||
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
|
||||
}
|
||||
|
||||
// What is added to a combatant's d20 for initiative: the sheet's initiative
|
||||
// bonus for a character, the Dexterity modifier for a monster, and nothing for
|
||||
// an NPC (they have no ability scores). Takes a participant with its
|
||||
// character (and proficiencies) and monster loaded.
|
||||
function initiativeBonus({ character, monster }) {
|
||||
if (character) return computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).initiative;
|
||||
if (monster) return abilityModifier(monster.dexScore);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Out of the fight: a dead character, or a monster or NPC brought to 0 HP.
|
||||
// The fallen are hidden from the players and skipped in the turn order; the
|
||||
// DM still sees them. It is worked out from HP each time, never stored, so
|
||||
// healing a monster or reviving a character brings them straight back. Takes
|
||||
// a participant with its character loaded.
|
||||
function isFallen(p) {
|
||||
if (p.character) return lifeState(p.character) === 'DEAD';
|
||||
return p.maxHp > 0 && p.currentHp <= 0;
|
||||
}
|
||||
|
||||
// The first combatant still standing at or after `from` in the order, going
|
||||
// round to the top when the end is reached. If nobody is standing, whoever is
|
||||
// at `from`.
|
||||
function standingFrom(order, from) {
|
||||
for (let step = 0; step < order.length; step += 1) {
|
||||
const p = order[(from + step) % order.length];
|
||||
if (!isFallen(p)) return p;
|
||||
}
|
||||
return order[from % order.length] ?? null;
|
||||
}
|
||||
|
||||
// An encounter's combatants in turn order, with what isFallen needs.
|
||||
async function loadTurnOrder(encounterId) {
|
||||
const participants = await prisma.encounterParticipant.findMany({
|
||||
where: { encounterId },
|
||||
include: { character: { select: { currentHp: true, deathSaveFailures: true, stable: true } } },
|
||||
});
|
||||
return inTurnOrder(participants);
|
||||
}
|
||||
|
||||
// The spell a character would raise the dead with: one flagged as reviving
|
||||
// in the campaign's spell list, at a level they can cast. Takes a character
|
||||
// with its spells loaded.
|
||||
function revivalSpell(character) {
|
||||
return character.spells.map((cs) => cs.spell).find((spell) => spell.revives && canUseGear(character.level, spell.level));
|
||||
}
|
||||
|
||||
const initiativeRollSchema = z.number().int().min(1, 'An initiative roll is a d20: 1 to 20').max(20, 'An initiative roll is a d20: 1 to 20');
|
||||
|
||||
// What every campaign member sees about a character in an encounter: the
|
||||
// sheet's headline numbers plus the weapons and spells it can actually use —
|
||||
// so the party can plan around each other mid-fight, not only the DM. Uses the
|
||||
@@ -76,6 +140,8 @@ function characterCombatInfo(character) {
|
||||
armorClass: sheet.armorClass,
|
||||
maxHp: sheet.hp.max,
|
||||
tempHp: character.tempHp,
|
||||
deathSaves: deathSaveInfo(character),
|
||||
canRevive: Boolean(revivalSpell(character)),
|
||||
speed: sheet.speed,
|
||||
initiativeBonus: sheet.initiative,
|
||||
passivePerception: sheet.passivePerception,
|
||||
@@ -89,13 +155,27 @@ function characterCombatInfo(character) {
|
||||
};
|
||||
}
|
||||
|
||||
// What is for sale, oldest first, with the weapon or item itself.
|
||||
const WARES = { include: { weapon: true, item: true }, orderBy: { createdAt: 'asc' } };
|
||||
|
||||
// The DM gets everything. Players get the scene as their characters see it:
|
||||
// no DM notes, no monster stat blocks, no NPC secrets or stats, and only the
|
||||
// rewards that have actually been handed out.
|
||||
async function serializeEncounter(encounterId, isDM) {
|
||||
// rewards that have actually been handed out. What an NPC sells and the rate
|
||||
// they change money at are for everyone: `npc.wares`, `npc.exchangeGold` and
|
||||
// `npc.exchangeCash`, and the scene's own `wares`. The fallen (isFallen) are left
|
||||
// out of a player's list altogether; of those, only the dead characters are
|
||||
// named, in `fallen`, so an ally can try to revive them.
|
||||
//
|
||||
// `viewerId` is the user the payload is for. A player is told where a scene
|
||||
// is once one of their characters has been there, and — when the campaign
|
||||
// hides monster hit points — gets a percentage for the health bar in place of
|
||||
// the numbers for anyone who is not a character.
|
||||
async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
const encounter = await prisma.encounter.findUnique({
|
||||
where: { id: encounterId },
|
||||
include: {
|
||||
campaign: { select: { showMonsterHp: true } },
|
||||
location: { select: { id: true, name: true, description: true } },
|
||||
participants: {
|
||||
include: {
|
||||
character: {
|
||||
@@ -107,24 +187,34 @@ async function serializeEncounter(encounterId, isDM) {
|
||||
},
|
||||
},
|
||||
monster: true,
|
||||
npc: true,
|
||||
npc: { include: { wares: WARES } },
|
||||
},
|
||||
},
|
||||
rewards: { include: { weapon: true, item: true }, orderBy: { createdAt: 'asc' } },
|
||||
wares: WARES,
|
||||
},
|
||||
});
|
||||
if (!encounter) return encounter;
|
||||
|
||||
const participants = inTurnOrder(encounter.participants).map((p) => {
|
||||
const hideHp = !isDM && !encounter.campaign.showMonsterHp;
|
||||
const inOrder = inTurnOrder(encounter.participants);
|
||||
const hiddenIds = new Set(inOrder.filter(isFallen).map((p) => p.id));
|
||||
const fallen = inOrder
|
||||
.filter((p) => p.character && hiddenIds.has(p.id))
|
||||
.map((p) => ({ participantId: p.id, characterId: p.character.id, name: p.displayName }));
|
||||
const participants = inOrder.filter((p) => isDM || !hiddenIds.has(p.id)).map((p) => {
|
||||
const { character, monster, npc, ...row } = p;
|
||||
const out = { ...row, kind: npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER' };
|
||||
// The bonus is part of a monster's stat block, so only the DM gets it for
|
||||
// those; a character's is on their sheet for all to see.
|
||||
const bonus = isDM || character ? { initiativeBonus: initiativeBonus(p) } : {};
|
||||
const out = { ...row, ...bonus, kind: npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER', hidden: hiddenIds.has(p.id) };
|
||||
if (character) {
|
||||
// A character combatant's HP is the character's own HP, not a
|
||||
// per-encounter copy, so the tracker and the sheet can never disagree.
|
||||
const { maxHp, ...combatInfo } = characterCombatInfo(character);
|
||||
out.currentHp = character.currentHp;
|
||||
out.maxHp = maxHp;
|
||||
out.character = { id: character.id, name: character.name };
|
||||
out.character = { id: character.id, name: character.name, userId: character.userId };
|
||||
out.characterCombatInfo = combatInfo;
|
||||
}
|
||||
if (monster) out.monster = isDM ? monster : { id: monster.id, name: monster.name };
|
||||
@@ -132,14 +222,26 @@ async function serializeEncounter(encounterId, isDM) {
|
||||
const { dmNotes, ac, maxHp, attackBonus, damageDice, campaignId, createdAt, ...publicNpc } = npc;
|
||||
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice } : publicNpc;
|
||||
}
|
||||
if (hideHp && !character) {
|
||||
// Rounded up, so anything still standing shows a sliver of bar.
|
||||
out.hpPercent = p.maxHp > 0 ? Math.ceil((clamp(p.currentHp, 0, p.maxHp) / p.maxHp) * 100) : null;
|
||||
out.currentHp = null;
|
||||
out.maxHp = null;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const { dmNotes, rewards, ...scene } = encounter;
|
||||
const { dmNotes, rewards, campaign, location, ...scene } = encounter;
|
||||
const knowsPlace = isDM || (location && (await visitedLocationIds(prisma, viewerId, encounter.campaignId)).has(location.id));
|
||||
return {
|
||||
...scene,
|
||||
locationId: knowsPlace ? scene.locationId : null,
|
||||
location: knowsPlace ? location : null,
|
||||
...(isDM ? { dmNotes } : {}),
|
||||
// A player is not told that someone they cannot see is being targeted.
|
||||
targetParticipantIds: isDM ? scene.targetParticipantIds : scene.targetParticipantIds.filter((id) => !hiddenIds.has(id)),
|
||||
participants,
|
||||
fallen,
|
||||
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
|
||||
};
|
||||
}
|
||||
@@ -154,15 +256,31 @@ function emitCharacterUpdated(req, characterId) {
|
||||
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('character:updated', { characterId });
|
||||
}
|
||||
|
||||
// While an encounter with a location is in progress, the characters in it
|
||||
// are at that location: starting it, moving it, or adding someone to it puts
|
||||
// them there, and that goes on their record of places visited.
|
||||
async function syncCharacterLocations(req) {
|
||||
const encounter = await prisma.encounter.findUnique({
|
||||
where: { id: req.encounter.id },
|
||||
include: { participants: { where: { characterId: { not: null } }, include: { character: { select: { id: true, currentLocationId: true } } } } },
|
||||
});
|
||||
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId) return;
|
||||
|
||||
const elsewhere = encounter.participants.map((p) => p.character).filter((c) => c.currentLocationId !== encounter.locationId);
|
||||
await moveCharacters(prisma, elsewhere.map((c) => c.id), encounter.locationId);
|
||||
for (const character of elsewhere) emitCharacterUpdated(req, character.id);
|
||||
}
|
||||
|
||||
async function respondWithEncounter(req, res, status = 200, extra = {}) {
|
||||
const encounter = await serializeEncounter(req.encounter.id, req.isDM);
|
||||
await syncCharacterLocations(req);
|
||||
const encounter = await serializeEncounter(req.encounter.id, req.isDM, req.user.id);
|
||||
emitEncounterUpdated(req);
|
||||
res.status(status).json({ ...extra, encounter });
|
||||
}
|
||||
|
||||
router.get('/:id', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
res.json({ encounter: await serializeEncounter(req.encounter.id, req.isDM) });
|
||||
res.json({ encounter: await serializeEncounter(req.encounter.id, req.isDM, req.user.id) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -174,6 +292,7 @@ const patchEncounterSchema = z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
description: z.string().max(4000).nullable().optional(),
|
||||
dmNotes: z.string().max(4000).nullable().optional(),
|
||||
locationId: z.string().nullable().optional(),
|
||||
archived: z.boolean().optional(), // put away (true) or bring back (false)
|
||||
});
|
||||
|
||||
@@ -185,13 +304,20 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
|
||||
if (archived === true && !req.encounter.archivedAt) data.archivedAt = new Date();
|
||||
if (archived === false) data.archivedAt = null;
|
||||
|
||||
// Starting a battle puts the first combatant in the initiative order on turn.
|
||||
if (data.locationId) {
|
||||
const location = await prisma.location.findFirst({ where: { id: data.locationId, campaignId: req.encounter.campaignId } });
|
||||
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
|
||||
}
|
||||
|
||||
// Starting a battle puts the first combatant still standing on turn.
|
||||
const mode = data.mode ?? req.encounter.mode;
|
||||
if (data.status === 'ACTIVE' && mode === 'BATTLE' && !req.encounter.activeParticipantId) {
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
|
||||
data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? null;
|
||||
data.activeParticipantId = standingFrom(await loadTurnOrder(req.encounter.id), 0)?.id ?? null;
|
||||
}
|
||||
|
||||
// Targets belong to the moment; beginning, ending or reopening clears them.
|
||||
if (data.status && data.status !== req.encounter.status) data.targetParticipantIds = [];
|
||||
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data });
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
@@ -221,7 +347,7 @@ const participantSchema = z
|
||||
monsterId: z.string().optional(),
|
||||
npcId: z.string().optional(),
|
||||
displayName: z.string().min(1).max(80).optional(),
|
||||
initiative: z.number().int().default(0),
|
||||
initiativeRoll: initiativeRollSchema.optional(),
|
||||
})
|
||||
.refine((data) => [data.characterId, data.monsterId, data.npcId].filter(Boolean).length === 1, {
|
||||
message: 'Provide exactly one of characterId, monsterId or npcId',
|
||||
@@ -231,9 +357,10 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
||||
try {
|
||||
const parsed = participantSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { characterId, monsterId, npcId, displayName, initiative } = parsed.data;
|
||||
const { characterId, monsterId, npcId, displayName, initiativeRoll } = parsed.data;
|
||||
|
||||
let currentHp, maxHp, resolvedName;
|
||||
let bonus = 0;
|
||||
|
||||
if (characterId) {
|
||||
const character = await prisma.character.findUnique({
|
||||
@@ -243,9 +370,14 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
||||
if (!character || character.campaignId !== req.encounter.campaignId) {
|
||||
return res.status(400).json({ error: 'That character is not part of this campaign.' });
|
||||
}
|
||||
// The dead stay where they fell: they join no new scene until revived.
|
||||
if (lifeState(character) === 'DEAD') {
|
||||
return res.status(400).json({ error: `${character.name} is dead and cannot join an encounter. Revive them first.` });
|
||||
}
|
||||
currentHp = character.currentHp;
|
||||
maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
|
||||
resolvedName = displayName || character.name;
|
||||
bonus = initiativeBonus({ character });
|
||||
} else if (monsterId) {
|
||||
const monster = await prisma.monster.findUnique({ where: { id: monsterId } });
|
||||
if (!monster || monster.campaignId !== req.encounter.campaignId) {
|
||||
@@ -254,6 +386,7 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
||||
currentHp = monster.maxHp;
|
||||
maxHp = monster.maxHp;
|
||||
resolvedName = displayName || monster.name;
|
||||
bonus = initiativeBonus({ monster });
|
||||
} else {
|
||||
const npc = await prisma.npc.findUnique({ where: { id: npcId } });
|
||||
if (!npc || npc.campaignId !== req.encounter.campaignId) {
|
||||
@@ -272,7 +405,8 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
||||
monsterId: monsterId || null,
|
||||
npcId: npcId || null,
|
||||
displayName: resolvedName,
|
||||
initiative,
|
||||
initiativeRoll: initiativeRoll ?? null,
|
||||
initiative: initiativeRoll == null ? 0 : initiativeRoll + bonus,
|
||||
currentHp,
|
||||
maxHp,
|
||||
isMonster: Boolean(monsterId),
|
||||
@@ -287,34 +421,53 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
||||
|
||||
const updateParticipantSchema = z.object({
|
||||
currentHp: z.number().int().optional(),
|
||||
maxHp: z.number().int().min(1).optional(),
|
||||
initiative: z.number().int().optional(),
|
||||
maxHp: z.number().int().min(1).max(100000).optional(),
|
||||
initiativeRoll: initiativeRollSchema.nullable().optional(), // null clears the roll
|
||||
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
|
||||
displayName: z.string().min(1).max(80).optional(),
|
||||
});
|
||||
|
||||
router.patch('/:id/participants/:participantId', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
// The DM runs every combatant. A player may do one thing here: enter the
|
||||
// initiative roll for their own character.
|
||||
router.patch('/:id/participants/:participantId', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = updateParticipantSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const existing = await prisma.encounterParticipant.findUnique({
|
||||
where: { id: req.params.participantId },
|
||||
include: { character: { include: { skillProficiencies: true, saveProficiencies: true } } },
|
||||
include: { character: { include: { skillProficiencies: true, saveProficiencies: true } }, monster: true },
|
||||
});
|
||||
if (!existing || existing.encounterId !== req.encounter.id) {
|
||||
return res.status(404).json({ error: 'Combatant not found in this encounter' });
|
||||
}
|
||||
if (!req.isDM) {
|
||||
const onlyTheRoll = Object.keys(parsed.data).every((key) => key === 'initiativeRoll');
|
||||
const ownCharacter = existing.character && existing.character.userId === req.user.id;
|
||||
if (!onlyTheRoll || !ownCharacter) {
|
||||
return res.status(403).json({ error: 'You can only enter the initiative roll for your own character' });
|
||||
}
|
||||
}
|
||||
|
||||
const data = { ...parsed.data };
|
||||
// The roll is what was on the die; the bonus is added here.
|
||||
if (data.initiativeRoll !== undefined) {
|
||||
data.initiative = data.initiativeRoll == null ? 0 : data.initiativeRoll + initiativeBonus(existing);
|
||||
}
|
||||
if (existing.character) {
|
||||
// Max HP comes from the sheet, and damage/healing lands on the character.
|
||||
const { character } = existing;
|
||||
const maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
|
||||
data.maxHp = maxHp;
|
||||
if (data.currentHp !== undefined) {
|
||||
data.currentHp = clamp(data.currentHp, 0, maxHp);
|
||||
await prisma.character.update({ where: { id: character.id }, data: { currentHp: data.currentHp } });
|
||||
if (lifeState(character) === 'DEAD' && data.currentHp > 0) {
|
||||
return res.status(400).json({ error: `${character.name} is dead. Revive them before healing.` });
|
||||
}
|
||||
// Temp HP soaks damage first; what is left lands on the character,
|
||||
// and at 0 HP it moves their death saves too.
|
||||
const after = changeHp(character, data.currentHp, maxHp);
|
||||
data.currentHp = after.currentHp;
|
||||
await prisma.character.update({ where: { id: character.id }, data: after });
|
||||
emitCharacterUpdated(req, character.id);
|
||||
}
|
||||
} else if (data.currentHp !== undefined) {
|
||||
@@ -332,15 +485,20 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, require
|
||||
|
||||
router.delete('/:id/participants/:participantId', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
|
||||
const order = inTurnOrder(participants);
|
||||
const order = await loadTurnOrder(req.encounter.id);
|
||||
const index = order.findIndex((p) => p.id === req.params.participantId);
|
||||
if (index === -1) return res.status(404).json({ error: 'Combatant not found in this encounter' });
|
||||
|
||||
// Removing whoever is on turn passes the turn to the next combatant.
|
||||
if (req.encounter.activeParticipantId === req.params.participantId) {
|
||||
const next = order.length > 1 ? order[(index + 1) % order.length] : null;
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { activeParticipantId: next ? next.id : null } });
|
||||
// Removing whoever is on turn passes the turn to the next combatant still
|
||||
// standing, and their targets go with the turn. Removing a target just
|
||||
// unmarks it.
|
||||
const { activeParticipantId, targetParticipantIds } = req.encounter;
|
||||
if (activeParticipantId === req.params.participantId) {
|
||||
const next = standingFrom([...order.slice(index + 1), ...order.slice(0, index)], 0);
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { activeParticipantId: next ? next.id : null, targetParticipantIds: [] } });
|
||||
} else if (targetParticipantIds.includes(req.params.participantId)) {
|
||||
const left = targetParticipantIds.filter((id) => id !== req.params.participantId);
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantIds: left } });
|
||||
}
|
||||
await prisma.encounterParticipant.delete({ where: { id: req.params.participantId } });
|
||||
await respondWithEncounter(req, res);
|
||||
@@ -349,25 +507,118 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir
|
||||
}
|
||||
});
|
||||
|
||||
// A dead character can be brought back in the middle of a battle by an ally:
|
||||
// the character on turn, if they are on their feet and can cast a spell that
|
||||
// raises the dead (revivalSpell). The dead come back at 1 HP, and with that
|
||||
// are back in everyone's list. A player tries it for their own character; the
|
||||
// DM for whoever is on turn. (The DM can also revive anyone outright, through
|
||||
// the character's death saves.)
|
||||
router.post('/:id/participants/:participantId/revive', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const { mode, status, activeParticipantId } = req.encounter;
|
||||
const onTurnId = mode === 'BATTLE' && status === 'ACTIVE' ? activeParticipantId : null;
|
||||
const onTurn = onTurnId
|
||||
? await prisma.encounterParticipant.findFirst({
|
||||
where: { id: onTurnId, encounterId: req.encounter.id },
|
||||
include: { character: { include: { spells: { include: { spell: true } } } } },
|
||||
})
|
||||
: null;
|
||||
const reviver = onTurn?.character;
|
||||
if (!req.isDM && (!reviver || reviver.userId !== req.user.id)) {
|
||||
return res.status(403).json({ error: "You can only try to revive someone on your own character's turn" });
|
||||
}
|
||||
if (!reviver) return res.status(400).json({ error: 'Only a character on turn in a battle can try to revive someone.' });
|
||||
|
||||
const target = await prisma.encounterParticipant.findUnique({ where: { id: req.params.participantId }, include: { character: true } });
|
||||
if (!target || target.encounterId !== req.encounter.id) {
|
||||
return res.status(404).json({ error: 'Combatant not found in this encounter' });
|
||||
}
|
||||
if (!target.character || lifeState(target.character) !== 'DEAD') {
|
||||
return res.status(400).json({ error: `${target.displayName} is not dead.` });
|
||||
}
|
||||
if (lifeState(reviver) !== 'UP') return res.status(400).json({ error: `${reviver.name} is in no state to revive anyone.` });
|
||||
|
||||
const spell = revivalSpell(reviver);
|
||||
if (!spell) return res.status(400).json({ error: `${reviver.name} has no spell that can bring back the dead.` });
|
||||
|
||||
await prisma.character.update({ where: { id: target.character.id }, data: { currentHp: 1, ...NO_DEATH_SAVES } });
|
||||
await prisma.encounterParticipant.update({ where: { id: target.id }, data: { currentHp: 1 } });
|
||||
emitCharacterUpdated(req, target.character.id);
|
||||
await respondWithEncounter(req, res, 200, { revivedWith: spell.name });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Targets — who the combatant on turn is going for
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The whole list, in the order picked; an empty one clears it.
|
||||
const targetsSchema = z.object({ participantIds: z.array(z.string()).max(50) });
|
||||
|
||||
// Combatants can be marked as targets, for everyone to see; the DM's screen
|
||||
// then offers to apply damage to them. One is an ordinary attack, more is an
|
||||
// area attack. The DM can mark anyone at any time. A player can while their
|
||||
// own character is on turn in a battle. Nobody can mark the combatant who is
|
||||
// on turn: you don't target yourself.
|
||||
router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = targetsSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const participantIds = [...new Set(parsed.data.participantIds)];
|
||||
|
||||
// Whoever is on turn, when a battle is under way.
|
||||
const { mode, status, activeParticipantId } = req.encounter;
|
||||
const onTurnId = mode === 'BATTLE' && status === 'ACTIVE' ? activeParticipantId : null;
|
||||
|
||||
if (!req.isDM) {
|
||||
const ownTurn = onTurnId
|
||||
? await prisma.encounterParticipant.findFirst({
|
||||
where: { id: onTurnId, encounterId: req.encounter.id, character: { userId: req.user.id } },
|
||||
select: { id: true },
|
||||
})
|
||||
: null;
|
||||
if (!ownTurn) return res.status(403).json({ error: "You can only pick targets on your own character's turn" });
|
||||
}
|
||||
|
||||
if (onTurnId && participantIds.includes(onTurnId)) {
|
||||
return res.status(400).json({ error: 'The combatant on turn cannot be their own target' });
|
||||
}
|
||||
|
||||
// A player can only mark who they can see: to them the fallen are not there.
|
||||
const order = await loadTurnOrder(req.encounter.id);
|
||||
const markable = new Set(order.filter((p) => req.isDM || !isFallen(p)).map((p) => p.id));
|
||||
if (!participantIds.every((id) => markable.has(id))) return res.status(400).json({ error: 'Combatant not found in this encounter' });
|
||||
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantIds: participantIds } });
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Turns and rounds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Passes the turn down the initiative order; going past the last combatant
|
||||
// starts the next round.
|
||||
// Passes the turn down the initiative order to the next combatant still
|
||||
// standing (the fallen have no turn); going past the last combatant starts
|
||||
// the next round.
|
||||
router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
|
||||
const order = inTurnOrder(participants);
|
||||
const order = await loadTurnOrder(req.encounter.id);
|
||||
if (order.length === 0) return res.status(400).json({ error: 'Add combatants before taking turns.' });
|
||||
|
||||
const index = order.findIndex((p) => p.id === req.encounter.activeParticipantId);
|
||||
const wrapped = index === order.length - 1;
|
||||
const upNext = standingFrom(order, index + 1);
|
||||
const wrapped = index !== -1 && order.indexOf(upNext) <= index;
|
||||
await prisma.encounter.update({
|
||||
where: { id: req.encounter.id },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id,
|
||||
activeParticipantId: upNext.id,
|
||||
targetParticipantIds: [], // the next combatant picks their own
|
||||
...(wrapped ? { currentRound: { increment: 1 } } : {}),
|
||||
},
|
||||
});
|
||||
@@ -379,21 +630,23 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
|
||||
|
||||
router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
|
||||
const order = await loadTurnOrder(req.encounter.id);
|
||||
const updated = await prisma.encounter.update({
|
||||
where: { id: req.encounter.id },
|
||||
data: {
|
||||
currentRound: { increment: 1 },
|
||||
status: 'ACTIVE',
|
||||
targetParticipantIds: [],
|
||||
// A new round starts back at the top of the initiative order.
|
||||
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
|
||||
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: standingFrom(order, 0)?.id ?? null } : {}),
|
||||
},
|
||||
});
|
||||
await syncCharacterLocations(req);
|
||||
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
|
||||
encounterId: updated.id,
|
||||
currentRound: updated.currentRound,
|
||||
});
|
||||
res.json({ encounter: await serializeEncounter(updated.id, req.isDM) });
|
||||
res.json({ encounter: await serializeEncounter(updated.id, req.isDM, req.user.id) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -404,18 +657,18 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rewardSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('GOLD'), gold: z.number().int().min(1), note: z.string().trim().max(200).optional().nullable() }),
|
||||
z.object({ type: z.literal('GOLD'), gold: z.number().int().min(1).max(1000000), currency: z.enum(CURRENCIES).default('GOLD'), note: z.string().trim().max(200).optional().nullable() }),
|
||||
z.object({
|
||||
type: z.literal('WEAPON'),
|
||||
weaponId: z.string(),
|
||||
quantity: z.number().int().min(1).default(1),
|
||||
quantity: z.number().int().min(1).max(999).default(1),
|
||||
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||
note: z.string().trim().max(200).optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('ITEM'),
|
||||
itemId: z.string(),
|
||||
quantity: z.number().int().min(1).default(1),
|
||||
quantity: z.number().int().min(1).max(999).default(1),
|
||||
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||
note: z.string().trim().max(200).optional().nullable(),
|
||||
}),
|
||||
@@ -481,7 +734,7 @@ router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, a
|
||||
|
||||
let recipients;
|
||||
if (parsed.data.split) {
|
||||
if (reward.type !== 'GOLD') return res.status(400).json({ error: 'Only gold can be split.' });
|
||||
if (reward.type !== 'GOLD') return res.status(400).json({ error: 'Only coins can be split.' });
|
||||
const participants = await prisma.encounterParticipant.findMany({
|
||||
where: { encounterId: req.encounter.id, characterId: { not: null } },
|
||||
include: { character: true },
|
||||
@@ -510,7 +763,7 @@ router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, a
|
||||
const remainder = reward.gold % recipients.length;
|
||||
for (const [i, character] of recipients.entries()) {
|
||||
const amount = share + (i < remainder ? 1 : 0);
|
||||
if (amount > 0) await adjustGold(tx, { characterId: character.id, amount, reason, userId: req.user.id });
|
||||
if (amount > 0) await adjustGold(tx, { characterId: character.id, amount, currency: reward.currency, reason, userId: req.user.id });
|
||||
}
|
||||
} else {
|
||||
const kind = reward.type === 'WEAPON' ? 'weapon' : 'item';
|
||||
@@ -530,4 +783,146 @@ router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, a
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trade — changing gold and cash with an NPC, and buying what is for sale.
|
||||
// The DM sets the rates and the wares up (campaigns.routes.js); here the
|
||||
// players, or the DM for them, use them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class TradeError extends Error {
|
||||
constructor(message, status = 400) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function money(amount, currency) {
|
||||
return currency === 'CASH' ? `$${amount}` : `${amount} ${currency.toLowerCase()}`;
|
||||
}
|
||||
|
||||
// Trading takes someone to trade with and the time to do it, so it happens
|
||||
// in a quest in progress and never in a battle.
|
||||
function assertTradeOpen(encounter) {
|
||||
if (encounter.mode !== 'QUEST') throw new TradeError('Trading happens in a quest, not in a battle.');
|
||||
if (encounter.status !== 'ACTIVE') throw new TradeError('Trading opens once the quest is in progress.');
|
||||
}
|
||||
|
||||
// The character doing the trading: in this scene, alive, and the player's
|
||||
// own (the DM can trade for anyone).
|
||||
async function loadTrader(req, characterId) {
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, characterId }, include: { character: true } });
|
||||
if (!row) throw new TradeError('That character is not in this encounter.');
|
||||
if (!req.isDM && row.character.userId !== req.user.id) throw new TradeError('You can only trade for your own character.', 403);
|
||||
if (isFallen(row)) throw new TradeError(`${row.character.name} is dead.`);
|
||||
return row.character;
|
||||
}
|
||||
|
||||
// An NPC to trade with has to be in the scene and still standing.
|
||||
async function loadTradingNpc(req, npcId) {
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, npcId }, include: { npc: true } });
|
||||
if (!row || isFallen(row)) throw new TradeError('That NPC is not here to trade with.');
|
||||
return row.npc;
|
||||
}
|
||||
|
||||
function tradeRoute(handler) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
assertTradeOpen(req.encounter);
|
||||
await handler(req, res);
|
||||
} catch (err) {
|
||||
if (err instanceof TradeError) return res.status(err.status).json({ error: err.message });
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Changes gold into cash, or cash into gold, at the NPC's own rate. Only
|
||||
// gold counts: silver and copper are not taken, and none come back. `amount`
|
||||
// is what is handed over, and has to come to a whole number the other way.
|
||||
router.post(
|
||||
'/:id/exchange',
|
||||
requireEncounterAccess,
|
||||
tradeRoute(async (req, res) => {
|
||||
const parsed = z
|
||||
.object({ characterId: z.string(), npcId: z.string(), from: z.enum(['GOLD', 'CASH']), amount: z.number().int().min(1).max(1000000) })
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { characterId, npcId, from, amount } = parsed.data;
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
|
||||
const character = await loadTrader(req, characterId);
|
||||
const npc = await loadTradingNpc(req, npcId);
|
||||
if (!npc.exchangeGold || !npc.exchangeCash) throw new TradeError(`${npc.name} does not change money.`);
|
||||
const rate = { GOLD: npc.exchangeGold, CASH: npc.exchangeCash };
|
||||
if (amount % rate[from] !== 0) {
|
||||
throw new TradeError(
|
||||
`${npc.name} gives ${money(rate.CASH, 'CASH')} for ${money(rate.GOLD, 'GOLD')}: hand over ${from.toLowerCase()} in multiples of ${rate[from]}.`
|
||||
);
|
||||
}
|
||||
const received = (amount / rate[from]) * rate[to];
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const base = { characterId: character.id, userId: req.user.id };
|
||||
await adjustGold(tx, { ...base, currency: from, amount: -amount, reason: `Changed with ${npc.name} for ${money(received, to)}` });
|
||||
await adjustGold(tx, { ...base, currency: to, amount: received, reason: `Changed with ${npc.name} from ${money(amount, from)}` });
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof InsufficientGoldError)) throw err;
|
||||
throw new TradeError(`${character.name} only has ${money(from === 'GOLD' ? character.gold : character.cash, from)}.`);
|
||||
}
|
||||
|
||||
emitCharacterUpdated(req, character.id);
|
||||
await respondWithEncounter(req, res, 201, { received, currency: to });
|
||||
})
|
||||
);
|
||||
|
||||
// Buys something that is for sale here: from an NPC in the scene, or from the
|
||||
// scene itself. The price is paid from the purse (coins make change; a cash
|
||||
// price takes cash), and the weapon or item joins what the character holds.
|
||||
router.post(
|
||||
'/:id/wares/:wareId/buy',
|
||||
requireEncounterAccess,
|
||||
tradeRoute(async (req, res) => {
|
||||
const parsed = z.object({ characterId: z.string(), quantity: z.number().int().min(1).max(999).default(1) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { characterId, quantity } = parsed.data;
|
||||
|
||||
const character = await loadTrader(req, characterId);
|
||||
const ware = await prisma.saleOffer.findUnique({ where: { id: req.params.wareId }, include: { weapon: true, item: true } });
|
||||
if (!ware || ware.campaignId !== req.encounter.campaignId || (ware.encounterId && ware.encounterId !== req.encounter.id)) {
|
||||
throw new TradeError('That is not for sale here.', 404);
|
||||
}
|
||||
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId)).name : null;
|
||||
const kind = ware.weapon ? 'weapon' : 'item';
|
||||
const entry = ware[kind];
|
||||
const total = ware.price * quantity;
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (ware.stock !== null) {
|
||||
// The atomic decrement is the stock check: two buyers can't both take the last one.
|
||||
const taken = await tx.saleOffer.updateMany({ where: { id: ware.id, stock: { gte: quantity } }, data: { stock: { decrement: quantity } } });
|
||||
if (taken.count === 0) throw new TradeError(ware.stock === 0 ? `${entry.name} is sold out.` : `Only ${ware.stock} of ${entry.name} left.`);
|
||||
}
|
||||
const what = `${quantity > 1 ? `${quantity} × ` : ''}${entry.name}`;
|
||||
await spendCoins(tx, {
|
||||
characterId: character.id,
|
||||
amount: total,
|
||||
currency: ware.currency,
|
||||
reason: seller ? `Bought ${what} from ${seller}` : `Bought ${what}`,
|
||||
userId: req.user.id,
|
||||
});
|
||||
await giveGear(tx, kind, { characterId: character.id, entry, quantity, level: ware.level });
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof InsufficientGoldError)) throw err;
|
||||
throw new TradeError(`${character.name} can't afford that: it costs ${money(total, ware.currency)}.`);
|
||||
}
|
||||
|
||||
emitCharacterUpdated(req, character.id);
|
||||
await respondWithEncounter(req, res, 201);
|
||||
})
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
|
||||
// Clients join a room per campaign (`campaign:<id>`) after authenticating with
|
||||
// the same JWT issued by /auth/login. Server-side route handlers emit into
|
||||
// these rooms (see character:updated, encounter:*-updated, character:created).
|
||||
// Events only ever carry ids: each client refetches its own view, since what
|
||||
// the DM, an owner and another player may see of the same thing differs.
|
||||
function attachSockets(io) {
|
||||
io.use((socket, next) => {
|
||||
const token = socket.handshake.auth && socket.handshake.auth.token;
|
||||
@@ -17,8 +20,11 @@ function attachSockets(io) {
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('campaign:join', (campaignId) => {
|
||||
if (typeof campaignId === 'string') socket.join(`campaign:${campaignId}`);
|
||||
socket.on('campaign:join', async (campaignId) => {
|
||||
if (typeof campaignId !== 'string') return;
|
||||
// Members only: a room's events are not for anyone who knows the id.
|
||||
const membership = await loadCampaignMembership(socket.user.id, campaignId).catch(() => null);
|
||||
if (membership) socket.join(`campaign:${campaignId}`);
|
||||
});
|
||||
socket.on('campaign:leave', (campaignId) => {
|
||||
if (typeof campaignId === 'string') socket.leave(`campaign:${campaignId}`);
|
||||
|
||||
+105
-9
@@ -1,24 +1,120 @@
|
||||
// The two ways a character's belongings change: gold through the ledger, and
|
||||
// The two ways a character's belongings change: coins through the ledger, and
|
||||
// gear from the campaign pools. Shared by the character sheet routes and by
|
||||
// encounter rewards so both follow the same rules. Every function takes a
|
||||
// Prisma client or transaction client as `db`.
|
||||
|
||||
// The coins, and what each is worth counted in copper: silver is 0.1 gold and
|
||||
// copper is 0.01 gold, so 1 gold = 10 silver = 100 copper and 1 silver = 10
|
||||
// copper. Whole numbers keep exchanges exact.
|
||||
const COINS = ['GOLD', 'SILVER', 'COPPER'];
|
||||
const COIN_VALUE = { GOLD: 100, SILVER: 10, COPPER: 1 };
|
||||
// Everything a purse can hold: the coins, plus cash ($) — modern money for
|
||||
// crossover worlds. Cash has no worth in coins, so it is never exchanged for
|
||||
// them and never used to make change.
|
||||
const CURRENCIES = [...COINS, 'CASH'];
|
||||
// Each currency is its own column on the character.
|
||||
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' };
|
||||
|
||||
class InsufficientGoldError extends Error {}
|
||||
|
||||
// amount is signed: positive = gained, negative = spent. Writes the ledger row
|
||||
// and returns it; throws InsufficientGoldError if the balance would go below 0
|
||||
// (inside a transaction, that rolls the whole thing back).
|
||||
async function adjustGold(db, { characterId, amount, reason, userId, include }) {
|
||||
// amount is signed: positive = gained, negative = spent, in `currency` coins.
|
||||
// Writes the ledger row and returns it; throws InsufficientGoldError if that
|
||||
// coin's balance would go below 0 (inside a transaction, that rolls the whole
|
||||
// thing back). This moves exactly the coins named and nothing else; to pay a
|
||||
// price out of whatever coins the purse holds, use spendCoins.
|
||||
async function adjustGold(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
|
||||
const column = COIN_COLUMN[currency];
|
||||
// The atomic increment is the balance check: two spends racing each other
|
||||
// can't both pass a stale read.
|
||||
const updated = await db.character.update({ where: { id: characterId }, data: { gold: { increment: amount } } });
|
||||
if (updated.gold < 0) throw new InsufficientGoldError();
|
||||
const updated = await db.character.update({ where: { id: characterId }, data: { [column]: { increment: amount } } });
|
||||
if (updated[column] < 0) throw new InsufficientGoldError();
|
||||
return db.goldTransaction.create({
|
||||
data: { characterId, amount, balanceAfter: updated.gold, reason: reason || null, createdById: userId },
|
||||
data: { characterId, currency, amount, balanceAfter: updated[column], reason: reason || null, createdById: userId },
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
// Works out which coins leave (and come back as change) when a purse pays
|
||||
// `amount` coins of `currency`. Returns the change to each balance, e.g.
|
||||
// { GOLD: -1, SILVER: 7, COPPER: 0 } for 3 silver paid from a purse with only
|
||||
// gold in it — or null if the whole purse isn't worth that much.
|
||||
// 1. Coins of the kind asked for go first.
|
||||
// 2. Any shortfall is met by breaking the nearest bigger coin; the change
|
||||
// comes back in as few coins as possible.
|
||||
// 3. Only if there are no bigger coins left are smaller ones counted out.
|
||||
function planPayment(purse, currency, amount) {
|
||||
const worth = (coin) => COIN_VALUE[coin];
|
||||
const delta = { GOLD: 0, SILVER: 0, COPPER: 0 };
|
||||
const left = (coin) => purse[coin] + delta[coin];
|
||||
let owed = amount * worth(currency);
|
||||
if (COINS.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
|
||||
|
||||
const pay = (coin, count) => {
|
||||
delta[coin] -= count;
|
||||
owed -= count * worth(coin);
|
||||
};
|
||||
pay(currency, Math.min(purse[currency], amount));
|
||||
|
||||
const bigger = COINS.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
|
||||
for (const coin of bigger) {
|
||||
if (owed <= 0) break;
|
||||
pay(coin, Math.min(left(coin), Math.ceil(owed / worth(coin))));
|
||||
// Overpaid: hand the difference back, biggest coins first.
|
||||
let change = -owed;
|
||||
for (const small of COINS.filter((c) => worth(c) < worth(coin))) {
|
||||
if (change <= 0) break;
|
||||
const count = Math.floor(change / worth(small));
|
||||
delta[small] += count;
|
||||
change -= count * worth(small);
|
||||
}
|
||||
if (owed < 0) owed = 0;
|
||||
}
|
||||
|
||||
for (const coin of COINS.filter((c) => worth(c) < worth(currency))) {
|
||||
if (owed <= 0) break;
|
||||
pay(coin, Math.min(left(coin), Math.floor(owed / worth(coin))));
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
// Pays `amount` coins of `currency` out of a character's purse, making change
|
||||
// as needed (see planPayment). Every coin that moved gets its own ledger row;
|
||||
// the rows are returned, the one in `currency` first when there is one. Throws
|
||||
// InsufficientGoldError if the purse as a whole can't cover it. Cash is paid in
|
||||
// cash alone: coins don't cover it, and it doesn't cover coins.
|
||||
async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
|
||||
if (!COINS.includes(currency)) return [await adjustGold(db, { characterId, amount: -amount, currency, reason, userId, include })];
|
||||
const character = await db.character.findUnique({ where: { id: characterId }, select: { gold: true, silver: true, copper: true } });
|
||||
const purse = { GOLD: character.gold, SILVER: character.silver, COPPER: character.copper };
|
||||
const delta = planPayment(purse, currency, amount);
|
||||
if (!delta) throw new InsufficientGoldError();
|
||||
|
||||
const moved = COINS.filter((coin) => delta[coin] !== 0).sort((a, b) => (b === currency) - (a === currency));
|
||||
const madeChange = moved.some((coin) => coin !== currency);
|
||||
const label = reason || 'Spent';
|
||||
const rows = [];
|
||||
for (const coin of moved) {
|
||||
rows.push(
|
||||
await adjustGold(db, {
|
||||
characterId,
|
||||
amount: delta[coin],
|
||||
currency: coin,
|
||||
// Says what the payment was when the coins that moved aren't the ones asked for.
|
||||
reason: madeChange ? `${label} (paid ${amount} ${currency.toLowerCase()}, with change)` : reason,
|
||||
userId,
|
||||
include,
|
||||
})
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// A purse's coins as one figure in gold, for messages: 265 copper-worth -> 2.65.
|
||||
function worthInGold(character) {
|
||||
const copper = COINS.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
|
||||
return copper / COIN_VALUE.GOLD;
|
||||
}
|
||||
|
||||
const HELD = {
|
||||
weapon: { model: 'characterWeapon', key: 'weaponId' },
|
||||
item: { model: 'inventoryItem', key: 'itemId' },
|
||||
@@ -37,4 +133,4 @@ async function giveGear(db, kind, { characterId, entry, quantity = 1, level, equ
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { InsufficientGoldError, adjustGold, giveGear };
|
||||
module.exports = { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Temporary hit points are a buffer: damage comes off them first, and only
|
||||
// what is left over reaches real HP. Takes the character's HP and temp HP as
|
||||
// they are and the HP a damage/heal asked for (which may be below zero), and
|
||||
// returns what both should become. Healing never touches temp HP.
|
||||
function absorbDamage(currentHp, tempHp, requestedHp) {
|
||||
const damage = currentHp - requestedHp;
|
||||
if (damage <= 0 || tempHp <= 0) return { currentHp: requestedHp, tempHp };
|
||||
const absorbed = Math.min(tempHp, damage);
|
||||
return { currentHp: requestedHp + absorbed, tempHp: tempHp - absorbed };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// At 0 HP — death saves
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A character at 0 HP is dying: they roll a d20 each turn, and three successes
|
||||
// or three failures settle it. The tallies and the "stable" flag live on the
|
||||
// Character row, next to the HP they belong to.
|
||||
|
||||
// Three successes steady a character; three failures kill them.
|
||||
const DEATH_SAVE_LIMIT = 3;
|
||||
|
||||
const NO_DEATH_SAVES = { deathSaveSuccesses: 0, deathSaveFailures: 0, stable: false };
|
||||
|
||||
// UP: on their feet. DYING: at 0 HP and rolling death saves. STABLE: at 0 HP
|
||||
// but no longer rolling. DEAD: three failures, or one blow too big to survive.
|
||||
function lifeState(character) {
|
||||
if (character.deathSaveFailures >= DEATH_SAVE_LIMIT) return 'DEAD';
|
||||
if (character.currentHp > 0) return 'UP';
|
||||
return character.stable ? 'STABLE' : 'DYING';
|
||||
}
|
||||
|
||||
// What the API sends about it, the same shape wherever a character's HP goes.
|
||||
function deathSaveInfo(character) {
|
||||
return { state: lifeState(character), successes: character.deathSaveSuccesses, failures: character.deathSaveFailures };
|
||||
}
|
||||
|
||||
// Turns a pair of tallies into what gets stored: the third failure is death,
|
||||
// and the third success makes the character stable, which clears the tallies.
|
||||
function settleDeathSaves(successes, failures) {
|
||||
if (failures >= DEATH_SAVE_LIMIT) return { deathSaveSuccesses: successes, deathSaveFailures: DEATH_SAVE_LIMIT, stable: false };
|
||||
if (successes >= DEATH_SAVE_LIMIT) return { ...NO_DEATH_SAVES, stable: true };
|
||||
return { deathSaveSuccesses: successes, deathSaveFailures: failures, stable: false };
|
||||
}
|
||||
|
||||
// Damage or healing on a finished character. `requestedHp` is the HP that was
|
||||
// asked for, which may be below zero or above max. Returns every column that
|
||||
// should be written: HP and temp HP, plus the death-save columns when the
|
||||
// change touches them.
|
||||
//
|
||||
// - Damage comes off temp HP first.
|
||||
// - Dropping to 0 starts the death saves from a clean slate.
|
||||
// - Damage taken while already at 0 is one failed save, and ends "stable".
|
||||
// - Damage that would leave as much as max HP still to take once HP is at 0
|
||||
// kills outright.
|
||||
// - Any healing from 0 puts the character back up and clears the tallies.
|
||||
function changeHp(character, requestedHp, maxHp) {
|
||||
const soaked = absorbDamage(character.currentHp, character.tempHp, requestedHp);
|
||||
const after = { currentHp: Math.min(Math.max(soaked.currentHp, 0), maxHp), tempHp: soaked.tempHp };
|
||||
if (after.currentHp > 0) return { ...after, ...NO_DEATH_SAVES };
|
||||
|
||||
const damage = character.currentHp - soaked.currentHp; // what got past temp HP
|
||||
if (damage <= 0) return after;
|
||||
if (-soaked.currentHp >= maxHp) return { ...after, ...settleDeathSaves(0, DEATH_SAVE_LIMIT) };
|
||||
if (character.currentHp > 0) return { ...after, ...NO_DEATH_SAVES };
|
||||
return { ...after, ...settleDeathSaves(character.deathSaveSuccesses, character.deathSaveFailures + 1) };
|
||||
}
|
||||
|
||||
// A dying character's death save, from what the d20 showed: 10 or more is a
|
||||
// success, less is a failure, a 1 is two failures, and a 20 puts them back on
|
||||
// their feet with 1 HP.
|
||||
function rollDeathSave(character, roll) {
|
||||
if (roll === 20) return { currentHp: 1, ...NO_DEATH_SAVES };
|
||||
if (roll >= 10) return settleDeathSaves(character.deathSaveSuccesses + 1, character.deathSaveFailures);
|
||||
return settleDeathSaves(character.deathSaveSuccesses, character.deathSaveFailures + (roll === 1 ? 2 : 1));
|
||||
}
|
||||
|
||||
module.exports = { absorbDamage, DEATH_SAVE_LIMIT, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave };
|
||||
@@ -0,0 +1,84 @@
|
||||
const { lifeState, settleDeathSaves, changeHp, rollDeathSave } = require('./hp');
|
||||
|
||||
const CLEAR = { deathSaveSuccesses: 0, deathSaveFailures: 0, stable: false };
|
||||
const hero = (over = {}) => ({ currentHp: 10, tempHp: 0, ...CLEAR, ...over });
|
||||
|
||||
describe('lifeState', () => {
|
||||
test('reads up, dying, stable and dead off the row', () => {
|
||||
expect(lifeState(hero())).toBe('UP');
|
||||
expect(lifeState(hero({ currentHp: 0 }))).toBe('DYING');
|
||||
expect(lifeState(hero({ currentHp: 0, stable: true }))).toBe('STABLE');
|
||||
expect(lifeState(hero({ currentHp: 0, deathSaveFailures: 3 }))).toBe('DEAD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeHp', () => {
|
||||
test('ordinary damage and healing leave the death saves clear', () => {
|
||||
expect(changeHp(hero(), 4, 20)).toEqual({ currentHp: 4, tempHp: 0, ...CLEAR });
|
||||
expect(changeHp(hero(), 99, 20)).toEqual({ currentHp: 20, tempHp: 0, ...CLEAR });
|
||||
});
|
||||
|
||||
test('dropping to 0 starts the saves from nothing', () => {
|
||||
expect(changeHp(hero(), -5, 20)).toEqual({ currentHp: 0, tempHp: 0, ...CLEAR });
|
||||
});
|
||||
|
||||
test('damage at 0 HP is a failed save and ends stable', () => {
|
||||
const down = hero({ currentHp: 0, deathSaveSuccesses: 2, deathSaveFailures: 1 });
|
||||
expect(changeHp(down, -3, 20)).toMatchObject({ currentHp: 0, deathSaveSuccesses: 2, deathSaveFailures: 2, stable: false });
|
||||
expect(changeHp(hero({ currentHp: 0, stable: true }), -3, 20)).toMatchObject({ deathSaveFailures: 1, stable: false });
|
||||
expect(lifeState(changeHp(hero({ currentHp: 0, deathSaveFailures: 2 }), -1, 20))).toBe('DEAD');
|
||||
});
|
||||
|
||||
test('temp HP that soaks the whole blow costs no save', () => {
|
||||
const down = hero({ currentHp: 0, tempHp: 5, deathSaveFailures: 1 });
|
||||
expect(changeHp(down, -5, 20)).toEqual({ currentHp: 0, tempHp: 0 });
|
||||
});
|
||||
|
||||
test('asking for the HP they already have at 0 changes nothing', () => {
|
||||
expect(changeHp(hero({ currentHp: 0, deathSaveFailures: 2 }), 0, 20)).toEqual({ currentHp: 0, tempHp: 0 });
|
||||
});
|
||||
|
||||
test('damage of max HP or more past zero kills outright', () => {
|
||||
expect(lifeState(changeHp(hero(), -19, 20))).toBe('DYING');
|
||||
expect(lifeState(changeHp(hero(), -20, 20))).toBe('DEAD');
|
||||
expect(lifeState(changeHp(hero({ currentHp: 0 }), -20, 20))).toBe('DEAD');
|
||||
// Temp HP counts against the blow first.
|
||||
expect(lifeState(changeHp(hero({ tempHp: 1 }), -20, 20))).toBe('DYING');
|
||||
});
|
||||
|
||||
test('healing from 0 puts them back up with a clean slate', () => {
|
||||
const down = hero({ currentHp: 0, deathSaveSuccesses: 1, deathSaveFailures: 2 });
|
||||
expect(changeHp(down, 1, 20)).toEqual({ currentHp: 1, tempHp: 0, ...CLEAR });
|
||||
expect(changeHp(hero({ currentHp: 0, stable: true }), 6, 20)).toEqual({ currentHp: 6, tempHp: 0, ...CLEAR });
|
||||
});
|
||||
});
|
||||
|
||||
describe('rollDeathSave', () => {
|
||||
const down = (successes, failures) => hero({ currentHp: 0, deathSaveSuccesses: successes, deathSaveFailures: failures });
|
||||
|
||||
test('10 or more succeeds, less fails, a 1 fails twice', () => {
|
||||
expect(rollDeathSave(down(0, 0), 10)).toEqual({ deathSaveSuccesses: 1, deathSaveFailures: 0, stable: false });
|
||||
expect(rollDeathSave(down(0, 0), 19)).toEqual({ deathSaveSuccesses: 1, deathSaveFailures: 0, stable: false });
|
||||
expect(rollDeathSave(down(0, 0), 9)).toEqual({ deathSaveSuccesses: 0, deathSaveFailures: 1, stable: false });
|
||||
expect(rollDeathSave(down(0, 0), 1)).toEqual({ deathSaveSuccesses: 0, deathSaveFailures: 2, stable: false });
|
||||
});
|
||||
|
||||
test('the third success makes them stable and clears the tallies', () => {
|
||||
expect(rollDeathSave(down(2, 2), 14)).toEqual({ ...CLEAR, stable: true });
|
||||
});
|
||||
|
||||
test('the third failure is death, and never counts past three', () => {
|
||||
expect(rollDeathSave(down(2, 2), 5)).toMatchObject({ deathSaveFailures: 3 });
|
||||
expect(rollDeathSave(down(0, 2), 1)).toMatchObject({ deathSaveFailures: 3 });
|
||||
});
|
||||
|
||||
test('a 20 is 1 HP and a clean slate', () => {
|
||||
expect(rollDeathSave(down(0, 2), 20)).toEqual({ currentHp: 1, ...CLEAR });
|
||||
});
|
||||
});
|
||||
|
||||
describe('settleDeathSaves', () => {
|
||||
test('failures win a tie at three', () => {
|
||||
expect(settleDeathSaves(3, 3)).toMatchObject({ deathSaveFailures: 3, stable: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Where characters are, and where they have been. A visit is recorded every
|
||||
// time a character is put somewhere and is never removed by moving on: the
|
||||
// set of visited locations is what a player is allowed to see. Every function
|
||||
// takes a Prisma client or transaction client as `db`.
|
||||
|
||||
async function recordVisits(db, characterIds, locationId) {
|
||||
if (characterIds.length === 0) return;
|
||||
await db.characterLocationVisit.createMany({
|
||||
data: characterIds.map((characterId) => ({ characterId, locationId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function moveCharacters(db, characterIds, locationId) {
|
||||
if (characterIds.length === 0) return;
|
||||
await db.character.updateMany({ where: { id: { in: characterIds } }, data: { currentLocationId: locationId } });
|
||||
await recordVisits(db, characterIds, locationId);
|
||||
}
|
||||
|
||||
// Ids of the locations any of this user's characters in the campaign has been to.
|
||||
async function visitedLocationIds(db, userId, campaignId) {
|
||||
const visits = await db.characterLocationVisit.findMany({
|
||||
where: { character: { userId, campaignId } },
|
||||
select: { locationId: true },
|
||||
distinct: ['locationId'],
|
||||
});
|
||||
return new Set(visits.map((v) => v.locationId));
|
||||
}
|
||||
|
||||
module.exports = { recordVisits, moveCharacters, visitedLocationIds };
|
||||
+96
-14
@@ -325,6 +325,50 @@ Example: a Fighter (d10) with CON 14 (+2) has 12 at level 1 and 28 at level 3.
|
||||
**Temp HP** is a tracked number shown beside HP. Damage is not taken from it
|
||||
automatically.
|
||||
|
||||
### At 0 HP: death saves
|
||||
|
||||
A character at 0 HP is in one of three states, shown on the sheet under the
|
||||
HP bar, on their encounter card, in the quick stats and in the party lists.
|
||||
|
||||
| State | Meaning | How it ends |
|
||||
|---|---|---|
|
||||
| Dying | Rolling death saves | Three successes, three failures, healing, or being stabilised |
|
||||
| Stable | At 0 HP, no longer rolling | Healing wakes them; damage makes them Dying again |
|
||||
| Dead | Three failures, or one blow too big to survive | The DM's **Revive at 1 HP**, or an ally's reviving spell in a battle |
|
||||
|
||||
**Rolling.** While Dying, type what the d20 showed and click **Death save**.
|
||||
The app does not roll it.
|
||||
|
||||
| Roll | Result |
|
||||
|---|---|
|
||||
| 1 | Two failures |
|
||||
| 2 to 9 | One failure |
|
||||
| 10 to 19 | One success |
|
||||
| 20 | Back up with 1 HP, tallies cleared |
|
||||
|
||||
- **Three successes:** Stable. The tallies are cleared.
|
||||
- **Three failures:** Dead.
|
||||
- **Stabilise** makes a Dying character Stable without a roll, for when
|
||||
someone steadies them (a Medicine check, a healer's kit, a spell).
|
||||
- **Damage at 0 HP** is one failed save, and makes a Stable character Dying
|
||||
again. Damage soaked entirely by temp HP does not count. The app cannot tell
|
||||
a critical hit; the DM adds its second failure by clicking a pip.
|
||||
- **Massive damage:** if a blow leaves as much as the character's max HP
|
||||
still to take once HP reaches 0, they are Dead at once. A character with
|
||||
12 max HP on 5 HP dies from 17 damage or more.
|
||||
- **Healing** from 0, by any amount, puts the character back up and clears
|
||||
the tallies and Stable.
|
||||
- **Dead** characters cannot be healed: Heal is refused with a message. The
|
||||
DM clicks **Revive at 1 HP**, then heals as normal. In a battle, an ally
|
||||
with a reviving spell can do the same on their own turn, and until then the
|
||||
dead are hidden from the players' encounter screen: see
|
||||
[ENCOUNTERS.md](ENCOUNTERS.md), section 6.
|
||||
- The **DM** can click a pip to set either tally by hand, to fix a mistake.
|
||||
|
||||
Dropping to 0 always starts from empty tallies. None of this changes any
|
||||
other number on the sheet, and nothing is added to the conditions in an
|
||||
encounter.
|
||||
|
||||
## 8. Other derived stats
|
||||
|
||||
| Stat | Formula |
|
||||
@@ -509,17 +553,50 @@ character already has adds to the stack. Items have no effect on any stat. An
|
||||
item can carry optional dice (a potion's healing, a bomb's damage) that grow
|
||||
with its level exactly like a weapon's (section 10).
|
||||
|
||||
**Money** is a single gold balance with a log behind it.
|
||||
**Money** is a purse of three coins, each counted on its own, with a log
|
||||
behind it.
|
||||
|
||||
- The sheet's Money card shows the balance and every change to it: the amount,
|
||||
what it was for, who recorded it, when, and the balance afterwards.
|
||||
- The **owning player and the DM** can both record a gain or a spend. Other
|
||||
members can see the log but not add to it.
|
||||
- The balance **cannot go below zero**; a spend larger than the balance is
|
||||
refused.
|
||||
- The log is the only way the balance changes. Entries cannot be edited or
|
||||
| Coin | Worth |
|
||||
|---|---|
|
||||
| Gold (gp) | 1 gold |
|
||||
| Silver (sp) | 0.1 gold — 10 to the gold |
|
||||
| Copper (cp) | 0.01 gold — 100 to the gold, 10 to the silver |
|
||||
|
||||
- The sheet's Money card shows the three balances, what the purse is worth in
|
||||
gold altogether, and every change: the amount and coin, what it was for, who
|
||||
recorded it, when, and that coin's balance afterwards.
|
||||
- The **owning player and the DM** can both record a gain or a spend, choosing
|
||||
the coin. Other players cannot see the purse or the log.
|
||||
- **Spending makes change for you.** A spend (or a gift to another character)
|
||||
is paid from the whole purse. Coins of the kind named go first; if there
|
||||
aren't enough, the nearest bigger coin is broken and the change comes back
|
||||
in as few coins as possible; if there are no bigger coins, smaller ones are
|
||||
counted out. Spending 3 silver from a purse of 5 gold leaves 4 gold and 7
|
||||
silver. Each coin that moved gets its own log entry, marked "with change".
|
||||
- A spend is refused only when the **whole purse** isn't worth enough.
|
||||
- **Exchange** turns coins of one kind into another at the worth above, and
|
||||
logs both sides. It moves exactly the coins named and has to come to whole
|
||||
coins: silver into gold goes in tens, copper into silver in tens, copper
|
||||
into gold in hundreds.
|
||||
- The log is the only way a balance changes. Entries cannot be edited or
|
||||
deleted; to correct a mistake, record the opposite entry.
|
||||
- There is one currency (gold). Silver, copper and so on are not tracked.
|
||||
- Starting gold (a campaign setting) is gold only. Encounter rewards and gifts
|
||||
between characters can be in any coin, or in cash.
|
||||
- **Cash ($)** is a fourth balance for crossover worlds, counted in whole
|
||||
dollars and shown as `$40`. It is gained, spent, given and awarded like a
|
||||
coin and has its own log entries, but it has no worth in coins: it cannot
|
||||
be exchanged on the sheet, a cash spend is paid from cash alone, and cash
|
||||
is never broken to cover a coin spend. It shows in the purse only when
|
||||
held.
|
||||
- **Gold and cash are changed for each other by an NPC**, not on the sheet.
|
||||
An NPC the DM has given an exchange rate ("1 gold = $5") changes them
|
||||
either way at that rate, in an NPC quest in progress that both the NPC and
|
||||
the character are in. Only gold and cash: silver and copper are not taken.
|
||||
Each NPC has their own rate. See [NPCS.md](NPCS.md), section 5.
|
||||
- **Buying.** In the same kind of scene a character can buy the weapons and
|
||||
items an NPC, or the encounter itself, has for sale. The price is paid from
|
||||
the purse like any other spend and logged as "Bought <thing> from <NPC>".
|
||||
See [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
### Giving to another character
|
||||
|
||||
@@ -552,7 +629,7 @@ character in the same campaign.
|
||||
| Appearance | Everyone in the campaign | Built from picks; afterwards the DM only |
|
||||
| Special abilities | Everyone in the campaign (sheet and quick stats) | Built from picks; afterwards the DM only |
|
||||
| Private notes | **The owner only — not even the DM** | Owner only |
|
||||
| Current location | Everyone in the campaign | DM picks it on the sheet, from the campaign's locations |
|
||||
| Current location | The owner and the DM. Another player sees it in the party list only if their own character has been to that place | DM picks it on the sheet; an encounter in progress with a location also moves its characters there |
|
||||
|
||||
**Character notes** are a separate, running log on the sheet:
|
||||
|
||||
@@ -620,7 +697,9 @@ players never receive.
|
||||
- "Begin Battle" puts the highest initiative on turn. "Next Turn" passes the
|
||||
turn down the order, and going past the last combatant starts the next
|
||||
round. "Next Round" jumps straight to the top of the next round.
|
||||
- A combatant at 0 HP is marked Down but still takes its turn in the order.
|
||||
- A monster or NPC at 0 HP is marked Down; a character is Dying and makes
|
||||
death saves on its card (section 7). Either still has its place in the
|
||||
order.
|
||||
- Conditions (Prone, Poisoned, …) are picked from a list and removed with a
|
||||
click.
|
||||
|
||||
@@ -671,13 +750,16 @@ Another Campaign" card on its sheet.
|
||||
| View private notes | Yes | No | No |
|
||||
| Run the builder | Yes | Yes | No |
|
||||
| Change HP, temp HP, armor bonus | Yes | Yes | No |
|
||||
| Enter a death save, or stabilise | Yes | Yes | No |
|
||||
| Set death-save tallies by hand, revive from dead | No | Yes | No |
|
||||
| Edit special abilities, background, appearance (after the build) | No | Yes | No |
|
||||
| Tick Equipped / Prepared | Yes | Yes | No |
|
||||
| Give, remove or re-count weapons, spells and items | No | Yes | No |
|
||||
| Level a weapon or item up or down | No | Yes | No |
|
||||
| Record a gold gain or spend | Yes | Yes | No |
|
||||
| Give gold, weapons, items or spells to another character | Yes | Yes | No |
|
||||
| View the money log | Yes | Yes | Yes |
|
||||
| Change gold and cash with an NPC, or buy what is for sale, in a quest in progress | Yes | Yes | No |
|
||||
| View the money log, gold and inventory | Yes | Yes | No |
|
||||
| Change level, XP, location or speed override | No | Yes | No |
|
||||
| Post a character note | Yes | Yes | Yes |
|
||||
| Copy to another campaign | Yes | Yes | No |
|
||||
@@ -709,11 +791,11 @@ calculations for them.
|
||||
- Rolling attacks, damage, saves or checks for a character (the dice roller,
|
||||
bottom left of every page, is a plain roller that knows nothing about the
|
||||
sheet)
|
||||
- Death saves, conditions on the sheet, and resting
|
||||
- Conditions on the sheet, and resting
|
||||
- Automatic levelling from XP
|
||||
- Alignment, languages, tool proficiencies and backgrounds-as-rules
|
||||
- Encumbrance and item weight
|
||||
- Currencies other than gold, and item prices
|
||||
- Cents, and changing silver or copper for cash
|
||||
|
||||
## 20. Everything the builder offers
|
||||
|
||||
|
||||
+247
-25
@@ -19,9 +19,10 @@ This describes the code as it stands. The rules live in
|
||||
7. [Initiative, turns and rounds](#7-initiative-turns-and-rounds)
|
||||
8. [Conditions](#8-conditions)
|
||||
9. [Rewards](#9-rewards)
|
||||
10. [What each side sees](#10-what-each-side-sees)
|
||||
11. [Permissions](#11-permissions)
|
||||
12. [What is not modelled](#12-what-is-not-modelled)
|
||||
10. [Trade](#10-trade)
|
||||
11. [What each side sees](#11-what-each-side-sees)
|
||||
12. [Permissions](#12-permissions)
|
||||
13. [What is not modelled](#13-what-is-not-modelled)
|
||||
|
||||
---
|
||||
|
||||
@@ -30,7 +31,11 @@ This describes the code as it stands. The rules live in
|
||||
- An encounter is one **scene** the DM runs: a fight, or a meeting with
|
||||
people who want something.
|
||||
- Encounters belong to a campaign and are listed on the campaign page. The DM
|
||||
creates them; every member can open them.
|
||||
creates them and sees all of them.
|
||||
- A player only sees an encounter that **one of their own characters is in**.
|
||||
Until the DM adds their character it is not in their list and its page will
|
||||
not open, so scenes the DM is still preparing stay hidden, name included.
|
||||
Taking their character out hides it again.
|
||||
- The encounter page updates live for everyone who has it open.
|
||||
- The DM runs everything. Players watch, and see less than the DM does.
|
||||
|
||||
@@ -42,11 +47,12 @@ time from the top of the page.
|
||||
| | Battle | NPC quest |
|
||||
|---|---|---|
|
||||
| For | A fight | A scene built around people and an objective |
|
||||
| Layout | Everyone in one list, in initiative order | Three groups: "People you meet", "The party", "Creatures" |
|
||||
| Layout | Everyone in one list, in initiative order; once in progress, those who have had their turn move to "Next Round" at the bottom | Three groups: "People you meet", "The party", "Creatures" |
|
||||
| Rounds and turns | Yes | No |
|
||||
| Initiative | Typed in per combatant | Not shown |
|
||||
| Initiative | The d20 roll, typed in per combatant; the bonus is added | The same; each group lists highest first |
|
||||
| Start button | Begin Battle | Begin Quest |
|
||||
| End button | End Battle | Complete Quest |
|
||||
| Trade (section 10) | No | Yes, while in progress |
|
||||
|
||||
Everything else (hit points, conditions, rewards, the scene description, DM
|
||||
notes) works the same in both.
|
||||
@@ -63,6 +69,17 @@ Status is a label. Nothing is locked when an encounter is complete: the DM
|
||||
can still change HP, add people and award rewards. Encounters cannot be
|
||||
renamed from the screen.
|
||||
|
||||
### Location
|
||||
|
||||
The DM can give an encounter a location from the campaign's list, with the
|
||||
picker at the top of the page. While the encounter is **in progress** its
|
||||
characters are at that location: beginning it, changing its location, or
|
||||
adding a character to it moves them there. Each place a character is moved to
|
||||
(this way or by the DM on the sheet) is remembered, and a player can see only
|
||||
the locations one of their own characters has been to — on the campaign page
|
||||
under "Places You've Been", and on the encounter itself. Archiving or deleting
|
||||
the encounter does not take a visited place away again.
|
||||
|
||||
### Order, archive and delete
|
||||
|
||||
**Order.** The campaign page lists encounters in the **campaign order**, which
|
||||
@@ -73,8 +90,8 @@ anyone else. ▲ and ▼ only show while sorting by campaign order.
|
||||
|
||||
**Archive.** "Archive" (on the list or the encounter page) puts an encounter
|
||||
away without changing it. It moves to the DM's "Archived" section and
|
||||
disappears from the players' list; anyone who already has its page open or
|
||||
bookmarked can still open it. "Restore" puts it back on the list.
|
||||
disappears from the players' list; a player whose character is in it can still
|
||||
open its page from a bookmark. "Restore" puts it back on the list.
|
||||
|
||||
**Delete.** "Delete" (on the encounter page, or on an archived encounter in
|
||||
the list) removes the scene, who was in it and its reward list, after a
|
||||
@@ -96,7 +113,7 @@ The DM adds people from the "Add to the scene" panel.
|
||||
|
||||
| Kind | Comes from | Can be added more than once |
|
||||
|---|---|---|
|
||||
| Character | The campaign's finished characters | No |
|
||||
| Character | The campaign's finished characters, except dead ones | No |
|
||||
| Monster | The campaign's monster list ([MONSTERS.md](MONSTERS.md)) | Yes |
|
||||
| NPC | The campaign's NPC list ([NPCS.md](NPCS.md)) | No |
|
||||
|
||||
@@ -116,6 +133,10 @@ Details start open for the DM and closed for players. Weapons and spells
|
||||
above a character's level are left out, because the character cannot use
|
||||
them.
|
||||
|
||||
A **dead character cannot be added** to any encounter: they are left out of
|
||||
the list to pick from and the server refuses them, until they are revived. A
|
||||
character who dies stays in the encounters they were already in.
|
||||
|
||||
The DM removes someone with "Remove" on their card. Removing whoever is on
|
||||
turn passes the turn to the next combatant.
|
||||
|
||||
@@ -130,23 +151,168 @@ The DM types an amount and clicks Damage or Heal on a card.
|
||||
| NPC | In the encounter. An NPC with no HP set has no health bar. |
|
||||
|
||||
- HP always stays between 0 and max.
|
||||
- At 0 HP a combatant is marked **Down** and dimmed. It keeps its place in
|
||||
the order; nothing is removed automatically.
|
||||
- **What players see of it.** A character's HP is always shown in numbers. For
|
||||
monsters and NPCs it depends on the campaign setting "Players see the exact
|
||||
hit points of monsters and NPCs": on (the default) shows the numbers, off
|
||||
shows only the health bar. The DM always sees the numbers.
|
||||
- At 0 HP a monster or NPC is **out of the fight**: see "The fallen" below.
|
||||
- A **character** at 0 HP is **Dying** instead, and its card grows a death-save
|
||||
box: see below.
|
||||
- A character's temporary HP is shown beside its HP but is not used up
|
||||
automatically.
|
||||
|
||||
### A character at 0 HP
|
||||
|
||||
The full rules are in [CHARACTERS.md](CHARACTERS.md), section 7; the box on
|
||||
the card is the same one as on the sheet, and both change the same character.
|
||||
|
||||
- **Dying.** The box shows two tallies, Saved and Failed, three pips each.
|
||||
Type what the d20 showed and click **Death save**: 10 or more is a success,
|
||||
less is a failure, a 1 is two failures, a 20 puts the character back up at
|
||||
1 HP. The DM can do this for anyone; a player for their own character.
|
||||
- **Damage while down** is one failed save, added when the DM clicks Damage.
|
||||
For a critical hit the DM clicks the next Failed pip to add the second.
|
||||
- **Stabilise** stops the saves without a roll (a Medicine check, a spell).
|
||||
Three successes do the same. A stable character stays at 0 HP.
|
||||
- **Heal** on a dying or stable character puts them back up and clears the
|
||||
tallies.
|
||||
- **Dead** after three failures, or when one blow leaves as much as the
|
||||
character's max HP still to take after reaching 0. Healing is refused; the
|
||||
DM clicks **Revive at 1 HP**, or an ally revives them (below).
|
||||
- The DM can click any pip to set a tally by hand.
|
||||
|
||||
Nothing rolls by itself: the app does not prompt for a save when the turn
|
||||
comes round. A dying or stable character keeps its place in the order and
|
||||
still gets its turn.
|
||||
|
||||
### The fallen
|
||||
|
||||
A combatant is **out of the fight** when it is:
|
||||
|
||||
- a **monster or NPC at 0 HP** (one with hit points to track), or
|
||||
- a **dead character**. Dying and stable characters are not: they stay in
|
||||
view.
|
||||
|
||||
What that does:
|
||||
|
||||
- **Players no longer see it.** Its card is gone from their screen, along
|
||||
with any target mark on it; the server does not send it to them at all.
|
||||
- **The DM's screen leaves it out as well**, until the DM ticks **Show dead**
|
||||
above the list (the box shows how many there are, and only appears when
|
||||
there are any). Ticked, they are listed in a group of their own at the
|
||||
bottom, "Out of the fight, hidden from players", each with a "Hidden from
|
||||
players" badge, and everything on the card still works. The tick is not
|
||||
remembered: it is off again when the page is reopened.
|
||||
- **It has no turn.** Next Turn, Next Round and Begin Battle pass over it.
|
||||
- It stays in the encounter: nothing is removed, and gold is still split with
|
||||
a dead character.
|
||||
|
||||
It is worked out from hit points each time, not stored, so it undoes itself:
|
||||
heal a monster or NPC above 0, or revive a character, and they are back on
|
||||
everyone's screen and back in the turn order.
|
||||
|
||||
**Reviving a dead character.** Players see dead characters by name only, in a
|
||||
**☠ Fallen** box above the list. There are two ways back:
|
||||
|
||||
- The **DM** ticks Show dead and clicks **Revive at 1 HP** on the dead
|
||||
character's card (or does it on the sheet), at any time.
|
||||
- An **ally** tries it: in a battle in progress, the player whose character is
|
||||
**on turn** gets a **Try revive** button beside each name in the Fallen box,
|
||||
if that character is capable. Capable means on their feet (above 0 HP) and
|
||||
knowing a spell that is ticked **Revives** in the campaign's spell list
|
||||
([SPELLS.md](SPELLS.md)), at a level they can cast. The library's Revivify
|
||||
(level 3) and Raise Dead (level 5) are ticked already.
|
||||
|
||||
Either way the character comes back at 1 HP with the death saves cleared. The
|
||||
app does not use up the spell, check how long ago they died, or end the
|
||||
reviver's turn.
|
||||
|
||||
## 7. Initiative, turns and rounds
|
||||
|
||||
Battles only.
|
||||
Initiative is entered in battles and quests alike. Turns and rounds are for
|
||||
battles only.
|
||||
|
||||
- The DM types each combatant's **initiative** when adding them, and can
|
||||
change it on the card. The app does not roll it. The list is ordered
|
||||
highest first; ties keep the order they were added in.
|
||||
- Each combatant has an **initiative roll** field. Type what the d20 showed,
|
||||
1 to 20; the field accepts nothing else. The app does not roll the die, but
|
||||
it adds the bonus for you and shows the total, which can go past 20 (a 20 on
|
||||
the die with a +3 bonus is 23): a character's initiative
|
||||
bonus from their sheet, a monster's Dexterity modifier, nothing for an NPC.
|
||||
- The DM can enter the roll for anyone, when adding them or on the card. A
|
||||
player can enter the roll for **their own character** on its card.
|
||||
- The list is ordered by the total, highest first; ties keep the order they
|
||||
were added in. Anyone who has not rolled yet waits at the bottom.
|
||||
- The bonus is worked out when the roll is entered. If a character's sheet
|
||||
changes afterwards, enter the roll again to pick up the new bonus.
|
||||
- **Begin Battle** puts the first combatant on turn. Their card is
|
||||
highlighted and the header names them.
|
||||
- **Next Turn** passes the turn to the next in the order. After the last
|
||||
combatant it returns to the top and the **round** goes up by one.
|
||||
- **Next Round** skips straight to the top of the next round.
|
||||
- Anyone out of the fight (section 6, "The fallen") is passed over.
|
||||
- **The list follows the turn.** While a battle is in progress, whoever is on
|
||||
turn is at the top of "Initiative order" with those still to act this round
|
||||
beneath them. Each combatant whose turn has passed moves down to a **Next
|
||||
Round** section at the bottom, still in initiative order. When the round
|
||||
rolls over everyone is back in "Initiative order". Everyone sees the same
|
||||
arrangement. Before the battle begins, and once it is complete, it is one
|
||||
plain list.
|
||||
|
||||
### Targets
|
||||
|
||||
Combatants can be marked as **targets**: whoever is being attacked (or
|
||||
healed) right now. One target is an ordinary attack; more than one is an
|
||||
**area attack**.
|
||||
|
||||
- Click **🎯 Target** beside a combatant's name to mark them; click it again
|
||||
to unmark. Each one marked is added to the targets.
|
||||
- The **DM** can mark anyone at any time, in a battle or a quest. A **player**
|
||||
can while their own character is on turn in a battle, so they can show the
|
||||
DM who they are going for. A player cannot mark someone who is out of the
|
||||
fight, and is not shown a mark the DM leaves on one.
|
||||
- The combatant **on turn** cannot be marked: nobody targets themselves, and
|
||||
their card has no Target button until the turn passes. To heal or hurt them
|
||||
on their own turn, use the buttons on their card.
|
||||
- A target's card is outlined in red for everyone and reads "Targeted by
|
||||
<whoever is on turn>".
|
||||
- While anyone is marked the DM gets an **attack bar** above the list, which
|
||||
stays in view while scrolling. It shows who is attacking whom, each target's
|
||||
AC and HP, and the attacker's attacks: a character's equipped weapons (all
|
||||
of them if none are equipped) and damaging spells, or a monster's or NPC's
|
||||
attack and damage. The amount box is ready to type in; **Damage** (or
|
||||
Enter) and **Heal** change HP exactly as the buttons on a card do.
|
||||
- Targets stay marked after damage, for a second attack. They are cleared
|
||||
when the turn or round moves on, when the encounter begins, ends or is
|
||||
reopened; a target that is removed from the encounter is unmarked.
|
||||
|
||||
**Area attacks**
|
||||
|
||||
An attack on several targets is harder to land and its damage is shared.
|
||||
|
||||
- **Harder to hit.** Every target's AC counts as higher for each target
|
||||
beyond the first, by the campaign's **area attack AC penalty**. The DM sets
|
||||
it in the campaign settings, from 0 (no penalty) to 10; it starts at 2, which
|
||||
gives:
|
||||
|
||||
| Targets | Added to each AC |
|
||||
|---|---|
|
||||
| 1 | 0 |
|
||||
| 2 | +2 |
|
||||
| 3 | +4 |
|
||||
| 4 | +6 |
|
||||
|
||||
The attack bar shows the AC to beat with the penalty already added
|
||||
("17 (15 +2)"), and each target's card says "area attack, AC +2".
|
||||
- **Who was hit.** Each target has a tick box, ticked to begin with. Untick
|
||||
anyone the roll missed; they take nothing.
|
||||
- **Sharing the amount.** Type the total. "Split between those hit" (the
|
||||
default) shares it evenly, any remainder going one point each to the first
|
||||
targets marked: 7 between three is 3, 2 and 2. "Full amount to each" gives
|
||||
every target hit the whole amount. Each target's share is shown before it
|
||||
is applied.
|
||||
- A target with no hit points to track is left out of the sharing.
|
||||
|
||||
The app does not roll the attack or decide whether it hits: the DM compares
|
||||
the roll with the AC shown and types the damage.
|
||||
|
||||
## 8. Conditions
|
||||
|
||||
@@ -166,7 +332,7 @@ The DM lines up loot in the Rewards panel, on any encounter at any time.
|
||||
|
||||
| Reward | What the DM sets |
|
||||
|---|---|
|
||||
| Gold | An amount |
|
||||
| Coins or cash | An amount, and which: gold, silver, copper or cash ($) |
|
||||
| Weapon | A weapon from the pool, a level (not below its starting level), a quantity |
|
||||
| Item | An item from the pool, a level, a quantity |
|
||||
|
||||
@@ -177,9 +343,9 @@ Each can carry a short note ("Goblin chief's purse").
|
||||
| Button | What happens |
|
||||
|---|---|
|
||||
| Award (pick a character) | The whole reward goes to that character. It can be any finished character in the campaign, not only those in the encounter. |
|
||||
| Split (gold only) | Shared evenly between the characters in the encounter. Any remainder goes one gold each, highest initiative first: 101 gold between two is 51 and 50. |
|
||||
| Split (coins and cash only) | Shared evenly between the characters in the encounter. Any remainder goes one each, highest initiative first: 101 gold between two is 51 and 50. |
|
||||
|
||||
- **Gold** is added to the character's balance with a money-log entry reading
|
||||
- **Coins or cash** are added to the character's balance with a money-log entry reading
|
||||
"Reward: <encounter name>".
|
||||
- A **weapon or item** is added to what the character holds, at the reward's
|
||||
level, stacking with any they already have. If the character is below that
|
||||
@@ -190,7 +356,48 @@ Each can carry a short note ("Goblin chief's purse").
|
||||
Until a reward is awarded only the DM can see it. Afterwards players see what
|
||||
it was and who received it.
|
||||
|
||||
## 10. What each side sees
|
||||
## 10. Trade
|
||||
|
||||
In an NPC quest the party can **change money** and **buy things**. Both take
|
||||
someone to deal with and the time to do it, so:
|
||||
|
||||
- Trading is open only in an **NPC quest that is in progress**. Before "Begin
|
||||
Quest" and after "Complete Quest" everything on offer is still shown, but
|
||||
the buttons are off.
|
||||
- There is **no trading in a battle**. Switching a quest to a battle hides the
|
||||
Trade card and the server refuses any trade; switching back brings it back.
|
||||
- An NPC trades only while they are **in the scene and not at 0 HP**.
|
||||
- The character trading has to be **in the encounter** and alive.
|
||||
|
||||
The "Trade" card sits beside the scene, above Rewards. It appears for players
|
||||
when there is something to trade, and always for the DM in a quest.
|
||||
|
||||
| Part of the card | What it does |
|
||||
|---|---|
|
||||
| Who is trading | Picks the character. A player chooses among their own characters in the scene; the DM among all of them. Their purse is shown beside it |
|
||||
| 💱 A money changer | One row per NPC in the scene who has an exchange rate. Type an amount, choose "gold into cash" or "cash into gold", and the button shows what comes back |
|
||||
| 🛒 "<NPC> sells" | Everything that NPC has for sale: what it is, its level and dice, the price, and how many are left. "Buy" buys one |
|
||||
| 🛒 "For sale here" | Things sold by the scene itself rather than by anyone in it |
|
||||
| Set up (DM only) | Puts weapons and items from the campaign pools up for sale at this encounter, and edits their price and stock |
|
||||
|
||||
- **Changing money** uses the NPC's own rate, the same both ways, and only
|
||||
gold and cash: no silver, no copper, no change. The amount handed over has
|
||||
to be a whole multiple of that side of the rate. The full rules and
|
||||
examples are in [NPCS.md](NPCS.md), section 5.
|
||||
- **Buying** pays the price from the character's purse (coins make change; a
|
||||
cash price takes cash only), adds the weapon or item to what the character
|
||||
holds at the level it is sold at, and uses up stock. A character below that
|
||||
level holds it but cannot use it yet.
|
||||
- Every trade is written to the character's money log.
|
||||
- **An NPC's rate and wares are set on the campaign page**, under "Trade" on
|
||||
the NPC's row, and follow the NPC into every quest they are in. **An
|
||||
encounter's own wares are set here**, in the DM's part of the card, and
|
||||
exist only in this encounter: a market stall, a vending machine, a chest
|
||||
with a price tag.
|
||||
- Removing an NPC from the scene takes their rate and wares out of it.
|
||||
Deleting the encounter deletes its own wares; what was bought stays bought.
|
||||
|
||||
## 11. What each side sees
|
||||
|
||||
| | DM | Players |
|
||||
|---|---|---|
|
||||
@@ -199,37 +406,52 @@ it was and who received it.
|
||||
| Characters | Full card | Full card |
|
||||
| Monsters | Full stat block | Name, HP, conditions |
|
||||
| NPCs | Everything | Name, role, attitude, description, HP bar |
|
||||
| Monsters and NPCs at 0 HP | With "Show dead" ticked, marked hidden | Nothing |
|
||||
| Dead characters | Full card with "Show dead" ticked, marked hidden | The name only, in the Fallen box |
|
||||
| Rewards | All | Only awarded ones |
|
||||
| Exchange rates and things for sale | Yes | Yes, for NPCs they can see and for the scene itself |
|
||||
| Controls (add, damage, turns, rewards) | Yes | None |
|
||||
|
||||
The server only sends a player what the right-hand column lists, so hidden
|
||||
information cannot be dug out of the page.
|
||||
|
||||
## 11. Permissions
|
||||
## 12. Permissions
|
||||
|
||||
| Action | DM | Players |
|
||||
|---|---|---|
|
||||
| Create an encounter | Yes | No |
|
||||
| Open an encounter | Yes | Yes |
|
||||
| See and open an encounter | Yes, all of them | Only ones their own character is in |
|
||||
| Change mode, status, scene or DM notes | Yes | No |
|
||||
| Rearrange, archive, restore or delete encounters | Yes | No |
|
||||
| See archived encounters in the list | Yes | No |
|
||||
| Add or remove anyone | Yes | No |
|
||||
| Change HP, initiative or conditions | Yes | No |
|
||||
| Change HP or conditions | Yes | No |
|
||||
| Enter an initiative roll | Yes, for anyone | For their own character only |
|
||||
| Mark targets | Yes, any time | On their own character's turn |
|
||||
| Enter a death save, or stabilise | Yes, for anyone | For their own character only |
|
||||
| Set death-save tallies by hand | Yes | No |
|
||||
| Revive a dead character outright | Yes | No |
|
||||
| Try to revive a dead character | Yes, for the character on turn | On their own character's turn, if it has a reviving spell |
|
||||
| Advance turns and rounds | Yes | No |
|
||||
| Add, remove or award rewards | Yes | No |
|
||||
| Set exchange rates, and put things up for sale | Yes | No |
|
||||
| Change money or buy, in a quest in progress | Yes, for any character in it | For their own character in it |
|
||||
|
||||
People outside the campaign cannot open its encounters.
|
||||
|
||||
## 12. What is not modelled
|
||||
## 13. What is not modelled
|
||||
|
||||
- Rolling initiative, attacks, damage or saves (use the dice roller, bottom
|
||||
left of every page)
|
||||
- Applying an attack's damage automatically
|
||||
- Death saves and stabilising
|
||||
- Death saves for monsters and NPCs, and prompting for a save on a dying
|
||||
character's turn
|
||||
- A roll, a spell slot or a time limit for reviving the dead
|
||||
- Conditions with durations or mechanical effects
|
||||
- Maps, positions, distances and areas of effect
|
||||
- Experience points and encounter difficulty
|
||||
- Spells as rewards
|
||||
- Spells as rewards, and spells for sale
|
||||
- Selling to an NPC, haggling, and buying more than one of a thing in a
|
||||
single step
|
||||
- Renaming an encounter from the screen
|
||||
- A log of what happened during the encounter
|
||||
|
||||
+14
-4
@@ -15,7 +15,7 @@ level up the same way weapons do), [SPELLS.md](SPELLS.md),
|
||||
4. [Item levels](#4-item-levels)
|
||||
5. [Dice on items](#5-dice-on-items)
|
||||
6. [Starting items](#6-starting-items)
|
||||
7. [Items as rewards](#7-items-as-rewards)
|
||||
7. [Items as rewards and for sale](#7-items-as-rewards-and-for-sale)
|
||||
8. [Permissions](#8-permissions)
|
||||
9. [What is not modelled](#9-what-is-not-modelled)
|
||||
|
||||
@@ -121,21 +121,30 @@ In the builder's equipment step a new character can pick items that are:
|
||||
Starting items and starting weapons share the campaign's "starting items"
|
||||
allowance. Each arrives at level 1, quantity 1, unequipped.
|
||||
|
||||
## 7. Items as rewards
|
||||
## 7. Items as rewards and for sale
|
||||
|
||||
On an encounter the DM can line up an item as a reward, at a chosen level and
|
||||
quantity, and later award it to a character. Awarding adds it to the
|
||||
character's inventory, stacking with any they already hold. See
|
||||
[ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
|
||||
The DM can also put an item **up for sale**, at a level, a price (in gold,
|
||||
silver, copper or cash) and an optional stock. An NPC can sell it, wherever
|
||||
that NPC is met, or an encounter can sell it itself. Players see what is for
|
||||
sale and buy it in an NPC quest that is in progress: the price leaves their
|
||||
purse and the item joins their inventory. See [NPCS.md](NPCS.md), section 5,
|
||||
and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
## 8. Permissions
|
||||
|
||||
| Action | DM | Owning player | Other players |
|
||||
|---|---|---|---|
|
||||
| See the item pool | Yes | Yes | Yes |
|
||||
| See the item pool | Yes | Only what their own character holds, plus the starting options while they have a character to build | The same, for their own character |
|
||||
| Add, change or remove pool items | Yes | No | No |
|
||||
| Give an item from the pool, or take one away | Yes | No | No |
|
||||
| Give a held item to another character | Yes | Yes | No |
|
||||
| Put an item up for sale | Yes | No | No |
|
||||
| Buy an item that is for sale, in a quest in progress | Yes, for any character in it | Yes, if the character is in it | No |
|
||||
| Change quantity or level | Yes | No | No |
|
||||
| Equip or unequip | Yes | Yes, if the character can use it | No |
|
||||
| See a character's inventory | Yes | Yes | Yes |
|
||||
@@ -146,6 +155,7 @@ character's inventory, stacking with any they already hold. See
|
||||
- Armor and shields as items
|
||||
- Using up items automatically, charges, and attunement
|
||||
- Weight, carrying capacity and containers
|
||||
- Prices, buying and selling (record the gold change in the money log)
|
||||
- A price on the item itself: a price is set each time it is put up for sale
|
||||
- Selling items back (record the gold change in the money log)
|
||||
- Swaps as a single step (each side gives separately), and asking the
|
||||
recipient to accept
|
||||
|
||||
+19
-13
@@ -36,10 +36,10 @@ This describes the code as it stands. The built-in list is
|
||||
| Name | Up to 80 characters |
|
||||
| AC | Armor Class |
|
||||
| Max HP | Hit points each copy starts an encounter with |
|
||||
| Speed | In feet. 30 unless set otherwise |
|
||||
| Speed | Walking speed in feet. 30 unless set otherwise. Other speeds (fly, swim, climb) go in Notes |
|
||||
| Ability scores | STR, DEX, CON, INT, WIS, CHA. 10 unless set otherwise |
|
||||
| Attack bonus | Added to the monster's attack roll |
|
||||
| Damage | Dice for its main attack, e.g. `1d6+2` |
|
||||
| Damage | Dice for its main attack, e.g. `1d6+2`, with the damage type if you like: `1d6+2 necrotic` |
|
||||
| Challenge rating | A rough danger rating, e.g. `1/4`, `2` |
|
||||
| Special trait | A short note such as "Pack Tactics" |
|
||||
| Notes | Anything else: what it attacks with, how it behaves |
|
||||
@@ -52,21 +52,25 @@ from its scores for display only.
|
||||
|
||||
The list is the "Monsters" card on the campaign page (DM only).
|
||||
|
||||
- **Adding by hand:** "+ Add Monster" asks for name, AC, max HP, attack bonus
|
||||
and damage. Speed is set to 30 and every ability score to 10; challenge
|
||||
rating, trait and notes are left empty.
|
||||
- **Adding by hand:** "+ Add Monster" opens the whole stat block: name,
|
||||
challenge rating, AC, HP, speed, attack bonus, damage, the six ability
|
||||
scores, special trait and notes. Only name, AC and HP must be typed; speed
|
||||
starts at 30, attack bonus at 0 and each ability score at 10.
|
||||
- **Adding from the bestiary:** "📚 From Library" copies a complete stat block.
|
||||
- **Changing:** there is no edit form (the API accepts changes, but no screen
|
||||
offers them). Remove and re-add, or add from the library.
|
||||
- **Removing:** the ✕ button, immediately, with no confirmation. Copies
|
||||
- **Changing:** "Edit" on a monster's row opens the same form filled in.
|
||||
Saving changes the template, and copies already in encounters show the new
|
||||
stat block straight away; their current HP is left alone.
|
||||
- The list shows each monster's full stat block: the numbers in columns, and
|
||||
its ability scores, trait and notes under its name.
|
||||
- **Removing:** the ✕ button, after a confirmation. Copies
|
||||
already in encounters stay there with their name and HP, but lose their
|
||||
stat block.
|
||||
|
||||
## 4. The built-in bestiary
|
||||
|
||||
38 monsters, from harmless to dangerous. Each library row shows challenge
|
||||
rating, AC, HP, attack and damage, plus its trait and notes, before you add
|
||||
it. Adding copies the monster into your campaign; the copy is yours, and later
|
||||
38 monsters, from harmless to dangerous, every one with a complete stat
|
||||
block. Each library row shows challenge rating, AC, HP, speed, attack, damage
|
||||
and the six ability scores, plus its trait and notes, before you add it. Adding copies the monster into your campaign; the copy is yours, and later
|
||||
changes to the library do not affect it.
|
||||
|
||||
The full bestiary, weakest first. "Attack" is the bonus added to the
|
||||
@@ -146,8 +150,9 @@ and its notes.
|
||||
This is enforced by the server: stat blocks are never sent to a player's
|
||||
browser.
|
||||
|
||||
Players do see a monster's exact HP numbers. If you would rather keep those
|
||||
secret, that is not currently possible.
|
||||
Players see a monster's exact HP numbers unless the campaign setting "Players
|
||||
see the exact hit points of monsters and NPCs in encounters" is unticked.
|
||||
Then they get the health bar only.
|
||||
|
||||
## 7. Permissions
|
||||
|
||||
@@ -163,6 +168,7 @@ secret, that is not currently possible.
|
||||
## 8. What is not modelled
|
||||
|
||||
- Multiple attacks, spells, legendary actions and resistances (use Notes)
|
||||
- Trade: monsters do not sell things or change money (NPCs do)
|
||||
- Rolling a monster's attacks or damage (use the dice roller)
|
||||
- Saving throw bonuses and skills
|
||||
- Experience points for defeating monsters
|
||||
|
||||
+99
-16
@@ -1,7 +1,8 @@
|
||||
# NPCs
|
||||
|
||||
How non-player characters work in this app: what the DM records about them,
|
||||
how they are brought into a scene, and what players can and cannot see.
|
||||
how they are brought into a scene, what they sell and the rate they change
|
||||
money at, and what players can and cannot see.
|
||||
|
||||
This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
[MONSTERS.md](MONSTERS.md).
|
||||
@@ -12,10 +13,11 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
2. [What an NPC holds](#2-what-an-npc-holds)
|
||||
3. [Managing NPCs](#3-managing-npcs)
|
||||
4. [NPCs in encounters](#4-npcs-in-encounters)
|
||||
5. [What players see](#5-what-players-see)
|
||||
6. [NPC or monster?](#6-npc-or-monster)
|
||||
7. [Permissions](#7-permissions)
|
||||
8. [What is not modelled](#8-what-is-not-modelled)
|
||||
5. [Trade: changing money and selling](#5-trade-changing-money-and-selling)
|
||||
6. [What players see](#6-what-players-see)
|
||||
7. [NPC or monster?](#7-npc-or-monster)
|
||||
8. [Permissions](#8-permissions)
|
||||
9. [What is not modelled](#9-what-is-not-modelled)
|
||||
|
||||
---
|
||||
|
||||
@@ -27,6 +29,8 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
- An NPC does nothing by sitting in the list. Players meet one when the DM
|
||||
**adds it to an encounter**.
|
||||
- NPCs are mainly for talking to. Combat stats are optional.
|
||||
- An NPC can also **trade**: change gold and cash at a rate of their own, and
|
||||
sell weapons and items (section 5).
|
||||
|
||||
## 2. What an NPC holds
|
||||
|
||||
@@ -38,9 +42,11 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
| Description | What the party can see and knows | Everyone |
|
||||
| DM notes | Secrets, motives, what they are hiding | **DM only** |
|
||||
| AC | Optional | DM only |
|
||||
| HP | Optional. Without it the NPC has no health bar | See section 5 |
|
||||
| HP | Optional. Without it the NPC has no health bar | See section 6 |
|
||||
| Attack bonus | Optional | DM only |
|
||||
| Damage | Optional dice, e.g. `1d6` | DM only |
|
||||
| Exchange rate | Optional: so much gold for so much cash (section 5) | Everyone, in an encounter |
|
||||
| Wares | Optional: weapons and items for sale, each with a price (section 5) | Everyone, in an encounter |
|
||||
|
||||
A new NPC is Neutral unless you choose otherwise.
|
||||
|
||||
@@ -48,10 +54,14 @@ A new NPC is Neutral unless you choose otherwise.
|
||||
|
||||
The NPC list is the "NPCs" card on the campaign page (DM only).
|
||||
|
||||
- **Adding:** "+ Add NPC" opens a form with every field above. Only the name
|
||||
is required.
|
||||
- **Changing:** there is no edit form. To change an NPC, remove it and add it
|
||||
again (the API accepts changes, but no screen offers them).
|
||||
- **Adding:** "+ Add NPC" opens a form with every field above except the
|
||||
exchange rate and wares. Only the name is required.
|
||||
- **Trade:** the "Trade" button on an NPC's row opens their exchange rate and
|
||||
their wares (section 5). The row shows a 💱 badge with the rate and a 🛒
|
||||
badge with how many things are for sale.
|
||||
- **Changing:** there is no edit form for the other fields. To change them,
|
||||
remove the NPC and add it again (the API accepts changes, but no screen
|
||||
offers them). Removing an NPC removes their wares too.
|
||||
- **Removing:** the ✕ button, after a confirmation. This also **removes the
|
||||
NPC from every encounter** it was in.
|
||||
|
||||
@@ -73,7 +83,72 @@ An NPC's card shows its attitude as a coloured badge, its role under its
|
||||
name, and its description in full. The DM's card also shows AC, attack,
|
||||
damage and the DM notes.
|
||||
|
||||
## 5. What players see
|
||||
## 5. Trade: changing money and selling
|
||||
|
||||
Only the DM sets trade up. Players see it and use it on the encounter page,
|
||||
in the "Trade" card. The rules for when trading is open are in
|
||||
[ENCOUNTERS.md](ENCOUNTERS.md), section 10: in short, in an **NPC quest that
|
||||
is in progress**, never in a battle, and only with an NPC who is in the scene
|
||||
and not at 0 HP.
|
||||
|
||||
### Changing money
|
||||
|
||||
An NPC with an **exchange rate** changes gold into cash ($) and cash into
|
||||
gold. The rate is two whole numbers, "this much gold = this much cash", and
|
||||
each NPC has their own, so one money changer can be a better deal than
|
||||
another.
|
||||
|
||||
| Rate | Hand over | Get |
|
||||
|---|---|---|
|
||||
| 1 gold = $5 | 3 gold | $15 |
|
||||
| 1 gold = $5 | $20 | 4 gold |
|
||||
| 2 gold = $15 | 6 gold | $45 |
|
||||
| 2 gold = $15 | $30 | 4 gold |
|
||||
|
||||
- The same rate is used both ways.
|
||||
- **Only gold and cash.** Silver and copper are not taken, a gold coin is not
|
||||
broken to make up an amount, and no change is given.
|
||||
- What is handed over has to be a whole multiple of that side of the rate: at
|
||||
2 gold = $15, gold goes in twos and cash in fifteens.
|
||||
- Both sides go on the character's money log, naming the NPC: "Changed with
|
||||
Vex for $45" and "Changed with Vex from 6 gold".
|
||||
- An NPC never runs out of gold or cash to change.
|
||||
- To set a rate, type both numbers under "Trade" and press "Save rate".
|
||||
"Stop changing money" clears it. An NPC with no rate does not change money.
|
||||
|
||||
### Selling
|
||||
|
||||
An NPC's **wares** are weapons and items from the campaign's own pools, each
|
||||
put up for sale with:
|
||||
|
||||
| Field | What it is |
|
||||
|---|---|
|
||||
| What | One weapon or item from the pool |
|
||||
| Level | The gear level it is handed over at, not below its starting level |
|
||||
| Price | For one, in gold, silver, copper or cash |
|
||||
| Stock | How many can be bought. Empty means no limit |
|
||||
|
||||
- Wares belong to the NPC, not to an encounter: they are on offer in every
|
||||
quest that NPC is in.
|
||||
- Buying pays the price from the character's purse, adds the weapon or item
|
||||
to what they hold (stacking with any they already have) and lowers the
|
||||
stock. A price in coins is paid with change, exactly like any other spend;
|
||||
a price in cash takes cash only.
|
||||
- At 0 stock a ware shows as sold out and cannot be bought. It stays listed
|
||||
until the DM removes it.
|
||||
- The ✕ beside a ware stops it being sold. Things already bought stay bought.
|
||||
- "Edit" beside a ware changes its price, the coin (or cash) the price is in,
|
||||
and its stock: restock a sold-out ware, or clear the stock for no limit.
|
||||
What is sold, its level and who sells it cannot be changed; remove the ware
|
||||
and put it up again for that.
|
||||
- Removing a weapon or item from the campaign pool removes it from sale
|
||||
everywhere.
|
||||
|
||||
A stall or a machine with nobody behind it can sell things too: those wares
|
||||
belong to an encounter rather than an NPC, and are set up on the encounter
|
||||
page. See [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
## 6. What players see
|
||||
|
||||
Players only ever see an NPC through an encounter it is in:
|
||||
|
||||
@@ -81,6 +156,7 @@ Players only ever see an NPC through an encounter it is in:
|
||||
|---|---|
|
||||
| Name, role, attitude, description | DM notes |
|
||||
| Health bar and conditions, if the NPC has HP | AC, attack bonus, damage |
|
||||
| The exchange rate, and everything the NPC has for sale with its price and stock | The NPC list, and wares of NPCs who are not in the scene |
|
||||
|
||||
This is enforced by the server, not just hidden on screen: the hidden fields
|
||||
are never sent to a player's browser.
|
||||
@@ -88,7 +164,7 @@ are never sent to a player's browser.
|
||||
Changing an NPC's attitude changes what players see straight away, so
|
||||
flipping a "Friendly" NPC to "Hostile" is itself a reveal.
|
||||
|
||||
## 6. NPC or monster?
|
||||
## 7. NPC or monster?
|
||||
|
||||
| | NPC | Monster |
|
||||
|---|---|---|
|
||||
@@ -99,11 +175,12 @@ flipping a "Friendly" NPC to "Hostile" is itself a reveal.
|
||||
| Combat stats | Optional, basic | Full stat block |
|
||||
| Can be in an encounter more than once | No | Yes (a pack of goblins) |
|
||||
| Built-in library | No | Yes |
|
||||
| Can change money and sell things | Yes | No |
|
||||
|
||||
A named villain the party will both talk to and fight works well as an NPC
|
||||
with HP, AC, attack and damage filled in.
|
||||
|
||||
## 7. Permissions
|
||||
## 8. Permissions
|
||||
|
||||
| Action | DM | Players |
|
||||
|---|---|---|
|
||||
@@ -111,12 +188,18 @@ with HP, AC, attack and damage filled in.
|
||||
| Add or remove NPCs | Yes | No |
|
||||
| Add an NPC to an encounter | Yes | No |
|
||||
| Change an NPC's HP or conditions in an encounter | Yes | No |
|
||||
| See an NPC in an encounter | Everything | Name, role, attitude, description, health bar |
|
||||
| See an NPC in an encounter | Everything | Name, role, attitude, description, health bar, exchange rate, wares |
|
||||
| Set an NPC's exchange rate | Yes | No |
|
||||
| Put things up for sale, or take them down | Yes | No |
|
||||
| Change money with an NPC, or buy from one | Yes, for any character in the scene | For their own character in the scene |
|
||||
|
||||
## 8. What is not modelled
|
||||
## 9. What is not modelled
|
||||
|
||||
- Ability scores, saves, skills and spells for NPCs
|
||||
- NPC inventory, gold and shops
|
||||
- An NPC's own purse: they never run short of gold or cash
|
||||
- Selling things to an NPC, haggling, and different rates for buying and
|
||||
selling cash
|
||||
- Changing silver or copper for cash
|
||||
- Where an NPC is (they are not tied to locations)
|
||||
- Relationships, reputation and attitude toward individual characters
|
||||
- A record of which NPCs the party has met, outside of encounters
|
||||
|
||||
+6
-4
@@ -46,15 +46,16 @@ The list is the "Spell List" card on the campaign page (DM only).
|
||||
| What it does | One sentence. Shown wherever the spell is shown |
|
||||
| Damage | Picked from a list, or "No damage". Fixed; it does not grow |
|
||||
| Starting option | Whether a brand-new character may pick it in the builder (section 8) |
|
||||
| Revives | Whether it brings back the dead. A character who can cast one may revive a dead ally on their turn in a battle ([ENCOUNTERS.md](ENCOUNTERS.md), section 6). Ticked already on the library's Revivify and Raise Dead |
|
||||
| Casting time, range, duration | Stored for library spells but not shown on any screen |
|
||||
|
||||
A cantrip is a spell of level 0. The app always calls it a **cantrip**, never
|
||||
"level 0" or "level 1".
|
||||
|
||||
- **Adding:** by hand with the "+ Add Spell" form (name, level, school,
|
||||
damage and what it does), or from the library.
|
||||
- **Changing:** damage (the drop-down in the table) and the starting-option
|
||||
tick box. Name, level, school and description cannot be changed on screen
|
||||
damage, what it does and the two tick boxes), or from the library.
|
||||
- **Changing:** damage (the drop-down in the table) and the Revives and
|
||||
starting-option tick boxes. Name, level, school and description cannot be changed on screen
|
||||
once created (the API accepts it); remove and re-add instead.
|
||||
- **Damage choices:** the drop-down offers "No damage", then 1 to 6, 8 or 10
|
||||
dice of d4, d6, d8, d10 or d12 (1d4 up to 10d12), plus the three unusual
|
||||
@@ -187,7 +188,8 @@ spells arrive unprepared.
|
||||
|
||||
| Action | DM | Owning player | Other players |
|
||||
|---|---|---|---|
|
||||
| See the spell list and the library | Yes | Yes | Yes |
|
||||
| See the spell list | Yes | Only what their own character holds, plus the starting options while they have a character to build | The same, for their own character |
|
||||
| See the built-in library | Yes | No | No |
|
||||
| Add, change or remove spells in the list | Yes | No | No |
|
||||
| Grant a spell from the list, or remove one | Yes | No | No |
|
||||
| Give a known spell to another character | Yes | Yes | No |
|
||||
|
||||
+14
-4
@@ -19,7 +19,7 @@ This describes the code as it stands. The formulas live in
|
||||
6. [How damage grows](#6-how-damage-grows)
|
||||
7. [Attack and damage numbers](#7-attack-and-damage-numbers)
|
||||
8. [Starting weapons](#8-starting-weapons)
|
||||
9. [Weapons as rewards](#9-weapons-as-rewards)
|
||||
9. [Weapons as rewards and for sale](#9-weapons-as-rewards-and-for-sale)
|
||||
10. [Permissions](#10-permissions)
|
||||
11. [What is not modelled](#11-what-is-not-modelled)
|
||||
12. [The complete weapon library](#12-the-complete-weapon-library)
|
||||
@@ -185,21 +185,31 @@ In the builder's equipment step a new character can pick weapons that are:
|
||||
Starting weapons and starting items share the campaign's "starting items"
|
||||
allowance. They arrive at level 1, unequipped.
|
||||
|
||||
## 9. Weapons as rewards
|
||||
## 9. Weapons as rewards and for sale
|
||||
|
||||
On an encounter the DM can line up a weapon as a reward, at a chosen level
|
||||
(not below its starting level) and quantity, and later award it to a
|
||||
character. Awarding adds it to what the character holds, exactly as giving it
|
||||
on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
|
||||
The DM can also put a weapon **up for sale**, at a level, a price (in gold,
|
||||
silver, copper or cash) and an optional stock. An NPC can sell it, wherever
|
||||
that NPC is met, or an encounter can sell it itself. Players see what is for
|
||||
sale and buy it in an NPC quest that is in progress: the price leaves their
|
||||
purse and the weapon joins what they hold, unequipped. See
|
||||
[NPCS.md](NPCS.md), section 5, and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
## 10. Permissions
|
||||
|
||||
| Action | DM | Owning player | Other players |
|
||||
|---|---|---|---|
|
||||
| See the pool and the library | Yes | Yes | Yes |
|
||||
| See the pool | Yes | Only what their own character holds, plus the starting options while they have a character to build | The same, for their own character |
|
||||
| See the built-in library | Yes | No | No |
|
||||
| Add, change or remove pool entries | Yes | No | No |
|
||||
| Give a weapon from the pool, or take one away | Yes | No | No |
|
||||
| Give a held weapon to another character | Yes | Yes | No |
|
||||
| Put a weapon up for sale | Yes | No | No |
|
||||
| Buy a weapon that is for sale, in a quest in progress | Yes, for any character in it | Yes, if the character is in it | No |
|
||||
| Change a held weapon's level or quantity | Yes | No | No |
|
||||
| Equip or unequip | Yes | Yes, if the character can use it | No |
|
||||
| See a character's weapons | Yes | Yes | Yes |
|
||||
@@ -211,7 +221,7 @@ on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
- Range and ammunition
|
||||
- Critical hits, advantage, and rolling the attack itself (use the dice roller)
|
||||
- Magic bonuses such as +1 weapons (use a higher level or growth instead)
|
||||
- Price and weight
|
||||
- A price on the weapon itself (a price is set each time it is put up for sale), and weight
|
||||
|
||||
## 12. The complete weapon library
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+158
-12
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
BuildOptions,
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CampaignMemberSummary,
|
||||
CharacterDetail,
|
||||
CharacterSummary,
|
||||
Currency,
|
||||
DiceMode,
|
||||
Encounter,
|
||||
EncounterMode,
|
||||
@@ -17,6 +18,7 @@ import type {
|
||||
Monster,
|
||||
Note,
|
||||
Npc,
|
||||
SaleOffer,
|
||||
QuickStats,
|
||||
Spell,
|
||||
Weapon,
|
||||
@@ -162,6 +164,28 @@ export function usePatchCharacter(characterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// What can be done for a character at 0 HP: enter a death save (what the d20
|
||||
// showed), steady them, or — the DM only — set the tallies by hand or bring
|
||||
// back the dead.
|
||||
export type DeathSaveAction =
|
||||
| { action: 'roll'; roll: number }
|
||||
| { action: 'stabilize' }
|
||||
| { action: 'set'; successes: number; failures: number }
|
||||
| { action: 'revive' };
|
||||
|
||||
export function useDeathSave(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: DeathSaveAction) =>
|
||||
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/death-saves`, data)).data.character,
|
||||
onSuccess: (character) => {
|
||||
invalidateCharacter(qc, characterId); // the sheet and its quick stats
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
|
||||
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCharacter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -172,7 +196,7 @@ export function useDeleteCharacter(campaignId: string) {
|
||||
|
||||
// What one character can hand to another. Whatever is given leaves the giver.
|
||||
export type Gift =
|
||||
| { kind: 'gold'; amount: number }
|
||||
| { kind: 'gold'; amount: number; currency?: Currency }
|
||||
| { kind: 'weapon' | 'item'; id: string; quantity: number }
|
||||
| { kind: 'spell'; id: string };
|
||||
|
||||
@@ -211,6 +235,16 @@ export function useQuickStats(characterId: string | undefined, enabled: boolean)
|
||||
});
|
||||
}
|
||||
|
||||
// Changes `amount` coins of one kind into another at their fixed worth.
|
||||
export function useExchangeCoins(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { from: Currency; to: Currency; amount: number }) =>
|
||||
(await apiClient.post<{ character: CharacterDetail; received: number }>(`/characters/${characterId}/gold/exchange`, data)).data,
|
||||
onSuccess: () => invalidateCharacter(qc, characterId),
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Character build flow
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -373,10 +407,11 @@ export function useRemoveCharacterSpell(characterId: string) {
|
||||
// Money tracker
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export function useGoldLedger(characterId: string | undefined) {
|
||||
// Only the character's own player and the DM may read it.
|
||||
export function useGoldLedger(characterId: string | undefined, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['character', characterId, 'gold'],
|
||||
enabled: Boolean(characterId),
|
||||
enabled: Boolean(characterId) && enabled,
|
||||
queryFn: async () =>
|
||||
(await apiClient.get<{ gold: number; transactions: GoldTransaction[] }>(`/characters/${characterId}/gold`)).data
|
||||
.transactions,
|
||||
@@ -387,8 +422,8 @@ export function useGoldLedger(characterId: string | undefined) {
|
||||
export function useAdjustGold(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { amount: number; reason?: string }) =>
|
||||
(await apiClient.post<{ gold: number; transaction: GoldTransaction }>(`/characters/${characterId}/gold`, data)).data,
|
||||
mutationFn: async (data: { amount: number; currency?: Currency; reason?: string }) =>
|
||||
(await apiClient.post<{ transaction: GoldTransaction }>(`/characters/${characterId}/gold`, data)).data,
|
||||
onSuccess: () => invalidateCharacter(qc, characterId), // prefix match also refreshes the ledger
|
||||
});
|
||||
}
|
||||
@@ -429,9 +464,12 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) {
|
||||
isStartingOption?: boolean;
|
||||
damageDice?: string | null;
|
||||
damageScaling?: number;
|
||||
revives?: boolean;
|
||||
minLevel?: number;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
exchangeGold?: number | null;
|
||||
exchangeCash?: number | null;
|
||||
}) =>
|
||||
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, pool] }),
|
||||
@@ -502,6 +540,20 @@ export function useCreateMonster(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Changes a monster's stat block. Encounters read it live, so copies already
|
||||
// in a scene show the new numbers (their HP stays as it was).
|
||||
export function useUpdateMonster(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...data }: { id: string } & Partial<Omit<Monster, 'id' | 'campaignId'>>) =>
|
||||
(await apiClient.patch<{ monster: Monster }>(`/campaigns/${campaignId}/monsters/${id}`, data)).data.monster,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] });
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMonster(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -514,10 +566,10 @@ export function useDeleteMonster(campaignId: string) {
|
||||
// Default content library — search/browse, "add to campaign"
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export function useCampaignLibrary(campaignId: string | undefined) {
|
||||
export function useCampaignLibrary(campaignId: string | undefined, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['campaign', campaignId, 'library'],
|
||||
enabled: Boolean(campaignId),
|
||||
enabled: Boolean(campaignId) && enabled, // DM only
|
||||
staleTime: Infinity, // static reference data — never changes under a campaign
|
||||
queryFn: async () => (await apiClient.get<CampaignLibrary>(`/campaigns/${campaignId}/library`)).data,
|
||||
});
|
||||
@@ -660,7 +712,7 @@ export function useEncounter(encounterId: string | undefined) {
|
||||
export function useAddParticipant(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiative?: number }) =>
|
||||
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiativeRoll?: number }) =>
|
||||
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
});
|
||||
@@ -701,16 +753,44 @@ export function usePatchEncounter(encounterId: string) {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
dmNotes?: string | null;
|
||||
locationId?: string | null;
|
||||
}) => (await apiClient.patch(`/encounters/${encounterId}`, data)).data,
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
// The campaign page lists encounters by name, mode and status.
|
||||
if (variables.status || variables.mode || variables.name) qc.invalidateQueries({ queryKey: ['campaign'] });
|
||||
// The campaign page lists encounters by name, mode and status, and an
|
||||
// encounter in progress carries its characters to its location.
|
||||
if (variables.status || variables.mode || variables.name || variables.locationId !== undefined) {
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] });
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
|
||||
|
||||
// Sets which combatants are marked as targets (an empty list clears them).
|
||||
// The DM at any time; a player while their own character is on turn.
|
||||
export function useSetTargets(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (participantIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/targets`, { participantIds })).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
});
|
||||
}
|
||||
|
||||
// The character on turn trying to bring a dead ally back; the server decides
|
||||
// whether they have the means.
|
||||
export function useReviveParticipant(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (participantId: string) => (await apiClient.post(`/encounters/${encounterId}/participants/${participantId}/revive`)).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
qc.invalidateQueries({ queryKey: ['character'] }); // the revived character's sheet
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useNextTurn(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -724,7 +804,7 @@ export function useNextTurn(encounterId: string) {
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export type NewReward =
|
||||
| { type: 'GOLD'; gold: number; note?: string }
|
||||
| { type: 'GOLD'; gold: number; currency?: Currency; note?: string }
|
||||
| { type: 'WEAPON'; weaponId: string; quantity?: number; level?: number; note?: string }
|
||||
| { type: 'ITEM'; itemId: string; quantity?: number; level?: number; note?: string };
|
||||
|
||||
@@ -780,6 +860,72 @@ export function useCreateNpc(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Trade — an NPC's exchange rate and wares are set up by the DM; in a quest
|
||||
// in progress, characters in the scene change money and buy
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export type NewWare = ({ npcId: string } | { encounterId: string }) &
|
||||
({ weaponId: string } | { itemId: string }) & { price: number; currency: Currency; level?: number; stock: number | null };
|
||||
|
||||
// Wares reach the screen inside the NPC list and inside each encounter.
|
||||
function invalidateWares(qc: QueryClient, campaignId: string) {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'npcs'] });
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||
}
|
||||
|
||||
export function useCreateWare(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: NewWare) => (await apiClient.post<{ ware: SaleOffer }>(`/campaigns/${campaignId}/wares`, data)).data.ware,
|
||||
onSuccess: () => invalidateWares(qc, campaignId),
|
||||
});
|
||||
}
|
||||
|
||||
// The price and what is left can change; what is sold, and by whom, cannot.
|
||||
export function usePatchWare(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...data }: { id: string; price?: number; currency?: Currency; stock?: number | null }) =>
|
||||
(await apiClient.patch<{ ware: SaleOffer }>(`/campaigns/${campaignId}/wares/${id}`, data)).data.ware,
|
||||
onSuccess: () => invalidateWares(qc, campaignId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWare(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (wareId: string) => apiClient.delete(`/campaigns/${campaignId}/wares/${wareId}`),
|
||||
onSuccess: () => invalidateWares(qc, campaignId),
|
||||
});
|
||||
}
|
||||
|
||||
// Both change what a character holds, so the sheet and the party list refresh too.
|
||||
function invalidateAfterTrade(qc: QueryClient, encounterId: string) {
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] });
|
||||
}
|
||||
|
||||
// Changes gold into cash or cash into gold with an NPC in the scene, at their rate.
|
||||
export function useExchangeWithNpc(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterId: string; npcId: string; from: 'GOLD' | 'CASH'; amount: number }) =>
|
||||
(await apiClient.post<{ received: number; currency: Currency }>(`/encounters/${encounterId}/exchange`, data)).data,
|
||||
onSuccess: () => invalidateAfterTrade(qc, encounterId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBuyWare(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ wareId, ...data }: { wareId: string; characterId: string; quantity?: number }) =>
|
||||
(await apiClient.post(`/encounters/${encounterId}/wares/${wareId}/buy`, data)).data,
|
||||
onSuccess: () => invalidateAfterTrade(qc, encounterId),
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Notes
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -87,11 +87,14 @@ export interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
inviteCode: string;
|
||||
inviteCode?: string; // DM only
|
||||
startingGold: number;
|
||||
startingItemCount: number;
|
||||
startingSpellCount: number;
|
||||
skipMotivationRoll: boolean;
|
||||
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
|
||||
// Added to each target's AC for every target beyond the first in an area attack; 0 is off.
|
||||
areaAttackAcPenalty: number;
|
||||
ownerId: string;
|
||||
archivedAt: string | null; // set while the campaign is archived
|
||||
role: 'DM' | 'PLAYER';
|
||||
@@ -104,6 +107,22 @@ export interface CampaignMemberSummary {
|
||||
role: 'DM' | 'PLAYER';
|
||||
}
|
||||
|
||||
// The coins a character carries. 1 gold = 10 silver = 100 copper.
|
||||
export type Coin = 'GOLD' | 'SILVER' | 'COPPER';
|
||||
// Coins, plus cash ($): modern money for crossover worlds, with no worth in coins.
|
||||
export type Currency = Coin | 'CASH';
|
||||
|
||||
// Where a character stands at 0 HP. UP: on their feet. DYING: rolling death
|
||||
// saves, three of either kind settles it. STABLE: at 0 HP but no longer
|
||||
// rolling. DEAD: three failures, or one blow too big to survive.
|
||||
export type LifeState = 'UP' | 'DYING' | 'STABLE' | 'DEAD';
|
||||
|
||||
export interface DeathSaves {
|
||||
state: LifeState;
|
||||
successes: number;
|
||||
failures: number;
|
||||
}
|
||||
|
||||
export interface CharacterSummary {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
@@ -117,7 +136,12 @@ export interface CharacterSummary {
|
||||
buildStage: BuildStage;
|
||||
currentHp: number;
|
||||
tempHp: number;
|
||||
gold: number;
|
||||
deathSaves: DeathSaves;
|
||||
// The purse; all four are null for another player's character.
|
||||
gold: number | null;
|
||||
silver: number | null;
|
||||
copper: number | null;
|
||||
cash: number | null;
|
||||
maxHp: number | null;
|
||||
armorClass: number | null;
|
||||
currentLocationId: string | null;
|
||||
@@ -194,6 +218,7 @@ export interface Spell {
|
||||
description: string | null;
|
||||
damageDice: string | null;
|
||||
isStartingOption: boolean;
|
||||
revives: boolean; // brings back the dead: lets its caster revive a dead ally in a battle
|
||||
}
|
||||
|
||||
export interface CharacterSpell {
|
||||
@@ -232,8 +257,9 @@ export interface InventoryItem {
|
||||
export interface GoldTransaction {
|
||||
id: string;
|
||||
characterId: string;
|
||||
currency: Currency; // which coin this entry is in
|
||||
amount: number; // signed: positive = gained, negative = spent
|
||||
balanceAfter: number;
|
||||
balanceAfter: number; // that coin's balance afterwards
|
||||
reason: string | null;
|
||||
createdAt: string;
|
||||
createdBy: { displayName: string } | null;
|
||||
@@ -266,6 +292,7 @@ export interface CharacterDetail {
|
||||
rolledScores: Record<Ability, number>;
|
||||
currentHp: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
armorBonus: number;
|
||||
specialAbilities: string | null;
|
||||
background: string | null;
|
||||
@@ -276,7 +303,13 @@ export interface CharacterDetail {
|
||||
diceMode: DiceMode;
|
||||
currentLocationId: string | null;
|
||||
currentLocation: Location | null;
|
||||
gold: number;
|
||||
gold: number | null;
|
||||
silver: number | null;
|
||||
copper: number | null;
|
||||
cash: number | null;
|
||||
// True when this is another player's character: no gold, inventory or
|
||||
// location is sent, only what the party can see across the table.
|
||||
restricted: boolean;
|
||||
buildStage: BuildStage;
|
||||
buildState: Record<string, unknown> | null;
|
||||
skillProficiencies: SkillProficiency[];
|
||||
@@ -311,6 +344,23 @@ export type EncounterStatus = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
|
||||
export type EncounterMode = 'BATTLE' | 'QUEST';
|
||||
export type NpcDisposition = 'FRIENDLY' | 'NEUTRAL' | 'HOSTILE';
|
||||
|
||||
// Something for sale: a weapon or item from the campaign pools, at a price.
|
||||
// It is an NPC's (on offer wherever they are met) or an encounter's own.
|
||||
export interface SaleOffer {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
npcId: string | null;
|
||||
encounterId: string | null;
|
||||
weaponId: string | null;
|
||||
itemId: string | null;
|
||||
price: number; // for one, in `currency`
|
||||
currency: Currency;
|
||||
level: number; // the gear level it is handed over at
|
||||
stock: number | null; // how many are left; null is no limit
|
||||
weapon: Weapon | null;
|
||||
item: Item | null;
|
||||
}
|
||||
|
||||
// What players see of an NPC in an encounter.
|
||||
export interface NpcPublic {
|
||||
id: string;
|
||||
@@ -318,6 +368,11 @@ export interface NpcPublic {
|
||||
role: string | null;
|
||||
disposition: NpcDisposition;
|
||||
description: string | null;
|
||||
// A money changer's rate: exchangeGold gold for exchangeCash cash, either
|
||||
// way. Both null for an NPC who does not change money.
|
||||
exchangeGold: number | null;
|
||||
exchangeCash: number | null;
|
||||
wares?: SaleOffer[]; // what they have for sale
|
||||
}
|
||||
|
||||
// The DM's full record. dmNotes and the combat stats never reach a player.
|
||||
@@ -333,7 +388,8 @@ export interface EncounterReward {
|
||||
id: string;
|
||||
encounterId: string;
|
||||
type: 'GOLD' | 'WEAPON' | 'ITEM';
|
||||
gold: number | null;
|
||||
gold: number | null; // for a GOLD reward: how many coins...
|
||||
currency: Currency; // ...and which coin
|
||||
weaponId: string | null;
|
||||
itemId: string | null;
|
||||
quantity: number;
|
||||
@@ -372,6 +428,8 @@ export interface CharacterCombatInfo {
|
||||
characterClass: CharacterClass | null;
|
||||
armorClass: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
canRevive: boolean; // has a spell that brings back the dead, at a level they can cast
|
||||
speed: number;
|
||||
initiativeBonus: number;
|
||||
passivePerception: number;
|
||||
@@ -390,14 +448,24 @@ export interface EncounterParticipant {
|
||||
characterId: string | null;
|
||||
monsterId: string | null;
|
||||
displayName: string;
|
||||
// The d20 rolled for initiative (1–20), null until rolled. `initiative` is
|
||||
// that roll plus the combatant's bonus, and is what the order sorts on.
|
||||
initiativeRoll: number | null;
|
||||
initiative: number;
|
||||
currentHp: number;
|
||||
maxHp: number;
|
||||
initiativeBonus?: number; // a monster's or NPC's is sent to the DM only
|
||||
// Null for a player when the campaign hides monster hit points; hpPercent
|
||||
// (0–100) is sent in their place to draw the health bar.
|
||||
currentHp: number | null;
|
||||
maxHp: number | null;
|
||||
hpPercent?: number | null;
|
||||
conditions: string[];
|
||||
isMonster: boolean;
|
||||
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
|
||||
// Out of the fight (a dead character, or a monster or NPC at 0 HP) and so
|
||||
// hidden from the players. Only the DM is ever sent one of these.
|
||||
hidden: boolean;
|
||||
npcId: string | null;
|
||||
character?: { id: string; name: string } | null;
|
||||
character?: { id: string; name: string; userId: string | null } | null;
|
||||
// The DM gets the whole stat block; players only the id and name.
|
||||
monster?: ({ id: string; name: string } & Partial<Monster>) | null;
|
||||
npc?: (NpcPublic & Partial<Npc>) | null;
|
||||
@@ -412,13 +480,23 @@ export interface Encounter {
|
||||
status: EncounterStatus;
|
||||
currentRound: number;
|
||||
activeParticipantId: string | null;
|
||||
// The combatants marked as targets right now, for everyone to see, in the
|
||||
// order they were picked. More than one is an area attack.
|
||||
targetParticipantIds: string[];
|
||||
description: string | null;
|
||||
dmNotes?: string | null; // DM only
|
||||
// Where the scene is. A player is only told once their character has been there.
|
||||
locationId: string | null;
|
||||
location?: Pick<Location, 'id' | 'name' | 'description'> | null;
|
||||
archivedAt: string | null; // archived encounters only reach the DM's list
|
||||
sortOrder: number; // place in the campaign's own order, lowest first
|
||||
createdAt: string;
|
||||
participants?: EncounterParticipant[];
|
||||
participants?: EncounterParticipant[]; // a player's list leaves out the fallen
|
||||
// The dead characters in the scene. Players do not get their cards, only
|
||||
// this, so that an ally can try to revive them.
|
||||
fallen?: { participantId: string; characterId: string; name: string }[];
|
||||
rewards?: EncounterReward[]; // players only receive the awarded ones
|
||||
wares?: SaleOffer[]; // for sale at the scene itself, not by an NPC in it
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
@@ -454,6 +532,7 @@ export interface LibrarySpell {
|
||||
duration: string | null;
|
||||
description: string | null;
|
||||
damageDice?: string; // default damage; absent for spells that deal none
|
||||
revives?: boolean; // brings back the dead
|
||||
classes: CharacterClass[];
|
||||
}
|
||||
|
||||
@@ -500,6 +579,7 @@ export interface QuickStats {
|
||||
currentHp: number;
|
||||
maxHp: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
armorClass: number;
|
||||
initiative: number;
|
||||
speed: number;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { useDeathSave } from '../api/queries';
|
||||
import { InfoTip } from './InfoTip';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import type { DeathSaves as DeathSavesInfo } from '../api/types';
|
||||
|
||||
const STATE_LABELS = { DYING: 'Dying', STABLE: 'Stable', DEAD: '☠ Dead' } as const;
|
||||
|
||||
// Nothing for a character on their feet; otherwise where they stand at 0 HP.
|
||||
export function LifeBadge({ deathSaves }: { deathSaves: DeathSavesInfo }) {
|
||||
if (deathSaves.state === 'UP') return null;
|
||||
return <span className={`badge ${deathSaves.state === 'STABLE' ? '' : 'badge-hostile'}`}>{STATE_LABELS[deathSaves.state]}</span>;
|
||||
}
|
||||
|
||||
// Three pips for one tally. For the DM each is a button: clicking a pip sets
|
||||
// the tally to it, and clicking the last filled one takes it back off.
|
||||
function Pips({ kind, count, onSet }: { kind: 'success' | 'failure'; count: number; onSet?: (count: number) => void }) {
|
||||
const noun = kind === 'success' ? 'successes' : 'failures';
|
||||
return (
|
||||
<span className="death-pips" role="group" aria-label={`${count} of 3 ${noun}`}>
|
||||
{[1, 2, 3].map((n) => {
|
||||
const className = `death-pip is-${kind}${n <= count ? ' is-filled' : ''}`;
|
||||
return onSet ? (
|
||||
<button key={n} type="button" className={className} aria-label={`Set ${noun} to ${n === count ? n - 1 : n}`} onClick={() => onSet(n === count ? n - 1 : n)} />
|
||||
) : (
|
||||
<span key={n} className={className} />
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// What happens to a character at 0 HP: the death-save tallies, and the ways
|
||||
// out of them. `canAct` is the character's own player or the DM; setting the
|
||||
// tallies by hand and raising the dead from here are the DM's alone.
|
||||
export function DeathSaves({
|
||||
characterId,
|
||||
name,
|
||||
deathSaves,
|
||||
canAct,
|
||||
isDM,
|
||||
onError,
|
||||
}: {
|
||||
characterId: string;
|
||||
name: string;
|
||||
deathSaves: DeathSavesInfo;
|
||||
canAct: boolean;
|
||||
isDM: boolean;
|
||||
onError?: (err: unknown) => void;
|
||||
}) {
|
||||
const deathSave = useDeathSave(characterId);
|
||||
const [roll, setRoll] = useState('');
|
||||
const { state, successes, failures } = deathSaves;
|
||||
if (state === 'UP') return null;
|
||||
|
||||
const send = (action: Parameters<typeof deathSave.mutate>[0]) => deathSave.mutate(action, { onError });
|
||||
|
||||
function handleRoll() {
|
||||
const value = Number(roll);
|
||||
if (!roll || !Number.isInteger(value)) return;
|
||||
send({ action: 'roll', roll: value });
|
||||
setRoll('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`death-saves is-${state.toLowerCase()}`}>
|
||||
<div className="flex-row flex-wrap">
|
||||
<LifeBadge deathSaves={deathSaves} />
|
||||
{state === 'DYING' && (
|
||||
<>
|
||||
<span className="death-tally">
|
||||
Saved <Pips kind="success" count={successes} onSet={isDM ? (n) => send({ action: 'set', successes: n, failures }) : undefined} />
|
||||
</span>
|
||||
<span className="death-tally">
|
||||
Failed <Pips kind="failure" count={failures} onSet={isDM ? (n) => send({ action: 'set', successes, failures: n }) : undefined} />
|
||||
</span>
|
||||
<InfoTip term="DEATH_SAVES" />
|
||||
</>
|
||||
)}
|
||||
{state === 'STABLE' && <span className="combatant-sub">Out cold at 0 HP, but no longer rolling. Any healing wakes them; damage starts the saves again.</span>}
|
||||
{state === 'DEAD' && <span className="combatant-sub">{isDM ? 'Healing does nothing now.' : 'The DM can bring them back, or an ally with a reviving spell on their turn in a battle.'}</span>}
|
||||
</div>
|
||||
|
||||
{state === 'DYING' && canAct && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="d20"
|
||||
aria-label={`Death save roll for ${name}`}
|
||||
title="Type what the d20 showed (1 to 20)."
|
||||
value={roll}
|
||||
onChange={(e) => setRoll(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRoll()}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
<button className="btn" onClick={handleRoll} disabled={!roll || deathSave.isPending}>
|
||||
Death save
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
title="Someone steadied them: a Medicine check, a healer's kit or a spell. They stay at 0 HP but stop rolling."
|
||||
onClick={() => send({ action: 'stabilize' })}
|
||||
disabled={deathSave.isPending}
|
||||
>
|
||||
Stabilise
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'DEAD' && isDM && (
|
||||
<button className="btn mt-sm" onClick={() => send({ action: 'revive' })} disabled={deathSave.isPending}>
|
||||
Revive at 1 HP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { hpBarClass } from '../lib/format';
|
||||
|
||||
// A health bar with temporary hit points on it. Temp HP sits on top of real
|
||||
// HP, so it is drawn as its own blue segment after the HP fill, and the bar
|
||||
// stretches to fit when the two together come to more than the maximum (full
|
||||
// health plus a buffer). The fill's colour still reflects real HP only: temp
|
||||
// HP doesn't make a bloodied character look healthy.
|
||||
export function HpBar({ current, max, temp = 0, height, className = 'mt-sm' }: {
|
||||
current: number;
|
||||
max: number;
|
||||
temp?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const hp = Math.max(0, Math.min(current, max));
|
||||
const buffer = Math.max(0, temp);
|
||||
const scale = Math.max(max, hp + buffer, 1);
|
||||
return (
|
||||
<div className={`hp-bar-track ${className}`} style={height ? { height } : undefined}>
|
||||
<div className={hpBarClass(hp, max)} style={{ width: `${(hp / scale) * 100}%` }} />
|
||||
{buffer > 0 && <div className="hp-bar-fill hp-temp" style={{ width: `${(buffer / scale) * 100}%` }} title="Temporary HP" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useId, useState } from 'react';
|
||||
import { useEffect, useId, useRef, useState, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
// Short explanations for the stat labels sprinkled across the sheet, quick
|
||||
// stats, party table, and builder — "what is this, and how is it worked
|
||||
@@ -7,6 +8,7 @@ export const STAT_HELP: Record<string, string> = {
|
||||
AC: 'Armor Class — how hard you are to hit. 10 + your Dexterity modifier + any armor bonus.',
|
||||
HP: 'Hit Points — damage you can take before falling unconscious. Max HP is set by your class hit die, Constitution modifier, and level.',
|
||||
TEMP_HP: 'Temporary Hit Points — an extra buffer that absorbs damage first and disappears when it runs out (it does not stack with more temp HP).',
|
||||
DEATH_SAVES: 'Death saves — at 0 HP you roll a d20 at the start of each of your turns. 10 or more is a success, less is a failure; a 1 counts as two failures and a 20 puts you back up with 1 HP. Three successes and you are stable; three failures and you die. Being hit while down is a failure (two if it was a critical hit). Any healing ends it.',
|
||||
INITIATIVE: 'Initiative — added to a d20 roll at the start of a fight to decide turn order. Equal to your Dexterity modifier.',
|
||||
PROFICIENCY_BONUS: 'Proficiency Bonus — added to attacks, saves, and skills you are trained in. Grows with level (+2 at level 1, up to +6 at level 20).',
|
||||
PASSIVE_PERCEPTION: 'Passive Perception — your always-on alertness score, used instead of rolling when you are not actively searching. 10 + your Perception skill bonus.',
|
||||
@@ -38,8 +40,36 @@ export const STAT_HELP: Record<string, string> = {
|
||||
// (a species, a skill, an ability) without adding it to the shared list.
|
||||
export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?: keyof typeof STAT_HELP; text?: string; label?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [place, setPlace] = useState<CSSProperties | null>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const id = useId();
|
||||
const text = custom ?? (term ? STAT_HELP[term] : undefined);
|
||||
|
||||
// The bubble is drawn on the page itself, fixed to where the ⓘ is on
|
||||
// screen, rather than inside whatever holds the ⓘ: a scrolling table or a
|
||||
// drawer would otherwise cut it off. It sits above the ⓘ when there is room
|
||||
// and below when there isn't, and never runs off the sides.
|
||||
useEffect(() => {
|
||||
if (!open || !trigger.current) return;
|
||||
const rect = trigger.current.getBoundingClientRect();
|
||||
const halfWidth = Math.min(130, window.innerWidth / 2 - 8);
|
||||
const centre = Math.min(Math.max(rect.left + rect.width / 2, halfWidth + 8), window.innerWidth - halfWidth - 8);
|
||||
const above = rect.top > 160;
|
||||
setPlace({
|
||||
left: centre,
|
||||
...(above ? { bottom: window.innerHeight - rect.top + 8 } : { top: rect.bottom + 8 }),
|
||||
});
|
||||
// Its position is only right until the page moves.
|
||||
const close = () => setOpen(false);
|
||||
window.addEventListener('scroll', close, true);
|
||||
window.addEventListener('resize', close);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', close, true);
|
||||
window.removeEventListener('resize', close);
|
||||
setPlace(null); // so the next opening is measured afresh, not shown where the last one was
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
@@ -49,6 +79,7 @@ export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?
|
||||
className="info-tip-trigger"
|
||||
aria-describedby={id}
|
||||
aria-label={label}
|
||||
ref={trigger}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
@@ -56,10 +87,13 @@ export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?
|
||||
>
|
||||
ⓘ
|
||||
</button>
|
||||
{open && (
|
||||
<span role="tooltip" id={id} className="info-tip-bubble">
|
||||
{open &&
|
||||
place &&
|
||||
createPortal(
|
||||
<span role="tooltip" id={id} className="info-tip-bubble" style={place}>
|
||||
{text}
|
||||
</span>
|
||||
</span>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ChangeEvent, FocusEvent, InputHTMLAttributes } from 'react';
|
||||
|
||||
type NumberInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'min' | 'max' | 'step'> & {
|
||||
min?: number;
|
||||
max?: number;
|
||||
// For a field that always holds a number (a level, a bonus): if it is left
|
||||
// empty, it goes back to its starting value instead of being saved blank.
|
||||
keepValue?: boolean;
|
||||
};
|
||||
|
||||
// A whole-number field that cannot hold a value outside its limits. A plain
|
||||
// <input type="number" max={20}> only limits the arrow buttons; typing 500
|
||||
// into it works. This one corrects the text as it is typed: anything above
|
||||
// `max` becomes `max`, anything below `min` becomes `min`, decimals are
|
||||
// dropped. Works the same whether the field is controlled (`value`) or not
|
||||
// (`defaultValue`): the handlers passed in see the corrected value.
|
||||
export function NumberInput({ min, max, keepValue, onChange, onBlur, ...rest }: NumberInputProps) {
|
||||
function limit(input: HTMLInputElement, leaving: boolean) {
|
||||
const raw = input.value;
|
||||
if (raw === '' || raw === '-') {
|
||||
// Nothing typed yet, or the start of a negative number.
|
||||
if (leaving) input.value = keepValue ? String(rest.defaultValue ?? rest.value ?? min ?? 0) : '';
|
||||
return;
|
||||
}
|
||||
let value = Math.trunc(Number(raw));
|
||||
if (Number.isNaN(value)) {
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
if (max !== undefined && value > max) value = max;
|
||||
// While typing, a value below a minimum above 1 may just be unfinished
|
||||
// ("1" on the way to "15"), so that case waits until the field is left.
|
||||
if (min !== undefined && value < min && (leaving || min <= 1)) value = min;
|
||||
if (String(value) !== raw) input.value = String(value);
|
||||
}
|
||||
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
limit(e.target, false);
|
||||
onChange?.(e);
|
||||
}
|
||||
|
||||
function handleBlur(e: FocusEvent<HTMLInputElement>) {
|
||||
limit(e.target, true);
|
||||
onBlur?.(e);
|
||||
}
|
||||
|
||||
return <input {...rest} type="number" inputMode="numeric" step={1} min={min} max={max} onChange={handleChange} onBlur={handleBlur} />;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuickStats } from '../api/queries';
|
||||
import { Spinner, ErrorBanner } from './Spinner';
|
||||
import { fmtMod, hpBarClass } from '../lib/format';
|
||||
import { fmtMod } from '../lib/format';
|
||||
import { HpBar } from './HpBar';
|
||||
import { DeathSaves } from './DeathSaves';
|
||||
|
||||
export function QuickStatsFab({ characterId }: { characterId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -50,9 +52,9 @@ export function QuickStatsDrawer({ characterId, onClose }: { characterId: string
|
||||
<span className="stat-value">{fmtMod(data.initiative)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hp-bar-track mt-sm">
|
||||
<div className={hpBarClass(data.currentHp, data.maxHp)} style={{ width: `${Math.max(0, Math.min(100, (data.currentHp / data.maxHp) * 100))}%` }} />
|
||||
</div>
|
||||
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
|
||||
{/* Read-only here; the saves are rolled on the sheet or in the encounter. */}
|
||||
<DeathSaves characterId={characterId} name={data.name} deathSaves={data.deathSaves} canAct={false} isDM={false} />
|
||||
|
||||
{data.spellcasting && (
|
||||
<div className="card mt-md">
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBuyWare, useExchangeWithNpc } from '../api/queries';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import { WaresEditor } from './Wares';
|
||||
import { stockLabel, wareName } from '../lib/wares';
|
||||
import { formatMoney, formatPurse } from '../lib/format';
|
||||
import type { CharacterSummary, Encounter, Item, SaleOffer, Weapon } from '../api/types';
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// Trade in a quest: changing gold and cash with an NPC who does that, at
|
||||
// their own rate, and buying what the NPCs in the scene — or the scene itself
|
||||
// — have for sale. Everyone sees what is on offer; a player trades for their
|
||||
// own character, the DM for anyone. Trading is open only while the quest is in
|
||||
// progress, and there is none in a battle. The DM sets it all up: an NPC's
|
||||
// rate and wares on the campaign page, the scene's own wares here.
|
||||
export function TradePanel({
|
||||
encounter,
|
||||
isDM,
|
||||
userId,
|
||||
characters,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
isDM: boolean;
|
||||
userId: string | undefined;
|
||||
characters: CharacterSummary[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const exchange = useExchangeWithNpc(encounter.id);
|
||||
const buy = useBuyWare(encounter.id);
|
||||
|
||||
const [traderId, setTraderId] = useState('');
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({});
|
||||
const [sides, setSides] = useState<Record<string, ExchangeSide>>({});
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
if (encounter.mode !== 'QUEST') return null;
|
||||
|
||||
const here = (encounter.participants ?? []).filter((p) => !p.hidden);
|
||||
const npcs = here.flatMap((p) => (p.npc ? [p.npc] : []));
|
||||
const changers = npcs.filter((npc) => npc.exchangeGold && npc.exchangeCash);
|
||||
const stalls: [string, string, SaleOffer[]][] = [
|
||||
...npcs.map((npc): [string, string, SaleOffer[]] => [npc.id, `${npc.name} sells`, npc.wares ?? []]),
|
||||
['here', 'For sale here', encounter.wares ?? []],
|
||||
];
|
||||
const openStalls = stalls.filter(([, , wares]) => wares.length > 0);
|
||||
if (!isDM && changers.length === 0 && openStalls.length === 0) return null;
|
||||
|
||||
// Who can do the trading: a player's own characters in the scene, or for the DM anyone's.
|
||||
const traders = here.flatMap((p) => (p.character && (isDM || p.character.userId === userId) ? [p.character] : []));
|
||||
const trader = traders.find((c) => c.id === traderId) ?? traders[0];
|
||||
const purse = characters.find((c) => c.id === trader?.id);
|
||||
const open = encounter.status === 'ACTIVE';
|
||||
const busy = exchange.isPending || buy.isPending;
|
||||
|
||||
function handleExchange(npc: (typeof changers)[number]) {
|
||||
const from = sides[npc.id] ?? 'GOLD';
|
||||
const amount = Math.floor(Number(amounts[npc.id]));
|
||||
if (!trader || !(amount > 0)) return;
|
||||
setDone(null);
|
||||
exchange.mutate(
|
||||
{ characterId: trader.id, npcId: npc.id, from, amount },
|
||||
{
|
||||
onError,
|
||||
onSuccess: ({ received, currency }) => {
|
||||
setDone(`${trader.name} changed ${formatMoney(amount, from, true)} for ${formatMoney(received, currency, true)} with ${npc.name}.`);
|
||||
setAmounts((a) => ({ ...a, [npc.id]: '' }));
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleBuy(ware: SaleOffer) {
|
||||
if (!trader) return;
|
||||
setDone(null);
|
||||
buy.mutate(
|
||||
{ wareId: ware.id, characterId: trader.id },
|
||||
{ onError, onSuccess: () => setDone(`${trader.name} bought ${(ware.weapon ?? ware.item)?.name} for ${formatMoney(ware.price, ware.currency, true)}.`) }
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">Trade</div>
|
||||
{!open && (
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
{encounter.status === 'PLANNING' ? 'Trading opens once the quest is in progress.' : 'The quest is over; trading is closed.'}
|
||||
</p>
|
||||
)}
|
||||
{traders.length > 0 && (
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<select aria-label="Who is trading" value={trader?.id ?? ''} onChange={(e) => setTraderId(e.target.value)} style={{ flex: 1, minWidth: 110 }}>
|
||||
{traders.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{purse && purse.gold !== null && <span className="mono">{formatPurse(purse)}</span>}
|
||||
</div>
|
||||
)}
|
||||
{traders.length === 0 && (changers.length > 0 || openStalls.length > 0) && (
|
||||
<p className="combatant-sub">{isDM ? 'Add a character to the scene to trade for them.' : 'Your character has to be in the scene to trade.'}</p>
|
||||
)}
|
||||
{done && <p className="combatant-sub">✓ {done}</p>}
|
||||
|
||||
{changers.map((npc) => {
|
||||
const from = sides[npc.id] ?? 'GOLD';
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
const rate = { GOLD: npc.exchangeGold ?? 1, CASH: npc.exchangeCash ?? 1 };
|
||||
const amount = Math.floor(Number(amounts[npc.id]));
|
||||
const whole = amount > 0 && amount % rate[from] === 0;
|
||||
return (
|
||||
<div key={npc.id} className="reward-row">
|
||||
<strong>💱 {npc.name}</strong>
|
||||
<span className="combatant-sub">
|
||||
{' '}
|
||||
changes {formatMoney(rate.GOLD, 'GOLD', true)} for {formatMoney(rate.CASH, 'CASH')}, either way. No silver or copper.
|
||||
</span>
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label={`How much to change with ${npc.name}`}
|
||||
value={amounts[npc.id] ?? ''}
|
||||
onChange={(e) => setAmounts((a) => ({ ...a, [npc.id]: e.target.value }))}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<select
|
||||
aria-label={`What to hand ${npc.name}`}
|
||||
value={from}
|
||||
onChange={(e) => setSides((s) => ({ ...s, [npc.id]: e.target.value as ExchangeSide }))}
|
||||
>
|
||||
<option value="GOLD">gold into cash</option>
|
||||
<option value="CASH">cash into gold</option>
|
||||
</select>
|
||||
<button className="btn" disabled={!open || !trader || !whole || busy} onClick={() => handleExchange(npc)}>
|
||||
{whole ? `Get ${formatMoney((amount / rate[from]) * rate[to], to, true)}` : 'Exchange'}
|
||||
</button>
|
||||
</div>
|
||||
{amount > 0 && !whole && (
|
||||
<div className="combatant-sub">
|
||||
Hand over {from.toLowerCase()} in multiples of {rate[from]}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{openStalls.map(([key, label, wares]) => (
|
||||
<div key={key} className="mt-sm">
|
||||
<div className="section-label">🛒 {label}</div>
|
||||
{wares.map((ware) => (
|
||||
<div key={ware.id} className="reward-row">
|
||||
<div className="flex-between">
|
||||
<span>
|
||||
<strong>{wareName(ware)}</strong>
|
||||
<span className="combatant-sub"> · {stockLabel(ware)}</span>
|
||||
</span>
|
||||
<button className="btn" disabled={!open || !trader || ware.stock === 0 || busy} onClick={() => handleBuy(ware)}>
|
||||
Buy · {formatMoney(ware.price, ware.currency)}
|
||||
</button>
|
||||
</div>
|
||||
{ware.item?.description && <div className="combatant-sub">{ware.item.description}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isDM && (
|
||||
<div className="mt-md">
|
||||
<div className="section-label">Set up: for sale at this encounter</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
For a stall or a machine with nobody behind it. An NPC's wares and exchange rate are set under "Trade" on the{' '}
|
||||
<Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link>, and follow the NPC into every quest.
|
||||
</p>
|
||||
<WaresEditor
|
||||
campaignId={encounter.campaignId}
|
||||
seller={{ encounterId: encounter.id }}
|
||||
wares={encounter.wares ?? []}
|
||||
weapons={weapons}
|
||||
items={items}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react';
|
||||
import { useCreateWare, useDeleteWare, usePatchWare } from '../api/queries';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import { COIN_LABELS, CURRENCIES, formatMoney } from '../lib/format';
|
||||
import { scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import { stockLabel, wareName } from '../lib/wares';
|
||||
import type { Currency, Item, SaleOffer, Weapon } from '../api/types';
|
||||
|
||||
// One ware in the DM's list. "Edit" swaps the line for its price and stock.
|
||||
function WareRow({ campaignId, ware, onError }: { campaignId: string; ware: SaleOffer; onError: (err: unknown) => void }) {
|
||||
const patchWare = usePatchWare(campaignId);
|
||||
const deleteWare = useDeleteWare(campaignId);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [price, setPrice] = useState('');
|
||||
const [currency, setCurrency] = useState<Currency>(ware.currency);
|
||||
const [stock, setStock] = useState('');
|
||||
|
||||
function startEditing() {
|
||||
setPrice(String(ware.price));
|
||||
setCurrency(ware.currency);
|
||||
setStock(ware.stock === null ? '' : String(ware.stock));
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const amount = Math.floor(Number(price));
|
||||
if (!(amount > 0)) return;
|
||||
patchWare.mutate({ id: ware.id, price: amount, currency, stock: stock === '' ? null : Number(stock) }, { onError, onSuccess: () => setEditing(false) });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0.3rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div className="flex-between">
|
||||
<span>
|
||||
{wareName(ware)} — <strong className="mono">{formatMoney(ware.price, ware.currency)}</strong>
|
||||
<span className="combatant-sub"> · {stockLabel(ware)}</span>
|
||||
</span>
|
||||
<span className="flex-row">
|
||||
<button className="btn btn-ghost" aria-label={`Edit the price and stock of ${wareName(ware)}`} onClick={() => (editing ? setEditing(false) : startEditing())}>
|
||||
{editing ? 'Cancel' : 'Edit'}
|
||||
</button>
|
||||
<button className="btn btn-ghost" aria-label={`Stop selling ${wareName(ware)}`} onClick={() => deleteWare.mutate(ware.id, { onError })}>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput min={1} max={1000000} placeholder="Price" aria-label="Price for one" value={price} onChange={(e) => setPrice(e.target.value)} style={{ width: 90 }} />
|
||||
<select aria-label="Price is in" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={9999}
|
||||
placeholder="Stock (empty = no limit)"
|
||||
aria-label="How many are in stock"
|
||||
title="How many can be bought. Leave empty for no limit."
|
||||
value={stock}
|
||||
onChange={(e) => setStock(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 120 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={!price || patchWare.isPending}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The DM's list of what one seller has for sale — an NPC, or an encounter
|
||||
// itself — with a form to put something from the campaign pools up for sale.
|
||||
export function WaresEditor({
|
||||
campaignId,
|
||||
seller,
|
||||
wares,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
campaignId: string;
|
||||
seller: { npcId: string } | { encounterId: string };
|
||||
wares: SaleOffer[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const createWare = useCreateWare(campaignId);
|
||||
|
||||
const [kind, setKind] = useState<'item' | 'weapon'>('item');
|
||||
const [poolId, setPoolId] = useState('');
|
||||
const [level, setLevel] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [currency, setCurrency] = useState<Currency>('GOLD');
|
||||
const [stock, setStock] = useState('');
|
||||
|
||||
const pool: (Weapon | Item)[] = kind === 'weapon' ? weapons : items;
|
||||
const picked = pool.find((entry) => entry.id === poolId);
|
||||
|
||||
function handleAdd() {
|
||||
const amount = Math.floor(Number(price));
|
||||
if (!picked || !(amount > 0)) return;
|
||||
createWare.mutate(
|
||||
{
|
||||
...seller,
|
||||
...(kind === 'weapon' ? { weaponId: picked.id } : { itemId: picked.id }),
|
||||
price: amount,
|
||||
currency,
|
||||
level: level ? Number(level) : undefined,
|
||||
stock: stock === '' ? null : Number(stock),
|
||||
},
|
||||
{
|
||||
onError,
|
||||
onSuccess: () => {
|
||||
setPoolId('');
|
||||
setLevel('');
|
||||
setPrice('');
|
||||
setStock('');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{wares.length === 0 && (
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Nothing for sale yet.
|
||||
</p>
|
||||
)}
|
||||
{wares.map((ware) => (
|
||||
<WareRow key={ware.id} campaignId={campaignId} ware={ware} onError={onError} />
|
||||
))}
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<select
|
||||
aria-label="What kind of thing to sell"
|
||||
value={kind}
|
||||
onChange={(e) => {
|
||||
setKind(e.target.value as typeof kind);
|
||||
setPoolId('');
|
||||
setLevel('');
|
||||
}}
|
||||
>
|
||||
<option value="item">Item</option>
|
||||
<option value="weapon">Weapon</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label={kind === 'weapon' ? 'Weapon to sell' : 'Item to sell'}
|
||||
value={poolId}
|
||||
onChange={(e) => {
|
||||
setPoolId(e.target.value);
|
||||
setLevel('');
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 110 }}
|
||||
>
|
||||
<option value="">{pool.length ? 'Choose…' : 'The pool is empty'}</option>
|
||||
{pool.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{picked && (
|
||||
<select aria-label="Level" value={level || picked.minLevel} onChange={(e) => setLevel(e.target.value)}>
|
||||
{Array.from({ length: MAX_GEAR_LEVEL - picked.minLevel + 1 }, (_, i) => picked.minLevel + i).map((l) => (
|
||||
<option key={l} value={l}>
|
||||
Lv {l}
|
||||
{picked.damageDice ? ` · ${scaledDamageDice(picked.damageDice, l, picked.damageScaling, picked.minLevel)}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput min={1} max={1000000} placeholder="Price" aria-label="Price for one" value={price} onChange={(e) => setPrice(e.target.value)} style={{ width: 90 }} />
|
||||
<select aria-label="Price is in" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={9999}
|
||||
placeholder="Stock (empty = no limit)"
|
||||
aria-label="How many are in stock"
|
||||
title="How many can be bought. Leave empty for no limit."
|
||||
value={stock}
|
||||
onChange={(e) => setStock(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 120 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleAdd} disabled={!picked || !price || createWare.isPending}>
|
||||
Put up for sale
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,12 +29,16 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet HP
|
||||
// A character arriving somewhere new adds it to the places their player can see.
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'locations'] });
|
||||
};
|
||||
const onCharacterCreated = () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||
};
|
||||
const onParticipantUpdated = () => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||
// A player's list only holds the encounters their characters are in.
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
|
||||
};
|
||||
const onRoundAdvanced = () => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||
|
||||
+108
-4
@@ -450,6 +450,7 @@ input[type='radio'] {
|
||||
}
|
||||
|
||||
.hp-bar-track {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
background: var(--bg-inset);
|
||||
@@ -470,6 +471,10 @@ input[type='radio'] {
|
||||
.hp-bar-fill.hp-critical {
|
||||
background: var(--danger-strong);
|
||||
}
|
||||
/* Temporary HP: a separate segment after the real HP. */
|
||||
.hp-bar-fill.hp-temp {
|
||||
background: var(--arcane-strong);
|
||||
}
|
||||
|
||||
.divider {
|
||||
border: none;
|
||||
@@ -626,10 +631,9 @@ input[type='radio'] {
|
||||
color: var(--accent-strong);
|
||||
outline: none;
|
||||
}
|
||||
/* Placed by InfoTip.tsx against the viewport, so no scrolling container can clip it. */
|
||||
.info-tip-bubble {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 50%;
|
||||
position: fixed;
|
||||
transform: translateX(-50%);
|
||||
width: max-content;
|
||||
max-width: 260px;
|
||||
@@ -643,7 +647,7 @@ input[type='radio'] {
|
||||
letter-spacing: normal;
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-deep);
|
||||
z-index: 100;
|
||||
z-index: 200;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -801,6 +805,57 @@ table.data-table tbody tr:hover {
|
||||
.combatant.is-down {
|
||||
opacity: 0.6;
|
||||
}
|
||||
/* The marked target stands out even when it is down or not on turn. */
|
||||
.combatant.is-targeted {
|
||||
opacity: 1;
|
||||
border-color: var(--danger-strong);
|
||||
box-shadow: 0 0 0 1px var(--danger-strong), 0 0 22px rgba(216, 84, 84, 0.3), var(--shadow-deep);
|
||||
}
|
||||
.target-marker {
|
||||
color: var(--danger-strong);
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.btn-target {
|
||||
padding: 0.15rem 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.btn-target.is-on {
|
||||
border-color: var(--danger-strong);
|
||||
color: var(--danger-strong);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
/* What players see of the dead: names, and a way to try to bring them back. */
|
||||
.fallen-bar {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* The DM's attack bar stays in reach while scrolling the initiative order. */
|
||||
.attack-bar {
|
||||
position: sticky;
|
||||
top: 4.5rem;
|
||||
z-index: 20;
|
||||
border-color: var(--danger-strong);
|
||||
}
|
||||
.attack-target {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.attack-target-name {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.attack-bar-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
.combatant-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.1rem;
|
||||
@@ -865,6 +920,55 @@ table.data-table tbody tr:hover {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* At 0 HP: the death-save tallies and what can be done about them. */
|
||||
.death-saves {
|
||||
margin-top: 0.6rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border: 1px solid var(--danger);
|
||||
border-radius: 6px;
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.death-saves.is-stable {
|
||||
border-color: var(--border);
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.death-tally {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.death-pips {
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.death-pip {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--text-dim);
|
||||
background: transparent;
|
||||
}
|
||||
button.death-pip {
|
||||
cursor: pointer;
|
||||
}
|
||||
.death-pip.is-success {
|
||||
border-color: var(--success-strong);
|
||||
}
|
||||
.death-pip.is-failure {
|
||||
border-color: var(--danger-strong);
|
||||
}
|
||||
.death-pip.is-success.is-filled {
|
||||
background: var(--success-strong);
|
||||
}
|
||||
.death-pip.is-failure.is-filled {
|
||||
background: var(--danger-strong);
|
||||
}
|
||||
|
||||
.combatant-details {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Coin, Currency } from '../api/types';
|
||||
|
||||
export const SPECIES_LABELS: Record<string, string> = {
|
||||
HUMAN: 'Human',
|
||||
HALF_ELF: 'Half-Elf',
|
||||
@@ -70,6 +72,38 @@ export function hpBarClass(current: number, max: number): string {
|
||||
return 'hp-bar-fill';
|
||||
}
|
||||
|
||||
// Coins. Silver is worth 0.1 gold and copper 0.01 gold.
|
||||
export const COINS: Coin[] = ['GOLD', 'SILVER', 'COPPER'];
|
||||
// Everything a purse can hold: the coins, plus cash ($) for crossover worlds.
|
||||
// Cash has no worth in coins, so it is never exchanged or used to make change.
|
||||
export const CURRENCIES: Currency[] = [...COINS, 'CASH'];
|
||||
export const COIN_LABELS: Record<Currency, string> = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper', CASH: 'Cash ($)' };
|
||||
export const COIN_FIELD: Record<Currency, 'gold' | 'silver' | 'copper' | 'cash'> = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' };
|
||||
// What one coin is worth, counted in copper.
|
||||
export const COIN_VALUE: Record<Coin, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
|
||||
const COIN_SHORT: Record<Coin, string> = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' };
|
||||
|
||||
// "12 gp", "5 sp", "$40". With `long`, coins are spelled out: "12 gold".
|
||||
export function formatMoney(amount: number, currency: Currency, long = false): string {
|
||||
if (currency === 'CASH') return `$${amount}`;
|
||||
return `${amount} ${long ? COIN_LABELS[currency].toLowerCase() : COIN_SHORT[currency]}`;
|
||||
}
|
||||
|
||||
type Purse = { gold: number | null; silver: number | null; copper: number | null; cash: number | null };
|
||||
|
||||
// "12 gp · 5 sp · 3 cp · $40". Gold always shows; the rest only when held.
|
||||
export function formatPurse(purse: Purse): string {
|
||||
return CURRENCIES.filter((c) => c === 'GOLD' || (purse[COIN_FIELD[c]] ?? 0) > 0)
|
||||
.map((c) => formatMoney(purse[COIN_FIELD[c]] ?? 0, c))
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// The purse's coins as one figure in gold, e.g. 12.65. Cash is not counted.
|
||||
export function purseWorthInGold(purse: Purse): number {
|
||||
const copper = COINS.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
|
||||
return copper / COIN_VALUE.GOLD;
|
||||
}
|
||||
|
||||
export function extractErrorMessage(err: unknown): string {
|
||||
const anyErr = err as { response?: { data?: { error?: unknown } } };
|
||||
const apiError = anyErr?.response?.data?.error;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { scaledDamageDice } from './previewCalc';
|
||||
import type { SaleOffer } from '../api/types';
|
||||
|
||||
// "🗡 Longsword · Lv 2 · 1d10": what a ware is, for anyone looking at it.
|
||||
export function wareName(ware: SaleOffer): string {
|
||||
const entry = ware.weapon ?? ware.item;
|
||||
if (!entry) return 'Removed from the pool';
|
||||
const dice = entry.damageDice ? ` · ${scaledDamageDice(entry.damageDice, ware.level, entry.damageScaling, entry.minLevel)}` : '';
|
||||
return `${ware.weapon ? '🗡' : '🎒'} ${entry.name} · Lv ${ware.level}${dice}`;
|
||||
}
|
||||
|
||||
export function stockLabel(ware: SaleOffer): string {
|
||||
return ware.stock === null ? 'always in stock' : ware.stock === 0 ? 'sold out' : `${ware.stock} left`;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
useCampaign,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useDeleteEncounter,
|
||||
useCampaignMonsters,
|
||||
useCreateMonster,
|
||||
useUpdateMonster,
|
||||
useDeleteMonster,
|
||||
useCampaignWeapons,
|
||||
useCreateWeapon,
|
||||
@@ -40,11 +41,14 @@ import {
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { usePagination, Pagination } from '../components/Paginated';
|
||||
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, spellLevelLabel, spellRequirementLabel } from '../lib/format';
|
||||
import { WaresEditor } from '../components/Wares';
|
||||
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel, spellRequirementLabel } from '../lib/format';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
import { DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import type { CharacterSummary, Encounter, EncounterMode, LibraryMonster, LibrarySpell, LibraryWeapon, NpcDisposition } from '../api/types';
|
||||
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import type { CharacterSummary, Encounter, EncounterMode, Item, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Weapon } from '../api/types';
|
||||
|
||||
// How the encounter list can be ordered. "custom" is the campaign's own order,
|
||||
// which the DM arranges; the rest only change what this browser shows.
|
||||
@@ -101,7 +105,7 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
|
||||
<div className="flex-row">
|
||||
{!inProgress && (
|
||||
<div style={{ textAlign: 'right', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
|
||||
<LifeBadge deathSaves={character.deathSaves} /> HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
|
||||
</div>
|
||||
)}
|
||||
<Link to={inProgress ? `/characters/${character.id}/build` : `/characters/${character.id}`} className="btn">
|
||||
@@ -234,6 +238,214 @@ function SpellDamageSelect({
|
||||
);
|
||||
}
|
||||
|
||||
type MonsterInput = Omit<Monster, 'id' | 'campaignId'>;
|
||||
type AbilityScores = Pick<Monster, 'strScore' | 'dexScore' | 'conScore' | 'intScore' | 'wisScore' | 'chaScore'>;
|
||||
const scoreField = (ability: string) => `${ability.toLowerCase()}Score` as keyof AbilityScores;
|
||||
|
||||
// "STR 8 · DEX 14 · …", in stat-block order.
|
||||
function abilityLine(scores: AbilityScores): string {
|
||||
return ABILITY_ORDER.map((a) => `${a} ${scores[scoreField(a)]}`).join(' · ');
|
||||
}
|
||||
|
||||
// The whole stat block, field for field as a printed one gives it. Adds a
|
||||
// monster, or edits one when `monster` is passed (its values fill the form).
|
||||
function MonsterForm({
|
||||
monster,
|
||||
saving,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
monster?: Monster;
|
||||
saving: boolean;
|
||||
// Rejects if the save failed, so the form keeps what was typed.
|
||||
onSave: (data: MonsterInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => (
|
||||
<div className="field" style={{ marginBottom: 0, ...style }}>
|
||||
<label htmlFor={`monster-${name}`}>{label}</label>
|
||||
{input}
|
||||
</div>
|
||||
);
|
||||
const number = (name: keyof MonsterInput, label: string, min: number, max: number, fallback?: number) =>
|
||||
field(
|
||||
name,
|
||||
label,
|
||||
<NumberInput
|
||||
id={`monster-${name}`}
|
||||
name={name}
|
||||
min={min}
|
||||
max={max}
|
||||
defaultValue={(monster?.[name] as number | undefined) ?? fallback}
|
||||
keepValue={fallback !== undefined}
|
||||
required
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const fd = new FormData(form);
|
||||
const text = (key: string) => String(fd.get(key) || '').trim() || null;
|
||||
const num = (key: string) => Number(fd.get(key));
|
||||
try {
|
||||
await onSave({
|
||||
name: String(fd.get('name')).trim(),
|
||||
challengeRating: text('challengeRating'),
|
||||
ac: num('ac'),
|
||||
maxHp: num('maxHp'),
|
||||
speed: num('speed'),
|
||||
attackBonus: num('attackBonus'),
|
||||
damageDice: text('damageDice'),
|
||||
strScore: num('strScore'),
|
||||
dexScore: num('dexScore'),
|
||||
conScore: num('conScore'),
|
||||
intScore: num('intScore'),
|
||||
wisScore: num('wisScore'),
|
||||
chaScore: num('chaScore'),
|
||||
specialTrait: text('specialTrait'),
|
||||
notes: text('notes'),
|
||||
});
|
||||
if (!monster) form.reset();
|
||||
} catch {
|
||||
// The page shows the error; what was typed stays.
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-3">
|
||||
{field('name', 'Name', <input id="monster-name" name="name" defaultValue={monster?.name} required maxLength={80} />)}
|
||||
{field(
|
||||
'challengeRating',
|
||||
'CR (Challenge Rating)',
|
||||
<input id="monster-challengeRating" name="challengeRating" defaultValue={monster?.challengeRating ?? ''} placeholder="e.g. 1/4 or 3" maxLength={10} />
|
||||
)}
|
||||
{number('ac', 'AC (Armor Class)', 0, 50)}
|
||||
{number('maxHp', 'HP (Hit Points)', 1, 100000)}
|
||||
{number('speed', 'Speed (feet)', 0, 1000, 30)}
|
||||
{number('attackBonus', 'Attack bonus', -20, 50, 0)}
|
||||
{field(
|
||||
'damageDice',
|
||||
'Damage',
|
||||
<input id="monster-damageDice" name="damageDice" defaultValue={monster?.damageDice ?? ''} placeholder="e.g. 1d6+2 necrotic" maxLength={60} />
|
||||
)}
|
||||
</div>
|
||||
<div className="grid mt-sm" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))' }}>
|
||||
{ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))}
|
||||
</div>
|
||||
{field(
|
||||
'specialTrait',
|
||||
'Special trait',
|
||||
<input
|
||||
id="monster-specialTrait"
|
||||
name="specialTrait"
|
||||
defaultValue={monster?.specialTrait ?? ''}
|
||||
placeholder="The one thing that makes it different"
|
||||
maxLength={500}
|
||||
/>,
|
||||
{ marginTop: '0.5rem' }
|
||||
)}
|
||||
{field(
|
||||
'notes',
|
||||
'Notes',
|
||||
<textarea
|
||||
id="monster-notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
className="textarea-block"
|
||||
defaultValue={monster?.notes ?? ''}
|
||||
placeholder="Everything else: other speeds (fly 30, swim 40), extra attacks, spells, tactics, resistances, senses, what it drops"
|
||||
maxLength={2000}
|
||||
/>,
|
||||
{ marginTop: '0.5rem' }
|
||||
)}
|
||||
<div className="flex-row mt-sm">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{monster ? 'Save Changes' : 'Add Monster'}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" className="btn btn-ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// "1 gp = $5": the rate an NPC changes gold and cash at, or null if they don't.
|
||||
function rateLabel(npc: Pick<Npc, 'exchangeGold' | 'exchangeCash'>): string | null {
|
||||
if (!npc.exchangeGold || !npc.exchangeCash) return null;
|
||||
return `${formatMoney(npc.exchangeGold, 'GOLD')} = ${formatMoney(npc.exchangeCash, 'CASH')}`;
|
||||
}
|
||||
|
||||
// What an NPC trades: the rate they change gold and cash at, and their wares.
|
||||
// Players meet both on the encounter page, in a quest the NPC is part of.
|
||||
function NpcTrade({
|
||||
campaignId,
|
||||
npc,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
campaignId: string;
|
||||
npc: Npc;
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const patchNpc = usePatchPoolEntry(campaignId, 'npcs');
|
||||
const [gold, setGold] = useState(npc.exchangeGold ? String(npc.exchangeGold) : '');
|
||||
const [cash, setCash] = useState(npc.exchangeCash ? String(npc.exchangeCash) : '');
|
||||
const changesMoney = Boolean(rateLabel(npc));
|
||||
|
||||
return (
|
||||
<div className="card mt-sm" style={{ background: 'var(--bg-inset)' }}>
|
||||
<div className="section-label">Changes money</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Set a rate and the party can change gold into cash, or cash into gold, with {npc.name}. Silver and copper are not
|
||||
taken. Leave it empty for someone who does not change money.
|
||||
</p>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<NumberInput min={1} max={1000000} aria-label="Gold side of the rate" placeholder="Gold" value={gold} onChange={(e) => setGold(e.target.value)} style={{ width: 90 }} />
|
||||
<span>gold =</span>
|
||||
<span>$</span>
|
||||
<NumberInput min={1} max={1000000} aria-label="Cash side of the rate" placeholder="Cash" value={cash} onChange={(e) => setCash(e.target.value)} style={{ width: 90 }} />
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!gold || !cash || patchNpc.isPending}
|
||||
onClick={() => patchNpc.mutate({ id: npc.id, exchangeGold: Number(gold), exchangeCash: Number(cash) }, { onError })}
|
||||
>
|
||||
Save rate
|
||||
</button>
|
||||
{changesMoney && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
disabled={patchNpc.isPending}
|
||||
onClick={() =>
|
||||
patchNpc.mutate(
|
||||
{ id: npc.id, exchangeGold: null, exchangeCash: null },
|
||||
{
|
||||
onError,
|
||||
onSuccess: () => {
|
||||
setGold('');
|
||||
setCash('');
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
Stop changing money
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="section-label mt-md">For sale</div>
|
||||
<WaresEditor campaignId={campaignId} seller={{ npcId: npc.id }} wares={npc.wares ?? []} weapons={weapons} items={items} onError={onError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryPicker<T extends { key: string; name: string }>({
|
||||
items,
|
||||
onAdd,
|
||||
@@ -293,7 +505,7 @@ export function CampaignHomePage() {
|
||||
const { data: npcs } = useCampaignNpcs(campaignId, data?.campaign.role === 'DM');
|
||||
const { data: notes } = useCampaignNotes(campaignId);
|
||||
const { data: locations } = useCampaignLocations(campaignId);
|
||||
const { data: library } = useCampaignLibrary(campaignId);
|
||||
const { data: library } = useCampaignLibrary(campaignId, data?.campaign.role === 'DM');
|
||||
|
||||
const createCharacter = useCreateCharacter(campaignId);
|
||||
const deleteCharacter = useDeleteCharacter(campaignId);
|
||||
@@ -302,6 +514,7 @@ export function CampaignHomePage() {
|
||||
const setEncounterArchived = useSetEncounterArchived(campaignId);
|
||||
const deleteEncounter = useDeleteEncounter(campaignId);
|
||||
const createMonster = useCreateMonster(campaignId);
|
||||
const updateMonster = useUpdateMonster(campaignId);
|
||||
const deleteMonster = useDeleteMonster(campaignId);
|
||||
const createWeapon = useCreateWeapon(campaignId);
|
||||
const createSpell = useCreateSpell(campaignId);
|
||||
@@ -331,6 +544,7 @@ export function CampaignHomePage() {
|
||||
const [encounterSort, setEncounterSort] = useState<EncounterSort>(storedEncounterSort);
|
||||
const [showArchivedEncounters, setShowArchivedEncounters] = useState(false);
|
||||
const [showNpcForm, setShowNpcForm] = useState(false);
|
||||
const [tradeNpcId, setTradeNpcId] = useState<string | null>(null);
|
||||
const [noteBody, setNoteBody] = useState('');
|
||||
const [dmNoteBody, setDmNoteBody] = useState('');
|
||||
const [newLocationName, setNewLocationName] = useState('');
|
||||
@@ -339,6 +553,7 @@ export function CampaignHomePage() {
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('');
|
||||
const [showMonsterForm, setShowMonsterForm] = useState(false);
|
||||
const [editingMonsterId, setEditingMonsterId] = useState<string | null>(null);
|
||||
const [showWeaponForm, setShowWeaponForm] = useState(false);
|
||||
const [showSpellForm, setShowSpellForm] = useState(false);
|
||||
const [showItemForm, setShowItemForm] = useState(false);
|
||||
@@ -351,6 +566,7 @@ export function CampaignHomePage() {
|
||||
const itemsPage = usePagination(items, 8);
|
||||
const npcsPage = usePagination(npcs, 8);
|
||||
const monstersPage = usePagination(monsters, 8);
|
||||
const editingMonster = monsters?.find((m) => m.id === editingMonsterId);
|
||||
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
||||
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
|
||||
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
|
||||
@@ -486,27 +702,46 @@ export function CampaignHomePage() {
|
||||
startingItemCount: Number(fd.get('startingItemCount')),
|
||||
startingSpellCount: Number(fd.get('startingSpellCount')),
|
||||
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
|
||||
showMonsterHp: fd.get('showMonsterHp') === 'on',
|
||||
areaAttackAcPenalty: Number(fd.get('areaAttackAcPenalty')),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-3">
|
||||
<div className="field">
|
||||
<label>Starting gold</label>
|
||||
<input name="startingGold" type="number" min={0} defaultValue={campaign.startingGold} />
|
||||
<NumberInput name="startingGold" min={0} max={1000000} keepValue defaultValue={campaign.startingGold} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Starting items (weapons + items)</label>
|
||||
<input name="startingItemCount" type="number" min={0} defaultValue={campaign.startingItemCount} />
|
||||
<NumberInput name="startingItemCount" min={0} max={50} keepValue defaultValue={campaign.startingItemCount} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Starting spells</label>
|
||||
<input name="startingSpellCount" type="number" min={0} defaultValue={campaign.startingSpellCount} />
|
||||
<NumberInput name="startingSpellCount" min={0} max={50} keepValue defaultValue={campaign.startingSpellCount} />
|
||||
</div>
|
||||
</div>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" name="skipMotivationRoll" defaultChecked={campaign.skipMotivationRoll} />
|
||||
Skip the motivation step when building a character
|
||||
</label>
|
||||
<label className="checkbox-row mt-sm">
|
||||
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
|
||||
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
|
||||
</label>
|
||||
<label className="flex-row flex-wrap gap-sm mt-sm">
|
||||
Area attack AC penalty
|
||||
<NumberInput
|
||||
name="areaAttackAcPenalty"
|
||||
min={0}
|
||||
max={10}
|
||||
keepValue
|
||||
defaultValue={campaign.areaAttackAcPenalty}
|
||||
style={{ width: 70 }}
|
||||
title="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 the penalty off."
|
||||
/>
|
||||
<span className="combatant-sub">added to each target's AC for every target beyond the first (0 for none)</span>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
|
||||
Save Settings
|
||||
</button>
|
||||
@@ -750,6 +985,18 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isDM && locations && locations.length > 0 && (
|
||||
<div className="mt-lg card">
|
||||
<div className="card-title">Places You've Been</div>
|
||||
{locations.map((loc) => (
|
||||
<div key={loc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<strong>📍 {loc.name}</strong>
|
||||
{loc.description && <p className="subtitle" style={{ margin: '0.2rem 0 0', whiteSpace: 'pre-wrap' }}>{loc.description}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDM && (
|
||||
<div className="mt-lg card">
|
||||
<div className="card-title">
|
||||
@@ -760,7 +1007,8 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>
|
||||
The people your party meets. Add them to an encounter to bring them into a scene. Players never see your notes or an
|
||||
NPC's stats.
|
||||
NPC's stats. "Trade" sets what an NPC sells and the rate they change gold and cash at; players see both when they
|
||||
meet the NPC in a quest.
|
||||
</p>
|
||||
{showNpcForm && (
|
||||
<form
|
||||
@@ -800,9 +1048,9 @@ export function CampaignHomePage() {
|
||||
<textarea name="description" rows={2} className="textarea-block mt-sm" placeholder="What the party sees and knows (shown to players)" />
|
||||
<textarea name="dmNotes" rows={2} className="textarea-block mt-sm" placeholder="Secrets and motives (only you see this)" />
|
||||
<div className="grid grid-cols-3 mt-sm">
|
||||
<input name="ac" type="number" min={0} placeholder="AC (optional)" />
|
||||
<input name="maxHp" type="number" min={1} placeholder="HP (optional)" />
|
||||
<input name="attackBonus" type="number" placeholder="Attack bonus (optional)" />
|
||||
<NumberInput name="ac" min={0} max={50} placeholder="AC (optional)" />
|
||||
<NumberInput name="maxHp" min={1} max={100000} placeholder="HP (optional)" />
|
||||
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus (optional)" />
|
||||
<input name="damageDice" placeholder="Damage (optional, e.g. 1d6)" maxLength={20} />
|
||||
<button type="submit" className="btn btn-primary" disabled={createNpc.isPending}>Add NPC</button>
|
||||
</div>
|
||||
@@ -811,14 +1059,21 @@ export function CampaignHomePage() {
|
||||
<div className="mt-sm">
|
||||
{npcs?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No NPCs yet.</p>}
|
||||
{npcsPage.pageItems.map((npc) => (
|
||||
<div key={npc.id} className="flex-between" style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div key={npc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div className="flex-between">
|
||||
<div>
|
||||
<strong>{npc.name}</strong>
|
||||
{npc.role && <span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>{npc.role}</span>}
|
||||
<span className={`badge badge-${npc.disposition.toLowerCase()}`} style={{ marginLeft: 8 }}>{npc.disposition}</span>
|
||||
{rateLabel(npc) && <span className="badge" style={{ marginLeft: 8 }}>💱 {rateLabel(npc)}</span>}
|
||||
{(npc.wares?.length ?? 0) > 0 && <span className="badge" style={{ marginLeft: 8 }}>🛒 {npc.wares?.length} for sale</span>}
|
||||
{npc.description && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{npc.description}</div>}
|
||||
{npc.dmNotes && <div style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>🔒 {npc.dmNotes}</div>}
|
||||
</div>
|
||||
<div className="flex-row">
|
||||
<button className="btn btn-ghost" onClick={() => setTradeNpcId(tradeNpcId === npc.id ? null : npc.id)}>
|
||||
{tradeNpcId === npc.id ? 'Close' : 'Trade'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Remove ${npc.name}`}
|
||||
@@ -827,6 +1082,17 @@ export function CampaignHomePage() {
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{tradeNpcId === npc.id && (
|
||||
<NpcTrade
|
||||
campaignId={campaignId}
|
||||
npc={npc}
|
||||
weapons={weapons ?? []}
|
||||
items={items ?? []}
|
||||
onError={(err) => setFormError(extractErrorMessage(err))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Pagination page={npcsPage.page} pageCount={npcsPage.pageCount} onPrev={npcsPage.prevPage} onNext={npcsPage.nextPage} />
|
||||
@@ -838,7 +1104,10 @@ export function CampaignHomePage() {
|
||||
<div className="card-title">
|
||||
<span>Locations</span>
|
||||
</div>
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>Track where each character currently is.</p>
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>
|
||||
Track where each character currently is. Give an encounter a location and its characters move there when it
|
||||
begins. Players only see the places their own characters have been.
|
||||
</p>
|
||||
<form onSubmit={handleCreateLocation} className="flex-row flex-wrap">
|
||||
<input
|
||||
placeholder="Location name (e.g. The Rusty Tankard)"
|
||||
@@ -1093,6 +1362,7 @@ export function CampaignHomePage() {
|
||||
description: String(fd.get('description') || '').trim() || null,
|
||||
damageDice: String(fd.get('damageDice') || '') || null,
|
||||
isStartingOption: fd.get('isStartingOption') === 'on',
|
||||
revives: fd.get('revives') === 'on',
|
||||
});
|
||||
(e.target as HTMLFormElement).reset();
|
||||
}}
|
||||
@@ -1113,6 +1383,10 @@ export function CampaignHomePage() {
|
||||
<input type="checkbox" name="isStartingOption" />
|
||||
Starting option
|
||||
</label>
|
||||
<label className="checkbox-row" title="A character who can cast it may revive a dead ally on their turn in a battle">
|
||||
<input type="checkbox" name="revives" />
|
||||
Revives the dead
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary">Add</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -1123,6 +1397,7 @@ export function CampaignHomePage() {
|
||||
<th>Spell</th>
|
||||
<th>Level</th>
|
||||
<th>Damage</th>
|
||||
<th title="A character who can cast it may revive a dead ally on their turn in a battle">Revives</th>
|
||||
<th>Starting</th>
|
||||
<th />
|
||||
</tr>
|
||||
@@ -1138,6 +1413,14 @@ export function CampaignHomePage() {
|
||||
<td>
|
||||
<SpellDamageSelect spellName={s.name} value={s.damageDice} onChange={(damageDice) => patchSpell.mutate({ id: s.id, damageDice })} />
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${s.name} revives the dead`}
|
||||
checked={s.revives}
|
||||
onChange={(e) => patchSpell.mutate({ id: s.id, revives: e.target.checked })}
|
||||
/>
|
||||
</td>
|
||||
<PoolRowControls
|
||||
entry={s}
|
||||
onToggleStarting={(isStartingOption) => patchSpell.mutate({ id: s.id, isStartingOption })}
|
||||
@@ -1158,8 +1441,14 @@ export function CampaignHomePage() {
|
||||
<button className="btn btn-ghost" onClick={() => setShowMonsterLibrary((s) => !s)}>
|
||||
{showMonsterLibrary ? 'Close Library' : '📚 From Library'}
|
||||
</button>
|
||||
<button className="btn btn-ghost" onClick={() => setShowMonsterForm((s) => !s)}>
|
||||
{showMonsterForm ? 'Close' : '+ Add Monster'}
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
setShowMonsterForm(!(showMonsterForm || editingMonster));
|
||||
setEditingMonsterId(null);
|
||||
}}
|
||||
>
|
||||
{showMonsterForm || editingMonster ? 'Close' : '+ Add Monster'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1169,62 +1458,93 @@ export function CampaignHomePage() {
|
||||
adding={addMonsterFromLibrary.isPending}
|
||||
onAdd={(key) => addMonsterFromLibrary.mutate(key)}
|
||||
placeholder="Search the monster library…"
|
||||
renderMeta={(m) => `CR ${m.challengeRating} · AC ${m.ac} · HP ${m.maxHp} · attack +${m.attackBonus}${m.damageDice ? ` for ${m.damageDice}` : ''}`}
|
||||
renderMeta={(m) =>
|
||||
`CR ${m.challengeRating ?? '—'} · AC ${m.ac} · HP ${m.maxHp} · speed ${m.speed} · attack ${fmtMod(m.attackBonus)}${m.damageDice ? ` for ${m.damageDice}` : ''} · ${abilityLine(m)}`
|
||||
}
|
||||
renderDetail={(m) => [m.specialTrait, m.notes].filter(Boolean).join(' — ') || null}
|
||||
/>
|
||||
)}
|
||||
{showMonsterForm && (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
await createMonster.mutateAsync({
|
||||
name: String(fd.get('name')),
|
||||
ac: Number(fd.get('ac')),
|
||||
maxHp: Number(fd.get('maxHp')),
|
||||
speed: 30,
|
||||
strScore: 10,
|
||||
dexScore: 10,
|
||||
conScore: 10,
|
||||
intScore: 10,
|
||||
wisScore: 10,
|
||||
chaScore: 10,
|
||||
attackBonus: Number(fd.get('attackBonus') || 0),
|
||||
damageDice: String(fd.get('damageDice') || '') || null,
|
||||
challengeRating: null,
|
||||
specialTrait: null,
|
||||
notes: null,
|
||||
});
|
||||
(e.target as HTMLFormElement).reset();
|
||||
{(showMonsterForm || editingMonster) && (
|
||||
<div className="card mt-sm" style={{ background: 'var(--bg-inset)' }}>
|
||||
{editingMonster && <div className="section-label">Editing {editingMonster.name}</div>}
|
||||
<MonsterForm
|
||||
key={editingMonster?.id ?? 'new'}
|
||||
monster={editingMonster}
|
||||
saving={createMonster.isPending || updateMonster.isPending}
|
||||
onCancel={editingMonster ? () => setEditingMonsterId(null) : undefined}
|
||||
onSave={async (stats) => {
|
||||
setFormError(null);
|
||||
try {
|
||||
if (editingMonster) {
|
||||
await updateMonster.mutateAsync({ id: editingMonster.id, ...stats });
|
||||
setEditingMonsterId(null);
|
||||
} else {
|
||||
await createMonster.mutateAsync(stats);
|
||||
}
|
||||
} catch (err) {
|
||||
setFormError(extractErrorMessage(err));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
className="grid grid-cols-3"
|
||||
>
|
||||
<input name="name" placeholder="Name" required />
|
||||
<input name="ac" type="number" placeholder="AC" required />
|
||||
<input name="maxHp" type="number" placeholder="Max HP" required />
|
||||
<input name="attackBonus" type="number" placeholder="Attack bonus" />
|
||||
<input name="damageDice" placeholder="Damage dice" />
|
||||
<button type="submit" className="btn btn-primary">Add</button>
|
||||
</form>
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="table-wrap mt-sm">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>CR</th>
|
||||
<th>AC</th>
|
||||
<th>HP</th>
|
||||
<th>Speed</th>
|
||||
<th>Attack</th>
|
||||
<th>Damage</th>
|
||||
<th />
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{monstersPage.pageItems.map((m) => (
|
||||
<tr key={m.id}>
|
||||
<td>{m.name}</td>
|
||||
<td>
|
||||
<strong>{m.name}</strong>
|
||||
<div className="mono" style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{abilityLine(m)}</div>
|
||||
{m.specialTrait && (
|
||||
<div style={{ fontSize: '0.9rem' }}>
|
||||
<strong>Trait:</strong> {m.specialTrait}
|
||||
</div>
|
||||
)}
|
||||
{m.notes && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>{m.notes}</div>}
|
||||
</td>
|
||||
<td>{m.challengeRating || '—'}</td>
|
||||
<td>{m.ac}</td>
|
||||
<td>{m.maxHp}</td>
|
||||
<td>{m.speed}</td>
|
||||
<td>{fmtMod(m.attackBonus)}</td>
|
||||
<td>{m.damageDice || '—'}</td>
|
||||
<td>
|
||||
<button className="btn btn-ghost" onClick={() => deleteMonster.mutate(m.id)}>✕</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Edit ${m.name}`}
|
||||
onClick={() => {
|
||||
setEditingMonsterId(m.id);
|
||||
setShowMonsterForm(false);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
aria-label={`Remove ${m.name}`}
|
||||
onClick={() =>
|
||||
window.confirm(`Remove ${m.name}? Copies already in encounters stay, but lose their stat block.`) && deleteMonster.mutate(m.id)
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useBuildBack, useBuildChoose, useBuildFinalize, useBuildOptions, useBuildRoll, useCampaign, useCampaignItems, useCampaignSpells, useCampaignWeapons, useCharacter, usePatchDiceMode } from '../api/queries';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
|
||||
import { SPECIES_ABILITY_BONUSES, abilityModifier, speciesBonusFor } from '../lib/speciesData';
|
||||
@@ -898,8 +899,7 @@ function RollControl({
|
||||
<>
|
||||
<p>{label} Roll it at the table, then type the result in below.</p>
|
||||
<div className="flex-row" style={{ justifyContent: 'center' }}>
|
||||
<input
|
||||
type="number"
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={sides}
|
||||
placeholder={`1–${sides}`}
|
||||
@@ -1091,9 +1091,8 @@ function PhysicalAbilityRollForm({ onRoll, rolling }: { onRoll: (body?: { abilit
|
||||
<div key={i} className="flex-row flex-wrap mt-sm">
|
||||
<span className="badge">Set {i + 1}</span>
|
||||
{set.map((v, j) => (
|
||||
<input
|
||||
<NumberInput
|
||||
key={j}
|
||||
type="number"
|
||||
min={1}
|
||||
max={6}
|
||||
value={v}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
useRemoveCharacterSpell,
|
||||
useGoldLedger,
|
||||
useAdjustGold,
|
||||
useExchangeCoins,
|
||||
useCampaignCharacters,
|
||||
useGive,
|
||||
type Gift,
|
||||
@@ -30,14 +31,33 @@ import {
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { DeathSaves } from '../components/DeathSaves';
|
||||
import { QuickStatsFab } from '../components/QuickStatsDrawer';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
import { usePagination, Pagination } from '../components/Paginated';
|
||||
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, fmtMod, hpBarClass, extractErrorMessage, spellLevelLabel } from '../lib/format';
|
||||
import {
|
||||
ABILITY_LABELS,
|
||||
CLASS_LABELS,
|
||||
COINS,
|
||||
COIN_FIELD,
|
||||
COIN_LABELS,
|
||||
COIN_VALUE,
|
||||
CURRENCIES,
|
||||
SKILL_LABELS,
|
||||
SPECIES_LABELS,
|
||||
fmtMod,
|
||||
extractErrorMessage,
|
||||
formatMoney,
|
||||
formatPurse,
|
||||
purseWorthInGold,
|
||||
spellLevelLabel,
|
||||
} from '../lib/format';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import { CLASS_HIT_DIE } from '../lib/classData';
|
||||
import type { Ability, CharacterDetail } from '../api/types';
|
||||
import type { Ability, CharacterDetail, Coin, Currency } from '../api/types';
|
||||
|
||||
// The DM's level picker for one held weapon or item, from the level the gear
|
||||
// starts at up to the maximum. Each option shows what the dice become at that
|
||||
@@ -87,19 +107,26 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
|
||||
const others = characters?.filter((c) => c.id !== character.id && c.buildStage === 'COMPLETE') ?? [];
|
||||
if (others.length === 0) return null;
|
||||
|
||||
// "kind:id" identifies the thing picked; gold has no id.
|
||||
// "kind:id" identifies the thing picked; for coins the id is which coin.
|
||||
const [kind, id] = what.split(':');
|
||||
const coin = (kind === 'gold' ? id : 'GOLD') as Currency;
|
||||
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
|
||||
const heldItem = character.inventoryItems.find((i) => i.itemId === id);
|
||||
const max = kind === 'gold' ? character.gold : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
|
||||
// A gift of coins is paid from the whole purse, with change, so any coin
|
||||
// the purse is worth at least one of can be given. Cash is only paid in cash.
|
||||
const held = (c: Currency) => character[COIN_FIELD[c]] ?? 0;
|
||||
const purseInCopper = COINS.reduce((sum, c) => sum + held(c) * COIN_VALUE[c], 0);
|
||||
const affordable = (c: Currency) => (c === 'CASH' ? held(c) : Math.floor(purseInCopper / COIN_VALUE[c]));
|
||||
const coinsHeld = CURRENCIES.filter((c) => affordable(c) > 0);
|
||||
const max = kind === 'gold' ? affordable(coin) : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
|
||||
const count = Math.floor(Number(amount));
|
||||
const countOk = kind === 'spell' || (count >= 1 && count <= max);
|
||||
const nothingToGive = character.gold === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
|
||||
const nothingToGive = coinsHeld.length === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
|
||||
|
||||
function handleGive() {
|
||||
const gift: Gift | null =
|
||||
kind === 'gold'
|
||||
? { kind, amount: count }
|
||||
? { kind, amount: count, currency: coin }
|
||||
: kind === 'weapon' || kind === 'item'
|
||||
? { kind, id, quantity: count }
|
||||
: kind === 'spell'
|
||||
@@ -142,7 +169,11 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
|
||||
style={{ flex: 2, minWidth: 160 }}
|
||||
>
|
||||
<option value="">What to give…</option>
|
||||
{character.gold > 0 && <option value="gold:">Gold ({character.gold} gp)</option>}
|
||||
{coinsHeld.map((c) => (
|
||||
<option key={c} value={`gold:${c}`}>
|
||||
{COIN_LABELS[c]} ({formatMoney(held(c), c)} held)
|
||||
</option>
|
||||
))}
|
||||
{character.weapons.length > 0 && (
|
||||
<optgroup label="Weapons">
|
||||
{character.weapons.map((w) => (
|
||||
@@ -172,12 +203,11 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
|
||||
)}
|
||||
</select>
|
||||
{what && kind !== 'spell' && (
|
||||
<input
|
||||
type="number"
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={max}
|
||||
aria-label={kind === 'gold' ? 'How much gold' : 'How many'}
|
||||
title={kind === 'gold' ? 'How much gold' : `How many (up to ${max})`}
|
||||
aria-label={kind === 'gold' ? 'How many coins' : 'How many'}
|
||||
title={`How many (up to ${max})`}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
style={{ width: 90 }}
|
||||
@@ -212,7 +242,7 @@ export function CharacterSheetPage() {
|
||||
const { data: campaignWeapons } = useCampaignWeapons(character?.campaignId);
|
||||
const { data: campaignSpells } = useCampaignSpells(character?.campaignId);
|
||||
const { data: campaignItems } = useCampaignItems(character?.campaignId);
|
||||
const { data: goldLedger } = useGoldLedger(characterId);
|
||||
const { data: goldLedger } = useGoldLedger(characterId, Boolean(character) && !character?.restricted);
|
||||
const { data: locations } = useCampaignLocations(character?.campaignId);
|
||||
const { data: notes } = useCharacterNotes(characterId);
|
||||
|
||||
@@ -226,6 +256,7 @@ export function CharacterSheetPage() {
|
||||
const patchSpell = usePatchCharacterSpell(characterId);
|
||||
const removeSpell = useRemoveCharacterSpell(characterId);
|
||||
const adjustGold = useAdjustGold(characterId);
|
||||
const exchangeCoins = useExchangeCoins(characterId);
|
||||
const createNote = useCreateNote();
|
||||
const { data: campaigns } = useCampaigns();
|
||||
const copyCharacter = useCopyCharacter(characterId);
|
||||
@@ -235,6 +266,10 @@ export function CharacterSheetPage() {
|
||||
const [pickedItemId, setPickedItemId] = useState('');
|
||||
const [goldAmount, setGoldAmount] = useState('');
|
||||
const [goldReason, setGoldReason] = useState('');
|
||||
const [goldCurrency, setGoldCurrency] = useState<Currency>('GOLD');
|
||||
const [exchangeAmount, setExchangeAmount] = useState('');
|
||||
const [exchangeFrom, setExchangeFrom] = useState<Coin>('GOLD');
|
||||
const [exchangeTo, setExchangeTo] = useState<Coin>('SILVER');
|
||||
const [noteBody, setNoteBody] = useState('');
|
||||
const [pickedWeaponId, setPickedWeaponId] = useState('');
|
||||
const [pickedSpellId, setPickedSpellId] = useState('');
|
||||
@@ -271,7 +306,9 @@ export function CharacterSheetPage() {
|
||||
const amount = Number(hpDelta);
|
||||
if (!amount) return;
|
||||
const next = character!.currentHp + sign * amount;
|
||||
patchCharacter.mutate({ currentHp: next });
|
||||
setFormError(null);
|
||||
// Healing is refused for a character who has died.
|
||||
patchCharacter.mutate({ currentHp: next }, { onError: (err) => setFormError(extractErrorMessage(err)) });
|
||||
setHpDelta('');
|
||||
}
|
||||
|
||||
@@ -298,7 +335,7 @@ export function CharacterSheetPage() {
|
||||
if (!(amount > 0)) return;
|
||||
setFormError(null);
|
||||
try {
|
||||
await adjustGold.mutateAsync({ amount: sign * amount, reason: goldReason.trim() || undefined });
|
||||
await adjustGold.mutateAsync({ amount: sign * amount, currency: goldCurrency, reason: goldReason.trim() || undefined });
|
||||
setGoldAmount('');
|
||||
setGoldReason('');
|
||||
} catch (err) {
|
||||
@@ -306,6 +343,18 @@ export function CharacterSheetPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExchange() {
|
||||
const amount = Math.floor(Number(exchangeAmount));
|
||||
if (!(amount > 0) || exchangeFrom === exchangeTo) return;
|
||||
setFormError(null);
|
||||
try {
|
||||
await exchangeCoins.mutateAsync({ from: exchangeFrom, to: exchangeTo, amount });
|
||||
setExchangeAmount('');
|
||||
} catch (err) {
|
||||
setFormError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddNote(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!noteBody.trim()) return;
|
||||
@@ -412,15 +461,23 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hp-bar-track mt-sm">
|
||||
<div className={hpBarClass(character.currentHp, sheet.hp.max)} style={{ width: `${Math.max(0, Math.min(100, (character.currentHp / sheet.hp.max) * 100))}%` }} />
|
||||
</div>
|
||||
<HpBar current={character.currentHp} max={sheet.hp.max} temp={character.tempHp} />
|
||||
<DeathSaves
|
||||
characterId={characterId}
|
||||
name={character.name}
|
||||
deathSaves={character.deathSaves}
|
||||
canAct={canEdit}
|
||||
isDM={isDM}
|
||||
onError={(err) => setFormError(extractErrorMessage(err))}
|
||||
/>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex-row mt-sm flex-wrap">
|
||||
<input
|
||||
type="number"
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={9999}
|
||||
placeholder="Amount"
|
||||
aria-label="HP change"
|
||||
value={hpDelta}
|
||||
onChange={(e) => setHpDelta(e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
@@ -433,9 +490,11 @@ export function CharacterSheetPage() {
|
||||
</button>
|
||||
<label className="flex-row gap-sm" style={{ marginLeft: '1rem' }}>
|
||||
Temp HP <InfoTip term="TEMP_HP" />
|
||||
<input
|
||||
type="number"
|
||||
<NumberInput
|
||||
key={`temp:${character.tempHp}`}
|
||||
min={0}
|
||||
max={9999}
|
||||
keepValue
|
||||
defaultValue={character.tempHp}
|
||||
style={{ width: 70 }}
|
||||
onBlur={(e) => patchCharacter.mutate({ tempHp: Number(e.target.value) })}
|
||||
@@ -443,9 +502,11 @@ export function CharacterSheetPage() {
|
||||
</label>
|
||||
<label className="flex-row gap-sm">
|
||||
Armor Bonus <InfoTip term="ARMOR_BONUS" />
|
||||
<input
|
||||
type="number"
|
||||
<NumberInput
|
||||
key={`armor:${character.armorBonus}`}
|
||||
min={0}
|
||||
max={30}
|
||||
keepValue
|
||||
defaultValue={character.armorBonus}
|
||||
style={{ width: 70 }}
|
||||
onBlur={(e) => patchCharacter.mutate({ armorBonus: Number(e.target.value) })}
|
||||
@@ -458,10 +519,11 @@ export function CharacterSheetPage() {
|
||||
<div className="flex-row mt-sm flex-wrap">
|
||||
<label className="flex-row gap-sm">
|
||||
Level
|
||||
<input
|
||||
type="number"
|
||||
<NumberInput
|
||||
key={`level:${character.level}`}
|
||||
min={1}
|
||||
max={20}
|
||||
keepValue
|
||||
defaultValue={character.level}
|
||||
style={{ width: 60 }}
|
||||
onBlur={(e) => patchCharacter.mutate({ level: Number(e.target.value) })}
|
||||
@@ -671,6 +733,15 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{character.restricted && (
|
||||
<p className="subtitle mt-md">
|
||||
This is another player's character. You can see what the party sees across the table; what they carry and how
|
||||
much gold they have is theirs to share.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!character.restricted && (
|
||||
<>
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">Inventory</div>
|
||||
{itemsPage.pageItems.map((held) => (
|
||||
@@ -754,19 +825,31 @@ export function CharacterSheetPage() {
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">
|
||||
<span>Money</span>
|
||||
<span className="mono">{character.gold} gp</span>
|
||||
<span className="mono">{formatPurse(character)}</span>
|
||||
</div>
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>
|
||||
1 gold = 10 silver = 100 copper. Worth {purseWorthInGold(character)} gold in all. Spending and giving make
|
||||
change for you: if you are short of a coin, a bigger one is broken. Cash ($) is kept apart: it is never
|
||||
exchanged for coins.
|
||||
</p>
|
||||
{canEdit && (
|
||||
<div className="flex-row flex-wrap">
|
||||
<input
|
||||
type="number"
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label="Gold amount"
|
||||
aria-label="Coin amount"
|
||||
value={goldAmount}
|
||||
onChange={(e) => setGoldAmount(e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<select aria-label="Coin" value={goldCurrency} onChange={(e) => setGoldCurrency(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
placeholder="What for? (optional)"
|
||||
aria-label="Reason"
|
||||
@@ -783,24 +866,63 @@ export function CharacterSheetPage() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{canEdit && (
|
||||
<div className="flex-row flex-wrap mt-sm">
|
||||
<span className="subtitle">Exchange</span>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label="How many coins to exchange"
|
||||
value={exchangeAmount}
|
||||
onChange={(e) => setExchangeAmount(e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<select aria-label="Exchange from" value={exchangeFrom} onChange={(e) => setExchangeFrom(e.target.value as Coin)}>
|
||||
{COINS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="subtitle">into</span>
|
||||
<select aria-label="Exchange into" value={exchangeTo} onChange={(e) => setExchangeTo(e.target.value as Coin)}>
|
||||
{COINS.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn" onClick={handleExchange} disabled={!exchangeAmount || exchangeFrom === exchangeTo || exchangeCoins.isPending}>
|
||||
Exchange
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-sm">
|
||||
{goldLedger?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No transactions yet.</p>}
|
||||
{ledgerPage.pageItems.map((t) => (
|
||||
<div key={t.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div>
|
||||
<strong className="mono">{t.amount > 0 ? `+${t.amount}` : `−${Math.abs(t.amount)}`}</strong>
|
||||
<strong className="mono">
|
||||
{t.amount > 0 ? '+' : '−'}
|
||||
{formatMoney(Math.abs(t.amount), t.currency ?? 'GOLD')}
|
||||
</strong>
|
||||
<span style={{ marginLeft: 8 }}>{t.reason || (t.amount > 0 ? 'Gained' : 'Spent')}</span>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
|
||||
{t.createdBy ? `${t.createdBy.displayName} · ` : ''}
|
||||
{new Date(t.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span className="mono" style={{ color: 'var(--text-muted)' }}>{t.balanceAfter} gp</span>
|
||||
<span className="mono" style={{ color: 'var(--text-muted)' }}>
|
||||
{formatMoney(t.balanceAfter, t.currency ?? 'GOLD')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Composed by the character builder from the player's picks. Players
|
||||
read them; only the DM can rewrite them (new abilities on level-up,
|
||||
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
useCampaignNpcs,
|
||||
useCampaignWeapons,
|
||||
useCampaignItems,
|
||||
useCampaignLocations,
|
||||
useAddParticipant,
|
||||
usePatchParticipant,
|
||||
useRemoveParticipant,
|
||||
useAdvanceRound,
|
||||
useNextTurn,
|
||||
useSetTargets,
|
||||
useReviveParticipant,
|
||||
usePatchEncounter,
|
||||
useSetEncounterArchived,
|
||||
useDeleteEncounter,
|
||||
@@ -22,12 +25,18 @@ import {
|
||||
type NewReward,
|
||||
} from '../api/queries';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { ABILITY_LABELS, CLASS_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, hpBarClass, spellLevelLabel } from '../lib/format';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { DeathSaves } from '../components/DeathSaves';
|
||||
import { TradePanel } from '../components/TradePanel';
|
||||
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel } from '../lib/format';
|
||||
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import type {
|
||||
Ability,
|
||||
CharacterSummary,
|
||||
Currency,
|
||||
Encounter,
|
||||
EncounterMode,
|
||||
EncounterParticipant,
|
||||
@@ -56,6 +65,11 @@ const CONDITIONS = [
|
||||
'Concentrating',
|
||||
];
|
||||
|
||||
// An attack on more than one target is an area attack, and harder to land:
|
||||
// every target's AC counts as `step` higher for each target beyond the first.
|
||||
// The step is the campaign's "area attack AC penalty" setting.
|
||||
const areaPenalty = (targetCount: number, step: number) => Math.max(0, targetCount - 1) * step;
|
||||
|
||||
const KIND_LABELS = { CHARACTER: 'PC', MONSTER: 'Monster', NPC: 'NPC' } as const;
|
||||
const KIND_BADGES = { CHARACTER: 'badge-player', MONSTER: 'badge-hostile', NPC: 'badge-friendly' } as const;
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
|
||||
@@ -90,17 +104,32 @@ function subline(p: EncounterParticipant): string {
|
||||
function CombatantCard({
|
||||
participant: p,
|
||||
isDM,
|
||||
mode,
|
||||
isActive,
|
||||
onPatch,
|
||||
canRollInitiative,
|
||||
targetedBy,
|
||||
acPenalty,
|
||||
onTarget,
|
||||
onRemove,
|
||||
onError,
|
||||
}: {
|
||||
participant: EncounterParticipant;
|
||||
isDM: boolean;
|
||||
mode: EncounterMode;
|
||||
isActive: boolean;
|
||||
onPatch: (data: Partial<EncounterParticipant>) => void;
|
||||
// The DM for anyone; a player for their own character. The same goes for
|
||||
// that character's death saves.
|
||||
canRollInitiative: boolean;
|
||||
// Set while this combatant is a target: who is going for them, or '' when
|
||||
// nobody is on turn.
|
||||
targetedBy?: string;
|
||||
// What an area attack adds to this target's AC; 0 when they are the only one.
|
||||
acPenalty: number;
|
||||
// Marks this combatant as a target, or unmarks them. Absent for someone who
|
||||
// may not pick targets right now, and on the card of whoever is on turn.
|
||||
onTarget?: () => void;
|
||||
onRemove: () => void;
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const [hpDelta, setHpDelta] = useState('');
|
||||
// Details start open for the DM (who runs the scene from this screen) and
|
||||
@@ -112,9 +141,16 @@ function CombatantCard({
|
||||
const info = p.characterCombatInfo;
|
||||
const monster = p.monster;
|
||||
const npc = p.npc;
|
||||
const tracksHp = p.maxHp > 0;
|
||||
const isDown = tracksHp && p.currentHp <= 0;
|
||||
const pct = tracksHp ? Math.max(0, Math.min(100, (p.currentHp / p.maxHp) * 100)) : 0;
|
||||
// A player may get a percentage in place of a monster's or NPC's numbers.
|
||||
const currentHp = p.currentHp ?? 0;
|
||||
const maxHp = p.maxHp ?? 0;
|
||||
const barOnly = p.maxHp == null && p.hpPercent != null;
|
||||
const tracksHp = barOnly || maxHp > 0;
|
||||
const pct = barOnly ? (p.hpPercent ?? 0) : tracksHp ? Math.max(0, Math.min(100, (currentHp / maxHp) * 100)) : 0;
|
||||
const isDown = tracksHp && pct <= 0;
|
||||
// A dying character still has something to do each turn, so its card stays lit.
|
||||
const isDying = info?.deathSaves.state === 'DYING';
|
||||
const isTargeted = targetedBy !== undefined;
|
||||
// Players get a monster's name and health bar only; the stat block is the DM's.
|
||||
const monsterStats = monster && monster.ac !== undefined ? monster : null;
|
||||
const hasDetails = Boolean(info || monsterStats || npc?.dmNotes);
|
||||
@@ -122,7 +158,7 @@ function CombatantCard({
|
||||
function applyDelta(sign: 1 | -1) {
|
||||
const amount = Number(hpDelta);
|
||||
if (!amount) return;
|
||||
onPatch({ currentHp: p.currentHp + sign * amount });
|
||||
onPatch({ currentHp: currentHp + sign * amount });
|
||||
setHpDelta('');
|
||||
}
|
||||
|
||||
@@ -133,10 +169,16 @@ function CombatantCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown ? ' is-down' : ''}`}>
|
||||
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown && !isDying ? ' is-down' : ''}${isTargeted ? ' is-targeted' : ''}`}>
|
||||
<div className="flex-between flex-wrap">
|
||||
<div>
|
||||
{isActive && <div className="turn-marker">▶ On turn</div>}
|
||||
{isTargeted && (
|
||||
<div className="target-marker">
|
||||
🎯 Targeted{targetedBy && ` by ${targetedBy}`}
|
||||
{acPenalty > 0 && ` · area attack, AC +${acPenalty}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-row flex-wrap">
|
||||
<span className={`badge ${KIND_BADGES[p.kind]}`}>{KIND_LABELS[p.kind]}</span>
|
||||
{p.character ? (
|
||||
@@ -147,31 +189,56 @@ function CombatantCard({
|
||||
<span className="combatant-name">{p.displayName}</span>
|
||||
)}
|
||||
{npc && <span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</span>}
|
||||
{isDown && <span className="badge badge-hostile">Down</span>}
|
||||
{isDown && !info && <span className="badge badge-hostile">Down</span>}
|
||||
{p.hidden && (
|
||||
<span className="badge" title="Out of the fight. Players no longer see this card, and it has no turn.">
|
||||
Hidden from players
|
||||
</span>
|
||||
)}
|
||||
{onTarget && (
|
||||
<button
|
||||
className={`btn btn-target${isTargeted ? ' is-on' : ''}`}
|
||||
aria-pressed={isTargeted}
|
||||
title={isTargeted ? 'Stop targeting' : `Mark ${p.displayName} as a target. Marking more than one makes it an area attack.`}
|
||||
onClick={onTarget}
|
||||
>
|
||||
🎯 {isTargeted ? 'Targeted' : 'Target'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="combatant-sub">{subline(p)}</div>
|
||||
</div>
|
||||
{mode === 'BATTLE' &&
|
||||
(isDM ? (
|
||||
<label className="flex-row gap-sm">
|
||||
Initiative
|
||||
<input
|
||||
key={`${p.id}:${p.initiative}`}
|
||||
type="number"
|
||||
defaultValue={p.initiative}
|
||||
{/* Battles and quests both take initiative: who speaks or acts first matters in either. */}
|
||||
{canRollInitiative ? (
|
||||
<label className="flex-row gap-sm" title="Type what the d20 showed (1 to 20). The initiative bonus is added for you.">
|
||||
Initiative roll
|
||||
<NumberInput
|
||||
key={`${p.id}:${p.initiativeRoll}`}
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="d20"
|
||||
aria-label={`Initiative roll for ${p.displayName}`}
|
||||
defaultValue={p.initiativeRoll ?? ''}
|
||||
style={{ width: 64 }}
|
||||
onBlur={(e) => Number(e.target.value) !== p.initiative && onPatch({ initiative: Number(e.target.value) })}
|
||||
onBlur={(e) => {
|
||||
const roll = e.target.value === '' ? null : Number(e.target.value);
|
||||
if (roll !== p.initiativeRoll) onPatch({ initiativeRoll: roll });
|
||||
}}
|
||||
/>
|
||||
<span className="mono">
|
||||
{p.initiativeBonus !== undefined && fmtMod(p.initiativeBonus)}
|
||||
{p.initiativeRoll != null && ` = ${p.initiative}`}
|
||||
</span>
|
||||
</label>
|
||||
) : (
|
||||
<StatChip label="Initiative">{p.initiative}</StatChip>
|
||||
))}
|
||||
<StatChip label="Initiative">{p.initiativeRoll == null ? '—' : p.initiative}</StatChip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="stat-chips mt-sm">
|
||||
{tracksHp && (
|
||||
{tracksHp && !barOnly && (
|
||||
<StatChip label="HP">
|
||||
{p.currentHp}/{p.maxHp}
|
||||
{currentHp}/{maxHp}
|
||||
{info && info.tempHp > 0 && ` +${info.tempHp}`}
|
||||
</StatChip>
|
||||
)}
|
||||
@@ -203,14 +270,12 @@ function CombatantCard({
|
||||
|
||||
{tracksHp && (
|
||||
<>
|
||||
<div className="hp-bar-track mt-sm">
|
||||
<div className={hpBarClass(p.currentHp, p.maxHp)} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<HpBar current={pct} max={100} temp={info && maxHp > 0 ? (info.tempHp / maxHp) * 100 : 0} />
|
||||
{isDM && (
|
||||
<div className="flex-row gap-sm mt-sm">
|
||||
<input
|
||||
type="number"
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={99999}
|
||||
placeholder="Amount"
|
||||
aria-label={`HP change for ${p.displayName}`}
|
||||
value={hpDelta}
|
||||
@@ -225,6 +290,16 @@ function CombatantCard({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{info && p.character && (
|
||||
<DeathSaves
|
||||
characterId={p.character.id}
|
||||
name={p.displayName}
|
||||
deathSaves={info.deathSaves}
|
||||
canAct={canRollInitiative}
|
||||
isDM={isDM}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -361,8 +436,178 @@ function CombatantCard({
|
||||
);
|
||||
}
|
||||
|
||||
// What a combatant hits with, one line per attack: a character's weapons
|
||||
// (the equipped ones, when any are) and damaging spells, or the single attack
|
||||
// of a monster or NPC.
|
||||
function attackLines(p: EncounterParticipant): string[] {
|
||||
const info = p.characterCombatInfo;
|
||||
if (info) {
|
||||
const equipped = info.weapons.filter((w) => w.equipped);
|
||||
const weapons = (equipped.length ? equipped : info.weapons).map(
|
||||
(w) => `${w.name}: ${fmtMod(w.attackBonus)} to hit · ${w.damageDice} ${fmtMod(w.damageBonus)} ${w.damageType}`
|
||||
);
|
||||
const spells = info.spells
|
||||
.filter((s) => s.damageDice)
|
||||
.map((s) => `${s.name}: ${s.damageDice}${info.spellcasting ? ` · ${fmtMod(info.spellcasting.attackBonus)} to hit / DC ${info.spellcasting.saveDC}` : ''}`);
|
||||
return [...weapons, ...spells];
|
||||
}
|
||||
const stats = p.monster ?? p.npc;
|
||||
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
|
||||
return [[stats.attackBonus != null && `${fmtMod(stats.attackBonus)} to hit`, stats.damageDice].filter(Boolean).join(' · ')];
|
||||
}
|
||||
|
||||
type AreaMode = 'SPLIT' | 'EACH';
|
||||
|
||||
// How `amount` lands on `count` targets: all of it on each, or shared out
|
||||
// evenly, any remainder going one point each to the first targets picked
|
||||
// (7 between three is 3, 2 and 2).
|
||||
function shares(amount: number, count: number, mode: AreaMode): number[] {
|
||||
if (mode === 'EACH') return Array.from({ length: count }, () => amount);
|
||||
const base = Math.floor(amount / count);
|
||||
return Array.from({ length: count }, (_, i) => base + (i < amount % count ? 1 : 0));
|
||||
}
|
||||
|
||||
// The DM's shortcut once targets are marked: who is attacking whom, the
|
||||
// numbers needed to call the hit, and a box to apply the damage without
|
||||
// hunting for each target's card. With more than one target it is an area
|
||||
// attack: each AC is shown with the penalty added, the DM ticks who was hit,
|
||||
// and the amount is split between them or dealt to each in full.
|
||||
function AttackBar({
|
||||
attacker,
|
||||
targets,
|
||||
acStep,
|
||||
onApply,
|
||||
onRemove,
|
||||
onClear,
|
||||
}: {
|
||||
attacker: EncounterParticipant | undefined;
|
||||
targets: EncounterParticipant[];
|
||||
acStep: number; // the campaign's area attack AC penalty
|
||||
onApply: (changes: { participantId: string; currentHp: number }[]) => void;
|
||||
onRemove: (participantId: string) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const [amount, setAmount] = useState('');
|
||||
const [mode, setMode] = useState<AreaMode>('SPLIT');
|
||||
const [missed, setMissed] = useState<string[]>([]);
|
||||
|
||||
const isArea = targets.length > 1;
|
||||
const penalty = areaPenalty(targets.length, acStep);
|
||||
const tracksHp = (t: EncounterParticipant) => (t.maxHp ?? 0) > 0;
|
||||
const hit = targets.filter((t) => tracksHp(t) && !missed.includes(t.id));
|
||||
const value = Number(amount) || 0;
|
||||
const portions = shares(value, hit.length, isArea ? mode : 'EACH');
|
||||
const attacks = attacker ? attackLines(attacker) : [];
|
||||
|
||||
function apply(sign: 1 | -1) {
|
||||
if (!value || hit.length === 0) return;
|
||||
onApply(hit.map((t, i) => ({ participantId: t.id, currentHp: (t.currentHp ?? 0) + sign * portions[i] })));
|
||||
setAmount('');
|
||||
setMissed([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card attack-bar">
|
||||
<div className="flex-between flex-wrap">
|
||||
<div className="attack-bar-title">
|
||||
{attacker && <>{attacker.displayName} → </>}🎯 {isArea ? `${targets.length} targets` : targets[0].displayName}
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={onClear}>
|
||||
✕ Clear {isArea ? 'targets' : 'target'}
|
||||
</button>
|
||||
</div>
|
||||
{isArea && (
|
||||
<p className="combatant-sub" style={{ margin: '0.25rem 0 0' }}>
|
||||
Area attack{penalty > 0 && `: every target's AC counts as ${penalty} higher (+${acStep} for each target beyond the first)`}. Untick anyone the
|
||||
roll missed.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{targets.map((t) => {
|
||||
const info = t.characterCombatInfo;
|
||||
const ac = info?.armorClass ?? t.monster?.ac ?? t.npc?.ac;
|
||||
const portion = portions[hit.indexOf(t)];
|
||||
return (
|
||||
<div key={t.id} className="attack-target">
|
||||
{isArea && (
|
||||
<label className="flex-row gap-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`${t.displayName} was hit`}
|
||||
disabled={!tracksHp(t)}
|
||||
checked={hit.includes(t)}
|
||||
onChange={(e) => setMissed(e.target.checked ? missed.filter((id) => id !== t.id) : [...missed, t.id])}
|
||||
/>
|
||||
<span className="attack-target-name">{t.displayName}</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="stat-chips">
|
||||
{ac != null && (
|
||||
<StatChip label={isArea ? 'AC to beat' : 'Target AC'}>
|
||||
{ac + penalty}
|
||||
{penalty > 0 && ` (${ac} +${penalty})`}
|
||||
</StatChip>
|
||||
)}
|
||||
{tracksHp(t) ? (
|
||||
<StatChip label={isArea ? 'HP' : 'Target HP'}>
|
||||
{t.currentHp ?? 0}/{t.maxHp}
|
||||
{info && info.tempHp > 0 && ` +${info.tempHp}`}
|
||||
</StatChip>
|
||||
) : (
|
||||
<span className="combatant-sub">No hit points to track.</span>
|
||||
)}
|
||||
{isArea && value > 0 && portion !== undefined && <StatChip label="Share">{portion}</StatChip>}
|
||||
</div>
|
||||
{isArea && (
|
||||
<button className="btn btn-ghost" aria-label={`Stop targeting ${t.displayName}`} onClick={() => onRemove(t.id)}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{attacks.map((line) => (
|
||||
<div key={line} className="combatant-sub mt-sm">
|
||||
⚔ {line}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{targets.some(tracksHp) && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{/* Remounted when the targets change, so the cursor lands here as soon as one is marked. */}
|
||||
<NumberInput
|
||||
key={targets.map((t) => t.id).join()}
|
||||
autoFocus
|
||||
min={1}
|
||||
max={99999}
|
||||
placeholder={isArea ? 'Total' : 'Amount'}
|
||||
aria-label={isArea ? 'HP change for the targets hit' : `HP change for ${targets[0].displayName}`}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && apply(-1)}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
{isArea && (
|
||||
<select aria-label="How the amount is shared" value={mode} onChange={(e) => setMode(e.target.value as AreaMode)} style={{ width: 'auto' }}>
|
||||
<option value="SPLIT">Split between those hit</option>
|
||||
<option value="EACH">Full amount to each</option>
|
||||
</select>
|
||||
)}
|
||||
<button className="btn btn-danger" onClick={() => apply(-1)} disabled={!value || hit.length === 0}>
|
||||
− Damage
|
||||
</button>
|
||||
<button className="btn" onClick={() => apply(1)} disabled={!value || hit.length === 0}>
|
||||
+ Heal
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function rewardLabel(r: EncounterReward): string {
|
||||
if (r.type === 'GOLD') return `${r.gold} gold`;
|
||||
if (r.type === 'GOLD') return formatMoney(r.gold ?? 0, r.currency ?? 'GOLD', true);
|
||||
const entry = r.type === 'WEAPON' ? r.weapon : r.item;
|
||||
return `${entry?.name ?? 'Removed from the pool'}${r.quantity > 1 ? ` ×${r.quantity}` : ''} · Lv ${r.level}`;
|
||||
}
|
||||
@@ -388,6 +633,7 @@ function RewardsPanel({
|
||||
|
||||
const [type, setType] = useState<NewReward['type']>('GOLD');
|
||||
const [gold, setGold] = useState('');
|
||||
const [currency, setCurrency] = useState<Currency>('GOLD');
|
||||
const [poolId, setPoolId] = useState('');
|
||||
const [quantity, setQuantity] = useState('1');
|
||||
const [level, setLevel] = useState('');
|
||||
@@ -407,7 +653,7 @@ function RewardsPanel({
|
||||
if (type === 'GOLD') {
|
||||
const amount = Math.floor(Number(gold));
|
||||
if (!(amount > 0)) return;
|
||||
reward = { type, gold: amount, ...shared };
|
||||
reward = { type, gold: amount, currency, ...shared };
|
||||
} else {
|
||||
if (!picked) return;
|
||||
const gear = { quantity: Math.max(1, Math.floor(Number(quantity)) || 1), level: level ? Number(level) : undefined, ...shared };
|
||||
@@ -430,7 +676,7 @@ function RewardsPanel({
|
||||
<div className="card-title">Rewards</div>
|
||||
{isDM && rewards.length === 0 && (
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Line up gold, weapons and items here. Players only see a reward once you award it.
|
||||
Line up coins, weapons and items here. Players only see a reward once you award it.
|
||||
</p>
|
||||
)}
|
||||
{rewards.map((r) => (
|
||||
@@ -500,20 +746,29 @@ function RewardsPanel({
|
||||
setLevel('');
|
||||
}}
|
||||
>
|
||||
<option value="GOLD">Gold</option>
|
||||
<option value="GOLD">Coins</option>
|
||||
<option value="WEAPON">Weapon</option>
|
||||
<option value="ITEM">Item</option>
|
||||
</select>
|
||||
{type === 'GOLD' ? (
|
||||
<input
|
||||
type="number"
|
||||
<>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label="Gold amount"
|
||||
aria-label="Coin amount"
|
||||
value={gold}
|
||||
onChange={(e) => setGold(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 90 }}
|
||||
/>
|
||||
<select aria-label="Coin" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
@@ -542,9 +797,9 @@ function RewardsPanel({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={999}
|
||||
aria-label="Quantity"
|
||||
title="Quantity"
|
||||
value={quantity}
|
||||
@@ -577,6 +832,7 @@ function RewardsPanel({
|
||||
|
||||
export function EncounterPage() {
|
||||
const { encounterId = '' } = useParams();
|
||||
const { user } = useAuth();
|
||||
const { data: encounter, isLoading, error } = useEncounter(encounterId);
|
||||
useCampaignSocket(encounter?.campaignId);
|
||||
|
||||
@@ -587,12 +843,15 @@ export function EncounterPage() {
|
||||
const { data: npcs } = useCampaignNpcs(encounter?.campaignId, isDM);
|
||||
const { data: weapons } = useCampaignWeapons(encounter?.campaignId);
|
||||
const { data: items } = useCampaignItems(encounter?.campaignId);
|
||||
const { data: locations } = useCampaignLocations(encounter?.campaignId);
|
||||
|
||||
const addParticipant = useAddParticipant(encounterId);
|
||||
const patchParticipant = usePatchParticipant(encounterId);
|
||||
const removeParticipant = useRemoveParticipant(encounterId);
|
||||
const advanceRound = useAdvanceRound(encounterId);
|
||||
const nextTurn = useNextTurn(encounterId);
|
||||
const setTargets = useSetTargets(encounterId);
|
||||
const revive = useReviveParticipant(encounterId);
|
||||
const patchEncounter = usePatchEncounter(encounterId);
|
||||
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
|
||||
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
|
||||
@@ -602,20 +861,39 @@ export function EncounterPage() {
|
||||
const [pickId, setPickId] = useState('');
|
||||
const [initiative, setInitiative] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
// The DM's own screen leaves the fallen out too, until asked for.
|
||||
const [showDead, setShowDead] = useState(false);
|
||||
|
||||
if (isLoading) return <Spinner label="Setting the scene…" />;
|
||||
if (error || !encounter) return <ErrorBanner message="Could not load this encounter." />;
|
||||
|
||||
const isBattle = encounter.mode === 'BATTLE';
|
||||
const participants = encounter.participants ?? []; // already in initiative order
|
||||
// The fallen: dead characters, and monsters and NPCs at 0 HP. Only the DM is
|
||||
// sent their cards, and they sit apart from those still in the fight.
|
||||
const standing = participants.filter((p) => !p.hidden);
|
||||
const outOfTheFight = participants.filter((p) => p.hidden);
|
||||
const fallen = encounter.fallen ?? [];
|
||||
const active = isBattle && encounter.status === 'ACTIVE' ? participants.find((p) => p.id === encounter.activeParticipantId) : undefined;
|
||||
const targetIds = encounter.targetParticipantIds ?? [];
|
||||
const targets = targetIds.map((id) => participants.find((p) => p.id === id)).filter((p): p is EncounterParticipant => p !== undefined);
|
||||
// The DM marks targets whenever they like; a player while their own character is on turn.
|
||||
const acStep = campaignData?.campaign.areaAttackAcPenalty ?? 0;
|
||||
const canTarget = isDM || (active?.character != null && active.character.userId === user?.id);
|
||||
// A player whose own character is on turn, on their feet and able to raise the dead.
|
||||
const canTryRevive =
|
||||
active?.character != null &&
|
||||
active.character.userId === user?.id &&
|
||||
active.characterCombatInfo?.deathSaves.state === 'UP' &&
|
||||
active.characterCombatInfo.canRevive;
|
||||
const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? [];
|
||||
const onError = (err: unknown) => setFormError(extractErrorMessage(err));
|
||||
const fail = { onError };
|
||||
|
||||
const pickOptions: { id: string; name: string }[] =
|
||||
pickType === 'character'
|
||||
? builtCharacters.filter((c) => !participants.some((p) => p.characterId === c.id))
|
||||
? // The dead join no new scene until they are revived.
|
||||
builtCharacters.filter((c) => c.deathSaves.state !== 'DEAD' && !participants.some((p) => p.characterId === c.id))
|
||||
: pickType === 'monster'
|
||||
? (monsters ?? [])
|
||||
: (npcs ?? []).filter((n) => !participants.some((p) => p.npcId === n.id));
|
||||
@@ -624,7 +902,7 @@ export function EncounterPage() {
|
||||
if (!pickId) return;
|
||||
setFormError(null);
|
||||
addParticipant.mutate(
|
||||
{ [`${pickType}Id`]: pickId, initiative: Number(initiative) || 0 },
|
||||
{ [`${pickType}Id`]: pickId, initiativeRoll: initiative ? Number(initiative) : undefined },
|
||||
{ onError, onSuccess: () => setPickId('') }
|
||||
);
|
||||
}
|
||||
@@ -634,21 +912,42 @@ export function EncounterPage() {
|
||||
key={p.id}
|
||||
participant={p}
|
||||
isDM={isDM}
|
||||
mode={encounter.mode}
|
||||
isActive={p.id === active?.id}
|
||||
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
|
||||
canRollInitiative={isDM || (p.character != null && p.character.userId === user?.id)}
|
||||
targetedBy={targetIds.includes(p.id) ? (active?.displayName ?? '') : undefined}
|
||||
acPenalty={areaPenalty(targets.length, acStep)}
|
||||
onTarget={
|
||||
// Nobody targets themselves: the combatant on turn has no button.
|
||||
canTarget && p.id !== active?.id
|
||||
? () => setTargets.mutate(targetIds.includes(p.id) ? targetIds.filter((id) => id !== p.id) : [...targetIds, p.id], fail)
|
||||
: undefined
|
||||
}
|
||||
onRemove={() => removeParticipant.mutate(p.id, fail)}
|
||||
onError={onError}
|
||||
/>
|
||||
);
|
||||
|
||||
// A quest scene is grouped by who is there rather than by initiative.
|
||||
// In a battle under way the turn passes down the initiative order, so
|
||||
// everyone above whoever is on turn has had their go this round. They drop
|
||||
// to "Next Round" at the bottom, which leaves the combatant on turn at the
|
||||
// top with those still to act beneath them. A new round puts everyone back.
|
||||
const turnIndex = active ? participants.indexOf(active) : -1;
|
||||
const hasActed = (p: EncounterParticipant) => participants.indexOf(p) < turnIndex;
|
||||
|
||||
// A quest scene is grouped by who is there; within each group the order is
|
||||
// still initiative, highest first.
|
||||
const groups: [string, EncounterParticipant[]][] = isBattle
|
||||
? [['Initiative order', participants]]
|
||||
? [
|
||||
['Initiative order', standing.filter((p) => !hasActed(p))],
|
||||
['Next Round', standing.filter(hasActed)],
|
||||
]
|
||||
: [
|
||||
['People you meet', participants.filter((p) => p.kind === 'NPC')],
|
||||
['The party', participants.filter((p) => p.kind === 'CHARACTER')],
|
||||
['Creatures', participants.filter((p) => p.kind === 'MONSTER')],
|
||||
['People you meet', standing.filter((p) => p.kind === 'NPC')],
|
||||
['The party', standing.filter((p) => p.kind === 'CHARACTER')],
|
||||
['Creatures', standing.filter((p) => p.kind === 'MONSTER')],
|
||||
];
|
||||
if (showDead) groups.push(['Out of the fight, hidden from players', outOfTheFight]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -661,6 +960,7 @@ export function EncounterPage() {
|
||||
<span className="badge badge-dm">{MODE_LABELS[encounter.mode]}</span>
|
||||
<span className={`badge ${encounter.status === 'ACTIVE' ? 'badge-live' : ''}`}>{STATUS_LABELS[encounter.status]}</span>
|
||||
{isBattle && <span className="badge">Round {encounter.currentRound}</span>}
|
||||
{encounter.location && <span className="badge">📍 {encounter.location.name}</span>}
|
||||
{encounter.archivedAt && <span className="badge">Archived</span>}
|
||||
{active && <span className="turn-marker">▶ {active.displayName}'s turn</span>}
|
||||
</div>
|
||||
@@ -679,6 +979,19 @@ export function EncounterPage() {
|
||||
<option value="BATTLE">Battle</option>
|
||||
<option value="QUEST">NPC quest</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label="Location"
|
||||
title="Where this scene takes place. Its characters move here when it begins."
|
||||
value={encounter.locationId ?? ''}
|
||||
onChange={(e) => patchEncounter.mutate({ locationId: e.target.value || null }, fail)}
|
||||
>
|
||||
<option value="">No location</option>
|
||||
{locations?.map((loc) => (
|
||||
<option key={loc.id} value={loc.id}>
|
||||
📍 {loc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{encounter.status === 'PLANNING' && (
|
||||
<button className="btn btn-primary" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' }, fail)}>
|
||||
{isBattle ? 'Begin Battle' : 'Begin Quest'}
|
||||
@@ -757,7 +1070,51 @@ export function EncounterPage() {
|
||||
|
||||
<div className="encounter-layout mt-md">
|
||||
<div>
|
||||
{participants.length === 0 && (
|
||||
{isDM && targets.length > 0 && (
|
||||
<AttackBar
|
||||
attacker={active}
|
||||
targets={targets}
|
||||
acStep={acStep}
|
||||
onApply={(changes) => Promise.all(changes.map((change) => patchParticipant.mutateAsync(change))).catch(onError)}
|
||||
onRemove={(id) => setTargets.mutate(targetIds.filter((x) => x !== id), fail)}
|
||||
onClear={() => setTargets.mutate([], fail)}
|
||||
/>
|
||||
)}
|
||||
{!isDM && fallen.length > 0 && (
|
||||
<div className="card fallen-bar">
|
||||
<div className="card-title">☠ Fallen</div>
|
||||
{fallen.map((f) => (
|
||||
<div key={f.participantId} className="flex-between flex-wrap">
|
||||
<span className="combatant-name">{f.name}</span>
|
||||
{canTryRevive && (
|
||||
<button
|
||||
className="btn"
|
||||
title={`${active?.displayName} tries to bring ${f.name} back, at 1 HP.`}
|
||||
disabled={revive.isPending}
|
||||
onClick={() => {
|
||||
setFormError(null);
|
||||
revive.mutate(f.participantId, fail);
|
||||
}}
|
||||
>
|
||||
Try revive
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>
|
||||
{canTryRevive
|
||||
? `${active?.displayName} has a spell that can bring back the dead.`
|
||||
: 'A character with a spell that brings back the dead can try to revive them on their own turn.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isDM && outOfTheFight.length > 0 && (
|
||||
<label className="checkbox-row mt-md" title="Dead characters, and monsters and NPCs at 0 HP. Players never see them.">
|
||||
<input type="checkbox" checked={showDead} onChange={(e) => setShowDead(e.target.checked)} />
|
||||
Show dead ({outOfTheFight.length})
|
||||
</label>
|
||||
)}
|
||||
{participants.length === 0 && fallen.length === 0 && (
|
||||
<div className="empty-state card">
|
||||
{isDM
|
||||
? 'Nobody here yet. Add characters, monsters or NPCs from the panel alongside.'
|
||||
@@ -803,16 +1160,16 @@ export function EncounterPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-row gap-sm mt-sm">
|
||||
{isBattle && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Initiative"
|
||||
aria-label="Initiative"
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="Initiative roll (d20)"
|
||||
title="What the d20 showed, 1 to 20. Leave empty to roll later; the bonus is added for you."
|
||||
aria-label="Initiative roll"
|
||||
value={initiative}
|
||||
onChange={(e) => setInitiative(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={handleAdd} disabled={!pickId || addParticipant.isPending}>
|
||||
Add
|
||||
</button>
|
||||
@@ -825,6 +1182,16 @@ export function EncounterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TradePanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
userId={user?.id}
|
||||
characters={builtCharacters}
|
||||
weapons={weapons ?? []}
|
||||
items={items ?? []}
|
||||
onError={onError}
|
||||
/>
|
||||
|
||||
<RewardsPanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
|
||||
@@ -5,7 +5,9 @@ import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { usePagination, Pagination } from '../components/Paginated';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
import { CLASS_LABELS, SPECIES_LABELS, hpBarClass } from '../lib/format';
|
||||
import { CLASS_LABELS, SPECIES_LABELS, formatPurse } from '../lib/format';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
|
||||
// A DM's one-screen view of every character's stats at a glance; a player
|
||||
@@ -55,7 +57,7 @@ export function PartyOverviewPage() {
|
||||
<th>
|
||||
AC <InfoTip term="AC" />
|
||||
</th>
|
||||
<th>Gold</th>
|
||||
<th>Coins</th>
|
||||
<th>Location</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -77,20 +79,19 @@ export function PartyOverviewPage() {
|
||||
<td>
|
||||
{c.maxHp != null ? (
|
||||
<div style={{ minWidth: 90 }}>
|
||||
<span className="mono">{c.currentHp}/{c.maxHp}</span>
|
||||
<div className="hp-bar-track mt-sm" style={{ height: 6 }}>
|
||||
<div
|
||||
className={hpBarClass(c.currentHp, c.maxHp)}
|
||||
style={{ width: `${Math.max(0, Math.min(100, (c.currentHp / c.maxHp) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono">
|
||||
{c.currentHp}/{c.maxHp}
|
||||
{c.tempHp > 0 && ` +${c.tempHp}`}
|
||||
</span>{' '}
|
||||
<LifeBadge deathSaves={c.deathSaves} />
|
||||
<HpBar current={c.currentHp} max={c.maxHp} temp={c.tempHp} height={6} />
|
||||
</div>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td>{c.armorClass ?? '—'}</td>
|
||||
<td className="mono">{c.gold}</td>
|
||||
<td className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
|
||||
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.0.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.0.0",
|
||||
"version": "1.10.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.0.0",
|
||||
"version": "1.10.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "0.0.0",
|
||||
"version": "1.10.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.0.0",
|
||||
"version": "1.10.0",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
@@ -10,6 +10,7 @@
|
||||
"scripts": {
|
||||
"dev:backend": "npm run dev --workspace backend",
|
||||
"dev:frontend": "npm run dev --workspace frontend",
|
||||
"test:backend": "npm run test --workspace backend"
|
||||
"test:backend": "npm run test --workspace backend",
|
||||
"seed:hollowcrown": "npm run seed:hollowcrown --workspace backend"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user