Compare commits

..
10 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 6b466d497e V1.8.0: the fallen are hidden, and allies can revive the dead
- A dead character, or a monster or NPC at 0 HP, is out of the fight: the
  server no longer sends it to players, target marks included, and it has
  no turn. Healing or reviving brings it straight back.
- The DM's screen leaves the fallen out too until "Show dead" is ticked,
  which lists them in a group of their own, marked hidden.
- Players see dead characters by name in a Fallen box. The player whose
  character is on turn in a battle gets "Try revive" there if that
  character is on their feet and knows a spell that revives, at a level
  they can cast. The dead come back at 1 HP.
- Spells have a "Revives" tick in the campaign's spell list. Revivify and
  Raise Dead have it, in the library and in existing campaigns.
- A dead character cannot be added to an encounter until revived.
- All packages at version 1.8.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:25:46 +02:00
joshuaandClaude Opus 5.5 f8462a6e31 V1.7.2: the combatant on turn cannot be their own target
- In a battle under way, whoever is on turn has no Target button on their
  card, and the server refuses a target list that includes them.
- All packages at version 1.7.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:28:28 +02:00
joshuaandClaude Opus 5.5 754eb0ee68 V1.7.1: area attack AC penalty is a campaign setting
- The amount an area attack adds to each target's AC per extra target is
  now set per campaign, from 0 (no penalty) to 10. It starts at 2, so
  existing campaigns behave as before.
- The DM changes it in the campaign settings; the attack bar and the
  targets' cards use it.
- All packages at version 1.7.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:11:46 +02:00
joshuaandClaude Opus 5.5 f8cee43ca2 V1.7.0: area attacks on several targets
- More than one combatant can be marked as a target; the Target button now
  adds to the targets instead of moving the mark.
- Several targets make an area attack, which is harder to land: every
  target's AC counts as 2 higher for each target beyond the first. The
  attack bar shows the AC to beat, and each target's card names the penalty.
- The DM unticks any target the roll missed, then applies one total: split
  evenly between those hit (remainder to the first marked), or the full
  amount to each. Each share is shown before it is applied.
- The single target column from V1.6.0 becomes a list, carried over by the
  migration.
- All packages at version 1.7.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:01:42 +02:00
joshuaandClaude Opus 5.5 e4456b0df2 V1.6.0: targets and an attack bar in encounters
- One combatant at a time can be marked as the target with the Target
  button on its card. The DM can mark anyone at any time; a player can
  while their own character is on turn in a battle.
- The target's card is outlined for everyone and names who is going for it.
- While a target is marked the DM gets an attack bar above the list: who is
  attacking whom, the target's AC and HP, the attacker's weapons, spells or
  monster attack, and a box to apply damage or healing straight away.
- The target is cleared when the turn or round moves on, when the encounter
  begins, ends or is reopened, or when the target is removed.
- All packages at version 1.6.0.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:37:38 +02:00
joshuaandClaude Opus 5.5 4362b7423e V1.4.0: silver and copper, with automatic change
- A character's purse holds gold, silver and copper as separate coins:
  1 gold = 10 silver = 100 copper. The money log records each entry's coin.
- Spending and giving are paid from the whole purse: coins of the kind
  named go first, then the nearest bigger coin is broken (change comes back
  in as few coins as possible), then smaller coins are counted out. A
  payment is refused only when the whole purse isn't worth enough.
- Exchange converts one coin into another at the fixed worth.
- Encounter rewards and gifts between characters can be in any coin; the
  party overview shows the whole purse.
- All packages at version 1.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:56:24 +02:00
joshuaandClaude Opus 5.5 98a89185c1 V1.3.1: initiative in quests, tooltips no longer clipped, deploy script
- Quests take initiative rolls like battles do; each quest group lists
  highest first. The total is roll plus bonus and can exceed 20 (test added).
- Info tooltips are drawn against the viewport, so the party overview
  table (or any scrolling container) can't cut them off.
- backend: `npm run deploy` applies migrations and regenerates the Prisma
  client in one step; README explains why both are needed on a server.
- All packages at version 1.3.1.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:53:48 +02:00
joshuaandClaude Opus 5.5 2637a510df Set frontend package version to 1.0.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:05:57 +02:00
50 changed files with 7985 additions and 551 deletions
+1
View File
@@ -6,3 +6,4 @@ build/
*.log
.DS_Store
.idea/
temp/
+15 -2
View File
@@ -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
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.0.0",
"version": "1.8.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -15,6 +15,8 @@
"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');
+62 -3
View File
@@ -118,6 +118,14 @@ enum RewardType {
ITEM
}
// The coins a character carries. Their worth is fixed in utils/grants.js:
// 1 gold = 10 silver = 100 copper.
enum Currency {
GOLD
SILVER
COPPER
}
enum NoteVisibility {
DM_ONLY
SHARED
@@ -158,6 +166,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.
@@ -188,10 +201,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 +267,22 @@ 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 is counted on its own and only changes through the
// money ledger (GoldTransaction).
gold Int @default(0)
silver Int @default(0)
copper Int @default(0)
background String?
appearance String?
@@ -271,6 +311,7 @@ model Character {
spells CharacterSpell[]
notes Note[]
encounterEntries EncounterParticipant[]
locationVisits CharacterLocationVisit[]
@@map("characters")
}
@@ -382,12 +423,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 +458,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[]
@@ -495,8 +541,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,6 +559,7 @@ 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[]
@@ -519,7 +573,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)
@@ -548,6 +603,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
+32
View File
@@ -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],
});
+19 -194
View File
@@ -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 })) });
// 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 })),
run(
{
...aldenfell,
// Everyday weapons are what a new character picks from; the rest are earned.
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') },
);
+210
View File
@@ -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 };
+25 -23
View File
@@ -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
+2 -2
View File
@@ -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'] },
];
+712 -24
View File
@@ -439,8 +439,8 @@ describe('who can change what on a character', () => {
expect(itemRes.status).toBe(201);
const itemId = itemRes.body.item.id;
// Players can see the pool but not add to it or help themselves from it.
expect((await request(app).get(`/campaigns/${campaign.id}/items`).set(playerAuth)).body.items).toHaveLength(1);
// Players can't see what is in the pool, add to it, or help themselves from it.
expect((await request(app).get(`/campaigns/${campaign.id}/items`).set(playerAuth)).body.items).toHaveLength(0);
expect((await request(app).post(`/campaigns/${campaign.id}/items`).set(playerAuth).send({ name: 'Crown' })).status).toBe(403);
expect((await request(app).post(`/characters/${characterId}/weapons`).set(playerAuth).send({ weaponId })).status).toBe(403);
expect((await request(app).post(`/characters/${characterId}/inventory`).set(playerAuth).send({ itemId })).status).toBe(403);
@@ -584,7 +584,7 @@ describe('hit points', () => {
const full = (await patch(playerAuth, { currentHp: 9999 })).body.character;
expect(full.currentHp).toBe(level5.sheet.hp.max);
expect((await patch(playerAuth, { currentHp: -50 })).body.character.currentHp).toBe(0);
expect((await patch(playerAuth, { currentHp: -1 })).body.character.currentHp).toBe(0);
await patch(playerAuth, { currentHp: 9999 });
const level1 = (await patch(dmAuth, { level: 1 })).body.character;
@@ -598,7 +598,7 @@ describe('hit points', () => {
const maxHp = (await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.sheet.hp.max;
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Brawl' })).body.encounter.id;
const added = await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId, initiative: 12 });
const added = await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId, initiativeRoll: 12 });
const participantId = added.body.participant.id;
const combatant = async () => (await request(app).get(`/encounters/${encounterId}`).set(playerAuth)).body.encounter.participants[0];
const sheetHp = async () => (await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.currentHp;
@@ -613,7 +613,7 @@ describe('hit points', () => {
expect((await combatant()).currentHp).toBe(maxHp - 1);
// The tracker is clamped like the sheet, and can't give a character a different max.
await request(app).patch(`/encounters/${encounterId}/participants/${participantId}`).set(dmAuth).send({ currentHp: -20, maxHp: 500 });
await request(app).patch(`/encounters/${encounterId}/participants/${participantId}`).set(dmAuth).send({ currentHp: -1, maxHp: 500 });
expect(await sheetHp()).toBe(0);
expect((await combatant()).maxHp).toBe(maxHp);
@@ -629,7 +629,7 @@ describe('hit points', () => {
// A combatant can only be edited through its own encounter.
const otherEncounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Elsewhere' })).body.encounter.id;
const wrong = await request(app).patch(`/encounters/${otherEncounterId}/participants/${participantId}`).set(dmAuth).send({ initiative: 1 });
const wrong = await request(app).patch(`/encounters/${otherEncounterId}/participants/${participantId}`).set(dmAuth).send({ initiativeRoll: 1 });
expect(wrong.status).toBe(404);
});
});
@@ -654,7 +654,7 @@ describe('money tracker', () => {
expect((await gold(playerAuth, { amount: 0 })).status).toBe(400);
expect((await gold(playerAuth, { amount: 1.5 })).status).toBe(400);
// Another player can read the ledger but not write to it.
// Another player can neither write to the ledger nor read it.
const stranger = await request(app)
.post('/auth/register')
.send({ email: uniqueEmail('gold-other'), password: 'password123', displayName: 'Other' });
@@ -663,7 +663,9 @@ describe('money tracker', () => {
await request(app).post(`/campaigns/join/${campaign.inviteCode}`).set(strangerAuth);
expect((await gold(strangerAuth, { amount: 5 })).status).toBe(403);
const ledger = await request(app).get(`/characters/${characterId}/gold`).set(strangerAuth);
expect((await request(app).get(`/characters/${characterId}/gold`).set(strangerAuth)).status).toBe(403);
const ledger = await request(app).get(`/characters/${characterId}/gold`).set(playerAuth);
expect(ledger.status).toBe(200);
expect(ledger.body.gold).toBe(45);
expect(ledger.body.transactions.map((t) => [t.amount, t.balanceAfter, t.reason, t.createdBy.displayName])).toEqual([
@@ -890,13 +892,17 @@ describe('encounters', () => {
}
itIfDb('have a mode, a public description and DM-only notes', async () => {
const { dmAuth, playerAuth, c } = await scene('encmode');
const { dmAuth, playerAuth, c, heroId } = await scene('encmode');
const quest = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'The Missing Miller', mode: 'QUEST', description: 'Find the miller.' })).body.encounter;
expect(quest.mode).toBe('QUEST');
const battle = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Ambush' })).body.encounter;
expect(battle.mode).toBe('BATTLE');
const e = `/encounters/${quest.id}`;
// A player whose character isn't in a scene can't see it exists.
expect((await request(app).get(e).set(playerAuth)).status).toBe(404);
expect((await request(app).get(`${c}/encounters`).set(playerAuth)).body.encounters).toEqual([]);
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
expect((await request(app).patch(e).set(playerAuth).send({ mode: 'BATTLE' })).status).toBe(403);
const patched = await request(app).patch(e).set(dmAuth).send({ dmNotes: 'The miller is the werewolf.' });
expect(patched.body.encounter.dmNotes).toBe('The miller is the werewolf.');
@@ -905,14 +911,20 @@ describe('encounters', () => {
expect(playerView.description).toBe('Find the miller.');
expect(playerView.dmNotes).toBeUndefined();
const playerList = (await request(app).get(`${c}/encounters`).set(playerAuth)).body.encounters;
expect(playerList.map((x) => [x.name, x.mode]).sort()).toEqual([['Ambush', 'BATTLE'], ['The Missing Miller', 'QUEST']]);
expect(playerList.map((x) => [x.name, x.mode])).toEqual([['The Missing Miller', 'QUEST']]);
expect((await request(app).get(`/encounters/${battle.id}`).set(playerAuth)).status).toBe(404);
expect(playerList.every((x) => x.dmNotes === undefined)).toBe(true);
const dmList = (await request(app).get(`${c}/encounters`).set(dmAuth)).body.encounters;
expect(dmList.map((x) => x.name).sort()).toEqual(['Ambush', 'The Missing Miller']);
expect(dmList.find((x) => x.id === quest.id).dmNotes).toBe('The miller is the werewolf.');
// Taken out of the scene, the player loses sight of it again.
const heroRow = (await request(app).get(e).set(dmAuth)).body.encounter.participants.find((p) => p.characterId === heroId);
await request(app).delete(`${e}/participants/${heroRow.id}`).set(dmAuth);
expect((await request(app).get(e).set(playerAuth)).status).toBe(404);
});
itIfDb('NPCs are DM-managed and join encounters without leaking the DM\'s notes or stats', async () => {
const { dmAuth, playerAuth, c } = await scene('npcs');
const { dmAuth, playerAuth, c, heroId } = await scene('npcs');
const other = await setupCampaign('npcs-other');
expect((await request(app).post(`${c}/npcs`).set(playerAuth).send({ name: 'Nope' })).status).toBe(403);
@@ -942,12 +954,13 @@ describe('encounters', () => {
expect(dmView['Mayor Orla'].npc).toMatchObject({ role: 'Mayor', disposition: 'HOSTILE', dmNotes: 'Secretly bankrupt.', ac: 11 });
expect(dmView['Old Hermit']).toMatchObject({ kind: 'NPC', maxHp: 0 }); // no combat stats
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
const playerView = byName((await request(app).get(e).set(playerAuth)).body.encounter);
expect(playerView['Mayor Orla'].npc).toEqual({ id: mayor.id, name: 'Mayor Orla', role: 'Mayor', disposition: 'HOSTILE', description: 'Tired but kind.' });
// Deleting an NPC takes it out of the scenes it was in.
expect((await request(app).delete(`${c}/npcs/${hermit.id}`).set(dmAuth)).status).toBe(204);
expect(Object.keys(byName((await request(app).get(e).set(dmAuth)).body.encounter))).toEqual(['Mayor Orla']);
expect(Object.keys(byName((await request(app).get(e).set(dmAuth)).body.encounter)).sort()).toEqual(['Aria', 'Mayor Orla']);
});
itIfDb('show the DM full monster stat blocks and everyone a character\'s headline numbers', async () => {
@@ -955,8 +968,8 @@ describe('encounters', () => {
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster;
const encounterId = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Cave' })).body.encounter.id;
const e = `/encounters/${encounterId}`;
await request(app).post(`${e}/participants`).set(dmAuth).send({ monsterId: goblin.id, initiative: 5 });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId, initiative: 15 });
await request(app).post(`${e}/participants`).set(dmAuth).send({ monsterId: goblin.id, initiativeRoll: 5 });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId, initiativeRoll: 20 });
const [hero, monster] = (await request(app).get(e).set(dmAuth)).body.encounter.participants;
expect(hero.kind).toBe('CHARACTER');
@@ -980,9 +993,9 @@ describe('encounters', () => {
expect((await request(app).post(`${e}/next-turn`).set(dmAuth)).status).toBe(400); // nobody to take a turn
const add = async (body) => (await request(app).post(`${e}/participants`).set(dmAuth).send(body)).body.participant.id;
const slow = await add({ monsterId: goblin.id, initiative: 3 });
const fast = await add({ characterId: heroId, initiative: 18 });
const mid = await add({ characterId: allyId, initiative: 10 });
const slow = await add({ monsterId: goblin.id, initiativeRoll: 1 });
const fast = await add({ characterId: heroId, initiativeRoll: 20 });
const mid = await add({ characterId: allyId, initiativeRoll: 10 });
const state = (res) => [res.body.encounter.activeParticipantId, res.body.encounter.currentRound, res.body.encounter.status];
expect((await request(app).post(`${e}/next-turn`).set(playerAuth)).status).toBe(403);
@@ -1009,8 +1022,8 @@ describe('encounters', () => {
const encounterId = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Dragon Hoard' })).body.encounter.id;
const e = `/encounters/${encounterId}`;
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId, initiative: 12 });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: allyId, initiative: 8 });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId, initiativeRoll: 20 });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: allyId, initiativeRoll: 1 });
const add = (body, auth = dmAuth) => request(app).post(`${e}/rewards`).set(auth).send(body);
expect((await add({ type: 'GOLD', gold: 5 }, playerAuth)).status).toBe(403);
@@ -1350,7 +1363,7 @@ describe('locations', () => {
const listRes = await request(app).get(`/campaigns/${campaign.id}/locations`).set(playerAuth);
expect(listRes.status).toBe(200);
expect(listRes.body.locations.map((l) => l.id)).toContain(locationId);
expect(listRes.body.locations).toEqual([]); // nowhere this player's character has been
const charRes = await request(app)
.post(`/campaigns/${campaign.id}/characters`)
@@ -1375,7 +1388,8 @@ describe('default content library', () => {
itIfDb('lists library entries and copies one into the campaign', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('library');
const listRes = await request(app).get(`/campaigns/${campaign.id}/library`).set(playerAuth);
expect((await request(app).get(`/campaigns/${campaign.id}/library`).set(playerAuth)).status).toBe(403);
const listRes = await request(app).get(`/campaigns/${campaign.id}/library`).set(dmAuth);
expect(listRes.status).toBe(200);
expect(listRes.body.weapons.length).toBeGreaterThan(20);
expect(listRes.body.spells.length).toBeGreaterThan(50);
@@ -1468,7 +1482,7 @@ describe('DM-only campaign notes', () => {
});
describe('encounter combat info', () => {
itIfDb('shows a character combatant\'s weapons to every campaign member, not just the DM', async () => {
itIfDb('shows a character combatant\'s weapons to the other players in the encounter, not just the DM', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('encountercombat');
const weaponRes = await request(app)
@@ -1511,7 +1525,9 @@ describe('encounter combat info', () => {
const encounterRes = await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Ambush' });
const encounterId = encounterRes.body.encounter.id;
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId, initiative: 10 });
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId, initiativeRoll: 10 });
const watcherId = await buildCharacter(campaign.id, playerAuth, 'Watcher');
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId: watcherId });
const playerViewRes = await request(app).get(`/encounters/${encounterId}`).set(playerAuth);
expect(playerViewRes.status).toBe(200);
@@ -1611,6 +1627,7 @@ describe('ordering, archiving and deleting encounters', () => {
const ids = {};
for (const name of ['One', 'Two', 'Three']) {
ids[name] = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name })).body.encounter.id;
await request(app).post(`/encounters/${ids[name]}/participants`).set(dmAuth).send({ characterId: heroId });
}
const names = async (auth) => (await request(app).get(`${c}/encounters`).set(auth)).body.encounters.map((e) => e.name);
@@ -1637,7 +1654,6 @@ describe('ordering, archiving and deleting encounters', () => {
expect(await names(playerAuth)).toEqual(['One', 'Two', 'Three']);
// Deleting takes the scene but leaves what it already gave out.
await request(app).post(`/encounters/${ids.One}/participants`).set(dmAuth).send({ characterId: heroId });
const rewardRes = await request(app).post(`/encounters/${ids.One}/rewards`).set(dmAuth).send({ type: 'GOLD', gold: 12 });
await request(app).post(`/encounters/${ids.One}/rewards/${rewardRes.body.reward.id}/award`).set(dmAuth).send({ characterId: heroId });
const goldBefore = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character.gold;
@@ -1651,3 +1667,675 @@ describe('ordering, archiving and deleting encounters', () => {
expect(goldBefore).toBeGreaterThanOrEqual(12);
});
});
describe('what a player may see', () => {
itIfDb('the pools: starting options while building, then only what their character holds', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('poolview');
const c = `/campaigns/${campaign.id}`;
const weapon = (name, isStartingOption) =>
request(app).post(`${c}/weapons`).set(dmAuth).send({ name, damageDice: '1d6', damageType: 'slashing', attackAbility: 'STR', isStartingOption });
await weapon('Starter Sword', true);
const secret = (await weapon('Secret Blade', false)).body.weapon;
await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope', isStartingOption: true });
await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Crown Shard' });
await request(app).post(`${c}/spells`).set(dmAuth).send({ name: 'Spark', level: 0, isStartingOption: true });
await request(app).post(`${c}/spells`).set(dmAuth).send({ name: 'Doom', level: 5 });
const names = async (pool, auth) => (await request(app).get(`${c}/${pool}`).set(auth)).body[pool].map((x) => x.name);
expect(await names('weapons', dmAuth)).toEqual(['Secret Blade', 'Starter Sword']);
// No character yet: nothing to choose from, nothing held.
expect(await names('weapons', playerAuth)).toEqual([]);
expect(await names('items', playerAuth)).toEqual([]);
expect(await names('spells', playerAuth)).toEqual([]);
// Building: the starting options, and only those.
const hero = (await request(app).post(`${c}/characters`).set(playerAuth).send({ name: 'Newcomer' })).body.character;
expect(await names('weapons', playerAuth)).toEqual(['Starter Sword']);
expect(await names('items', playerAuth)).toEqual(['Rope']);
expect(await names('spells', playerAuth)).toEqual(['Spark']);
await request(app).delete(`/characters/${hero.id}`).set(dmAuth);
// Built: only what the character holds.
const heroId = await buildCharacter(campaign.id, playerAuth, 'Veteran');
expect(await names('weapons', playerAuth)).toEqual([]);
await request(app).post(`/characters/${heroId}/weapons`).set(dmAuth).send({ weaponId: secret.id });
expect(await names('weapons', playerAuth)).toEqual(['Secret Blade']);
expect(await names('items', playerAuth)).toEqual([]);
// The built-in library is the DM's tool.
expect((await request(app).get(`${c}/library`).set(playerAuth)).status).toBe(403);
expect((await request(app).get(`${c}/library`).set(dmAuth)).status).toBe(200);
});
itIfDb('the invite code is the DM’s alone', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('invitehide');
const c = `/campaigns/${campaign.id}`;
expect((await request(app).get(c).set(dmAuth)).body.campaign.inviteCode).toBe(campaign.inviteCode);
expect((await request(app).get(c).set(playerAuth)).body.campaign.inviteCode).toBeUndefined();
const listed = (await request(app).get('/campaigns').set(playerAuth)).body.campaigns.find((x) => x.id === campaign.id);
expect(listed.inviteCode).toBeUndefined();
});
itIfDb('another player’s character: HP and AC, but not their gold, pack or money log', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('sheetview');
const c = `/campaigns/${campaign.id}`;
await request(app).patch(c).set(dmAuth).send({ startingGold: 20 });
const mineId = await buildCharacter(campaign.id, playerAuth, 'Mine');
const theirsId = await buildCharacter(campaign.id, dmAuth, 'Theirs');
const item = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Love Letter' })).body.item;
await request(app).post(`/characters/${theirsId}/inventory`).set(dmAuth).send({ itemId: item.id });
const theirs = (await request(app).get(`/characters/${theirsId}`).set(playerAuth)).body.character;
expect(theirs.restricted).toBe(true);
expect(theirs.gold).toBeNull();
expect(theirs.inventoryItems).toEqual([]);
expect(theirs.sheet.armorClass).toEqual(expect.any(Number));
expect(theirs.currentHp).toEqual(expect.any(Number));
expect((await request(app).get(`/characters/${theirsId}/gold`).set(playerAuth)).status).toBe(403);
const mine = (await request(app).get(`/characters/${mineId}`).set(playerAuth)).body.character;
expect(mine.restricted).toBe(false);
expect(mine.gold).toBe(20);
expect((await request(app).get(`/characters/${mineId}/gold`).set(playerAuth)).status).toBe(200);
const dmView = (await request(app).get(`/characters/${theirsId}`).set(dmAuth)).body.character;
expect(dmView.gold).toBe(20);
expect(dmView.inventoryItems.map((i) => i.item.name)).toEqual(['Love Letter']);
const list = (await request(app).get(`${c}/characters`).set(playerAuth)).body.characters;
expect(list.find((x) => x.id === mineId).gold).toBe(20);
expect(list.find((x) => x.id === theirsId)).toMatchObject({ gold: null, armorClass: expect.any(Number), currentHp: expect.any(Number) });
const dmList = (await request(app).get(`${c}/characters`).set(dmAuth)).body.characters;
expect(dmList.find((x) => x.id === theirsId).gold).toBe(20);
});
itIfDb('locations: only where their character has been, and an encounter in progress takes them there', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('locview');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Wanderer');
const strangerId = await buildCharacter(campaign.id, dmAuth, 'Stranger');
const place = async (name) => (await request(app).post(`${c}/locations`).set(dmAuth).send({ name, description: `About ${name}.` })).body.location;
const cave = await place('Cave');
const keep = await place('Keep');
const vault = await place('Vault');
const seen = async () => (await request(app).get(`${c}/locations`).set(playerAuth)).body.locations.map((l) => l.name);
expect((await request(app).get(`${c}/locations`).set(dmAuth)).body.locations).toHaveLength(3);
expect(await seen()).toEqual([]);
// A planned encounter with a location moves nobody and reveals nothing.
const other = await setupCampaign('locview-other');
const foreign = (await request(app).post(`/campaigns/${other.campaign.id}/locations`).set(other.dmAuth).send({ name: 'Elsewhere' })).body.location;
expect((await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Bad', locationId: foreign.id })).status).toBe(400);
const enc = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Into the Cave', locationId: cave.id })).body.encounter;
const e = `/encounters/${enc.id}`;
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
expect(await seen()).toEqual([]);
expect((await request(app).get(e).set(playerAuth)).body.encounter.location).toBeNull();
expect((await request(app).get(e).set(dmAuth)).body.encounter.location.name).toBe('Cave');
// Beginning it puts the characters in it there.
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
expect((await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character.currentLocation.name).toBe('Cave');
expect(await seen()).toEqual(['Cave']);
expect((await request(app).get(e).set(playerAuth)).body.encounter.location).toMatchObject({ name: 'Cave', description: 'About Cave.' });
// Moving a running encounter, or joining one, does the same.
expect((await request(app).patch(e).set(dmAuth).send({ locationId: foreign.id })).status).toBe(400);
await request(app).patch(e).set(dmAuth).send({ locationId: keep.id });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: strangerId });
expect((await request(app).get(`/characters/${strangerId}`).set(dmAuth)).body.character.currentLocation.name).toBe('Keep');
expect(await seen()).toEqual(['Cave', 'Keep']);
// Places stay known after moving on, and after the encounter is archived or deleted.
await request(app).patch(e).set(dmAuth).send({ status: 'COMPLETE', archived: true });
expect(await seen()).toEqual(['Cave', 'Keep']);
await request(app).delete(e).set(dmAuth);
expect(await seen()).toEqual(['Cave', 'Keep']);
// The DM placing a character by hand counts as a visit too.
await request(app).patch(`/characters/${heroId}`).set(dmAuth).send({ currentLocationId: vault.id });
expect(await seen()).toEqual(['Cave', 'Keep', 'Vault']);
// Where the party is: named only if this player has been there themselves.
await request(app).patch(`/characters/${strangerId}`).set(dmAuth).send({ currentLocationId: cave.id });
const list = async () => (await request(app).get(`${c}/characters`).set(playerAuth)).body.characters.find((x) => x.id === strangerId);
expect((await list()).currentLocationName).toBe('Cave');
const hidden = await place('Hidden Grove');
await request(app).patch(`/characters/${strangerId}`).set(dmAuth).send({ currentLocationId: hidden.id });
expect((await list()).currentLocationName).toBeNull();
expect((await request(app).get(`/characters/${strangerId}`).set(playerAuth)).body.character.currentLocation).toBeNull();
});
itIfDb('monster hit points: numbers, or only a bar when the campaign says so', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('hpview');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Watcher');
const monster = (await request(app).post(`${c}/monsters`).set(dmAuth).send({ name: 'Ogre', ac: 11, maxHp: 59 })).body.monster;
const npc = (await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Captain', maxHp: 40 })).body.npc;
const enc = (await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Bridge' })).body.encounter;
const e = `/encounters/${enc.id}`;
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
const ogreRow = (await request(app).post(`${e}/participants`).set(dmAuth).send({ monsterId: monster.id })).body.participant;
await request(app).post(`${e}/participants`).set(dmAuth).send({ npcId: npc.id });
const view = async (auth) => Object.fromEntries((await request(app).get(e).set(auth)).body.encounter.participants.map((p) => [p.displayName, p]));
expect(campaign.showMonsterHp).toBe(true);
expect((await view(playerAuth)).Ogre).toMatchObject({ currentHp: 59, maxHp: 59 });
expect((await request(app).patch(c).set(playerAuth).send({ showMonsterHp: false })).status).toBe(403);
expect((await request(app).patch(c).set(dmAuth).send({ showMonsterHp: false })).body.campaign.showMonsterHp).toBe(false);
await request(app).patch(`${e}/participants/${ogreRow.id}`).set(dmAuth).send({ currentHp: 1 });
const player = await view(playerAuth);
expect(player.Ogre).toMatchObject({ currentHp: null, maxHp: null, hpPercent: 2 }); // 1 of 59, rounded up
expect(player.Captain).toMatchObject({ currentHp: null, maxHp: null, hpPercent: 100 });
expect(player.Watcher.currentHp).toEqual(expect.any(Number)); // characters keep their numbers
expect(player.Watcher.hpPercent).toBeUndefined();
const dm = await view(dmAuth);
expect(dm.Ogre).toMatchObject({ currentHp: 1, maxHp: 59 });
expect(dm.Ogre.hpPercent).toBeUndefined();
await request(app).patch(`${e}/participants/${ogreRow.id}`).set(dmAuth).send({ currentHp: 0 });
// At 0 HP it is out of the fight, and out of the players' sight.
expect((await view(playerAuth)).Ogre).toBeUndefined();
expect((await view(dmAuth)).Ogre).toMatchObject({ currentHp: 0, hidden: true });
});
});
describe('initiative', () => {
itIfDb('is the d20 roll plus the combatant\'s bonus, and a player enters only their own', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('initroll');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Quick');
const allyId = await buildCharacter(campaign.id, dmAuth, 'Steady');
const heroBonus = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character.sheet.initiative;
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster; // DEX 14: +2
const npc = (await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Mayor', maxHp: 9 })).body.npc;
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Roll for it' })).body.encounter.id}`;
const add = (body) => request(app).post(`${e}/participants`).set(dmAuth).send(body);
// A roll is a d20: nothing outside 1 to 20, and no fractions.
expect((await add({ monsterId: goblin.id, initiativeRoll: 21 })).status).toBe(400);
expect((await add({ monsterId: goblin.id, initiativeRoll: 0 })).status).toBe(400);
expect((await add({ monsterId: goblin.id, initiativeRoll: 7.5 })).status).toBe(400);
const goblinRow = (await add({ monsterId: goblin.id, initiativeRoll: 10 })).body.participant;
expect(goblinRow).toMatchObject({ initiativeRoll: 10, initiative: 12 });
const npcRow = (await add({ npcId: npc.id, initiativeRoll: 10 })).body.participant;
expect(npcRow).toMatchObject({ initiativeRoll: 10, initiative: 10 }); // an NPC has no bonus
const heroRow = (await add({ characterId: heroId })).body.participant;
expect(heroRow).toMatchObject({ initiativeRoll: null, initiative: 0 });
const allyRow = (await add({ characterId: allyId })).body.participant;
const patch = (row, auth, body) => request(app).patch(`${e}/participants/${row.id}`).set(auth).send(body);
const order = async (auth) => (await request(app).get(e).set(auth)).body.encounter.participants;
// Not rolled yet: at the bottom of the order.
expect((await order(dmAuth)).slice(0, 2).map((p) => p.displayName)).toEqual(['Goblin', 'Mayor']);
// The player types what their die showed; the bonus is added for them.
expect((await patch(heroRow, playerAuth, { initiativeRoll: 25 })).status).toBe(400);
const rolled = await patch(heroRow, playerAuth, { initiativeRoll: 20 });
expect(rolled.status).toBe(200);
expect(rolled.body.participant).toMatchObject({ initiativeRoll: 20, initiative: 20 + heroBonus });
expect((await order(playerAuth))[0]).toMatchObject({ displayName: 'Quick', initiativeRoll: 20, initiativeBonus: heroBonus });
// ...and nothing else: not someone else's roll, not a monster's, not their own HP.
expect((await patch(allyRow, playerAuth, { initiativeRoll: 15 })).status).toBe(403);
expect((await patch(goblinRow, playerAuth, { initiativeRoll: 1 })).status).toBe(403);
expect((await patch(heroRow, playerAuth, { initiativeRoll: 20, currentHp: 999 })).status).toBe(403);
expect((await patch(heroRow, playerAuth, { conditions: ['hasted'] })).status).toBe(403);
// The DM sees every bonus; a player isn't handed a monster's.
const dmView = Object.fromEntries((await order(dmAuth)).map((p) => [p.displayName, p]));
expect(dmView.Goblin.initiativeBonus).toBe(2);
expect(dmView.Mayor.initiativeBonus).toBe(0);
const playerView = Object.fromEntries((await order(playerAuth)).map((p) => [p.displayName, p]));
expect(playerView.Goblin.initiativeBonus).toBeUndefined();
expect(playerView.Goblin.initiative).toBe(12);
expect(playerView.Quick.character.userId).toBeTruthy();
// Only the die stops at 20: a natural 20 plus a bonus goes past it.
expect((await patch(goblinRow, dmAuth, { initiativeRoll: 20 })).body.participant).toMatchObject({ initiativeRoll: 20, initiative: 22 });
// Quests take initiative exactly as battles do.
await request(app).patch(e).set(dmAuth).send({ mode: 'QUEST' });
expect((await patch(heroRow, playerAuth, { initiativeRoll: 19 })).body.participant.initiative).toBe(19 + heroBonus);
await request(app).patch(e).set(dmAuth).send({ mode: 'BATTLE' });
await patch(heroRow, playerAuth, { initiativeRoll: 20 });
// The DM can re-roll anyone, or clear a roll.
expect((await patch(goblinRow, dmAuth, { initiativeRoll: 1 })).body.participant.initiative).toBe(3);
expect((await patch(heroRow, dmAuth, { initiativeRoll: null })).body.participant).toMatchObject({ initiativeRoll: null, initiative: 0 });
expect((await order(dmAuth)).map((p) => p.displayName).slice(0, 2)).toEqual(['Mayor', 'Goblin']);
});
});
describe('targets', () => {
itIfDb('are picked by the DM or the player on turn, shown to all, and go when the turn does', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('target');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Archer');
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster;
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Ambush' })).body.encounter.id}`;
const add = async (body) => (await request(app).post(`${e}/participants`).set(dmAuth).send(body)).body.participant;
const hero = await add({ characterId: heroId, initiativeRoll: 20 });
const first = await add({ monsterId: goblin.id, initiativeRoll: 10 });
const second = await add({ monsterId: goblin.id, initiativeRoll: 1 });
const target = (auth, ...participantIds) => request(app).put(`${e}/targets`).set(auth).send({ participantIds });
const seen = async (auth) => (await request(app).get(e).set(auth)).body.encounter.targetParticipantIds;
// Before the battle only the DM can mark anyone.
expect((await target(playerAuth, first.id)).status).toBe(403);
expect((await target(dmAuth, first.id)).body.encounter.targetParticipantIds).toEqual([first.id]);
expect(await seen(playerAuth)).toEqual([first.id]);
expect((await target(dmAuth, first.id, 'nobody')).status).toBe(400);
// Beginning the battle clears them and puts the hero on turn: now the
// player can, and several at once is an area attack, kept in the order picked.
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
expect(await seen(dmAuth)).toEqual([]);
expect((await target(playerAuth, second.id, first.id, second.id)).status).toBe(200);
expect(await seen(dmAuth)).toEqual([second.id, first.id]);
// Removing a target unmarks it and leaves the rest.
await request(app).delete(`${e}/participants/${second.id}`).set(dmAuth);
expect(await seen(dmAuth)).toEqual([first.id]);
// The turn passing clears them, and the player is no longer on turn.
await request(app).post(`${e}/next-turn`).set(dmAuth);
expect(await seen(dmAuth)).toEqual([]);
expect((await target(playerAuth, first.id)).status).toBe(403);
// Whoever is on turn can't be a target, their own or as part of a group.
expect((await target(dmAuth, first.id)).status).toBe(400);
expect((await target(dmAuth, hero.id, first.id)).status).toBe(400);
// The goblin's turn: the DM marks the hero, and can clear it again.
expect((await target(dmAuth, hero.id)).body.encounter.targetParticipantIds).toEqual([hero.id]);
expect((await target(dmAuth)).body.encounter.targetParticipantIds).toEqual([]);
});
});
describe('area attack AC penalty', () => {
itIfDb('is a campaign setting the DM changes, 2 unless told otherwise', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('areapenalty');
const c = `/campaigns/${campaign.id}`;
expect(campaign.areaAttackAcPenalty).toBe(2);
expect((await request(app).patch(c).set(playerAuth).send({ areaAttackAcPenalty: 0 })).status).toBe(403);
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: 11 })).status).toBe(400);
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: -1 })).status).toBe(400);
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: 0 })).body.campaign.areaAttackAcPenalty).toBe(0);
// Players are told it too: their cards show the penalty on a marked target.
expect((await request(app).get(c).set(playerAuth)).body.campaign.areaAttackAcPenalty).toBe(0);
});
});
describe('number limits', () => {
itIfDb('are enforced by the server, whatever a form sends', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('limits');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Bounded');
const sheet = (body) => request(app).patch(`/characters/${heroId}`).set(dmAuth).send(body);
expect((await sheet({ level: 21 })).status).toBe(400);
expect((await sheet({ level: 0 })).status).toBe(400);
expect((await sheet({ level: 20 })).status).toBe(200);
expect((await sheet({ armorBonus: 31 })).status).toBe(400);
expect((await sheet({ tempHp: -1 })).status).toBe(400);
expect((await sheet({ tempHp: 10000 })).status).toBe(400);
expect((await request(app).patch(c).set(dmAuth).send({ startingGold: -5 })).status).toBe(400);
expect((await request(app).patch(c).set(dmAuth).send({ startingItemCount: 51 })).status).toBe(400);
expect((await request(app).post(`${c}/monsters`).set(dmAuth).send({ name: 'Wall', ac: 51, maxHp: 10 })).status).toBe(400);
expect((await request(app).post(`${c}/monsters`).set(dmAuth).send({ name: 'Ghost', ac: 10, maxHp: 0 })).status).toBe(400);
expect((await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Titan', attackBonus: 51 })).status).toBe(400);
});
});
describe('temporary hit points', () => {
itIfDb('soak damage before real HP, on the sheet and in an encounter; healing leaves them alone', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('temphp');
const heroId = await buildCharacter(campaign.id, playerAuth, 'Shielded');
const ch = `/characters/${heroId}`;
const sheet = async () => (await request(app).get(ch).set(playerAuth)).body.character;
const max = (await sheet()).sheet.hp.max;
expect((await sheet()).currentHp).toBe(max);
// On the sheet: 5 temp, 3 damage — all of it soaked.
await request(app).patch(ch).set(playerAuth).send({ tempHp: 5 });
let now = (await request(app).patch(ch).set(playerAuth).send({ currentHp: max - 3 })).body.character;
expect([now.currentHp, now.tempHp]).toEqual([max, 2]);
// More damage than is left: the rest reaches real HP.
now = (await request(app).patch(ch).set(playerAuth).send({ currentHp: max - 3 })).body.character;
expect([now.currentHp, now.tempHp]).toEqual([max - 1, 0]);
// Healing doesn't touch temp HP, and setting temp HP doesn't touch HP.
await request(app).patch(ch).set(playerAuth).send({ tempHp: 4 });
now = (await request(app).patch(ch).set(playerAuth).send({ currentHp: max })).body.character;
expect([now.currentHp, now.tempHp]).toEqual([max, 4]);
// In an encounter the damage lands on the same sheet, the same way.
const e = `/encounters/${(await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Arrows' })).body.encounter.id}`;
const row = (await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId })).body.participant;
const hit = await request(app).patch(`${e}/participants/${row.id}`).set(dmAuth).send({ currentHp: max - 6 });
expect(hit.body.participant.currentHp).toBe(max - 2);
now = await sheet();
expect([now.currentHp, now.tempHp]).toEqual([max - 2, 0]);
// A blow far past zero still only takes what there is.
await request(app).patch(ch).set(playerAuth).send({ tempHp: 2 });
await request(app).patch(`${e}/participants/${row.id}`).set(dmAuth).send({ currentHp: -500 });
now = await sheet();
expect([now.currentHp, now.tempHp]).toEqual([0, 0]);
});
});
describe('death saves', () => {
itIfDb('start at 0 HP, are settled by three of a kind, and end with healing', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('deathsaves');
const heroId = await buildCharacter(campaign.id, playerAuth, 'Fallen');
const ch = `/characters/${heroId}`;
const sheet = async () => (await request(app).get(ch).set(playerAuth)).body.character;
const hp = (auth, currentHp) => request(app).patch(ch).set(auth).send({ currentHp });
const save = (auth, body) => request(app).post(`${ch}/death-saves`).set(auth).send(body);
const max = (await sheet()).sheet.hp.max;
expect((await sheet()).deathSaves).toEqual({ state: 'UP', successes: 0, failures: 0 });
// No saves while standing, and a save is a d20.
expect((await save(playerAuth, { action: 'roll', roll: 12 })).status).toBe(400);
await hp(playerAuth, 0);
expect((await sheet()).deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 0 });
expect((await save(playerAuth, { action: 'roll', roll: 21 })).status).toBe(400);
// 10 and up succeeds, below fails, and a 1 fails twice.
expect((await save(playerAuth, { action: 'roll', roll: 10 })).body.character.deathSaves).toEqual({ state: 'DYING', successes: 1, failures: 0 });
expect((await save(playerAuth, { action: 'roll', roll: 9 })).body.character.deathSaves).toEqual({ state: 'DYING', successes: 1, failures: 1 });
// Being hit while down is a failed save; healing clears the lot.
expect((await hp(dmAuth, -1)).body.character.deathSaves).toEqual({ state: 'DYING', successes: 1, failures: 2 });
expect((await hp(playerAuth, 3)).body.character.deathSaves).toEqual({ state: 'UP', successes: 0, failures: 0 });
// Three successes: stable, at 0 HP, with nothing left to roll.
await hp(playerAuth, 0);
for (const roll of [15, 11, 19]) await save(playerAuth, { action: 'roll', roll });
let now = await sheet();
expect([now.currentHp, now.deathSaves]).toEqual([0, { state: 'STABLE', successes: 0, failures: 0 }]);
expect((await save(playerAuth, { action: 'roll', roll: 12 })).status).toBe(400);
// Damage ends that; a 20 puts them back up with 1 HP.
expect((await hp(dmAuth, -1)).body.character.deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 1 });
now = (await save(playerAuth, { action: 'roll', roll: 20 })).body.character;
expect([now.currentHp, now.deathSaves]).toEqual([1, { state: 'UP', successes: 0, failures: 0 }]);
// Someone else can steady them without a roll.
await hp(playerAuth, 0);
expect((await save(playerAuth, { action: 'stabilize' })).body.character.deathSaves.state).toBe('STABLE');
// The tallies are the DM's to set by hand; a 1 on top of two failures kills.
expect((await save(playerAuth, { action: 'set', successes: 0, failures: 0 })).status).toBe(403);
expect((await save(dmAuth, { action: 'set', successes: 2, failures: 2 })).body.character.deathSaves).toEqual({ state: 'DYING', successes: 2, failures: 2 });
expect((await save(playerAuth, { action: 'roll', roll: 1 })).body.character.deathSaves.state).toBe('DEAD');
// The dead can't be healed or roll; only the DM brings them back.
expect((await hp(playerAuth, max)).status).toBe(400);
expect((await save(playerAuth, { action: 'roll', roll: 20 })).status).toBe(400);
expect((await save(playerAuth, { action: 'revive' })).status).toBe(403);
now = (await save(dmAuth, { action: 'revive' })).body.character;
expect([now.currentHp, now.deathSaves]).toEqual([1, { state: 'UP', successes: 0, failures: 0 }]);
});
itIfDb('follow the character into an encounter, where one huge blow kills outright', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('deathsaves-enc');
const heroId = await buildCharacter(campaign.id, playerAuth, 'Doomed');
const ch = `/characters/${heroId}`;
const max = (await request(app).get(ch).set(playerAuth)).body.character.sheet.hp.max;
const e = `/encounters/${(await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Last Stand' })).body.encounter.id}`;
const row = (await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId })).body.participant;
const strike = (currentHp) => request(app).patch(`${e}/participants/${row.id}`).set(dmAuth).send({ currentHp });
const combatant = async () => (await request(app).get(e).set(playerAuth)).body.encounter.participants[0];
// Temp HP that soaks the whole blow keeps them on their feet.
await request(app).patch(ch).set(playerAuth).send({ tempHp: 2 });
await strike(max - 2);
expect((await combatant()).characterCombatInfo.deathSaves.state).toBe('UP');
// Dropped by the DM in the tracker; the player's own save shows there.
await strike(1 - max); // max - 1 past zero: one short of killing outright
expect((await combatant()).characterCombatInfo.deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 0 });
await request(app).post(`${ch}/death-saves`).set(playerAuth).send({ action: 'roll', roll: 4 });
expect((await combatant()).characterCombatInfo.deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 1 });
// The party list carries it too.
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters.find((c) => c.id === heroId);
expect(listed.deathSaves.state).toBe('DYING');
// Healed, then hit for max HP more than they have left.
await strike(max);
await strike(-max);
const dmView = (await request(app).get(e).set(dmAuth)).body.encounter.participants[0];
expect([dmView.hidden, dmView.characterCombatInfo.deathSaves.state]).toEqual([true, 'DEAD']);
expect((await strike(5)).status).toBe(400);
});
itIfDb('hide the fallen from players, skip their turns, and let an ally with a reviving spell bring a character back', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('fallen');
const c = `/campaigns/${campaign.id}`;
const healerId = await buildCharacter(campaign.id, playerAuth, 'Healer');
const doomedId = await buildCharacter(campaign.id, playerAuth, 'Doomed');
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster;
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Crypt' })).body.encounter.id}`;
const add = async (body) => (await request(app).post(`${e}/participants`).set(dmAuth).send(body)).body.participant.id;
const healer = await add({ characterId: healerId, initiativeRoll: 20 });
const doomed = await add({ characterId: doomedId, initiativeRoll: 10 });
const gob = await add({ monsterId: goblin.id, initiativeRoll: 1 });
const setHp = (id, currentHp) => request(app).patch(`${e}/participants/${id}`).set(dmAuth).send({ currentHp });
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
const ids = (encounter) => encounter.participants.map((p) => p.id);
const revive = (auth, id) => request(app).post(`${e}/participants/${id}/revive`).set(auth);
const nextTurn = async () => (await request(app).post(`${e}/next-turn`).set(dmAuth)).body.encounter;
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' }); // the healer is on turn
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [gob] });
// A monster at 0 HP is gone from the player's list, target mark and all; the DM keeps it.
await setHp(gob, 0);
let seen = await view(playerAuth);
expect([ids(seen), seen.targetParticipantIds, seen.fallen]).toEqual([[healer, doomed], [], []]);
seen = await view(dmAuth);
expect([ids(seen), seen.targetParticipantIds]).toEqual([[healer, doomed, gob], [gob]]);
expect(seen.participants.map((p) => p.hidden)).toEqual([false, false, true]);
expect((await request(app).put(`${e}/targets`).set(playerAuth).send({ participantIds: [gob] })).status).toBe(400);
// A dying character stays in view; a dead one is hidden, and named among the fallen.
await setHp(doomed, 0);
expect(ids(await view(playerAuth))).toEqual([healer, doomed]);
await request(app).post(`/characters/${doomedId}/death-saves`).set(dmAuth).send({ action: 'set', successes: 0, failures: 3 });
seen = await view(playerAuth);
expect([ids(seen), seen.fallen]).toEqual([[healer], [{ participantId: doomed, characterId: doomedId, name: 'Doomed' }]]);
// The dead join no other scene until they are revived.
const elsewhere = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Elsewhere' })).body.encounter.id}`;
const join = () => request(app).post(`${elsewhere}/participants`).set(dmAuth).send({ characterId: doomedId });
expect((await join()).status).toBe(400);
// The fallen have no turn: with only the healer standing, the round rolls straight over.
seen = await nextTurn();
expect([seen.activeParticipantId, seen.currentRound]).toEqual([healer, 2]);
// Reviving takes a spell that brings back the dead, at a level the caster can use.
expect(seen.participants[0].characterCombatInfo.canRevive).toBe(false);
expect((await revive(playerAuth, doomed)).status).toBe(400);
const revivify = (await request(app).post(`${c}/spells/from-library`).set(dmAuth).send({ key: 'revivify' })).body.spell;
expect(revivify.revives).toBe(true);
await request(app).post(`/characters/${healerId}/spells`).set(dmAuth).send({ spellId: revivify.id });
expect((await revive(playerAuth, doomed)).status).toBe(400); // level 1 can't cast a level 3 spell
await request(app).patch(`/characters/${healerId}`).set(dmAuth).send({ level: 3 });
expect((await view(playerAuth)).participants[0].characterCombatInfo.canRevive).toBe(true);
expect((await revive(playerAuth, gob)).status).toBe(400); // monsters are healed, not revived
const revived = await revive(playerAuth, doomed);
expect([revived.status, revived.body.revivedWith]).toEqual([200, 'Revivify']);
expect([ids(revived.body.encounter), revived.body.encounter.fallen]).toEqual([[healer, doomed], []]);
const back = (await request(app).get(`/characters/${doomedId}`).set(playerAuth)).body.character;
expect([back.currentHp, back.deathSaves]).toEqual([1, { state: 'UP', successes: 0, failures: 0 }]);
expect((await revive(playerAuth, doomed)).status).toBe(400); // not dead any more
expect((await join()).status).toBe(201);
// Only on the reviver's own turn, and healing a monster brings it back into view.
expect((await nextTurn()).activeParticipantId).toBe(doomed);
await setHp(gob, 3);
expect(ids(await view(playerAuth))).toEqual([healer, doomed, gob]);
expect((await nextTurn()).activeParticipantId).toBe(gob);
expect((await revive(playerAuth, doomed)).status).toBe(403);
});
});
describe('silver and copper', () => {
itIfDb('are kept as separate coins, exchanged at fixed worth, given and rewarded', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('coins');
const c = `/campaigns/${campaign.id}`;
await request(app).patch(c).set(dmAuth).send({ startingGold: 5 });
const heroId = await buildCharacter(campaign.id, playerAuth, 'Purse');
const friendId = await buildCharacter(campaign.id, dmAuth, 'Friend');
const ch = `/characters/${heroId}`;
const purse = async (id = heroId, auth = playerAuth) => {
const { gold, silver, copper } = (await request(app).get(`/characters/${id}`).set(auth)).body.character;
return [gold, silver, copper];
};
const money = (body) => request(app).post(`${ch}/gold`).set(playerAuth).send(body);
const exchange = (body) => request(app).post(`${ch}/gold/exchange`).set(playerAuth).send(body);
expect(await purse()).toEqual([5, 0, 0]);
// Each coin has its own balance; an amount with no coin named is gold.
expect((await money({ amount: 7, currency: 'SILVER', reason: 'Sold a pelt' })).status).toBe(201);
expect((await money({ amount: 3, currency: 'COPPER' })).body.copper).toBe(3);
expect((await money({ amount: 1 })).body.gold).toBe(6);
expect(await purse()).toEqual([6, 7, 3]);
expect((await money({ amount: 1, currency: 'PLATINUM' })).status).toBe(400);
// A spend the whole purse can't cover is refused and moves nothing.
const short = await money({ amount: -1000, currency: 'SILVER' });
expect(short.status).toBe(400);
expect(short.body.error).toMatch(/Not enough money/);
expect(await purse()).toEqual([6, 7, 3]);
expect((await request(app).patch(ch).set(dmAuth).send({ silver: 99 })).status).toBe(400);
// 1 gold = 10 silver = 100 copper.
expect((await exchange({ from: 'GOLD', to: 'SILVER', amount: 1 })).body.received).toBe(10);
expect(await purse()).toEqual([5, 17, 3]);
expect((await exchange({ from: 'GOLD', to: 'COPPER', amount: 1 })).body.received).toBe(100);
expect((await exchange({ from: 'SILVER', to: 'COPPER', amount: 1 })).body.received).toBe(10);
expect(await purse()).toEqual([4, 16, 113]);
expect((await exchange({ from: 'COPPER', to: 'SILVER', amount: 20 })).body.received).toBe(2);
expect((await exchange({ from: 'SILVER', to: 'GOLD', amount: 10 })).body.received).toBe(1);
expect((await exchange({ from: 'COPPER', to: 'SILVER', amount: 90 })).body.received).toBe(9);
expect(await purse()).toEqual([5, 17, 3]);
// It has to come to whole coins, be affordable, and be between two different coins.
const uneven = await exchange({ from: 'SILVER', to: 'GOLD', amount: 5 });
expect(uneven.status).toBe(400);
expect(uneven.body.error).toMatch(/multiples of 10/);
expect((await exchange({ from: 'COPPER', to: 'SILVER', amount: 1 })).status).toBe(400);
expect((await exchange({ from: 'COPPER', to: 'GOLD', amount: 50 })).body.error).toMatch(/multiples of 100/);
expect((await exchange({ from: 'SILVER', to: 'COPPER', amount: 18 })).status).toBe(400);
expect((await exchange({ from: 'GOLD', to: 'GOLD', amount: 1 })).status).toBe(400);
expect((await request(app).post(`/characters/${friendId}/gold/exchange`).set(playerAuth).send({ from: 'GOLD', to: 'SILVER', amount: 1 })).status).toBe(403);
expect(await purse()).toEqual([5, 17, 3]);
// The ledger says which coin every entry is in.
const ledger = (await request(app).get(`${ch}/gold`).set(playerAuth)).body;
expect([ledger.gold, ledger.silver, ledger.copper]).toEqual([5, 17, 3]);
expect(ledger.transactions[0]).toMatchObject({ currency: 'SILVER', amount: 9, balanceAfter: 17, reason: 'Exchanged from 90 copper' });
expect(ledger.transactions.find((t) => t.reason === 'Sold a pelt')).toMatchObject({ currency: 'SILVER', amount: 7, balanceAfter: 7 });
// Giving and rewards work per coin.
expect((await request(app).post(`${ch}/give`).set(playerAuth).send({ kind: 'gold', currency: 'SILVER', amount: 3, toCharacterId: friendId })).status).toBe(200);
expect((await request(app).post(`${ch}/give`).set(playerAuth).send({ kind: 'gold', currency: 'COPPER', amount: 5000, toCharacterId: friendId })).status).toBe(400);
expect(await purse()).toEqual([5, 14, 3]);
expect(await purse(friendId, dmAuth)).toEqual([5, 3, 0]);
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Loose change' })).body.encounter.id}`;
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: friendId });
const reward = (await request(app).post(`${e}/rewards`).set(dmAuth).send({ type: 'GOLD', gold: 9, currency: 'COPPER' })).body.reward;
expect(reward).toMatchObject({ gold: 9, currency: 'COPPER' });
await request(app).post(`${e}/rewards/${reward.id}/award`).set(dmAuth).send({ split: true });
const split = [(await purse())[2] - 3, (await purse(friendId, dmAuth))[2]].sort();
expect(split).toEqual([4, 5]); // 9 copper between two
// Another player still sees none of it.
const outsiderView = (await request(app).get(`/characters/${friendId}`).set(playerAuth)).body.character;
expect([outsiderView.gold, outsiderView.silver, outsiderView.copper]).toEqual([null, null, null]);
});
});
describe('paying with change', () => {
itIfDb('a spend or a gift is paid from the whole purse: bigger coins are broken, smaller ones counted out', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('change');
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ startingGold: 5 });
const heroId = await buildCharacter(campaign.id, playerAuth, 'Spender');
const friendId = await buildCharacter(campaign.id, dmAuth, 'Friend');
const ch = `/characters/${heroId}`;
const purse = async (id = heroId, auth = playerAuth) => {
const { gold, silver, copper } = (await request(app).get(`/characters/${id}`).set(auth)).body.character;
return [gold, silver, copper];
};
const money = (body) => request(app).post(`${ch}/gold`).set(playerAuth).send(body);
const newest = async (count) =>
(await request(app).get(`${ch}/gold`).set(playerAuth)).body.transactions.slice(0, count).map((t) => [t.currency, t.amount, t.balanceAfter, t.reason]);
// 3 silver from a purse of 5 gold: one gold is broken, 7 silver comes back.
const boots = await money({ amount: -3, currency: 'SILVER', reason: 'New boots' });
expect(boots.status).toBe(201);
expect([boots.body.gold, boots.body.silver, boots.body.copper]).toEqual([4, 7, 0]);
expect(await purse()).toEqual([4, 7, 0]);
expect((await newest(2)).sort()).toEqual([
['GOLD', -1, 4, 'New boots (paid 3 silver, with change)'],
['SILVER', 7, 7, 'New boots (paid 3 silver, with change)'],
]);
// 5 copper: the nearest bigger coin is a silver, so that is what breaks.
expect((await money({ amount: -5, currency: 'COPPER' })).status).toBe(201);
expect(await purse()).toEqual([4, 6, 5]);
// Enough of the coin named: nothing else moves, and the entry reads as usual.
await money({ amount: -2, reason: 'Horse feed' });
expect(await purse()).toEqual([2, 6, 5]);
expect((await newest(1))[0]).toEqual(['GOLD', -2, 2, 'Horse feed']);
// More than the whole purse is worth (2.65 gold): refused, nothing moves.
const tooMuch = await money({ amount: -3 });
expect(tooMuch.status).toBe(400);
expect(tooMuch.body.error).toMatch(/Not enough money/);
expect(tooMuch.body.error).toMatch(/2\.65 gold/);
expect(await purse()).toEqual([2, 6, 5]);
// No bigger coin to break: smaller ones are counted out, biggest first.
await money({ amount: -2 });
await money({ amount: 40, currency: 'COPPER' });
expect(await purse()).toEqual([0, 6, 45]);
expect((await money({ amount: -1, reason: 'Toll' })).status).toBe(201);
expect(await purse()).toEqual([0, 0, 5]);
// Giving works the same way for the giver; the recipient gets the coins named.
await money({ amount: 1 });
expect((await request(app).post(`${ch}/give`).set(playerAuth).send({ kind: 'gold', currency: 'SILVER', amount: 4, toCharacterId: friendId })).status).toBe(200);
expect(await purse()).toEqual([0, 6, 5]);
expect(await purse(friendId, dmAuth)).toEqual([5, 4, 0]);
const beyond = await request(app).post(`${ch}/give`).set(playerAuth).send({ kind: 'gold', amount: 1, toCharacterId: friendId });
expect(beyond.status).toBe(400);
expect(beyond.body.error).toMatch(/Not enough money/);
// Exchanging is the exception: it moves exactly the coins named.
expect((await request(app).post(`${ch}/gold/exchange`).set(playerAuth).send({ from: 'SILVER', to: 'COPPER', amount: 7 })).status).toBe(400);
expect(await purse()).toEqual([0, 6, 5]);
});
});
+73 -21
View File
@@ -6,6 +6,8 @@ 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 router = express.Router();
@@ -16,11 +18,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 +50,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 +197,10 @@ function characterSummary(character) {
buildStage: character.buildStage,
currentHp: character.currentHp,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
gold: character.gold,
silver: character.silver,
copper: character.copper,
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
currentLocationId: character.currentLocationId,
@@ -205,7 +215,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, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
}),
});
} catch (err) {
next(err);
}
@@ -252,7 +274,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 +282,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 +388,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 +405,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 +448,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 +515,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,7 +524,7 @@ 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(),
attackBonus: z.number().int().min(-20).max(50).optional(),
damageDice: z.string().max(20).optional().nullable(),
challengeRating: z.string().max(10).optional().nullable(),
specialTrait: z.string().max(500).optional().nullable(),
@@ -533,9 +572,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 +590,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({
@@ -610,9 +657,9 @@ 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(),
});
@@ -676,12 +723,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);
@@ -733,7 +785,7 @@ router.delete('/:id/locations/:locationId', requireCampaignMember(), requireDM()
// 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) {
+186 -34
View File
@@ -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, 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,26 @@ 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,
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 +139,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 +148,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 +173,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 && ['gold', 'silver', 'copper'].some((coin) => req.body[coin] !== undefined)) {
return res.status(400).json({ error: 'Gold, silver and copper 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 +183,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.' });
}
// 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)) {
// 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.
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 +222,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 +238,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 +423,8 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
speedOverride: source.speedOverride,
specialAbilities: source.specialAbilities,
gold: source.gold,
silver: source.silver,
copper: source.copper,
background: source.background,
appearance: source.appearance,
// Same privacy boundary as serializeCharacter: a DM copying a
@@ -351,10 +440,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 +456,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 +873,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 +910,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 +931,27 @@ 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();
}
// For a payment the whole purse can't cover.
function notEnoughMoney(character, amount, currency) {
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, transactions });
} catch (err) {
next(err);
}
@@ -853,30 +959,74 @@ 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.
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: { gold: true, silver: true, copper: true } });
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.
router.post('/:id/gold/exchange', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z
.object({ from: z.enum(CURRENCIES), to: z.enum(CURRENCIES), 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 +1170,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 +1197,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 +1234,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;
+291 -44
View File
@@ -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, adjustGold, 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,
@@ -91,11 +157,20 @@ function characterCombatInfo(character) {
// 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. 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: {
@@ -115,16 +190,25 @@ async function serializeEncounter(encounterId, isDM) {
});
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 +216,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 +250,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 +286,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 +298,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 +341,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 +351,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 +364,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 +380,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 +399,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 +415,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 +479,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 +501,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 +624,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 +651,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 +728,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 +757,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';
+8 -2
View File
@@ -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}`);
+99 -9
View File
@@ -1,24 +1,114 @@
// 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 CURRENCIES = ['GOLD', 'SILVER', 'COPPER'];
const COIN_VALUE = { GOLD: 100, SILVER: 10, COPPER: 1 };
// Each coin is its own column on the character.
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
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 (CURRENCIES.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 = CURRENCIES.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 CURRENCIES.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 CURRENCIES.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.
async function spendCoins(db, { characterId, amount, currency = 'GOLD', 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 = CURRENCIES.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 whole worth as one figure in gold, for messages: 265 copper-worth -> 2.65.
function worthInGold(character) {
const copper = CURRENCIES.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 +127,4 @@ async function giveGear(db, kind, { characterId, entry, quantity = 1, level, equ
});
}
module.exports = { InsufficientGoldError, adjustGold, giveGear };
module.exports = { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
+78
View File
@@ -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 };
+84
View File
@@ -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 });
});
});
+30
View File
@@ -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 };
+79 -13
View File
@@ -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,35 @@ 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.
### Giving to another character
@@ -552,7 +614,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 +682,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 +735,15 @@ 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 |
| 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,7 +775,7 @@ 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
+181 -14
View File
@@ -30,7 +30,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.
@@ -44,7 +48,7 @@ time from the top of the page.
| 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" |
| 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 |
@@ -63,6 +67,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 +88,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 +111,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 +131,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 +149,161 @@ 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.
### 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
@@ -199,6 +356,8 @@ 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 |
| Controls (add, damage, turns, rewards) | Yes | None |
@@ -210,12 +369,18 @@ information cannot be dug out of the page.
| 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 |
@@ -226,7 +391,9 @@ People outside the campaign cannot open its encounters.
- 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
+1 -1
View File
@@ -132,7 +132,7 @@ character's inventory, stacking with any they already hold. See
| 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 |
+6 -4
View File
@@ -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 |
+2 -1
View File
@@ -196,7 +196,8 @@ on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
| 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 |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "1.8.0",
"type": "module",
"scripts": {
"dev": "vite",
+74 -11
View File
@@ -7,6 +7,7 @@ import type {
CampaignMemberSummary,
CharacterDetail,
CharacterSummary,
Currency,
DiceMode,
Encounter,
EncounterMode,
@@ -162,6 +163,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 +195,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 +234,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 +406,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 +421,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,6 +463,7 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) {
isStartingOption?: boolean;
damageDice?: string | null;
damageScaling?: number;
revives?: boolean;
minLevel?: number;
name?: string;
description?: string | null;
@@ -514,10 +549,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 +695,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 +736,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 +787,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 };
+62 -9
View File
@@ -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,20 @@ export interface CampaignMemberSummary {
role: 'DM' | 'PLAYER';
}
// The coins a character carries. 1 gold = 10 silver = 100 copper.
export type Currency = 'GOLD' | 'SILVER' | 'COPPER';
// 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 +134,11 @@ export interface CharacterSummary {
buildStage: BuildStage;
currentHp: number;
tempHp: number;
gold: number;
deathSaves: DeathSaves;
// The purse; all three are null for another player's character.
gold: number | null;
silver: number | null;
copper: number | null;
maxHp: number | null;
armorClass: number | null;
currentLocationId: string | null;
@@ -194,6 +215,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 +254,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 +289,7 @@ export interface CharacterDetail {
rolledScores: Record<Ability, number>;
currentHp: number;
tempHp: number;
deathSaves: DeathSaves;
armorBonus: number;
specialAbilities: string | null;
background: string | null;
@@ -276,7 +300,12 @@ export interface CharacterDetail {
diceMode: DiceMode;
currentLocationId: string | null;
currentLocation: Location | null;
gold: number;
gold: number | null;
silver: number | null;
copper: 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[];
@@ -333,7 +362,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 +402,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 +422,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,12 +454,21 @@ 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
}
@@ -454,6 +505,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 +552,7 @@ export interface QuickStats {
currentHp: number;
maxHp: number;
tempHp: number;
deathSaves: DeathSaves;
armorClass: number;
initiative: number;
speed: number;
+118
View File
@@ -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>
);
}
+24
View File
@@ -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>
);
}
+40 -6
View File
@@ -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,11 +87,14 @@ export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?
>
ⓘ
</button>
{open && (
<span role="tooltip" id={id} className="info-tip-bubble">
{text}
</span>
)}
{open &&
place &&
createPortal(
<span role="tooltip" id={id} className="info-tip-bubble" style={place}>
{text}
</span>,
document.body,
)}
</span>
);
}
+48
View File
@@ -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} />;
}
+6 -4
View File
@@ -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">
+4
View File
@@ -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
View File
@@ -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;
+25
View File
@@ -1,3 +1,5 @@
import type { Currency } from '../api/types';
export const SPECIES_LABELS: Record<string, string> = {
HUMAN: 'Human',
HALF_ELF: 'Half-Elf',
@@ -70,6 +72,29 @@ 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 CURRENCIES: Currency[] = ['GOLD', 'SILVER', 'COPPER'];
export const COIN_LABELS: Record<Currency, string> = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper' };
export const COIN_SHORT: Record<Currency, string> = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' };
export const COIN_FIELD: Record<Currency, 'gold' | 'silver' | 'copper'> = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
// What one coin is worth, counted in copper.
export const COIN_VALUE: Record<Currency, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
type Purse = { gold: number | null; silver: number | null; copper: number | null };
// "12 gp · 5 sp · 3 cp". Gold always shows; silver and copper only when held.
export function formatPurse(purse: Purse): string {
return CURRENCIES.filter((c) => c === 'GOLD' || (purse[COIN_FIELD[c]] ?? 0) > 0)
.map((c) => `${purse[COIN_FIELD[c]] ?? 0} ${COIN_SHORT[c]}`)
.join(' · ');
}
// The whole purse as one figure in gold, e.g. 12.65.
export function purseWorthInGold(purse: Purse): number {
const copper = CURRENCIES.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;
+62 -12
View File
@@ -40,6 +40,8 @@ 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 { speciesIcon } from '../lib/speciesIcons';
@@ -101,7 +103,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">
@@ -293,7 +295,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);
@@ -486,27 +488,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 +771,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">
@@ -800,9 +833,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>
@@ -838,7 +871,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 +1129,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 +1150,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 +1164,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 +1180,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 })}
@@ -1200,9 +1250,9 @@ export function CampaignHomePage() {
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" />
<NumberInput name="ac" min={0} max={50} placeholder="AC" required />
<NumberInput name="maxHp" min={1} max={100000} placeholder="Max HP" required />
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus" />
<input name="damageDice" placeholder="Damage dice" />
<button type="submit" className="btn btn-primary">Add</button>
</form>
+3 -4
View File
@@ -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}
+150 -31
View File
@@ -17,6 +17,7 @@ import {
useRemoveCharacterSpell,
useGoldLedger,
useAdjustGold,
useExchangeCoins,
useCampaignCharacters,
useGive,
type Gift,
@@ -30,14 +31,32 @@ 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,
COIN_FIELD,
COIN_LABELS,
COIN_SHORT,
COIN_VALUE,
CURRENCIES,
SKILL_LABELS,
SPECIES_LABELS,
fmtMod,
extractErrorMessage,
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, 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 +106,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.
const held = (c: Currency) => character[COIN_FIELD[c]] ?? 0;
const purseInCopper = CURRENCIES.reduce((sum, c) => sum + held(c) * COIN_VALUE[c], 0);
const affordable = (c: Currency) => 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 +168,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]} ({held(c)} {COIN_SHORT[c]} held)
</option>
))}
{character.weapons.length > 0 && (
<optgroup label="Weapons">
{character.weapons.map((w) => (
@@ -172,12 +202,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 +241,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 +255,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 +265,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<Currency>('GOLD');
const [exchangeTo, setExchangeTo] = useState<Currency>('SILVER');
const [noteBody, setNoteBody] = useState('');
const [pickedWeaponId, setPickedWeaponId] = useState('');
const [pickedSpellId, setPickedSpellId] = useState('');
@@ -271,7 +305,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 +334,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 +342,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 +460,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 +489,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 +501,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 +518,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 +732,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 +824,30 @@ 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.
</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 +864,62 @@ 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 Currency)}>
{CURRENCIES.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 Currency)}>
{CURRENCIES.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 ? `+${t.amount}` : `−${Math.abs(t.amount)}`} {COIN_SHORT[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)' }}>
{t.balanceAfter} {COIN_SHORT[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,
+407 -61
View File
@@ -8,11 +8,14 @@ import {
useCampaignNpcs,
useCampaignWeapons,
useCampaignItems,
useCampaignLocations,
useAddParticipant,
usePatchParticipant,
useRemoveParticipant,
useAdvanceRound,
useNextTurn,
useSetTargets,
useReviveParticipant,
usePatchEncounter,
useSetEncounterArchived,
useDeleteEncounter,
@@ -22,12 +25,17 @@ 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 { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type {
Ability,
CharacterSummary,
Currency,
Encounter,
EncounterMode,
EncounterParticipant,
@@ -56,6 +64,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 +103,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 +140,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 +157,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 +168,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 +188,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 +269,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 +289,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 +435,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 `${r.gold} ${COIN_LABELS[r.currency ?? 'GOLD'].toLowerCase()}`;
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 +632,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 +652,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 +675,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 +745,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"
min={1}
placeholder="Amount"
aria-label="Gold amount"
value={gold}
onChange={(e) => setGold(e.target.value)}
style={{ flex: 1, minWidth: 90 }}
/>
<>
<NumberInput
min={1}
max={1000000}
placeholder="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 +796,9 @@ function RewardsPanel({
</option>
))}
</select>
<input
type="number"
<NumberInput
min={1}
max={999}
aria-label="Quantity"
title="Quantity"
value={quantity}
@@ -577,6 +831,7 @@ function RewardsPanel({
export function EncounterPage() {
const { encounterId = '' } = useParams();
const { user } = useAuth();
const { data: encounter, isLoading, error } = useEncounter(encounterId);
useCampaignSocket(encounter?.campaignId);
@@ -587,12 +842,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 +860,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 +901,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 +911,32 @@ 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.
// 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]]
: [
['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 +949,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 +968,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 +1059,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 +1149,16 @@ export function EncounterPage() {
</select>
</div>
<div className="flex-row gap-sm mt-sm">
{isBattle && (
<input
type="number"
placeholder="Initiative"
aria-label="Initiative"
value={initiative}
onChange={(e) => setInitiative(e.target.value)}
style={{ flex: 1, minWidth: 0 }}
/>
)}
<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>
+11 -10
View File
@@ -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>
);
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.0.0",
"version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.0.0",
"version": "1.8.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.0.0",
"version": "1.8.0",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "0.0.0",
"version": "1.8.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.0.0",
"version": "1.8.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"
}
}