Compare commits

...
Author SHA1 Message Date
joshuaandClaude Opus 5.5 c852ecb2e4 V1.10.0: NPCs change money and sell things
- An NPC can change gold and cash at a rate of their own ("2 gold = $15"),
  the same both ways. Only gold and cash are taken: no silver, no copper
  and no change, and the amount has to be a whole multiple of the rate.
- An NPC can sell weapons and items from the campaign pools, each with a
  price in any coin or in cash, a level and an optional stock. The wares
  follow the NPC into every quest. An encounter can sell things itself
  too, for a stall with nobody behind it.
- Trading is open only in an NPC quest in progress, never in a battle,
  with an NPC who is in the scene and standing, for a character who is in
  it. Players see every rate and ware and trade for their own character
  in a new Trade card; the DM can trade for anyone.
- Only the DM sets trade up: "Trade" on an NPC's row on the campaign page,
  and the DM's part of the Trade card for an encounter's own wares. A
  ware's price and stock can be edited afterwards.
- Every exchange and purchase is written to the character's money log.
- All packages at version 1.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:45:40 +02:00
joshuaandClaude Opus 5.5 bdce3399ee V1.9.0: full monster stat blocks, and cash ($)
- "+ Add Monster" takes the whole stat block: challenge rating, speed, the
  six ability scores, special trait and notes, as well as name, AC, HP,
  attack and damage. Damage can name its type ("1d6+2 necrotic").
- Monsters can be edited: "Edit" on a row opens the same form filled in.
  Copies already in encounters show the new stat block.
- The monster list shows each full stat block, the library picker shows
  speed and ability scores too, and removing a monster asks first.
- Characters have a fourth balance, cash ($), for crossover worlds. It is
  gained, spent, given and awarded like a coin, but it has no worth in
  coins: it is never exchanged or used to make change.
- `npm test` in the backend runs again (the script pointed at a jest path
  that does not exist in the workspace).
- All packages at version 1.9.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:15:55 +02:00
joshuaandClaude Opus 5.5 f764878960 V1.8.1: combatants who have had their turn move to "Next Round"
- In a battle in progress, whoever is on turn is at the top of the list
  with those still to act beneath them. Each combatant whose turn has
  passed drops to a "Next Round" section at the bottom, and a new round
  puts everyone back.
- All packages at version 1.8.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:49:44 +02:00
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
57 changed files with 9630 additions and 661 deletions
+1
View File
@@ -6,3 +6,4 @@ build/
*.log
.DS_Store
.idea/
temp/
+18 -5
View File
@@ -14,10 +14,10 @@ monsters, and a real-time encounter/combat tracker.
| `docs/CHARACTERS.md` | Building a character, every number on the sheet, levels, money, permissions |
| `docs/WEAPONS.md` | The weapon pool, weapon levels and how damage grows |
| `docs/SPELLS.md` | The spell list, who can use which spells, the spell library |
| `docs/ITEMS.md` | The item pool and character inventory |
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, what players see |
| `docs/ITEMS.md` | The item pool, character inventory, items for sale |
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, trade, what players see |
| `docs/MONSTERS.md` | The monster list and built-in bestiary |
| `docs/NPCS.md` | Non-player characters |
| `docs/NPCS.md` | Non-player characters, what they sell and the rates they change money at |
`CLAUDE_BUILD_PROMPT.md`, `docs/GAME_RULES_REFERENCE.md` and
`docs/ROLL_YOUR_CHARACTER.md` are the original specifications the first
@@ -71,8 +71,21 @@ already has an active "Aldenfell"; archive or delete that one in the app
`SEED_ADMIN_PASSWORD` and `SEED_ADMIN_NAME` set the account it creates.
`npx prisma db seed -- --reset` wipes every user and campaign first.
On a server, apply new migrations with `npx prisma migrate deploy`. Change
your password from the account page (click your name in the top bar).
A second campaign, Hollowcrown: The Rift Between Worlds, has its own seed and
is not part of the default one. It follows the same rules (it only adds, and
skips if the admin already has an active Hollowcrown):
```bash
cd backend
npm run seed:hollowcrown
```
On a server, after pulling a new version, run `npm run deploy` in `backend`
and then restart the backend. It applies new migrations **and** regenerates
the Prisma client; without the second half the server keeps running with the
old data model and pages that use anything new fail to load (for example
"Could not load this encounter"). Change your password from the account page
(click your name in the top bar).
## Tests
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.0.0",
"version": "1.10.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -10,11 +10,13 @@
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"test": "jest",
"docs:lists": "node scripts/generate-doc-lists.js",
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:seed": "prisma db seed",
"deploy": "prisma migrate deploy && prisma generate",
"seed:hollowcrown": "node prisma/seed-hollowcrown.js",
"prisma:studio": "prisma studio"
},
"dependencies": {
@@ -0,0 +1,23 @@
-- AlterTable: whether players see a monster's exact hit points or only its health bar
ALTER TABLE "campaigns" ADD COLUMN "showMonsterHp" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable: where an encounter takes place
ALTER TABLE "encounters" ADD COLUMN "locationId" TEXT;
ALTER TABLE "encounters" ADD CONSTRAINT "encounters_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable: every location a character has been to
CREATE TABLE "character_location_visits" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"firstVisitedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "character_location_visits_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "character_location_visits_characterId_locationId_key" ON "character_location_visits"("characterId", "locationId");
ALTER TABLE "character_location_visits" ADD CONSTRAINT "character_location_visits_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "character_location_visits" ADD CONSTRAINT "character_location_visits_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Characters have been to wherever they are now.
INSERT INTO "character_location_visits" ("id", "characterId", "locationId")
SELECT 'visit_' || "id", "id", "currentLocationId" FROM "characters" WHERE "currentLocationId" IS NOT NULL;
@@ -0,0 +1,9 @@
-- AlterTable: the d20 a combatant rolled for initiative; "initiative" is now roll + bonus
ALTER TABLE "encounter_participants" ADD COLUMN "initiativeRoll" INTEGER;
-- Initiatives typed in before this were totals. Keep them as they are, and
-- treat the nearest possible die face as the roll so those combatants still
-- count as having rolled.
UPDATE "encounter_participants"
SET "initiativeRoll" = LEAST(GREATEST("initiative", 1), 20)
WHERE "initiative" <> 0;
@@ -0,0 +1,10 @@
-- CreateEnum: the three coins. Silver is worth 0.1 gold, copper 0.01 gold.
CREATE TYPE "Currency" AS ENUM ('GOLD', 'SILVER', 'COPPER');
-- AlterTable: a character's purse holds each coin separately
ALTER TABLE "characters" ADD COLUMN "silver" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "characters" ADD COLUMN "copper" INTEGER NOT NULL DEFAULT 0;
-- AlterTable: every ledger entry and coin reward is in one coin; all existing ones are gold
ALTER TABLE "gold_transactions" ADD COLUMN "currency" "Currency" NOT NULL DEFAULT 'GOLD';
ALTER TABLE "encounter_rewards" ADD COLUMN "currency" "Currency" NOT NULL DEFAULT 'GOLD';
@@ -0,0 +1,4 @@
-- AlterTable: death saves, counted while a character is at 0 HP
ALTER TABLE "characters" ADD COLUMN "deathSaveSuccesses" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "deathSaveFailures" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "stable" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,2 @@
-- AlterTable: the combatant currently marked as the target
ALTER TABLE "encounters" ADD COLUMN "targetParticipantId" TEXT;
@@ -0,0 +1,9 @@
-- AlterTable: several combatants can be marked as targets at once (an area attack)
ALTER TABLE "encounters" ADD COLUMN "targetParticipantIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- Carry over a single target marked before this.
UPDATE "encounters"
SET "targetParticipantIds" = ARRAY["targetParticipantId"]
WHERE "targetParticipantId" IS NOT NULL;
ALTER TABLE "encounters" DROP COLUMN "targetParticipantId";
@@ -0,0 +1,2 @@
-- AlterTable: how much an area attack adds to each AC per extra target
ALTER TABLE "campaigns" ADD COLUMN "areaAttackAcPenalty" INTEGER NOT NULL DEFAULT 2;
@@ -0,0 +1,5 @@
-- AlterTable: spells that bring back the dead
ALTER TABLE "spells" ADD COLUMN "revives" BOOLEAN NOT NULL DEFAULT false;
-- The library's two revival spells, wherever a campaign already has them.
UPDATE "spells" SET "revives" = true WHERE "name" IN ('Revivify', 'Raise Dead');
@@ -0,0 +1,6 @@
-- AlterEnum: cash ($), a modern currency for crossover worlds. It has no fixed
-- worth in gold, so it is never exchanged or used to make change.
ALTER TYPE "Currency" ADD VALUE 'CASH';
-- AlterTable: cash is counted on its own, like each coin
ALTER TABLE "characters" ADD COLUMN "cash" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,28 @@
-- AlterTable: an NPC who changes money does it at their own rate, this many
-- gold for that much cash. Both are null for an NPC who does not.
ALTER TABLE "npcs" ADD COLUMN "exchangeGold" INTEGER;
ALTER TABLE "npcs" ADD COLUMN "exchangeCash" INTEGER;
-- CreateTable: something for sale, by an NPC or at an encounter itself
CREATE TABLE "sale_offers" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"npcId" TEXT,
"encounterId" TEXT,
"weaponId" TEXT,
"itemId" TEXT,
"price" INTEGER NOT NULL,
"currency" "Currency" NOT NULL DEFAULT 'GOLD',
"level" INTEGER NOT NULL DEFAULT 1,
"stock" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sale_offers_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_npcId_fkey" FOREIGN KEY ("npcId") REFERENCES "npcs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_weaponId_fkey" FOREIGN KEY ("weaponId") REFERENCES "weapons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+101 -3
View File
@@ -118,6 +118,16 @@ enum RewardType {
ITEM
}
// The money a character carries. The coins' worth is fixed in utils/grants.js:
// 1 gold = 10 silver = 100 copper. Cash ($) is modern money for crossover
// worlds; it has no worth in coins and is never exchanged for them.
enum Currency {
GOLD
SILVER
COPPER
CASH
}
enum NoteVisibility {
DM_ONLY
SHARED
@@ -158,6 +168,11 @@ model Campaign {
startingItemCount Int @default(0)
startingSpellCount Int @default(0)
skipMotivationRoll Boolean @default(false)
// Off: players see only a health bar for monsters and NPCs, not the numbers.
showMonsterHp Boolean @default(true)
// An attack on several targets is harder to land: each target's AC counts
// as this much higher for every target beyond the first. 0 turns it off.
areaAttackAcPenalty Int @default(2)
ownerId String
// Set while the campaign is archived: kept in full, but moved off the main
// list and closed to new members until the DM restores it.
@@ -173,6 +188,7 @@ model Campaign {
monsters Monster[]
npcs Npc[]
encounters Encounter[]
saleOffers SaleOffer[]
notes Note[]
locations Location[]
@@ -188,10 +204,28 @@ model Location {
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
characters Character[]
encounters Encounter[]
visits CharacterLocationVisit[]
@@map("locations")
}
// A location a character has been to. Written whenever a character is put
// somewhere (by the DM, or by an encounter held there), and never removed by
// moving on: it is what decides which locations a player may see.
model CharacterLocationVisit {
id String @id @default(cuid())
characterId String
locationId String
firstVisitedAt DateTime @default(now())
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
@@unique([characterId, locationId])
@@map("character_location_visits")
}
model CampaignMember {
id String @id @default(cuid())
campaignId String
@@ -236,13 +270,23 @@ model Character {
currentHp Int @default(0)
tempHp Int @default(0)
// Death saves, counted while the character is at 0 HP (utils/hp.js). Three
// failures is dead; three successes sets `stable` and clears the tallies.
deathSaveSuccesses Int @default(0)
deathSaveFailures Int @default(0)
stable Boolean @default(false) // at 0 HP but no longer rolling
armorBonus Int @default(0) // flat AC bonus from equipped armor/shield
speedOverride Int?
// The next three are composed by the builder from the player's picks
// (game/data/characterOptions.js). After that only the DM can edit them.
specialAbilities String? // shown in the quick-stats panel
// The purse: each coin (and cash) is counted on its own and only changes through the
// money ledger (GoldTransaction).
gold Int @default(0)
silver Int @default(0)
copper Int @default(0)
cash Int @default(0) // whole dollars
background String?
appearance String?
@@ -271,6 +315,7 @@ model Character {
spells CharacterSpell[]
notes Note[]
encounterEntries EncounterParticipant[]
locationVisits CharacterLocationVisit[]
@@map("characters")
}
@@ -328,6 +373,7 @@ model Weapon {
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
assigned CharacterWeapon[]
rewards EncounterReward[]
offers SaleOffer[]
@@map("weapons")
}
@@ -362,6 +408,7 @@ model Item {
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
held InventoryItem[]
rewards EncounterReward[]
offers SaleOffer[]
@@map("items")
}
@@ -382,12 +429,14 @@ model InventoryItem {
@@map("inventory_items")
}
// Ledger behind Character.gold: every change to the balance is one row here.
// Ledger behind a character's purse: every change to a coin balance is one
// row here. (The name predates silver and copper.)
model GoldTransaction {
id String @id @default(cuid())
characterId String
currency Currency @default(GOLD) // which coin this entry is in
amount Int // signed: positive = gained, negative = spent
balanceAfter Int
balanceAfter Int // that coin's balance afterwards
reason String?
createdById String?
createdAt DateTime @default(now())
@@ -415,6 +464,9 @@ model Spell {
duration String?
description String?
isStartingOption Boolean @default(false)
// Brings back the dead: a character who can cast it may revive a dead ally
// on their turn in a battle (encounters.routes.js).
revives Boolean @default(false)
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
known CharacterSpell[]
@@ -478,10 +530,15 @@ model Npc {
maxHp Int?
attackBonus Int?
damageDice String?
// A money changer swaps gold and cash at their own rate: exchangeGold gold
// for exchangeCash cash, either way. Both null: this NPC does not exchange.
exchangeGold Int?
exchangeCash Int?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
encounterAppearances EncounterParticipant[]
wares SaleOffer[]
@@map("npcs")
}
@@ -495,8 +552,15 @@ model Encounter {
currentRound Int @default(1)
// Whose turn it is in a battle; null until the fight starts.
activeParticipantId String?
// Who is being attacked (or healed) right now, shown to everyone, in the
// order they were picked. More than one makes it an area attack. Picked by
// the DM or by the player on turn, and cleared when the turn passes.
targetParticipantIds String[] @default([])
description String? // the scene / objective, shown to everyone
dmNotes String? // never sent to players
// Where the scene takes place. While the encounter is in progress, the
// characters in it are there too.
locationId String?
// Set while archived: kept in full, but off the campaign's list — players
// no longer see it there at all.
archivedAt DateTime?
@@ -506,8 +570,10 @@ model Encounter {
updatedAt DateTime @updatedAt
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
participants EncounterParticipant[]
rewards EncounterReward[]
wares SaleOffer[]
@@map("encounters")
}
@@ -519,7 +585,8 @@ model EncounterReward {
id String @id @default(cuid())
encounterId String
type RewardType
gold Int? // GOLD
gold Int? // GOLD: how many coins
currency Currency @default(GOLD) // GOLD: which coin they are
weaponId String? // WEAPON
itemId String? // ITEM
quantity Int @default(1)
@@ -536,6 +603,33 @@ model EncounterReward {
@@map("encounter_rewards")
}
// Something for sale: a weapon or item from the campaign pools, at a price.
// It belongs to an NPC (and is on offer wherever that NPC is met) or to an
// encounter itself (a stall, a vending machine). Only the DM sets these up;
// players see them, and buy, in a quest encounter in progress
// (encounters.routes.js).
model SaleOffer {
id String @id @default(cuid())
campaignId String
npcId String? // exactly one of npcId / encounterId is set
encounterId String?
weaponId String? // exactly one of weaponId / itemId is set
itemId String?
price Int // for one, in `currency`
currency Currency @default(GOLD)
level Int @default(1) // gear level it is handed over at
stock Int? // how many are left; null = no limit
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
npc Npc? @relation(fields: [npcId], references: [id], onDelete: Cascade)
encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: Cascade)
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@map("sale_offers")
}
// A row per combatant in an encounter — a character, a monster or an NPC.
// A monster's currentHp/maxHp live here, per instance. A character's HP has
// one source of truth, the Character row: encounters read and write that, and
@@ -548,6 +642,10 @@ model EncounterParticipant {
monsterId String?
npcId String?
displayName String // denormalized so a monster can be renamed per-instance, e.g. "Goblin #2"
// The d20 rolled for initiative (1–20); null until it is rolled.
initiativeRoll Int?
// The roll plus the combatant's initiative bonus, worked out when the roll
// is entered. This is what the turn order sorts on.
initiative Int @default(0)
currentHp Int
maxHp Int
+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],
});
+17 -192
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 })) });
run(
{
...aldenfell,
// Everyday weapons are what a new character picks from; the rest are earned.
await db.weapon.createMany({
data: [
...defaultWeapons.map((w) => ({ ...w, isStartingOption: true, campaignId })),
...aldenfell.weapons.map((w) => ({ ...w, campaignId })),
weapons: [...defaultWeapons.map((w) => ({ ...w, isStartingOption: true })), ...aldenfell.weapons],
items: aldenfell.items.map(({ starting, ...item }) => ({ ...item, isStartingOption: Boolean(starting) })),
spells: [
...aldenfell.spells.starting.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: true })),
...aldenfell.spells.earned.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: false })),
],
});
await db.item.createMany({
data: aldenfell.items.map(({ starting, ...item }) => ({ ...item, isStartingOption: Boolean(starting), campaignId })),
});
const spellRow = (isStartingOption) => (spellKey) => {
const template = SPELL_LIBRARY.find((s) => s.key === spellKey);
if (!template) throw new Error(`Unknown library spell "${spellKey}"`);
const { key, classes, ...spell } = template;
return { ...spell, isStartingOption, campaignId };
};
const spells = [...aldenfell.spells.starting.map(spellRow(true)), ...aldenfell.spells.earned.map(spellRow(false))];
await db.spell.createMany({ data: spells });
await db.monster.createMany({ data: aldenfell.monsters.map((m) => ({ ...m, campaignId })) });
await db.npc.createMany({ data: aldenfell.npcs.map((n) => ({ ...n, campaignId })) });
const monsters = byName(await db.monster.findMany({ where: { campaignId } }));
const npcs = byName(await db.npc.findMany({ where: { campaignId } }));
const weapons = byName(await db.weapon.findMany({ where: { campaignId } }));
const items = byName(await db.item.findMany({ where: { campaignId } }));
let rewardCount = 0;
for (const [index, enc] of aldenfell.encounters.entries()) {
const participants = [];
for (const [name, count, displayName] of enc.monsters || []) {
const monster = lookup(monsters, name, 'monster', enc.name);
const label = displayName || monster.name;
for (let i = 1; i <= count; i++) {
participants.push({
monsterId: monster.id,
displayName: count > 1 ? `${label} #${i}` : label,
currentHp: monster.maxHp,
maxHp: monster.maxHp,
isMonster: true,
});
}
}
for (const name of enc.npcs || []) {
const npc = lookup(npcs, name, 'NPC', enc.name);
// An NPC with no combat stats takes part with no hit points to track.
participants.push({ npcId: npc.id, displayName: npc.name, currentHp: npc.maxHp ?? 0, maxHp: npc.maxHp ?? 0, isMonster: false });
}
// Rewards list oldest-first, so these are stamped forwards.
const rewards = (enc.rewards || []).map(({ gold, weapon, item, quantity = 1, level, note }, i) => {
const createdAt = new Date(Date.now() + i);
if (gold) return { type: 'GOLD', gold, note, createdAt };
const entry = weapon ? lookup(weapons, weapon, 'weapon', enc.name) : lookup(items, item, 'item', enc.name);
return {
type: weapon ? 'WEAPON' : 'ITEM',
[weapon ? 'weaponId' : 'itemId']: entry.id,
quantity,
level: Math.max(level ?? entry.minLevel, entry.minLevel),
note,
createdAt,
};
});
rewardCount += rewards.length;
await db.encounter.create({
data: {
campaignId,
name: enc.name,
mode: enc.mode,
description: enc.description,
dmNotes: enc.dmNotes,
sortOrder: index,
participants: { create: participants },
rewards: { create: rewards },
},
});
}
const notes = [
...aldenfell.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
...aldenfell.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
];
await db.note.createMany({
data: notes.map((n, i) => ({ ...n, campaignId, authorId: admin.id, createdAt: bookOrder(i) })),
});
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
console.log(
` ${defaultWeapons.length} starting + ${aldenfell.weapons.length} earned weapons, ${aldenfell.items.length} items, ` +
`${spells.length} spells, ${aldenfell.locations.length} locations`,
);
console.log(
` ${aldenfell.monsters.length} monsters, ${aldenfell.npcs.length} NPCs, ${aldenfell.encounters.length} encounters ` +
`(${rewardCount} rewards lined up), ${notes.length} notes`,
);
}
async function main() {
if (RESET) {
await resetDatabase();
console.log('Database reset: every user and campaign was deleted.');
}
let admin = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } });
if (admin) {
console.log(`Admin account ${ADMIN_EMAIL} already exists — left as it is.`);
} else {
admin = await prisma.user.create({
data: { email: ADMIN_EMAIL, passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12), displayName: ADMIN_NAME },
});
console.log(`Admin/DM login created: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
console.log(' Change that password from the account page (click your name, top right) once you are logged in.');
}
const existing = await prisma.campaign.findFirst({
where: { ownerId: admin.id, name: aldenfell.campaign.name, archivedAt: null },
});
if (existing) {
console.log(`${ADMIN_EMAIL} already has an active "${existing.name}" campaign — nothing added.`);
console.log(' For a fresh copy, archive or delete it under Campaign Settings and run the seed again.');
return;
}
// One transaction, so a failed seed doesn't leave half a campaign behind.
console.log('Seeding campaign...');
await prisma.$transaction((tx) => seedCampaign(tx, admin), { timeout: 120000, maxWait: 20000 });
console.log('Seed complete.');
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
{ reset: process.argv.includes('--reset') },
);
+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'] },
];
File diff suppressed because it is too large Load Diff
+179 -23
View File
@@ -6,6 +6,9 @@ const { requireCampaignMember, requireDM } = require('../middleware/campaignAcce
const { generateInviteCode } = require('../utils/inviteCode');
const { computeCharacterSheet, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
const { visitedLocationIds } = require('../utils/locations');
const { deathSaveInfo } = require('../utils/hp');
const { CURRENCIES } = require('../utils/grants');
const router = express.Router();
@@ -16,11 +19,14 @@ function campaignSummary(campaign, role) {
id: campaign.id,
name: campaign.name,
description: campaign.description,
inviteCode: campaign.inviteCode,
// The invite code is the DM's to hand out.
inviteCode: role === 'DM' ? campaign.inviteCode : undefined,
startingGold: campaign.startingGold,
startingItemCount: campaign.startingItemCount,
startingSpellCount: campaign.startingSpellCount,
skipMotivationRoll: campaign.skipMotivationRoll,
showMonsterHp: campaign.showMonsterHp,
areaAttackAcPenalty: campaign.areaAttackAcPenalty,
ownerId: campaign.ownerId,
archivedAt: campaign.archivedAt,
role,
@@ -45,10 +51,12 @@ router.get('/', async (req, res, next) => {
const createCampaignSchema = z.object({
name: z.string().min(1).max(120),
description: z.string().max(2000).optional().nullable(),
startingGold: z.number().int().min(0).optional(),
startingItemCount: z.number().int().min(0).optional(),
startingSpellCount: z.number().int().min(0).optional(),
startingGold: z.number().int().min(0).max(1000000).optional(),
startingItemCount: z.number().int().min(0).max(50).optional(),
startingSpellCount: z.number().int().min(0).max(50).optional(),
skipMotivationRoll: z.boolean().optional(),
showMonsterHp: z.boolean().optional(),
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
});
// POST /campaigns — create, creator becomes DM/owner
@@ -190,7 +198,11 @@ function characterSummary(character) {
buildStage: character.buildStage,
currentHp: character.currentHp,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
gold: character.gold,
silver: character.silver,
copper: character.copper,
cash: character.cash,
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
currentLocationId: character.currentLocationId,
@@ -205,7 +217,19 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
include: { skillProficiencies: true, saveProficiencies: true, currentLocation: true },
orderBy: { createdAt: 'asc' },
});
res.json({ characters: characters.map(characterSummary) });
const summaries = characters.map(characterSummary);
if (req.membership.role === 'DM') return res.json({ characters: summaries });
// A player sees the party's headline numbers, but another character's
// purse is their own business, and so is a place this player has not been.
const visited = await visitedLocationIds(prisma, req.user.id, req.campaign.id);
res.json({
characters: summaries.map((c) => {
if (c.userId === req.user.id) return c;
const knowsPlace = c.currentLocationId && visited.has(c.currentLocationId);
return { ...c, gold: null, silver: null, copper: null, cash: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
}),
});
} catch (err) {
next(err);
}
@@ -252,7 +276,7 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
},
});
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:created', { character: characterSummary(character) });
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:created', { characterId: character.id });
res.status(201).json({ character: characterSummary(character) });
} catch (err) {
next(err);
@@ -260,12 +284,28 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
});
// ---------------------------------------------------------------------------
// Weapons (campaign-level pool; DM manages, anyone in campaign can read)
// Weapons (campaign-level pool; the DM manages it and sees all of it)
// ---------------------------------------------------------------------------
// What a player may see of a pool: whatever one of their own characters holds,
// plus the starting options while they still have a character to build. The
// rest of the pool — gear yet to be found, spells yet to be learned — is the
// DM's. `held` is the pool model's relation to the per-character rows.
async function poolScope(req, held) {
const where = { campaignId: req.campaign.id };
if (req.membership.role === 'DM') return where;
const mine = { userId: req.user.id, campaignId: req.campaign.id };
const building = await prisma.character.count({ where: { ...mine, buildStage: { not: 'COMPLETE' } } });
return {
...where,
OR: [{ [held]: { some: { character: mine } } }, ...(building > 0 ? [{ isStartingOption: true }] : [])],
};
}
router.get('/:id/weapons', requireCampaignMember(), async (req, res, next) => {
try {
const weapons = await prisma.weapon.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
const weapons = await prisma.weapon.findMany({ where: await poolScope(req, 'assigned'), orderBy: { name: 'asc' } });
res.json({ weapons });
} catch (err) {
next(err);
@@ -350,7 +390,7 @@ router.delete('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), as
router.get('/:id/spells', requireCampaignMember(), async (req, res, next) => {
try {
const spells = await prisma.spell.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
const spells = await prisma.spell.findMany({ where: await poolScope(req, 'known'), orderBy: { name: 'asc' } });
res.json({ spells });
} catch (err) {
next(err);
@@ -367,6 +407,7 @@ const spellSchema = z.object({
description: z.string().max(4000).optional().nullable(),
damageDice: z.string().max(20).optional().nullable(),
isStartingOption: z.boolean().optional(),
revives: z.boolean().optional(), // brings back the dead
});
router.post('/:id/spells', requireCampaignMember(), requireDM(), async (req, res, next) => {
@@ -409,7 +450,7 @@ router.delete('/:id/spells/:spellId', requireCampaignMember(), requireDM(), asyn
router.get('/:id/items', requireCampaignMember(), async (req, res, next) => {
try {
const items = await prisma.item.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
const items = await prisma.item.findMany({ where: await poolScope(req, 'held'), orderBy: { name: 'asc' } });
res.json({ items });
} catch (err) {
next(err);
@@ -476,8 +517,8 @@ router.get('/:id/monsters', requireCampaignMember(), requireDM(), async (req, re
const monsterSchema = z.object({
name: z.string().min(1).max(80),
ac: z.number().int().min(0),
maxHp: z.number().int().min(1),
ac: z.number().int().min(0).max(50),
maxHp: z.number().int().min(1).max(100000),
speed: z.number().int().min(0).optional(),
strScore: z.number().int().min(1).max(30).optional(),
dexScore: z.number().int().min(1).max(30).optional(),
@@ -485,8 +526,8 @@ const monsterSchema = z.object({
intScore: z.number().int().min(1).max(30).optional(),
wisScore: z.number().int().min(1).max(30).optional(),
chaScore: z.number().int().min(1).max(30).optional(),
attackBonus: z.number().int().optional(),
damageDice: z.string().max(20).optional().nullable(),
attackBonus: z.number().int().min(-20).max(50).optional(),
damageDice: z.string().max(60).optional().nullable(), // may name its type: "1d6+2 necrotic"
challengeRating: z.string().max(10).optional().nullable(),
specialTrait: z.string().max(500).optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
@@ -533,9 +574,11 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(),
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
try {
const isDM = req.membership.role === 'DM';
// Archived encounters are the DM's to see; players only get the live list.
// The DM sees every encounter. A player sees the live ones that one of
// their own characters is in, and nothing of the rest.
const mine = { archivedAt: null, participants: { some: { character: { userId: req.user.id } } } };
const encounters = await prisma.encounter.findMany({
where: { campaignId: req.campaign.id, ...(isDM ? {} : { archivedAt: null }) },
where: { campaignId: req.campaign.id, ...(isDM ? {} : mine) },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
// dmNotes never leaves the server for a player.
@@ -549,12 +592,18 @@ const createEncounterSchema = z.object({
name: z.string().min(1).max(120),
mode: z.enum(['BATTLE', 'QUEST']).optional(),
description: z.string().max(4000).optional().nullable(),
locationId: z.string().optional().nullable(),
});
router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = createEncounterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (parsed.data.locationId) {
const location = await prisma.location.findFirst({ where: { id: parsed.data.locationId, campaignId: req.campaign.id } });
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
}
// A new encounter goes to the top of the campaign's order.
const first = await prisma.encounter.aggregate({ where: { campaignId: req.campaign.id }, _min: { sortOrder: true } });
const encounter = await prisma.encounter.create({
@@ -597,7 +646,11 @@ router.put('/:id/encounters/order', requireCampaignMember(), requireDM(), async
router.get('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const npcs = await prisma.npc.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
const npcs = await prisma.npc.findMany({
where: { campaignId: req.campaign.id },
include: { wares: { include: WARE_INCLUDE, orderBy: { createdAt: 'asc' } } },
orderBy: { name: 'asc' },
});
res.json({ npcs });
} catch (err) {
next(err);
@@ -610,16 +663,35 @@ const npcSchema = z.object({
disposition: z.enum(['FRIENDLY', 'NEUTRAL', 'HOSTILE']).optional(),
description: z.string().max(2000).optional().nullable(),
dmNotes: z.string().max(4000).optional().nullable(),
ac: z.number().int().min(0).optional().nullable(),
maxHp: z.number().int().min(1).optional().nullable(),
attackBonus: z.number().int().optional().nullable(),
ac: z.number().int().min(0).max(50).optional().nullable(),
maxHp: z.number().int().min(1).max(100000).optional().nullable(),
attackBonus: z.number().int().min(-20).max(50).optional().nullable(),
damageDice: z.string().max(20).optional().nullable(),
// The money changer's rate: exchangeGold gold for exchangeCash cash. Both
// null for an NPC who does not change money.
exchangeGold: z.number().int().min(1).max(1000000).optional().nullable(),
exchangeCash: z.number().int().min(1).max(1000000).optional().nullable(),
});
// A rate has two sides: both are given, or both are cleared.
function halfARate(data) {
const sides = [data.exchangeGold, data.exchangeCash];
return sides.some((side) => side === undefined) !== sides.every((side) => side === undefined) || (sides[0] === null) !== (sides[1] === null);
}
const HALF_A_RATE = 'An exchange rate needs both sides: so much gold for so much cash.';
const WARE_INCLUDE = { weapon: true, item: true };
// Open encounter pages refetch: what is for sale in a scene just changed.
function emitTradeChanged(req) {
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('encounter:participant-updated', {});
}
router.post('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = npcSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
const npc = await prisma.npc.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
res.status(201).json({ npc });
} catch (err) {
@@ -631,8 +703,10 @@ router.patch('/:id/npcs/:npcId', requireCampaignMember(), requireDM(), async (re
try {
const parsed = npcSchema.partial().safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
const npc = await updatePoolEntry('npc', req.params.npcId, req.campaign.id, parsed.data);
if (!npc) return res.status(404).json({ error: 'NPC not found in this campaign' });
emitTradeChanged(req);
res.json({ npc });
} catch (err) {
next(err);
@@ -676,12 +750,17 @@ router.get('/:id/notes', requireCampaignMember(), async (req, res, next) => {
});
// ---------------------------------------------------------------------------
// Locations — DM-managed, used to track where each character currently is
// Locations — DM-managed, used to track where each character currently is.
// A player only sees the places one of their own characters has been to.
// ---------------------------------------------------------------------------
router.get('/:id/locations', requireCampaignMember(), async (req, res, next) => {
try {
const locations = await prisma.location.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
const where = { campaignId: req.campaign.id };
if (req.membership.role !== 'DM') {
where.id = { in: [...(await visitedLocationIds(prisma, req.user.id, req.campaign.id))] };
}
const locations = await prisma.location.findMany({ where, orderBy: { name: 'asc' } });
res.json({ locations });
} catch (err) {
next(err);
@@ -727,13 +806,90 @@ router.delete('/:id/locations/:locationId', requireCampaignMember(), requireDM()
}
});
// ---------------------------------------------------------------------------
// Wares — weapons and items for sale, by an NPC or at an encounter itself.
// Only the DM sets them up. Players see them, and buy, on the encounter page
// (encounters.routes.js); an NPC's wares go wherever the NPC does.
// ---------------------------------------------------------------------------
const wareSchema = z
.object({
npcId: z.string().optional(),
encounterId: z.string().optional(),
weaponId: z.string().optional(),
itemId: z.string().optional(),
price: z.number().int().min(1).max(1000000),
currency: z.enum(CURRENCIES).default('GOLD'),
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
stock: z.number().int().min(0).max(9999).optional().nullable(), // null: no limit
})
.refine((data) => Boolean(data.npcId) !== Boolean(data.encounterId), { message: 'Say who sells it: an NPC or an encounter, not both' })
.refine((data) => Boolean(data.weaponId) !== Boolean(data.itemId), { message: 'Pick one weapon or one item to sell' });
router.post('/:id/wares', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = wareSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const data = parsed.data;
const inCampaign = async (model, id) => (id ? prisma[model].findFirst({ where: { id, campaignId: req.campaign.id } }) : null);
const seller = (await inCampaign('npc', data.npcId)) || (await inCampaign('encounter', data.encounterId));
if (!seller) return res.status(400).json({ error: 'That seller is not part of this campaign.' });
const entry = (await inCampaign('weapon', data.weaponId)) || (await inCampaign('item', data.itemId));
if (!entry) return res.status(400).json({ error: `That ${data.weaponId ? 'weapon' : 'item'} is not part of this campaign.` });
const level = data.level ?? entry.minLevel;
if (level < entry.minLevel) {
return res.status(400).json({ error: `${entry.name} starts at level ${entry.minLevel} and can't go below it.` });
}
const ware = await prisma.saleOffer.create({ data: { ...data, level, campaignId: req.campaign.id }, include: WARE_INCLUDE });
emitTradeChanged(req);
res.status(201).json({ ware });
} catch (err) {
next(err);
}
});
// The price and what is left can change; what is sold, and by whom, cannot.
const patchWareSchema = z.object({
price: z.number().int().min(1).max(1000000).optional(),
currency: z.enum(CURRENCIES).optional(),
stock: z.number().int().min(0).max(9999).nullable().optional(),
});
router.patch('/:id/wares/:wareId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = patchWareSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (!(await updatePoolEntry('saleOffer', req.params.wareId, req.campaign.id, parsed.data))) {
return res.status(404).json({ error: 'Nothing like that is for sale in this campaign' });
}
emitTradeChanged(req);
res.json({ ware: await prisma.saleOffer.findUnique({ where: { id: req.params.wareId }, include: WARE_INCLUDE }) });
} catch (err) {
next(err);
}
});
router.delete('/:id/wares/:wareId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
if (!(await deletePoolEntry('saleOffer', req.params.wareId, req.campaign.id))) {
return res.status(404).json({ error: 'Nothing like that is for sale in this campaign' });
}
emitTradeChanged(req);
res.status(204).end();
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Default content library — static SRD-style weapons/spells/monsters any
// campaign can pull from, instead of the DM hand-typing everything. See
// backend/src/game/data/{weapons,spells,monsters}Library.js for the sets.
// ---------------------------------------------------------------------------
router.get('/:id/library', requireCampaignMember(), async (req, res, next) => {
router.get('/:id/library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
res.json({ weapons: WEAPON_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY });
} catch (err) {
+192 -33
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, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
const { recordVisits } = require('../utils/locations');
const { absorbDamage, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
const router = express.Router();
@@ -51,7 +53,14 @@ async function loadFullCharacter(id) {
// privateNotes is a real privacy boundary, not a UI hide — it's only ever
// included in the payload for the character's own owning player.
function serializeCharacter(character, isOwner) {
//
// Another player (neither the owner nor the DM) gets the sheet the party can
// see across the table — who the character is, HP, AC, abilities, what they
// fight with — but not what is in their pack, their purse, or where they are.
// `isDM` defaults to true because every caller but the plain read is a write
// route, which only the owner or the DM can reach.
function serializeCharacter(character, isOwner, isDM = true) {
const restricted = !isOwner && !isDM;
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
return {
id: character.id,
@@ -76,22 +85,27 @@ function serializeCharacter(character, isOwner) {
},
currentHp: character.currentHp,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
armorBonus: character.armorBonus,
specialAbilities: character.specialAbilities,
background: character.background,
appearance: character.appearance,
privateNotes: isOwner ? character.privateNotes : undefined,
diceMode: character.diceMode,
currentLocationId: character.currentLocationId,
currentLocation: character.currentLocation || null,
gold: character.gold,
currentLocationId: restricted ? null : character.currentLocationId,
currentLocation: restricted ? null : character.currentLocation || null,
gold: restricted ? null : character.gold,
silver: restricted ? null : character.silver,
copper: restricted ? null : character.copper,
cash: restricted ? null : character.cash,
restricted,
buildStage: character.buildStage,
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
skillProficiencies: character.skillProficiencies,
saveProficiencies: character.saveProficiencies,
// Each held entry carries what its level works out to: the dice at that
// level, and whether this character is high enough level to use it.
inventoryItems: character.inventoryItems.map((ii) => ({
inventoryItems: (restricted ? [] : character.inventoryItems).map((ii) => ({
...ii,
scaledDamageDice: ii.item.damageDice
? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel)
@@ -126,7 +140,7 @@ router.get('/build/options', (req, res) => {
router.get('/:id', requireCharacterAccess(), async (req, res, next) => {
try {
const character = await loadFullCharacter(req.character.id);
res.json({ character: serializeCharacter(character, req.isOwner) });
res.json({ character: serializeCharacter(character, req.isOwner, req.isDM) });
} catch (err) {
next(err);
}
@@ -135,8 +149,8 @@ router.get('/:id', requireCharacterAccess(), async (req, res, next) => {
const patchCharacterSchema = z.object({
name: z.string().min(1).max(80).optional(),
currentHp: z.number().int().optional(),
tempHp: z.number().int().min(0).optional(),
armorBonus: z.number().int().min(0).optional(),
tempHp: z.number().int().min(0).max(9999).optional(),
armorBonus: z.number().int().min(0).max(30).optional(),
speedOverride: z.number().int().min(0).nullable().optional(),
specialAbilities: z.string().max(4000).nullable().optional(),
background: z.string().max(4000).nullable().optional(),
@@ -160,8 +174,8 @@ function clamp(value, min, max) {
router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
if (req.body && req.body.gold !== undefined) {
return res.status(400).json({ error: 'Gold changes go through the money tracker.' });
if (req.body && Object.values(COIN_COLUMN).some((coin) => req.body[coin] !== undefined)) {
return res.status(400).json({ error: 'Gold, silver, copper and cash changes go through the money tracker.' });
}
const parsed = patchCharacterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
@@ -170,10 +184,28 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
return res.status(403).json({ error: 'Only the DM can change level, XP, speed, location, special abilities, background or appearance.' });
}
// Damage is soaked by temp HP before it reaches real HP — unless this
// same request sets temp HP outright.
const settingHp = parsed.data.currentHp !== undefined;
const settingTempHp = parsed.data.tempHp !== undefined;
if (req.character.buildStage === 'COMPLETE' && (settingHp || parsed.data.level !== undefined)) {
// Current HP stays within 0..max, including when a level change moves max.
if (req.character.buildStage === 'COMPLETE' && (parsed.data.currentHp !== undefined || parsed.data.level !== undefined)) {
const maxHp = computeCharacterSheet({ ...req.character, ...parsed.data }).hp.max;
parsed.data.currentHp = clamp(parsed.data.currentHp ?? req.character.currentHp, 0, maxHp);
if (!settingHp) {
parsed.data.currentHp = clamp(req.character.currentHp, 0, maxHp);
} else {
if (lifeState(req.character) === 'DEAD' && parsed.data.currentHp > 0) {
return res.status(400).json({ error: `${req.character.name} is dead. The DM has to revive them before they can be healed.` });
}
// Reaching 0, being hit while there, and being healed back up all
// move the death saves along with the HP.
const { tempHp, ...after } = changeHp(settingTempHp ? { ...req.character, tempHp: 0 } : req.character, parsed.data.currentHp, maxHp);
Object.assign(parsed.data, after, settingTempHp ? {} : { tempHp });
}
} else if (settingHp && !settingTempHp) {
const after = absorbDamage(req.character.currentHp, req.character.tempHp, parsed.data.currentHp);
parsed.data.currentHp = after.currentHp;
if (after.tempHp !== req.character.tempHp) parsed.data.tempHp = after.tempHp;
}
// privateNotes is for the owning player only — a DM editing someone
@@ -191,6 +223,7 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
}
const updated = await prisma.character.update({ where: { id: req.character.id }, data: parsed.data });
if (parsed.data.currentLocationId) await recordVisits(prisma, [updated.id], parsed.data.currentLocationId);
if (updated.level < req.character.level) {
// Dropping a level can put equipped gear and prepared spells out of reach.
const tooHigh = { characterId: updated.id, level: { gt: updated.level } };
@@ -206,7 +239,62 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
// Broadcast payload never includes privateNotes — a socket room has
// every campaign member in it, not just the owner. Only the direct HTTP
// response (below) is allowed to include it.
req.app.get('io').to(`campaign:${updated.campaignId}`).emit('character:updated', { character: serializeCharacter(full, false) });
req.app.get('io').to(`campaign:${updated.campaignId}`).emit('character:updated', { characterId: full.id });
res.json({ character: serializeCharacter(full, req.isOwner) });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Death saves — what happens to a character at 0 HP (utils/hp.js)
// ---------------------------------------------------------------------------
const deathSaveTally = z.number().int().min(0).max(3);
const deathSaveRoll = z.number().int().min(1, 'A death save is a d20: 1 to 20').max(20, 'A death save is a d20: 1 to 20');
const deathSaveSchema = z.discriminatedUnion('action', [
// What the d20 showed; the server works out what it means.
z.object({ action: z.literal('roll'), roll: deathSaveRoll }),
// Steadied by someone else: a Medicine check, a healer's kit, a spell.
z.object({ action: z.literal('stabilize') }),
// The DM setting the tallies by hand: a critical hit's second failure, or
// undoing a mistake.
z.object({ action: z.literal('set'), successes: deathSaveTally, failures: deathSaveTally }),
// The DM bringing back the dead, at 1 HP.
z.object({ action: z.literal('revive') }),
]);
// The owning player rolls their own death saves and can be steadied; setting
// the tallies by hand and raising the dead are the DM's.
router.post('/:id/death-saves', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = deathSaveSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { character } = req;
if (character.buildStage !== 'COMPLETE') return res.status(400).json({ error: 'Character build is not finished yet.' });
const input = parsed.data;
const state = lifeState(character);
if ((input.action === 'set' || input.action === 'revive') && !req.isDM) {
return res.status(403).json({ error: 'Only the DM can do that.' });
}
let data;
if (input.action === 'roll' || input.action === 'stabilize') {
if (state !== 'DYING') return res.status(400).json({ error: `${character.name} is not dying, so there is no death save to make.` });
data = input.action === 'roll' ? rollDeathSave(character, input.roll) : { ...NO_DEATH_SAVES, stable: true };
} else if (input.action === 'set') {
if (state === 'UP') return res.status(400).json({ error: `${character.name} is not at 0 HP.` });
data = settleDeathSaves(input.successes, input.failures);
} else {
if (state !== 'DEAD') return res.status(400).json({ error: `${character.name} is not dead.` });
data = { currentHp: 1, ...NO_DEATH_SAVES };
}
await prisma.character.update({ where: { id: character.id }, data });
const full = await loadFullCharacter(character.id);
req.app.get('io').to(`campaign:${full.campaignId}`).emit('character:updated', { characterId: full.id });
res.json({ character: serializeCharacter(full, req.isOwner) });
} catch (err) {
next(err);
@@ -336,6 +424,9 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
speedOverride: source.speedOverride,
specialAbilities: source.specialAbilities,
gold: source.gold,
silver: source.silver,
copper: source.copper,
cash: source.cash,
background: source.background,
appearance: source.appearance,
// Same privacy boundary as serializeCharacter: a DM copying a
@@ -351,10 +442,13 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
},
inventoryItems: { create: [...items.values()] },
goldTransactions: {
create:
source.gold > 0
? [{ amount: source.gold, balanceAfter: source.gold, reason: 'Carried over from another campaign', createdById: req.user.id }]
: [],
create: CURRENCIES.filter((currency) => source[COIN_COLUMN[currency]] > 0).map((currency) => ({
currency,
amount: source[COIN_COLUMN[currency]],
balanceAfter: source[COIN_COLUMN[currency]],
reason: 'Carried over from another campaign',
createdById: req.user.id,
})),
},
weapons: { create: [...weapons.values()] },
spells: { create: [...spells.values()] },
@@ -364,7 +458,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
});
const full = await loadFullCharacter(copyId);
req.app.get('io').to(`campaign:${targetCampaignId}`).emit('character:created', { character: serializeCharacter(full, false) });
req.app.get('io').to(`campaign:${targetCampaignId}`).emit('character:created', { characterId: full.id });
res.status(201).json({ character: serializeCharacter(full, true) });
} catch (err) {
next(err);
@@ -781,7 +875,7 @@ router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), asyn
});
const full = await loadFullCharacter(result.id);
req.app.get('io').to(`campaign:${result.campaignId}`).emit('character:created', { character: serializeCharacter(full, false) });
req.app.get('io').to(`campaign:${result.campaignId}`).emit('character:created', { characterId: full.id });
res.json({ character: serializeCharacter(full, req.isOwner) });
} catch (err) {
next(err);
@@ -818,6 +912,7 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
currentHp: character.currentHp,
maxHp: sheet.hp.max,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
armorClass: sheet.armorClass,
initiative: sheet.initiative,
speed: sheet.speed,
@@ -838,14 +933,30 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
const GOLD_TRANSACTION_INCLUDE = { createdBy: { select: { displayName: true } } };
// Which coin an amount is in; gold when none is given.
const currencySchema = z.enum(CURRENCIES).default('GOLD');
function coinName(currency) {
return currency.toLowerCase();
}
const PURSE_SELECT = { gold: true, silver: true, copper: true, cash: true };
// For a payment the whole purse can't cover. Cash stands apart from the coins.
function notEnoughMoney(character, amount, currency) {
if (currency === 'CASH') return `Not enough cash — ${character.name} has $${character.cash}, which doesn't cover $${amount}.`;
return `Not enough money — ${character.name} has ${character.gold} gold, ${character.silver} silver and ${character.copper} copper (worth ${worthInGold(character)} gold), which doesn't cover ${amount} ${coinName(currency)}.`;
}
router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
try {
if (!req.isDM && !req.isOwner) return res.status(403).json({ error: "Only the character's player and the DM can see its money" });
const transactions = await prisma.goldTransaction.findMany({
where: { characterId: req.character.id },
include: GOLD_TRANSACTION_INCLUDE,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
res.json({ gold: req.character.gold, transactions });
res.json({ gold: req.character.gold, silver: req.character.silver, copper: req.character.copper, cash: req.character.cash, transactions });
} catch (err) {
next(err);
}
@@ -853,30 +964,76 @@ router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
// amount is signed: positive = gained, negative = spent. The owning player
// and the DM can both record entries; each one is stamped with who made it.
// A spend is paid from the whole purse: if there aren't enough of the coin
// named, bigger coins are broken (or smaller ones counted out) to cover it.
// Cash is only ever paid in cash.
router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z
.object({
amount: z.number().int().refine((n) => n !== 0, 'Amount cannot be zero'),
amount: z.number().int().min(-1000000).max(1000000).refine((n) => n !== 0, 'Amount cannot be zero'),
currency: currencySchema,
reason: z.string().trim().max(200).optional().nullable(),
})
.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { amount, reason } = parsed.data;
const { amount, currency, reason } = parsed.data;
let transaction;
let transactions;
try {
// Throwing inside the transaction rolls the balance change back.
transaction = await prisma.$transaction((tx) =>
adjustGold(tx, { characterId: req.character.id, amount, reason, userId: req.user.id, include: GOLD_TRANSACTION_INCLUDE })
);
transactions = await prisma.$transaction(async (tx) => {
const entry = { characterId: req.character.id, currency, reason, userId: req.user.id, include: GOLD_TRANSACTION_INCLUDE };
return amount > 0 ? [await adjustGold(tx, { ...entry, amount })] : spendCoins(tx, { ...entry, amount: -amount });
});
} catch (err) {
if (!(err instanceof InsufficientGoldError)) throw err;
return res.status(400).json({ error: `Not enough gold — the balance is ${req.character.gold}.` });
return res.status(400).json({ error: notEnoughMoney(req.character, -amount, currency) });
}
emitCharacterUpdated(req);
res.status(201).json({ gold: transaction.balanceAfter, transaction });
const purse = await prisma.character.findUnique({ where: { id: req.character.id }, select: PURSE_SELECT });
res.status(201).json({ ...purse, transaction: transactions[0], transactions });
} catch (err) {
next(err);
}
});
// Changes coins of one kind into another at their fixed worth (1 gold = 10
// silver = 100 copper). `amount` is how many `from` coins to hand over; it has
// to come to a whole number of `to` coins. Both sides go on the ledger. Unlike
// a spend, this moves exactly the coins named: no change is made. Cash has no
// worth in coins, so it can't be exchanged.
router.post('/:id/gold/exchange', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z
.object({ from: z.enum(COINS), to: z.enum(COINS), amount: z.number().int().min(1).max(1000000) })
.refine((data) => data.from !== data.to, { message: 'Pick two different coins' })
.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { from, to, amount } = parsed.data;
const worth = amount * COIN_VALUE[from];
if (worth % COIN_VALUE[to] !== 0) {
const step = COIN_VALUE[to] / COIN_VALUE[from];
return res.status(400).json({ error: `${amount} ${coinName(from)} doesn't make a whole number of ${coinName(to)} — exchange in multiples of ${step}.` });
}
const received = worth / COIN_VALUE[to];
try {
await prisma.$transaction(async (tx) => {
const base = { characterId: req.character.id, userId: req.user.id };
await adjustGold(tx, { ...base, currency: from, amount: -amount, reason: `Exchanged for ${received} ${coinName(to)}` });
await adjustGold(tx, { ...base, currency: to, amount: received, reason: `Exchanged from ${amount} ${coinName(from)}` });
});
} catch (err) {
if (!(err instanceof InsufficientGoldError)) throw err;
return res.status(400).json({ error: `Not enough ${coinName(from)} — the balance is ${req.character[COIN_COLUMN[from]]}.` });
}
emitCharacterUpdated(req);
const character = await loadFullCharacter(req.character.id);
res.status(201).json({ character: serializeCharacter(character, req.isOwner, req.isDM), received });
} catch (err) {
next(err);
}
@@ -1020,7 +1177,7 @@ for (const gear of GEAR) {
// ---------------------------------------------------------------------------
const giveSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('gold'), toCharacterId: z.string(), amount: z.number().int().min(1) }),
z.object({ kind: z.literal('gold'), toCharacterId: z.string(), amount: z.number().int().min(1), currency: z.enum(CURRENCIES).default('GOLD') }),
z.object({ kind: z.literal('weapon'), toCharacterId: z.string(), id: z.string(), quantity: z.number().int().min(1).default(1) }),
z.object({ kind: z.literal('item'), toCharacterId: z.string(), id: z.string(), quantity: z.number().int().min(1).default(1) }),
z.object({ kind: z.literal('spell'), toCharacterId: z.string(), id: z.string() }),
@@ -1047,8 +1204,10 @@ router.post('/:id/give', requireCharacterAccess({ write: true }), async (req, re
try {
await prisma.$transaction(async (tx) => {
if (give.kind === 'gold') {
await adjustGold(tx, { characterId: giver.id, amount: -give.amount, reason: `Gave to ${recipient.name}`, userId: req.user.id });
await adjustGold(tx, { characterId: recipient.id, amount: give.amount, reason: `Received from ${giver.name}`, userId: req.user.id });
// The giver pays from their whole purse, with change; the recipient gets the coins named.
const coins = { amount: give.amount, currency: give.currency, userId: req.user.id };
await spendCoins(tx, { ...coins, characterId: giver.id, reason: `Gave to ${recipient.name}` });
await adjustGold(tx, { ...coins, characterId: recipient.id, reason: `Received from ${giver.name}` });
return;
}
@@ -1082,7 +1241,7 @@ router.post('/:id/give', requireCharacterAccess({ write: true }), async (req, re
});
} catch (err) {
if (err instanceof InsufficientGoldError) {
return res.status(400).json({ error: `Not enough gold — ${giver.name} has ${giver.gold}.` });
return res.status(400).json({ error: notEnoughMoney(giver, give.amount, give.currency || 'GOLD') });
}
if (err instanceof GiveError) return res.status(400).json({ error: err.message });
throw err;
+440 -45
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, InsufficientGoldError, adjustGold, spendCoins, giveGear } = require('../utils/grants');
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
const router = express.Router();
@@ -18,9 +20,20 @@ async function requireEncounterAccess(req, res, next) {
const membership = await loadCampaignMembership(req.user.id, encounter.campaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
// A player only gets into a scene one of their own characters is in. To
// anyone else it doesn't exist — the name alone can be a spoiler.
const isDM = membership.role === 'DM';
if (!isDM) {
const present = await prisma.encounterParticipant.findFirst({
where: { encounterId: encounter.id, character: { userId: req.user.id } },
select: { id: true },
});
if (!present) return res.status(404).json({ error: 'Encounter not found' });
}
req.encounter = encounter;
req.membership = membership;
req.isDM = membership.role === 'DM';
req.isDM = isDM;
return next();
} catch (err) {
return next(err);
@@ -37,10 +50,61 @@ function clamp(value, min, max) {
}
// Highest initiative first; ties keep the order combatants were added in.
// Anyone who has not rolled yet waits at the bottom.
function inTurnOrder(participants) {
return [...participants].sort((a, b) => b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
}
// What is added to a combatant's d20 for initiative: the sheet's initiative
// bonus for a character, the Dexterity modifier for a monster, and nothing for
// an NPC (they have no ability scores). Takes a participant with its
// character (and proficiencies) and monster loaded.
function initiativeBonus({ character, monster }) {
if (character) return computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).initiative;
if (monster) return abilityModifier(monster.dexScore);
return 0;
}
// Out of the fight: a dead character, or a monster or NPC brought to 0 HP.
// The fallen are hidden from the players and skipped in the turn order; the
// DM still sees them. It is worked out from HP each time, never stored, so
// healing a monster or reviving a character brings them straight back. Takes
// a participant with its character loaded.
function isFallen(p) {
if (p.character) return lifeState(p.character) === 'DEAD';
return p.maxHp > 0 && p.currentHp <= 0;
}
// The first combatant still standing at or after `from` in the order, going
// round to the top when the end is reached. If nobody is standing, whoever is
// at `from`.
function standingFrom(order, from) {
for (let step = 0; step < order.length; step += 1) {
const p = order[(from + step) % order.length];
if (!isFallen(p)) return p;
}
return order[from % order.length] ?? null;
}
// An encounter's combatants in turn order, with what isFallen needs.
async function loadTurnOrder(encounterId) {
const participants = await prisma.encounterParticipant.findMany({
where: { encounterId },
include: { character: { select: { currentHp: true, deathSaveFailures: true, stable: true } } },
});
return inTurnOrder(participants);
}
// The spell a character would raise the dead with: one flagged as reviving
// in the campaign's spell list, at a level they can cast. Takes a character
// with its spells loaded.
function revivalSpell(character) {
return character.spells.map((cs) => cs.spell).find((spell) => spell.revives && canUseGear(character.level, spell.level));
}
const initiativeRollSchema = z.number().int().min(1, 'An initiative roll is a d20: 1 to 20').max(20, 'An initiative roll is a d20: 1 to 20');
// What every campaign member sees about a character in an encounter: the
// sheet's headline numbers plus the weapons and spells it can actually use —
// so the party can plan around each other mid-fight, not only the DM. Uses the
@@ -76,6 +140,8 @@ function characterCombatInfo(character) {
armorClass: sheet.armorClass,
maxHp: sheet.hp.max,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
canRevive: Boolean(revivalSpell(character)),
speed: sheet.speed,
initiativeBonus: sheet.initiative,
passivePerception: sheet.passivePerception,
@@ -89,13 +155,27 @@ function characterCombatInfo(character) {
};
}
// What is for sale, oldest first, with the weapon or item itself.
const WARES = { include: { weapon: true, item: true }, orderBy: { createdAt: 'asc' } };
// The DM gets everything. Players get the scene as their characters see it:
// no DM notes, no monster stat blocks, no NPC secrets or stats, and only the
// rewards that have actually been handed out.
async function serializeEncounter(encounterId, isDM) {
// rewards that have actually been handed out. What an NPC sells and the rate
// they change money at are for everyone: `npc.wares`, `npc.exchangeGold` and
// `npc.exchangeCash`, and the scene's own `wares`. The fallen (isFallen) are left
// out of a player's list altogether; of those, only the dead characters are
// named, in `fallen`, so an ally can try to revive them.
//
// `viewerId` is the user the payload is for. A player is told where a scene
// is once one of their characters has been there, and — when the campaign
// hides monster hit points — gets a percentage for the health bar in place of
// the numbers for anyone who is not a character.
async function serializeEncounter(encounterId, isDM, viewerId) {
const encounter = await prisma.encounter.findUnique({
where: { id: encounterId },
include: {
campaign: { select: { showMonsterHp: true } },
location: { select: { id: true, name: true, description: true } },
participants: {
include: {
character: {
@@ -107,24 +187,34 @@ async function serializeEncounter(encounterId, isDM) {
},
},
monster: true,
npc: true,
npc: { include: { wares: WARES } },
},
},
rewards: { include: { weapon: true, item: true }, orderBy: { createdAt: 'asc' } },
wares: WARES,
},
});
if (!encounter) return encounter;
const participants = inTurnOrder(encounter.participants).map((p) => {
const hideHp = !isDM && !encounter.campaign.showMonsterHp;
const inOrder = inTurnOrder(encounter.participants);
const hiddenIds = new Set(inOrder.filter(isFallen).map((p) => p.id));
const fallen = inOrder
.filter((p) => p.character && hiddenIds.has(p.id))
.map((p) => ({ participantId: p.id, characterId: p.character.id, name: p.displayName }));
const participants = inOrder.filter((p) => isDM || !hiddenIds.has(p.id)).map((p) => {
const { character, monster, npc, ...row } = p;
const out = { ...row, kind: npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER' };
// The bonus is part of a monster's stat block, so only the DM gets it for
// those; a character's is on their sheet for all to see.
const bonus = isDM || character ? { initiativeBonus: initiativeBonus(p) } : {};
const out = { ...row, ...bonus, kind: npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER', hidden: hiddenIds.has(p.id) };
if (character) {
// A character combatant's HP is the character's own HP, not a
// per-encounter copy, so the tracker and the sheet can never disagree.
const { maxHp, ...combatInfo } = characterCombatInfo(character);
out.currentHp = character.currentHp;
out.maxHp = maxHp;
out.character = { id: character.id, name: character.name };
out.character = { id: character.id, name: character.name, userId: character.userId };
out.characterCombatInfo = combatInfo;
}
if (monster) out.monster = isDM ? monster : { id: monster.id, name: monster.name };
@@ -132,14 +222,26 @@ async function serializeEncounter(encounterId, isDM) {
const { dmNotes, ac, maxHp, attackBonus, damageDice, campaignId, createdAt, ...publicNpc } = npc;
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice } : publicNpc;
}
if (hideHp && !character) {
// Rounded up, so anything still standing shows a sliver of bar.
out.hpPercent = p.maxHp > 0 ? Math.ceil((clamp(p.currentHp, 0, p.maxHp) / p.maxHp) * 100) : null;
out.currentHp = null;
out.maxHp = null;
}
return out;
});
const { dmNotes, rewards, ...scene } = encounter;
const { dmNotes, rewards, campaign, location, ...scene } = encounter;
const knowsPlace = isDM || (location && (await visitedLocationIds(prisma, viewerId, encounter.campaignId)).has(location.id));
return {
...scene,
locationId: knowsPlace ? scene.locationId : null,
location: knowsPlace ? location : null,
...(isDM ? { dmNotes } : {}),
// A player is not told that someone they cannot see is being targeted.
targetParticipantIds: isDM ? scene.targetParticipantIds : scene.targetParticipantIds.filter((id) => !hiddenIds.has(id)),
participants,
fallen,
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
};
}
@@ -154,15 +256,31 @@ function emitCharacterUpdated(req, characterId) {
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('character:updated', { characterId });
}
// While an encounter with a location is in progress, the characters in it
// are at that location: starting it, moving it, or adding someone to it puts
// them there, and that goes on their record of places visited.
async function syncCharacterLocations(req) {
const encounter = await prisma.encounter.findUnique({
where: { id: req.encounter.id },
include: { participants: { where: { characterId: { not: null } }, include: { character: { select: { id: true, currentLocationId: true } } } } },
});
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId) return;
const elsewhere = encounter.participants.map((p) => p.character).filter((c) => c.currentLocationId !== encounter.locationId);
await moveCharacters(prisma, elsewhere.map((c) => c.id), encounter.locationId);
for (const character of elsewhere) emitCharacterUpdated(req, character.id);
}
async function respondWithEncounter(req, res, status = 200, extra = {}) {
const encounter = await serializeEncounter(req.encounter.id, req.isDM);
await syncCharacterLocations(req);
const encounter = await serializeEncounter(req.encounter.id, req.isDM, req.user.id);
emitEncounterUpdated(req);
res.status(status).json({ ...extra, encounter });
}
router.get('/:id', requireEncounterAccess, async (req, res, next) => {
try {
res.json({ encounter: await serializeEncounter(req.encounter.id, req.isDM) });
res.json({ encounter: await serializeEncounter(req.encounter.id, req.isDM, req.user.id) });
} catch (err) {
next(err);
}
@@ -174,6 +292,7 @@ const patchEncounterSchema = z.object({
name: z.string().min(1).max(120).optional(),
description: z.string().max(4000).nullable().optional(),
dmNotes: z.string().max(4000).nullable().optional(),
locationId: z.string().nullable().optional(),
archived: z.boolean().optional(), // put away (true) or bring back (false)
});
@@ -185,13 +304,20 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
if (archived === true && !req.encounter.archivedAt) data.archivedAt = new Date();
if (archived === false) data.archivedAt = null;
// Starting a battle puts the first combatant in the initiative order on turn.
if (data.locationId) {
const location = await prisma.location.findFirst({ where: { id: data.locationId, campaignId: req.encounter.campaignId } });
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
}
// Starting a battle puts the first combatant still standing on turn.
const mode = data.mode ?? req.encounter.mode;
if (data.status === 'ACTIVE' && mode === 'BATTLE' && !req.encounter.activeParticipantId) {
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? null;
data.activeParticipantId = standingFrom(await loadTurnOrder(req.encounter.id), 0)?.id ?? null;
}
// Targets belong to the moment; beginning, ending or reopening clears them.
if (data.status && data.status !== req.encounter.status) data.targetParticipantIds = [];
await prisma.encounter.update({ where: { id: req.encounter.id }, data });
await respondWithEncounter(req, res);
} catch (err) {
@@ -221,7 +347,7 @@ const participantSchema = z
monsterId: z.string().optional(),
npcId: z.string().optional(),
displayName: z.string().min(1).max(80).optional(),
initiative: z.number().int().default(0),
initiativeRoll: initiativeRollSchema.optional(),
})
.refine((data) => [data.characterId, data.monsterId, data.npcId].filter(Boolean).length === 1, {
message: 'Provide exactly one of characterId, monsterId or npcId',
@@ -231,9 +357,10 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
try {
const parsed = participantSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { characterId, monsterId, npcId, displayName, initiative } = parsed.data;
const { characterId, monsterId, npcId, displayName, initiativeRoll } = parsed.data;
let currentHp, maxHp, resolvedName;
let bonus = 0;
if (characterId) {
const character = await prisma.character.findUnique({
@@ -243,9 +370,14 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
if (!character || character.campaignId !== req.encounter.campaignId) {
return res.status(400).json({ error: 'That character is not part of this campaign.' });
}
// The dead stay where they fell: they join no new scene until revived.
if (lifeState(character) === 'DEAD') {
return res.status(400).json({ error: `${character.name} is dead and cannot join an encounter. Revive them first.` });
}
currentHp = character.currentHp;
maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
resolvedName = displayName || character.name;
bonus = initiativeBonus({ character });
} else if (monsterId) {
const monster = await prisma.monster.findUnique({ where: { id: monsterId } });
if (!monster || monster.campaignId !== req.encounter.campaignId) {
@@ -254,6 +386,7 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
currentHp = monster.maxHp;
maxHp = monster.maxHp;
resolvedName = displayName || monster.name;
bonus = initiativeBonus({ monster });
} else {
const npc = await prisma.npc.findUnique({ where: { id: npcId } });
if (!npc || npc.campaignId !== req.encounter.campaignId) {
@@ -272,7 +405,8 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
monsterId: monsterId || null,
npcId: npcId || null,
displayName: resolvedName,
initiative,
initiativeRoll: initiativeRoll ?? null,
initiative: initiativeRoll == null ? 0 : initiativeRoll + bonus,
currentHp,
maxHp,
isMonster: Boolean(monsterId),
@@ -287,34 +421,53 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
const updateParticipantSchema = z.object({
currentHp: z.number().int().optional(),
maxHp: z.number().int().min(1).optional(),
initiative: z.number().int().optional(),
maxHp: z.number().int().min(1).max(100000).optional(),
initiativeRoll: initiativeRollSchema.nullable().optional(), // null clears the roll
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
displayName: z.string().min(1).max(80).optional(),
});
router.patch('/:id/participants/:participantId', requireEncounterAccess, requireDM, async (req, res, next) => {
// The DM runs every combatant. A player may do one thing here: enter the
// initiative roll for their own character.
router.patch('/:id/participants/:participantId', requireEncounterAccess, async (req, res, next) => {
try {
const parsed = updateParticipantSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const existing = await prisma.encounterParticipant.findUnique({
where: { id: req.params.participantId },
include: { character: { include: { skillProficiencies: true, saveProficiencies: true } } },
include: { character: { include: { skillProficiencies: true, saveProficiencies: true } }, monster: true },
});
if (!existing || existing.encounterId !== req.encounter.id) {
return res.status(404).json({ error: 'Combatant not found in this encounter' });
}
if (!req.isDM) {
const onlyTheRoll = Object.keys(parsed.data).every((key) => key === 'initiativeRoll');
const ownCharacter = existing.character && existing.character.userId === req.user.id;
if (!onlyTheRoll || !ownCharacter) {
return res.status(403).json({ error: 'You can only enter the initiative roll for your own character' });
}
}
const data = { ...parsed.data };
// The roll is what was on the die; the bonus is added here.
if (data.initiativeRoll !== undefined) {
data.initiative = data.initiativeRoll == null ? 0 : data.initiativeRoll + initiativeBonus(existing);
}
if (existing.character) {
// Max HP comes from the sheet, and damage/healing lands on the character.
const { character } = existing;
const maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
data.maxHp = maxHp;
if (data.currentHp !== undefined) {
data.currentHp = clamp(data.currentHp, 0, maxHp);
await prisma.character.update({ where: { id: character.id }, data: { currentHp: data.currentHp } });
if (lifeState(character) === 'DEAD' && data.currentHp > 0) {
return res.status(400).json({ error: `${character.name} is dead. Revive them before healing.` });
}
// Temp HP soaks damage first; what is left lands on the character,
// and at 0 HP it moves their death saves too.
const after = changeHp(character, data.currentHp, maxHp);
data.currentHp = after.currentHp;
await prisma.character.update({ where: { id: character.id }, data: after });
emitCharacterUpdated(req, character.id);
}
} else if (data.currentHp !== undefined) {
@@ -332,15 +485,20 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, require
router.delete('/:id/participants/:participantId', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
const order = inTurnOrder(participants);
const order = await loadTurnOrder(req.encounter.id);
const index = order.findIndex((p) => p.id === req.params.participantId);
if (index === -1) return res.status(404).json({ error: 'Combatant not found in this encounter' });
// Removing whoever is on turn passes the turn to the next combatant.
if (req.encounter.activeParticipantId === req.params.participantId) {
const next = order.length > 1 ? order[(index + 1) % order.length] : null;
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { activeParticipantId: next ? next.id : null } });
// Removing whoever is on turn passes the turn to the next combatant still
// standing, and their targets go with the turn. Removing a target just
// unmarks it.
const { activeParticipantId, targetParticipantIds } = req.encounter;
if (activeParticipantId === req.params.participantId) {
const next = standingFrom([...order.slice(index + 1), ...order.slice(0, index)], 0);
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { activeParticipantId: next ? next.id : null, targetParticipantIds: [] } });
} else if (targetParticipantIds.includes(req.params.participantId)) {
const left = targetParticipantIds.filter((id) => id !== req.params.participantId);
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantIds: left } });
}
await prisma.encounterParticipant.delete({ where: { id: req.params.participantId } });
await respondWithEncounter(req, res);
@@ -349,25 +507,118 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir
}
});
// A dead character can be brought back in the middle of a battle by an ally:
// the character on turn, if they are on their feet and can cast a spell that
// raises the dead (revivalSpell). The dead come back at 1 HP, and with that
// are back in everyone's list. A player tries it for their own character; the
// DM for whoever is on turn. (The DM can also revive anyone outright, through
// the character's death saves.)
router.post('/:id/participants/:participantId/revive', requireEncounterAccess, async (req, res, next) => {
try {
const { mode, status, activeParticipantId } = req.encounter;
const onTurnId = mode === 'BATTLE' && status === 'ACTIVE' ? activeParticipantId : null;
const onTurn = onTurnId
? await prisma.encounterParticipant.findFirst({
where: { id: onTurnId, encounterId: req.encounter.id },
include: { character: { include: { spells: { include: { spell: true } } } } },
})
: null;
const reviver = onTurn?.character;
if (!req.isDM && (!reviver || reviver.userId !== req.user.id)) {
return res.status(403).json({ error: "You can only try to revive someone on your own character's turn" });
}
if (!reviver) return res.status(400).json({ error: 'Only a character on turn in a battle can try to revive someone.' });
const target = await prisma.encounterParticipant.findUnique({ where: { id: req.params.participantId }, include: { character: true } });
if (!target || target.encounterId !== req.encounter.id) {
return res.status(404).json({ error: 'Combatant not found in this encounter' });
}
if (!target.character || lifeState(target.character) !== 'DEAD') {
return res.status(400).json({ error: `${target.displayName} is not dead.` });
}
if (lifeState(reviver) !== 'UP') return res.status(400).json({ error: `${reviver.name} is in no state to revive anyone.` });
const spell = revivalSpell(reviver);
if (!spell) return res.status(400).json({ error: `${reviver.name} has no spell that can bring back the dead.` });
await prisma.character.update({ where: { id: target.character.id }, data: { currentHp: 1, ...NO_DEATH_SAVES } });
await prisma.encounterParticipant.update({ where: { id: target.id }, data: { currentHp: 1 } });
emitCharacterUpdated(req, target.character.id);
await respondWithEncounter(req, res, 200, { revivedWith: spell.name });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Targets — who the combatant on turn is going for
// ---------------------------------------------------------------------------
// The whole list, in the order picked; an empty one clears it.
const targetsSchema = z.object({ participantIds: z.array(z.string()).max(50) });
// Combatants can be marked as targets, for everyone to see; the DM's screen
// then offers to apply damage to them. One is an ordinary attack, more is an
// area attack. The DM can mark anyone at any time. A player can while their
// own character is on turn in a battle. Nobody can mark the combatant who is
// on turn: you don't target yourself.
router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
try {
const parsed = targetsSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const participantIds = [...new Set(parsed.data.participantIds)];
// Whoever is on turn, when a battle is under way.
const { mode, status, activeParticipantId } = req.encounter;
const onTurnId = mode === 'BATTLE' && status === 'ACTIVE' ? activeParticipantId : null;
if (!req.isDM) {
const ownTurn = onTurnId
? await prisma.encounterParticipant.findFirst({
where: { id: onTurnId, encounterId: req.encounter.id, character: { userId: req.user.id } },
select: { id: true },
})
: null;
if (!ownTurn) return res.status(403).json({ error: "You can only pick targets on your own character's turn" });
}
if (onTurnId && participantIds.includes(onTurnId)) {
return res.status(400).json({ error: 'The combatant on turn cannot be their own target' });
}
// A player can only mark who they can see: to them the fallen are not there.
const order = await loadTurnOrder(req.encounter.id);
const markable = new Set(order.filter((p) => req.isDM || !isFallen(p)).map((p) => p.id));
if (!participantIds.every((id) => markable.has(id))) return res.status(400).json({ error: 'Combatant not found in this encounter' });
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantIds: participantIds } });
await respondWithEncounter(req, res);
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Turns and rounds
// ---------------------------------------------------------------------------
// Passes the turn down the initiative order; going past the last combatant
// starts the next round.
// Passes the turn down the initiative order to the next combatant still
// standing (the fallen have no turn); going past the last combatant starts
// the next round.
router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
const order = inTurnOrder(participants);
const order = await loadTurnOrder(req.encounter.id);
if (order.length === 0) return res.status(400).json({ error: 'Add combatants before taking turns.' });
const index = order.findIndex((p) => p.id === req.encounter.activeParticipantId);
const wrapped = index === order.length - 1;
const upNext = standingFrom(order, index + 1);
const wrapped = index !== -1 && order.indexOf(upNext) <= index;
await prisma.encounter.update({
where: { id: req.encounter.id },
data: {
status: 'ACTIVE',
activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id,
activeParticipantId: upNext.id,
targetParticipantIds: [], // the next combatant picks their own
...(wrapped ? { currentRound: { increment: 1 } } : {}),
},
});
@@ -379,21 +630,23 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
const order = await loadTurnOrder(req.encounter.id);
const updated = await prisma.encounter.update({
where: { id: req.encounter.id },
data: {
currentRound: { increment: 1 },
status: 'ACTIVE',
targetParticipantIds: [],
// A new round starts back at the top of the initiative order.
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: standingFrom(order, 0)?.id ?? null } : {}),
},
});
await syncCharacterLocations(req);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
encounterId: updated.id,
currentRound: updated.currentRound,
});
res.json({ encounter: await serializeEncounter(updated.id, req.isDM) });
res.json({ encounter: await serializeEncounter(updated.id, req.isDM, req.user.id) });
} catch (err) {
next(err);
}
@@ -404,18 +657,18 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
// ---------------------------------------------------------------------------
const rewardSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('GOLD'), gold: z.number().int().min(1), note: z.string().trim().max(200).optional().nullable() }),
z.object({ type: z.literal('GOLD'), gold: z.number().int().min(1).max(1000000), currency: z.enum(CURRENCIES).default('GOLD'), note: z.string().trim().max(200).optional().nullable() }),
z.object({
type: z.literal('WEAPON'),
weaponId: z.string(),
quantity: z.number().int().min(1).default(1),
quantity: z.number().int().min(1).max(999).default(1),
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
note: z.string().trim().max(200).optional().nullable(),
}),
z.object({
type: z.literal('ITEM'),
itemId: z.string(),
quantity: z.number().int().min(1).default(1),
quantity: z.number().int().min(1).max(999).default(1),
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
note: z.string().trim().max(200).optional().nullable(),
}),
@@ -481,7 +734,7 @@ router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, a
let recipients;
if (parsed.data.split) {
if (reward.type !== 'GOLD') return res.status(400).json({ error: 'Only gold can be split.' });
if (reward.type !== 'GOLD') return res.status(400).json({ error: 'Only coins can be split.' });
const participants = await prisma.encounterParticipant.findMany({
where: { encounterId: req.encounter.id, characterId: { not: null } },
include: { character: true },
@@ -510,7 +763,7 @@ router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, a
const remainder = reward.gold % recipients.length;
for (const [i, character] of recipients.entries()) {
const amount = share + (i < remainder ? 1 : 0);
if (amount > 0) await adjustGold(tx, { characterId: character.id, amount, reason, userId: req.user.id });
if (amount > 0) await adjustGold(tx, { characterId: character.id, amount, currency: reward.currency, reason, userId: req.user.id });
}
} else {
const kind = reward.type === 'WEAPON' ? 'weapon' : 'item';
@@ -530,4 +783,146 @@ router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, a
}
});
// ---------------------------------------------------------------------------
// Trade — changing gold and cash with an NPC, and buying what is for sale.
// The DM sets the rates and the wares up (campaigns.routes.js); here the
// players, or the DM for them, use them.
// ---------------------------------------------------------------------------
class TradeError extends Error {
constructor(message, status = 400) {
super(message);
this.status = status;
}
}
function money(amount, currency) {
return currency === 'CASH' ? `$${amount}` : `${amount} ${currency.toLowerCase()}`;
}
// Trading takes someone to trade with and the time to do it, so it happens
// in a quest in progress and never in a battle.
function assertTradeOpen(encounter) {
if (encounter.mode !== 'QUEST') throw new TradeError('Trading happens in a quest, not in a battle.');
if (encounter.status !== 'ACTIVE') throw new TradeError('Trading opens once the quest is in progress.');
}
// The character doing the trading: in this scene, alive, and the player's
// own (the DM can trade for anyone).
async function loadTrader(req, characterId) {
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, characterId }, include: { character: true } });
if (!row) throw new TradeError('That character is not in this encounter.');
if (!req.isDM && row.character.userId !== req.user.id) throw new TradeError('You can only trade for your own character.', 403);
if (isFallen(row)) throw new TradeError(`${row.character.name} is dead.`);
return row.character;
}
// An NPC to trade with has to be in the scene and still standing.
async function loadTradingNpc(req, npcId) {
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, npcId }, include: { npc: true } });
if (!row || isFallen(row)) throw new TradeError('That NPC is not here to trade with.');
return row.npc;
}
function tradeRoute(handler) {
return async (req, res, next) => {
try {
assertTradeOpen(req.encounter);
await handler(req, res);
} catch (err) {
if (err instanceof TradeError) return res.status(err.status).json({ error: err.message });
next(err);
}
};
}
// Changes gold into cash, or cash into gold, at the NPC's own rate. Only
// gold counts: silver and copper are not taken, and none come back. `amount`
// is what is handed over, and has to come to a whole number the other way.
router.post(
'/:id/exchange',
requireEncounterAccess,
tradeRoute(async (req, res) => {
const parsed = z
.object({ characterId: z.string(), npcId: z.string(), from: z.enum(['GOLD', 'CASH']), amount: z.number().int().min(1).max(1000000) })
.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { characterId, npcId, from, amount } = parsed.data;
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
const character = await loadTrader(req, characterId);
const npc = await loadTradingNpc(req, npcId);
if (!npc.exchangeGold || !npc.exchangeCash) throw new TradeError(`${npc.name} does not change money.`);
const rate = { GOLD: npc.exchangeGold, CASH: npc.exchangeCash };
if (amount % rate[from] !== 0) {
throw new TradeError(
`${npc.name} gives ${money(rate.CASH, 'CASH')} for ${money(rate.GOLD, 'GOLD')}: hand over ${from.toLowerCase()} in multiples of ${rate[from]}.`
);
}
const received = (amount / rate[from]) * rate[to];
try {
await prisma.$transaction(async (tx) => {
const base = { characterId: character.id, userId: req.user.id };
await adjustGold(tx, { ...base, currency: from, amount: -amount, reason: `Changed with ${npc.name} for ${money(received, to)}` });
await adjustGold(tx, { ...base, currency: to, amount: received, reason: `Changed with ${npc.name} from ${money(amount, from)}` });
});
} catch (err) {
if (!(err instanceof InsufficientGoldError)) throw err;
throw new TradeError(`${character.name} only has ${money(from === 'GOLD' ? character.gold : character.cash, from)}.`);
}
emitCharacterUpdated(req, character.id);
await respondWithEncounter(req, res, 201, { received, currency: to });
})
);
// Buys something that is for sale here: from an NPC in the scene, or from the
// scene itself. The price is paid from the purse (coins make change; a cash
// price takes cash), and the weapon or item joins what the character holds.
router.post(
'/:id/wares/:wareId/buy',
requireEncounterAccess,
tradeRoute(async (req, res) => {
const parsed = z.object({ characterId: z.string(), quantity: z.number().int().min(1).max(999).default(1) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { characterId, quantity } = parsed.data;
const character = await loadTrader(req, characterId);
const ware = await prisma.saleOffer.findUnique({ where: { id: req.params.wareId }, include: { weapon: true, item: true } });
if (!ware || ware.campaignId !== req.encounter.campaignId || (ware.encounterId && ware.encounterId !== req.encounter.id)) {
throw new TradeError('That is not for sale here.', 404);
}
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId)).name : null;
const kind = ware.weapon ? 'weapon' : 'item';
const entry = ware[kind];
const total = ware.price * quantity;
try {
await prisma.$transaction(async (tx) => {
if (ware.stock !== null) {
// The atomic decrement is the stock check: two buyers can't both take the last one.
const taken = await tx.saleOffer.updateMany({ where: { id: ware.id, stock: { gte: quantity } }, data: { stock: { decrement: quantity } } });
if (taken.count === 0) throw new TradeError(ware.stock === 0 ? `${entry.name} is sold out.` : `Only ${ware.stock} of ${entry.name} left.`);
}
const what = `${quantity > 1 ? `${quantity} × ` : ''}${entry.name}`;
await spendCoins(tx, {
characterId: character.id,
amount: total,
currency: ware.currency,
reason: seller ? `Bought ${what} from ${seller}` : `Bought ${what}`,
userId: req.user.id,
});
await giveGear(tx, kind, { characterId: character.id, entry, quantity, level: ware.level });
});
} catch (err) {
if (!(err instanceof InsufficientGoldError)) throw err;
throw new TradeError(`${character.name} can't afford that: it costs ${money(total, ware.currency)}.`);
}
emitCharacterUpdated(req, character.id);
await respondWithEncounter(req, res, 201);
})
);
module.exports = router;
+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}`);
+105 -9
View File
@@ -1,24 +1,120 @@
// The two ways a character's belongings change: gold through the ledger, and
// The two ways a character's belongings change: coins through the ledger, and
// gear from the campaign pools. Shared by the character sheet routes and by
// encounter rewards so both follow the same rules. Every function takes a
// Prisma client or transaction client as `db`.
// The coins, and what each is worth counted in copper: silver is 0.1 gold and
// copper is 0.01 gold, so 1 gold = 10 silver = 100 copper and 1 silver = 10
// copper. Whole numbers keep exchanges exact.
const COINS = ['GOLD', 'SILVER', 'COPPER'];
const COIN_VALUE = { GOLD: 100, SILVER: 10, COPPER: 1 };
// Everything a purse can hold: the coins, plus cash ($) — modern money for
// crossover worlds. Cash has no worth in coins, so it is never exchanged for
// them and never used to make change.
const CURRENCIES = [...COINS, 'CASH'];
// Each currency is its own column on the character.
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' };
class InsufficientGoldError extends Error {}
// amount is signed: positive = gained, negative = spent. Writes the ledger row
// and returns it; throws InsufficientGoldError if the balance would go below 0
// (inside a transaction, that rolls the whole thing back).
async function adjustGold(db, { characterId, amount, reason, userId, include }) {
// amount is signed: positive = gained, negative = spent, in `currency` coins.
// Writes the ledger row and returns it; throws InsufficientGoldError if that
// coin's balance would go below 0 (inside a transaction, that rolls the whole
// thing back). This moves exactly the coins named and nothing else; to pay a
// price out of whatever coins the purse holds, use spendCoins.
async function adjustGold(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
const column = COIN_COLUMN[currency];
// The atomic increment is the balance check: two spends racing each other
// can't both pass a stale read.
const updated = await db.character.update({ where: { id: characterId }, data: { gold: { increment: amount } } });
if (updated.gold < 0) throw new InsufficientGoldError();
const updated = await db.character.update({ where: { id: characterId }, data: { [column]: { increment: amount } } });
if (updated[column] < 0) throw new InsufficientGoldError();
return db.goldTransaction.create({
data: { characterId, amount, balanceAfter: updated.gold, reason: reason || null, createdById: userId },
data: { characterId, currency, amount, balanceAfter: updated[column], reason: reason || null, createdById: userId },
include,
});
}
// Works out which coins leave (and come back as change) when a purse pays
// `amount` coins of `currency`. Returns the change to each balance, e.g.
// { GOLD: -1, SILVER: 7, COPPER: 0 } for 3 silver paid from a purse with only
// gold in it — or null if the whole purse isn't worth that much.
// 1. Coins of the kind asked for go first.
// 2. Any shortfall is met by breaking the nearest bigger coin; the change
// comes back in as few coins as possible.
// 3. Only if there are no bigger coins left are smaller ones counted out.
function planPayment(purse, currency, amount) {
const worth = (coin) => COIN_VALUE[coin];
const delta = { GOLD: 0, SILVER: 0, COPPER: 0 };
const left = (coin) => purse[coin] + delta[coin];
let owed = amount * worth(currency);
if (COINS.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
const pay = (coin, count) => {
delta[coin] -= count;
owed -= count * worth(coin);
};
pay(currency, Math.min(purse[currency], amount));
const bigger = COINS.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
for (const coin of bigger) {
if (owed <= 0) break;
pay(coin, Math.min(left(coin), Math.ceil(owed / worth(coin))));
// Overpaid: hand the difference back, biggest coins first.
let change = -owed;
for (const small of COINS.filter((c) => worth(c) < worth(coin))) {
if (change <= 0) break;
const count = Math.floor(change / worth(small));
delta[small] += count;
change -= count * worth(small);
}
if (owed < 0) owed = 0;
}
for (const coin of COINS.filter((c) => worth(c) < worth(currency))) {
if (owed <= 0) break;
pay(coin, Math.min(left(coin), Math.floor(owed / worth(coin))));
}
return delta;
}
// Pays `amount` coins of `currency` out of a character's purse, making change
// as needed (see planPayment). Every coin that moved gets its own ledger row;
// the rows are returned, the one in `currency` first when there is one. Throws
// InsufficientGoldError if the purse as a whole can't cover it. Cash is paid in
// cash alone: coins don't cover it, and it doesn't cover coins.
async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
if (!COINS.includes(currency)) return [await adjustGold(db, { characterId, amount: -amount, currency, reason, userId, include })];
const character = await db.character.findUnique({ where: { id: characterId }, select: { gold: true, silver: true, copper: true } });
const purse = { GOLD: character.gold, SILVER: character.silver, COPPER: character.copper };
const delta = planPayment(purse, currency, amount);
if (!delta) throw new InsufficientGoldError();
const moved = COINS.filter((coin) => delta[coin] !== 0).sort((a, b) => (b === currency) - (a === currency));
const madeChange = moved.some((coin) => coin !== currency);
const label = reason || 'Spent';
const rows = [];
for (const coin of moved) {
rows.push(
await adjustGold(db, {
characterId,
amount: delta[coin],
currency: coin,
// Says what the payment was when the coins that moved aren't the ones asked for.
reason: madeChange ? `${label} (paid ${amount} ${currency.toLowerCase()}, with change)` : reason,
userId,
include,
})
);
}
return rows;
}
// A purse's coins as one figure in gold, for messages: 265 copper-worth -> 2.65.
function worthInGold(character) {
const copper = COINS.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
return copper / COIN_VALUE.GOLD;
}
const HELD = {
weapon: { model: 'characterWeapon', key: 'weaponId' },
item: { model: 'inventoryItem', key: 'itemId' },
@@ -37,4 +133,4 @@ async function giveGear(db, kind, { characterId, entry, quantity = 1, level, equ
});
}
module.exports = { InsufficientGoldError, adjustGold, giveGear };
module.exports = { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
+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 };
+96 -14
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,50 @@ character already has adds to the stack. Items have no effect on any stat. An
item can carry optional dice (a potion's healing, a bomb's damage) that grow
with its level exactly like a weapon's (section 10).
**Money** is a single gold balance with a log behind it.
**Money** is a purse of three coins, each counted on its own, with a log
behind it.
- The sheet's Money card shows the balance and every change to it: the amount,
what it was for, who recorded it, when, and the balance afterwards.
- The **owning player and the DM** can both record a gain or a spend. Other
members can see the log but not add to it.
- The balance **cannot go below zero**; a spend larger than the balance is
refused.
- The log is the only way the balance changes. Entries cannot be edited or
| Coin | Worth |
|---|---|
| Gold (gp) | 1 gold |
| Silver (sp) | 0.1 gold — 10 to the gold |
| Copper (cp) | 0.01 gold — 100 to the gold, 10 to the silver |
- The sheet's Money card shows the three balances, what the purse is worth in
gold altogether, and every change: the amount and coin, what it was for, who
recorded it, when, and that coin's balance afterwards.
- The **owning player and the DM** can both record a gain or a spend, choosing
the coin. Other players cannot see the purse or the log.
- **Spending makes change for you.** A spend (or a gift to another character)
is paid from the whole purse. Coins of the kind named go first; if there
aren't enough, the nearest bigger coin is broken and the change comes back
in as few coins as possible; if there are no bigger coins, smaller ones are
counted out. Spending 3 silver from a purse of 5 gold leaves 4 gold and 7
silver. Each coin that moved gets its own log entry, marked "with change".
- A spend is refused only when the **whole purse** isn't worth enough.
- **Exchange** turns coins of one kind into another at the worth above, and
logs both sides. It moves exactly the coins named and has to come to whole
coins: silver into gold goes in tens, copper into silver in tens, copper
into gold in hundreds.
- The log is the only way a balance changes. Entries cannot be edited or
deleted; to correct a mistake, record the opposite entry.
- There is one currency (gold). Silver, copper and so on are not tracked.
- Starting gold (a campaign setting) is gold only. Encounter rewards and gifts
between characters can be in any coin, or in cash.
- **Cash ($)** is a fourth balance for crossover worlds, counted in whole
dollars and shown as `$40`. It is gained, spent, given and awarded like a
coin and has its own log entries, but it has no worth in coins: it cannot
be exchanged on the sheet, a cash spend is paid from cash alone, and cash
is never broken to cover a coin spend. It shows in the purse only when
held.
- **Gold and cash are changed for each other by an NPC**, not on the sheet.
An NPC the DM has given an exchange rate ("1 gold = $5") changes them
either way at that rate, in an NPC quest in progress that both the NPC and
the character are in. Only gold and cash: silver and copper are not taken.
Each NPC has their own rate. See [NPCS.md](NPCS.md), section 5.
- **Buying.** In the same kind of scene a character can buy the weapons and
items an NPC, or the encounter itself, has for sale. The price is paid from
the purse like any other spend and logged as "Bought <thing> from <NPC>".
See [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
### Giving to another character
@@ -552,7 +629,7 @@ character in the same campaign.
| Appearance | Everyone in the campaign | Built from picks; afterwards the DM only |
| Special abilities | Everyone in the campaign (sheet and quick stats) | Built from picks; afterwards the DM only |
| Private notes | **The owner only — not even the DM** | Owner only |
| Current location | Everyone in the campaign | DM picks it on the sheet, from the campaign's locations |
| Current location | The owner and the DM. Another player sees it in the party list only if their own character has been to that place | DM picks it on the sheet; an encounter in progress with a location also moves its characters there |
**Character notes** are a separate, running log on the sheet:
@@ -620,7 +697,9 @@ players never receive.
- "Begin Battle" puts the highest initiative on turn. "Next Turn" passes the
turn down the order, and going past the last combatant starts the next
round. "Next Round" jumps straight to the top of the next round.
- A combatant at 0 HP is marked Down but still takes its turn in the order.
- A monster or NPC at 0 HP is marked Down; a character is Dying and makes
death saves on its card (section 7). Either still has its place in the
order.
- Conditions (Prone, Poisoned, …) are picked from a list and removed with a
click.
@@ -671,13 +750,16 @@ Another Campaign" card on its sheet.
| View private notes | Yes | No | No |
| Run the builder | Yes | Yes | No |
| Change HP, temp HP, armor bonus | Yes | Yes | No |
| Enter a death save, or stabilise | Yes | Yes | No |
| Set death-save tallies by hand, revive from dead | No | Yes | No |
| Edit special abilities, background, appearance (after the build) | No | Yes | No |
| Tick Equipped / Prepared | Yes | Yes | No |
| Give, remove or re-count weapons, spells and items | No | Yes | No |
| Level a weapon or item up or down | No | Yes | No |
| Record a gold gain or spend | Yes | Yes | No |
| Give gold, weapons, items or spells to another character | Yes | Yes | No |
| View the money log | Yes | Yes | Yes |
| Change gold and cash with an NPC, or buy what is for sale, in a quest in progress | Yes | Yes | No |
| View the money log, gold and inventory | Yes | Yes | No |
| Change level, XP, location or speed override | No | Yes | No |
| Post a character note | Yes | Yes | Yes |
| Copy to another campaign | Yes | Yes | No |
@@ -709,11 +791,11 @@ calculations for them.
- Rolling attacks, damage, saves or checks for a character (the dice roller,
bottom left of every page, is a plain roller that knows nothing about the
sheet)
- Death saves, conditions on the sheet, and resting
- Conditions on the sheet, and resting
- Automatic levelling from XP
- Alignment, languages, tool proficiencies and backgrounds-as-rules
- Encumbrance and item weight
- Currencies other than gold, and item prices
- Cents, and changing silver or copper for cash
## 20. Everything the builder offers
+247 -25
View File
@@ -19,9 +19,10 @@ This describes the code as it stands. The rules live in
7. [Initiative, turns and rounds](#7-initiative-turns-and-rounds)
8. [Conditions](#8-conditions)
9. [Rewards](#9-rewards)
10. [What each side sees](#10-what-each-side-sees)
11. [Permissions](#11-permissions)
12. [What is not modelled](#12-what-is-not-modelled)
10. [Trade](#10-trade)
11. [What each side sees](#11-what-each-side-sees)
12. [Permissions](#12-permissions)
13. [What is not modelled](#13-what-is-not-modelled)
---
@@ -30,7 +31,11 @@ This describes the code as it stands. The rules live in
- An encounter is one **scene** the DM runs: a fight, or a meeting with
people who want something.
- Encounters belong to a campaign and are listed on the campaign page. The DM
creates them; every member can open them.
creates them and sees all of them.
- A player only sees an encounter that **one of their own characters is in**.
Until the DM adds their character it is not in their list and its page will
not open, so scenes the DM is still preparing stay hidden, name included.
Taking their character out hides it again.
- The encounter page updates live for everyone who has it open.
- The DM runs everything. Players watch, and see less than the DM does.
@@ -42,11 +47,12 @@ time from the top of the page.
| | Battle | NPC quest |
|---|---|---|
| For | A fight | A scene built around people and an objective |
| Layout | Everyone in one list, in initiative order | Three groups: "People you meet", "The party", "Creatures" |
| Layout | Everyone in one list, in initiative order; once in progress, those who have had their turn move to "Next Round" at the bottom | Three groups: "People you meet", "The party", "Creatures" |
| Rounds and turns | Yes | No |
| Initiative | Typed in per combatant | Not shown |
| Initiative | The d20 roll, typed in per combatant; the bonus is added | The same; each group lists highest first |
| Start button | Begin Battle | Begin Quest |
| End button | End Battle | Complete Quest |
| Trade (section 10) | No | Yes, while in progress |
Everything else (hit points, conditions, rewards, the scene description, DM
notes) works the same in both.
@@ -63,6 +69,17 @@ Status is a label. Nothing is locked when an encounter is complete: the DM
can still change HP, add people and award rewards. Encounters cannot be
renamed from the screen.
### Location
The DM can give an encounter a location from the campaign's list, with the
picker at the top of the page. While the encounter is **in progress** its
characters are at that location: beginning it, changing its location, or
adding a character to it moves them there. Each place a character is moved to
(this way or by the DM on the sheet) is remembered, and a player can see only
the locations one of their own characters has been to — on the campaign page
under "Places You've Been", and on the encounter itself. Archiving or deleting
the encounter does not take a visited place away again.
### Order, archive and delete
**Order.** The campaign page lists encounters in the **campaign order**, which
@@ -73,8 +90,8 @@ anyone else. ▲ and ▼ only show while sorting by campaign order.
**Archive.** "Archive" (on the list or the encounter page) puts an encounter
away without changing it. It moves to the DM's "Archived" section and
disappears from the players' list; anyone who already has its page open or
bookmarked can still open it. "Restore" puts it back on the list.
disappears from the players' list; a player whose character is in it can still
open its page from a bookmark. "Restore" puts it back on the list.
**Delete.** "Delete" (on the encounter page, or on an archived encounter in
the list) removes the scene, who was in it and its reward list, after a
@@ -96,7 +113,7 @@ The DM adds people from the "Add to the scene" panel.
| Kind | Comes from | Can be added more than once |
|---|---|---|
| Character | The campaign's finished characters | No |
| Character | The campaign's finished characters, except dead ones | No |
| Monster | The campaign's monster list ([MONSTERS.md](MONSTERS.md)) | Yes |
| NPC | The campaign's NPC list ([NPCS.md](NPCS.md)) | No |
@@ -116,6 +133,10 @@ Details start open for the DM and closed for players. Weapons and spells
above a character's level are left out, because the character cannot use
them.
A **dead character cannot be added** to any encounter: they are left out of
the list to pick from and the server refuses them, until they are revived. A
character who dies stays in the encounters they were already in.
The DM removes someone with "Remove" on their card. Removing whoever is on
turn passes the turn to the next combatant.
@@ -130,23 +151,168 @@ The DM types an amount and clicks Damage or Heal on a card.
| NPC | In the encounter. An NPC with no HP set has no health bar. |
- HP always stays between 0 and max.
- At 0 HP a combatant is marked **Down** and dimmed. It keeps its place in
the order; nothing is removed automatically.
- **What players see of it.** A character's HP is always shown in numbers. For
monsters and NPCs it depends on the campaign setting "Players see the exact
hit points of monsters and NPCs": on (the default) shows the numbers, off
shows only the health bar. The DM always sees the numbers.
- At 0 HP a monster or NPC is **out of the fight**: see "The fallen" below.
- A **character** at 0 HP is **Dying** instead, and its card grows a death-save
box: see below.
- A character's temporary HP is shown beside its HP but is not used up
automatically.
### A character at 0 HP
The full rules are in [CHARACTERS.md](CHARACTERS.md), section 7; the box on
the card is the same one as on the sheet, and both change the same character.
- **Dying.** The box shows two tallies, Saved and Failed, three pips each.
Type what the d20 showed and click **Death save**: 10 or more is a success,
less is a failure, a 1 is two failures, a 20 puts the character back up at
1 HP. The DM can do this for anyone; a player for their own character.
- **Damage while down** is one failed save, added when the DM clicks Damage.
For a critical hit the DM clicks the next Failed pip to add the second.
- **Stabilise** stops the saves without a roll (a Medicine check, a spell).
Three successes do the same. A stable character stays at 0 HP.
- **Heal** on a dying or stable character puts them back up and clears the
tallies.
- **Dead** after three failures, or when one blow leaves as much as the
character's max HP still to take after reaching 0. Healing is refused; the
DM clicks **Revive at 1 HP**, or an ally revives them (below).
- The DM can click any pip to set a tally by hand.
Nothing rolls by itself: the app does not prompt for a save when the turn
comes round. A dying or stable character keeps its place in the order and
still gets its turn.
### The fallen
A combatant is **out of the fight** when it is:
- a **monster or NPC at 0 HP** (one with hit points to track), or
- a **dead character**. Dying and stable characters are not: they stay in
view.
What that does:
- **Players no longer see it.** Its card is gone from their screen, along
with any target mark on it; the server does not send it to them at all.
- **The DM's screen leaves it out as well**, until the DM ticks **Show dead**
above the list (the box shows how many there are, and only appears when
there are any). Ticked, they are listed in a group of their own at the
bottom, "Out of the fight, hidden from players", each with a "Hidden from
players" badge, and everything on the card still works. The tick is not
remembered: it is off again when the page is reopened.
- **It has no turn.** Next Turn, Next Round and Begin Battle pass over it.
- It stays in the encounter: nothing is removed, and gold is still split with
a dead character.
It is worked out from hit points each time, not stored, so it undoes itself:
heal a monster or NPC above 0, or revive a character, and they are back on
everyone's screen and back in the turn order.
**Reviving a dead character.** Players see dead characters by name only, in a
**☠ Fallen** box above the list. There are two ways back:
- The **DM** ticks Show dead and clicks **Revive at 1 HP** on the dead
character's card (or does it on the sheet), at any time.
- An **ally** tries it: in a battle in progress, the player whose character is
**on turn** gets a **Try revive** button beside each name in the Fallen box,
if that character is capable. Capable means on their feet (above 0 HP) and
knowing a spell that is ticked **Revives** in the campaign's spell list
([SPELLS.md](SPELLS.md)), at a level they can cast. The library's Revivify
(level 3) and Raise Dead (level 5) are ticked already.
Either way the character comes back at 1 HP with the death saves cleared. The
app does not use up the spell, check how long ago they died, or end the
reviver's turn.
## 7. Initiative, turns and rounds
Battles only.
Initiative is entered in battles and quests alike. Turns and rounds are for
battles only.
- The DM types each combatant's **initiative** when adding them, and can
change it on the card. The app does not roll it. The list is ordered
highest first; ties keep the order they were added in.
- Each combatant has an **initiative roll** field. Type what the d20 showed,
1 to 20; the field accepts nothing else. The app does not roll the die, but
it adds the bonus for you and shows the total, which can go past 20 (a 20 on
the die with a +3 bonus is 23): a character's initiative
bonus from their sheet, a monster's Dexterity modifier, nothing for an NPC.
- The DM can enter the roll for anyone, when adding them or on the card. A
player can enter the roll for **their own character** on its card.
- The list is ordered by the total, highest first; ties keep the order they
were added in. Anyone who has not rolled yet waits at the bottom.
- The bonus is worked out when the roll is entered. If a character's sheet
changes afterwards, enter the roll again to pick up the new bonus.
- **Begin Battle** puts the first combatant on turn. Their card is
highlighted and the header names them.
- **Next Turn** passes the turn to the next in the order. After the last
combatant it returns to the top and the **round** goes up by one.
- **Next Round** skips straight to the top of the next round.
- Anyone out of the fight (section 6, "The fallen") is passed over.
- **The list follows the turn.** While a battle is in progress, whoever is on
turn is at the top of "Initiative order" with those still to act this round
beneath them. Each combatant whose turn has passed moves down to a **Next
Round** section at the bottom, still in initiative order. When the round
rolls over everyone is back in "Initiative order". Everyone sees the same
arrangement. Before the battle begins, and once it is complete, it is one
plain list.
### Targets
Combatants can be marked as **targets**: whoever is being attacked (or
healed) right now. One target is an ordinary attack; more than one is an
**area attack**.
- Click **🎯 Target** beside a combatant's name to mark them; click it again
to unmark. Each one marked is added to the targets.
- The **DM** can mark anyone at any time, in a battle or a quest. A **player**
can while their own character is on turn in a battle, so they can show the
DM who they are going for. A player cannot mark someone who is out of the
fight, and is not shown a mark the DM leaves on one.
- The combatant **on turn** cannot be marked: nobody targets themselves, and
their card has no Target button until the turn passes. To heal or hurt them
on their own turn, use the buttons on their card.
- A target's card is outlined in red for everyone and reads "Targeted by
<whoever is on turn>".
- While anyone is marked the DM gets an **attack bar** above the list, which
stays in view while scrolling. It shows who is attacking whom, each target's
AC and HP, and the attacker's attacks: a character's equipped weapons (all
of them if none are equipped) and damaging spells, or a monster's or NPC's
attack and damage. The amount box is ready to type in; **Damage** (or
Enter) and **Heal** change HP exactly as the buttons on a card do.
- Targets stay marked after damage, for a second attack. They are cleared
when the turn or round moves on, when the encounter begins, ends or is
reopened; a target that is removed from the encounter is unmarked.
**Area attacks**
An attack on several targets is harder to land and its damage is shared.
- **Harder to hit.** Every target's AC counts as higher for each target
beyond the first, by the campaign's **area attack AC penalty**. The DM sets
it in the campaign settings, from 0 (no penalty) to 10; it starts at 2, which
gives:
| Targets | Added to each AC |
|---|---|
| 1 | 0 |
| 2 | +2 |
| 3 | +4 |
| 4 | +6 |
The attack bar shows the AC to beat with the penalty already added
("17 (15 +2)"), and each target's card says "area attack, AC +2".
- **Who was hit.** Each target has a tick box, ticked to begin with. Untick
anyone the roll missed; they take nothing.
- **Sharing the amount.** Type the total. "Split between those hit" (the
default) shares it evenly, any remainder going one point each to the first
targets marked: 7 between three is 3, 2 and 2. "Full amount to each" gives
every target hit the whole amount. Each target's share is shown before it
is applied.
- A target with no hit points to track is left out of the sharing.
The app does not roll the attack or decide whether it hits: the DM compares
the roll with the AC shown and types the damage.
## 8. Conditions
@@ -166,7 +332,7 @@ The DM lines up loot in the Rewards panel, on any encounter at any time.
| Reward | What the DM sets |
|---|---|
| Gold | An amount |
| Coins or cash | An amount, and which: gold, silver, copper or cash ($) |
| Weapon | A weapon from the pool, a level (not below its starting level), a quantity |
| Item | An item from the pool, a level, a quantity |
@@ -177,9 +343,9 @@ Each can carry a short note ("Goblin chief's purse").
| Button | What happens |
|---|---|
| Award (pick a character) | The whole reward goes to that character. It can be any finished character in the campaign, not only those in the encounter. |
| Split (gold only) | Shared evenly between the characters in the encounter. Any remainder goes one gold each, highest initiative first: 101 gold between two is 51 and 50. |
| Split (coins and cash only) | Shared evenly between the characters in the encounter. Any remainder goes one each, highest initiative first: 101 gold between two is 51 and 50. |
- **Gold** is added to the character's balance with a money-log entry reading
- **Coins or cash** are added to the character's balance with a money-log entry reading
"Reward: <encounter name>".
- A **weapon or item** is added to what the character holds, at the reward's
level, stacking with any they already have. If the character is below that
@@ -190,7 +356,48 @@ Each can carry a short note ("Goblin chief's purse").
Until a reward is awarded only the DM can see it. Afterwards players see what
it was and who received it.
## 10. What each side sees
## 10. Trade
In an NPC quest the party can **change money** and **buy things**. Both take
someone to deal with and the time to do it, so:
- Trading is open only in an **NPC quest that is in progress**. Before "Begin
Quest" and after "Complete Quest" everything on offer is still shown, but
the buttons are off.
- There is **no trading in a battle**. Switching a quest to a battle hides the
Trade card and the server refuses any trade; switching back brings it back.
- An NPC trades only while they are **in the scene and not at 0 HP**.
- The character trading has to be **in the encounter** and alive.
The "Trade" card sits beside the scene, above Rewards. It appears for players
when there is something to trade, and always for the DM in a quest.
| Part of the card | What it does |
|---|---|
| Who is trading | Picks the character. A player chooses among their own characters in the scene; the DM among all of them. Their purse is shown beside it |
| 💱 A money changer | One row per NPC in the scene who has an exchange rate. Type an amount, choose "gold into cash" or "cash into gold", and the button shows what comes back |
| 🛒 "<NPC> sells" | Everything that NPC has for sale: what it is, its level and dice, the price, and how many are left. "Buy" buys one |
| 🛒 "For sale here" | Things sold by the scene itself rather than by anyone in it |
| Set up (DM only) | Puts weapons and items from the campaign pools up for sale at this encounter, and edits their price and stock |
- **Changing money** uses the NPC's own rate, the same both ways, and only
gold and cash: no silver, no copper, no change. The amount handed over has
to be a whole multiple of that side of the rate. The full rules and
examples are in [NPCS.md](NPCS.md), section 5.
- **Buying** pays the price from the character's purse (coins make change; a
cash price takes cash only), adds the weapon or item to what the character
holds at the level it is sold at, and uses up stock. A character below that
level holds it but cannot use it yet.
- Every trade is written to the character's money log.
- **An NPC's rate and wares are set on the campaign page**, under "Trade" on
the NPC's row, and follow the NPC into every quest they are in. **An
encounter's own wares are set here**, in the DM's part of the card, and
exist only in this encounter: a market stall, a vending machine, a chest
with a price tag.
- Removing an NPC from the scene takes their rate and wares out of it.
Deleting the encounter deletes its own wares; what was bought stays bought.
## 11. What each side sees
| | DM | Players |
|---|---|---|
@@ -199,37 +406,52 @@ it was and who received it.
| Characters | Full card | Full card |
| Monsters | Full stat block | Name, HP, conditions |
| NPCs | Everything | Name, role, attitude, description, HP bar |
| Monsters and NPCs at 0 HP | With "Show dead" ticked, marked hidden | Nothing |
| Dead characters | Full card with "Show dead" ticked, marked hidden | The name only, in the Fallen box |
| Rewards | All | Only awarded ones |
| Exchange rates and things for sale | Yes | Yes, for NPCs they can see and for the scene itself |
| Controls (add, damage, turns, rewards) | Yes | None |
The server only sends a player what the right-hand column lists, so hidden
information cannot be dug out of the page.
## 11. Permissions
## 12. Permissions
| Action | DM | Players |
|---|---|---|
| Create an encounter | Yes | No |
| Open an encounter | Yes | Yes |
| See and open an encounter | Yes, all of them | Only ones their own character is in |
| Change mode, status, scene or DM notes | Yes | No |
| Rearrange, archive, restore or delete encounters | Yes | No |
| See archived encounters in the list | Yes | No |
| Add or remove anyone | Yes | No |
| Change HP, initiative or conditions | Yes | No |
| Change HP or conditions | Yes | No |
| Enter an initiative roll | Yes, for anyone | For their own character only |
| Mark targets | Yes, any time | On their own character's turn |
| Enter a death save, or stabilise | Yes, for anyone | For their own character only |
| Set death-save tallies by hand | Yes | No |
| Revive a dead character outright | Yes | No |
| Try to revive a dead character | Yes, for the character on turn | On their own character's turn, if it has a reviving spell |
| Advance turns and rounds | Yes | No |
| Add, remove or award rewards | Yes | No |
| Set exchange rates, and put things up for sale | Yes | No |
| Change money or buy, in a quest in progress | Yes, for any character in it | For their own character in it |
People outside the campaign cannot open its encounters.
## 12. What is not modelled
## 13. What is not modelled
- Rolling initiative, attacks, damage or saves (use the dice roller, bottom
left of every page)
- Applying an attack's damage automatically
- Death saves and stabilising
- Death saves for monsters and NPCs, and prompting for a save on a dying
character's turn
- A roll, a spell slot or a time limit for reviving the dead
- Conditions with durations or mechanical effects
- Maps, positions, distances and areas of effect
- Experience points and encounter difficulty
- Spells as rewards
- Spells as rewards, and spells for sale
- Selling to an NPC, haggling, and buying more than one of a thing in a
single step
- Renaming an encounter from the screen
- A log of what happened during the encounter
+14 -4
View File
@@ -15,7 +15,7 @@ level up the same way weapons do), [SPELLS.md](SPELLS.md),
4. [Item levels](#4-item-levels)
5. [Dice on items](#5-dice-on-items)
6. [Starting items](#6-starting-items)
7. [Items as rewards](#7-items-as-rewards)
7. [Items as rewards and for sale](#7-items-as-rewards-and-for-sale)
8. [Permissions](#8-permissions)
9. [What is not modelled](#9-what-is-not-modelled)
@@ -121,21 +121,30 @@ In the builder's equipment step a new character can pick items that are:
Starting items and starting weapons share the campaign's "starting items"
allowance. Each arrives at level 1, quantity 1, unequipped.
## 7. Items as rewards
## 7. Items as rewards and for sale
On an encounter the DM can line up an item as a reward, at a chosen level and
quantity, and later award it to a character. Awarding adds it to the
character's inventory, stacking with any they already hold. See
[ENCOUNTERS.md](ENCOUNTERS.md).
The DM can also put an item **up for sale**, at a level, a price (in gold,
silver, copper or cash) and an optional stock. An NPC can sell it, wherever
that NPC is met, or an encounter can sell it itself. Players see what is for
sale and buy it in an NPC quest that is in progress: the price leaves their
purse and the item joins their inventory. See [NPCS.md](NPCS.md), section 5,
and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
## 8. Permissions
| Action | DM | Owning player | Other players |
|---|---|---|---|
| See the item pool | Yes | Yes | Yes |
| See the item pool | Yes | Only what their own character holds, plus the starting options while they have a character to build | The same, for their own character |
| Add, change or remove pool items | Yes | No | No |
| Give an item from the pool, or take one away | Yes | No | No |
| Give a held item to another character | Yes | Yes | No |
| Put an item up for sale | Yes | No | No |
| Buy an item that is for sale, in a quest in progress | Yes, for any character in it | Yes, if the character is in it | No |
| Change quantity or level | Yes | No | No |
| Equip or unequip | Yes | Yes, if the character can use it | No |
| See a character's inventory | Yes | Yes | Yes |
@@ -146,6 +155,7 @@ character's inventory, stacking with any they already hold. See
- Armor and shields as items
- Using up items automatically, charges, and attunement
- Weight, carrying capacity and containers
- Prices, buying and selling (record the gold change in the money log)
- A price on the item itself: a price is set each time it is put up for sale
- Selling items back (record the gold change in the money log)
- Swaps as a single step (each side gives separately), and asking the
recipient to accept
+19 -13
View File
@@ -36,10 +36,10 @@ This describes the code as it stands. The built-in list is
| Name | Up to 80 characters |
| AC | Armor Class |
| Max HP | Hit points each copy starts an encounter with |
| Speed | In feet. 30 unless set otherwise |
| Speed | Walking speed in feet. 30 unless set otherwise. Other speeds (fly, swim, climb) go in Notes |
| Ability scores | STR, DEX, CON, INT, WIS, CHA. 10 unless set otherwise |
| Attack bonus | Added to the monster's attack roll |
| Damage | Dice for its main attack, e.g. `1d6+2` |
| Damage | Dice for its main attack, e.g. `1d6+2`, with the damage type if you like: `1d6+2 necrotic` |
| Challenge rating | A rough danger rating, e.g. `1/4`, `2` |
| Special trait | A short note such as "Pack Tactics" |
| Notes | Anything else: what it attacks with, how it behaves |
@@ -52,21 +52,25 @@ from its scores for display only.
The list is the "Monsters" card on the campaign page (DM only).
- **Adding by hand:** "+ Add Monster" asks for name, AC, max HP, attack bonus
and damage. Speed is set to 30 and every ability score to 10; challenge
rating, trait and notes are left empty.
- **Adding by hand:** "+ Add Monster" opens the whole stat block: name,
challenge rating, AC, HP, speed, attack bonus, damage, the six ability
scores, special trait and notes. Only name, AC and HP must be typed; speed
starts at 30, attack bonus at 0 and each ability score at 10.
- **Adding from the bestiary:** "📚 From Library" copies a complete stat block.
- **Changing:** there is no edit form (the API accepts changes, but no screen
offers them). Remove and re-add, or add from the library.
- **Removing:** the ✕ button, immediately, with no confirmation. Copies
- **Changing:** "Edit" on a monster's row opens the same form filled in.
Saving changes the template, and copies already in encounters show the new
stat block straight away; their current HP is left alone.
- The list shows each monster's full stat block: the numbers in columns, and
its ability scores, trait and notes under its name.
- **Removing:** the ✕ button, after a confirmation. Copies
already in encounters stay there with their name and HP, but lose their
stat block.
## 4. The built-in bestiary
38 monsters, from harmless to dangerous. Each library row shows challenge
rating, AC, HP, attack and damage, plus its trait and notes, before you add
it. Adding copies the monster into your campaign; the copy is yours, and later
38 monsters, from harmless to dangerous, every one with a complete stat
block. Each library row shows challenge rating, AC, HP, speed, attack, damage
and the six ability scores, plus its trait and notes, before you add it. Adding copies the monster into your campaign; the copy is yours, and later
changes to the library do not affect it.
The full bestiary, weakest first. "Attack" is the bonus added to the
@@ -146,8 +150,9 @@ and its notes.
This is enforced by the server: stat blocks are never sent to a player's
browser.
Players do see a monster's exact HP numbers. If you would rather keep those
secret, that is not currently possible.
Players see a monster's exact HP numbers unless the campaign setting "Players
see the exact hit points of monsters and NPCs in encounters" is unticked.
Then they get the health bar only.
## 7. Permissions
@@ -163,6 +168,7 @@ secret, that is not currently possible.
## 8. What is not modelled
- Multiple attacks, spells, legendary actions and resistances (use Notes)
- Trade: monsters do not sell things or change money (NPCs do)
- Rolling a monster's attacks or damage (use the dice roller)
- Saving throw bonuses and skills
- Experience points for defeating monsters
+99 -16
View File
@@ -1,7 +1,8 @@
# NPCs
How non-player characters work in this app: what the DM records about them,
how they are brought into a scene, and what players can and cannot see.
how they are brought into a scene, what they sell and the rate they change
money at, and what players can and cannot see.
This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
[MONSTERS.md](MONSTERS.md).
@@ -12,10 +13,11 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
2. [What an NPC holds](#2-what-an-npc-holds)
3. [Managing NPCs](#3-managing-npcs)
4. [NPCs in encounters](#4-npcs-in-encounters)
5. [What players see](#5-what-players-see)
6. [NPC or monster?](#6-npc-or-monster)
7. [Permissions](#7-permissions)
8. [What is not modelled](#8-what-is-not-modelled)
5. [Trade: changing money and selling](#5-trade-changing-money-and-selling)
6. [What players see](#6-what-players-see)
7. [NPC or monster?](#7-npc-or-monster)
8. [Permissions](#8-permissions)
9. [What is not modelled](#9-what-is-not-modelled)
---
@@ -27,6 +29,8 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
- An NPC does nothing by sitting in the list. Players meet one when the DM
**adds it to an encounter**.
- NPCs are mainly for talking to. Combat stats are optional.
- An NPC can also **trade**: change gold and cash at a rate of their own, and
sell weapons and items (section 5).
## 2. What an NPC holds
@@ -38,9 +42,11 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
| Description | What the party can see and knows | Everyone |
| DM notes | Secrets, motives, what they are hiding | **DM only** |
| AC | Optional | DM only |
| HP | Optional. Without it the NPC has no health bar | See section 5 |
| HP | Optional. Without it the NPC has no health bar | See section 6 |
| Attack bonus | Optional | DM only |
| Damage | Optional dice, e.g. `1d6` | DM only |
| Exchange rate | Optional: so much gold for so much cash (section 5) | Everyone, in an encounter |
| Wares | Optional: weapons and items for sale, each with a price (section 5) | Everyone, in an encounter |
A new NPC is Neutral unless you choose otherwise.
@@ -48,10 +54,14 @@ A new NPC is Neutral unless you choose otherwise.
The NPC list is the "NPCs" card on the campaign page (DM only).
- **Adding:** "+ Add NPC" opens a form with every field above. Only the name
is required.
- **Changing:** there is no edit form. To change an NPC, remove it and add it
again (the API accepts changes, but no screen offers them).
- **Adding:** "+ Add NPC" opens a form with every field above except the
exchange rate and wares. Only the name is required.
- **Trade:** the "Trade" button on an NPC's row opens their exchange rate and
their wares (section 5). The row shows a 💱 badge with the rate and a 🛒
badge with how many things are for sale.
- **Changing:** there is no edit form for the other fields. To change them,
remove the NPC and add it again (the API accepts changes, but no screen
offers them). Removing an NPC removes their wares too.
- **Removing:** the ✕ button, after a confirmation. This also **removes the
NPC from every encounter** it was in.
@@ -73,7 +83,72 @@ An NPC's card shows its attitude as a coloured badge, its role under its
name, and its description in full. The DM's card also shows AC, attack,
damage and the DM notes.
## 5. What players see
## 5. Trade: changing money and selling
Only the DM sets trade up. Players see it and use it on the encounter page,
in the "Trade" card. The rules for when trading is open are in
[ENCOUNTERS.md](ENCOUNTERS.md), section 10: in short, in an **NPC quest that
is in progress**, never in a battle, and only with an NPC who is in the scene
and not at 0 HP.
### Changing money
An NPC with an **exchange rate** changes gold into cash ($) and cash into
gold. The rate is two whole numbers, "this much gold = this much cash", and
each NPC has their own, so one money changer can be a better deal than
another.
| Rate | Hand over | Get |
|---|---|---|
| 1 gold = $5 | 3 gold | $15 |
| 1 gold = $5 | $20 | 4 gold |
| 2 gold = $15 | 6 gold | $45 |
| 2 gold = $15 | $30 | 4 gold |
- The same rate is used both ways.
- **Only gold and cash.** Silver and copper are not taken, a gold coin is not
broken to make up an amount, and no change is given.
- What is handed over has to be a whole multiple of that side of the rate: at
2 gold = $15, gold goes in twos and cash in fifteens.
- Both sides go on the character's money log, naming the NPC: "Changed with
Vex for $45" and "Changed with Vex from 6 gold".
- An NPC never runs out of gold or cash to change.
- To set a rate, type both numbers under "Trade" and press "Save rate".
"Stop changing money" clears it. An NPC with no rate does not change money.
### Selling
An NPC's **wares** are weapons and items from the campaign's own pools, each
put up for sale with:
| Field | What it is |
|---|---|
| What | One weapon or item from the pool |
| Level | The gear level it is handed over at, not below its starting level |
| Price | For one, in gold, silver, copper or cash |
| Stock | How many can be bought. Empty means no limit |
- Wares belong to the NPC, not to an encounter: they are on offer in every
quest that NPC is in.
- Buying pays the price from the character's purse, adds the weapon or item
to what they hold (stacking with any they already have) and lowers the
stock. A price in coins is paid with change, exactly like any other spend;
a price in cash takes cash only.
- At 0 stock a ware shows as sold out and cannot be bought. It stays listed
until the DM removes it.
- The ✕ beside a ware stops it being sold. Things already bought stay bought.
- "Edit" beside a ware changes its price, the coin (or cash) the price is in,
and its stock: restock a sold-out ware, or clear the stock for no limit.
What is sold, its level and who sells it cannot be changed; remove the ware
and put it up again for that.
- Removing a weapon or item from the campaign pool removes it from sale
everywhere.
A stall or a machine with nobody behind it can sell things too: those wares
belong to an encounter rather than an NPC, and are set up on the encounter
page. See [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
## 6. What players see
Players only ever see an NPC through an encounter it is in:
@@ -81,6 +156,7 @@ Players only ever see an NPC through an encounter it is in:
|---|---|
| Name, role, attitude, description | DM notes |
| Health bar and conditions, if the NPC has HP | AC, attack bonus, damage |
| The exchange rate, and everything the NPC has for sale with its price and stock | The NPC list, and wares of NPCs who are not in the scene |
This is enforced by the server, not just hidden on screen: the hidden fields
are never sent to a player's browser.
@@ -88,7 +164,7 @@ are never sent to a player's browser.
Changing an NPC's attitude changes what players see straight away, so
flipping a "Friendly" NPC to "Hostile" is itself a reveal.
## 6. NPC or monster?
## 7. NPC or monster?
| | NPC | Monster |
|---|---|---|
@@ -99,11 +175,12 @@ flipping a "Friendly" NPC to "Hostile" is itself a reveal.
| Combat stats | Optional, basic | Full stat block |
| Can be in an encounter more than once | No | Yes (a pack of goblins) |
| Built-in library | No | Yes |
| Can change money and sell things | Yes | No |
A named villain the party will both talk to and fight works well as an NPC
with HP, AC, attack and damage filled in.
## 7. Permissions
## 8. Permissions
| Action | DM | Players |
|---|---|---|
@@ -111,12 +188,18 @@ with HP, AC, attack and damage filled in.
| Add or remove NPCs | Yes | No |
| Add an NPC to an encounter | Yes | No |
| Change an NPC's HP or conditions in an encounter | Yes | No |
| See an NPC in an encounter | Everything | Name, role, attitude, description, health bar |
| See an NPC in an encounter | Everything | Name, role, attitude, description, health bar, exchange rate, wares |
| Set an NPC's exchange rate | Yes | No |
| Put things up for sale, or take them down | Yes | No |
| Change money with an NPC, or buy from one | Yes, for any character in the scene | For their own character in the scene |
## 8. What is not modelled
## 9. What is not modelled
- Ability scores, saves, skills and spells for NPCs
- NPC inventory, gold and shops
- An NPC's own purse: they never run short of gold or cash
- Selling things to an NPC, haggling, and different rates for buying and
selling cash
- Changing silver or copper for cash
- Where an NPC is (they are not tied to locations)
- Relationships, reputation and attitude toward individual characters
- A record of which NPCs the party has met, outside of encounters
+6 -4
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 |
+14 -4
View File
@@ -19,7 +19,7 @@ This describes the code as it stands. The formulas live in
6. [How damage grows](#6-how-damage-grows)
7. [Attack and damage numbers](#7-attack-and-damage-numbers)
8. [Starting weapons](#8-starting-weapons)
9. [Weapons as rewards](#9-weapons-as-rewards)
9. [Weapons as rewards and for sale](#9-weapons-as-rewards-and-for-sale)
10. [Permissions](#10-permissions)
11. [What is not modelled](#11-what-is-not-modelled)
12. [The complete weapon library](#12-the-complete-weapon-library)
@@ -185,21 +185,31 @@ In the builder's equipment step a new character can pick weapons that are:
Starting weapons and starting items share the campaign's "starting items"
allowance. They arrive at level 1, unequipped.
## 9. Weapons as rewards
## 9. Weapons as rewards and for sale
On an encounter the DM can line up a weapon as a reward, at a chosen level
(not below its starting level) and quantity, and later award it to a
character. Awarding adds it to what the character holds, exactly as giving it
on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
The DM can also put a weapon **up for sale**, at a level, a price (in gold,
silver, copper or cash) and an optional stock. An NPC can sell it, wherever
that NPC is met, or an encounter can sell it itself. Players see what is for
sale and buy it in an NPC quest that is in progress: the price leaves their
purse and the weapon joins what they hold, unequipped. See
[NPCS.md](NPCS.md), section 5, and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
## 10. Permissions
| Action | DM | Owning player | Other players |
|---|---|---|---|
| See the pool and the library | Yes | Yes | Yes |
| See the pool | Yes | Only what their own character holds, plus the starting options while they have a character to build | The same, for their own character |
| See the built-in library | Yes | No | No |
| Add, change or remove pool entries | Yes | No | No |
| Give a weapon from the pool, or take one away | Yes | No | No |
| Give a held weapon to another character | Yes | Yes | No |
| Put a weapon up for sale | Yes | No | No |
| Buy a weapon that is for sale, in a quest in progress | Yes, for any character in it | Yes, if the character is in it | No |
| Change a held weapon's level or quantity | Yes | No | No |
| Equip or unequip | Yes | Yes, if the character can use it | No |
| See a character's weapons | Yes | Yes | Yes |
@@ -211,7 +221,7 @@ on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
- Range and ammunition
- Critical hits, advantage, and rolling the attack itself (use the dice roller)
- Magic bonuses such as +1 weapons (use a higher level or growth instead)
- Price and weight
- A price on the weapon itself (a price is set each time it is put up for sale), and weight
## 12. The complete weapon library
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "1.10.0",
"type": "module",
"scripts": {
"dev": "vite",
+158 -12
View File
@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
import { apiClient } from './client';
import type {
BuildOptions,
@@ -7,6 +7,7 @@ import type {
CampaignMemberSummary,
CharacterDetail,
CharacterSummary,
Currency,
DiceMode,
Encounter,
EncounterMode,
@@ -17,6 +18,7 @@ import type {
Monster,
Note,
Npc,
SaleOffer,
QuickStats,
Spell,
Weapon,
@@ -162,6 +164,28 @@ export function usePatchCharacter(characterId: string) {
});
}
// What can be done for a character at 0 HP: enter a death save (what the d20
// showed), steady them, or — the DM only — set the tallies by hand or bring
// back the dead.
export type DeathSaveAction =
| { action: 'roll'; roll: number }
| { action: 'stabilize' }
| { action: 'set'; successes: number; failures: number }
| { action: 'revive' };
export function useDeathSave(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: DeathSaveAction) =>
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/death-saves`, data)).data.character,
onSuccess: (character) => {
invalidateCharacter(qc, characterId); // the sheet and its quick stats
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
},
});
}
export function useDeleteCharacter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
@@ -172,7 +196,7 @@ export function useDeleteCharacter(campaignId: string) {
// What one character can hand to another. Whatever is given leaves the giver.
export type Gift =
| { kind: 'gold'; amount: number }
| { kind: 'gold'; amount: number; currency?: Currency }
| { kind: 'weapon' | 'item'; id: string; quantity: number }
| { kind: 'spell'; id: string };
@@ -211,6 +235,16 @@ export function useQuickStats(characterId: string | undefined, enabled: boolean)
});
}
// Changes `amount` coins of one kind into another at their fixed worth.
export function useExchangeCoins(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { from: Currency; to: Currency; amount: number }) =>
(await apiClient.post<{ character: CharacterDetail; received: number }>(`/characters/${characterId}/gold/exchange`, data)).data,
onSuccess: () => invalidateCharacter(qc, characterId),
});
}
// --------------------------------------------------------------------------
// Character build flow
// --------------------------------------------------------------------------
@@ -373,10 +407,11 @@ export function useRemoveCharacterSpell(characterId: string) {
// Money tracker
// --------------------------------------------------------------------------
export function useGoldLedger(characterId: string | undefined) {
// Only the character's own player and the DM may read it.
export function useGoldLedger(characterId: string | undefined, enabled = true) {
return useQuery({
queryKey: ['character', characterId, 'gold'],
enabled: Boolean(characterId),
enabled: Boolean(characterId) && enabled,
queryFn: async () =>
(await apiClient.get<{ gold: number; transactions: GoldTransaction[] }>(`/characters/${characterId}/gold`)).data
.transactions,
@@ -387,8 +422,8 @@ export function useGoldLedger(characterId: string | undefined) {
export function useAdjustGold(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { amount: number; reason?: string }) =>
(await apiClient.post<{ gold: number; transaction: GoldTransaction }>(`/characters/${characterId}/gold`, data)).data,
mutationFn: async (data: { amount: number; currency?: Currency; reason?: string }) =>
(await apiClient.post<{ transaction: GoldTransaction }>(`/characters/${characterId}/gold`, data)).data,
onSuccess: () => invalidateCharacter(qc, characterId), // prefix match also refreshes the ledger
});
}
@@ -429,9 +464,12 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) {
isStartingOption?: boolean;
damageDice?: string | null;
damageScaling?: number;
revives?: boolean;
minLevel?: number;
name?: string;
description?: string | null;
exchangeGold?: number | null;
exchangeCash?: number | null;
}) =>
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, pool] }),
@@ -502,6 +540,20 @@ export function useCreateMonster(campaignId: string) {
});
}
// Changes a monster's stat block. Encounters read it live, so copies already
// in a scene show the new numbers (their HP stays as it was).
export function useUpdateMonster(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: { id: string } & Partial<Omit<Monster, 'id' | 'campaignId'>>) =>
(await apiClient.patch<{ monster: Monster }>(`/campaigns/${campaignId}/monsters/${id}`, data)).data.monster,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] });
qc.invalidateQueries({ queryKey: ['encounter'] });
},
});
}
export function useDeleteMonster(campaignId: string) {
const qc = useQueryClient();
return useMutation({
@@ -514,10 +566,10 @@ export function useDeleteMonster(campaignId: string) {
// Default content library — search/browse, "add to campaign"
// --------------------------------------------------------------------------
export function useCampaignLibrary(campaignId: string | undefined) {
export function useCampaignLibrary(campaignId: string | undefined, enabled = true) {
return useQuery({
queryKey: ['campaign', campaignId, 'library'],
enabled: Boolean(campaignId),
enabled: Boolean(campaignId) && enabled, // DM only
staleTime: Infinity, // static reference data — never changes under a campaign
queryFn: async () => (await apiClient.get<CampaignLibrary>(`/campaigns/${campaignId}/library`)).data,
});
@@ -660,7 +712,7 @@ export function useEncounter(encounterId: string | undefined) {
export function useAddParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiative?: number }) =>
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiativeRoll?: number }) =>
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
@@ -701,16 +753,44 @@ export function usePatchEncounter(encounterId: string) {
name?: string;
description?: string | null;
dmNotes?: string | null;
locationId?: string | null;
}) => (await apiClient.patch(`/encounters/${encounterId}`, data)).data,
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
// The campaign page lists encounters by name, mode and status.
if (variables.status || variables.mode || variables.name) qc.invalidateQueries({ queryKey: ['campaign'] });
// The campaign page lists encounters by name, mode and status, and an
// encounter in progress carries its characters to its location.
if (variables.status || variables.mode || variables.name || variables.locationId !== undefined) {
qc.invalidateQueries({ queryKey: ['campaign'] });
qc.invalidateQueries({ queryKey: ['character'] });
}
},
});
}
type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
// Sets which combatants are marked as targets (an empty list clears them).
// The DM at any time; a player while their own character is on turn.
export function useSetTargets(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (participantIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/targets`, { participantIds })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
// The character on turn trying to bring a dead ally back; the server decides
// whether they have the means.
export function useReviveParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (participantId: string) => (await apiClient.post(`/encounters/${encounterId}/participants/${participantId}/revive`)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
qc.invalidateQueries({ queryKey: ['character'] }); // the revived character's sheet
},
});
}
export function useNextTurn(encounterId: string) {
const qc = useQueryClient();
return useMutation({
@@ -724,7 +804,7 @@ export function useNextTurn(encounterId: string) {
// --------------------------------------------------------------------------
export type NewReward =
| { type: 'GOLD'; gold: number; note?: string }
| { type: 'GOLD'; gold: number; currency?: Currency; note?: string }
| { type: 'WEAPON'; weaponId: string; quantity?: number; level?: number; note?: string }
| { type: 'ITEM'; itemId: string; quantity?: number; level?: number; note?: string };
@@ -780,6 +860,72 @@ export function useCreateNpc(campaignId: string) {
});
}
// --------------------------------------------------------------------------
// Trade — an NPC's exchange rate and wares are set up by the DM; in a quest
// in progress, characters in the scene change money and buy
// --------------------------------------------------------------------------
export type NewWare = ({ npcId: string } | { encounterId: string }) &
({ weaponId: string } | { itemId: string }) & { price: number; currency: Currency; level?: number; stock: number | null };
// Wares reach the screen inside the NPC list and inside each encounter.
function invalidateWares(qc: QueryClient, campaignId: string) {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'npcs'] });
qc.invalidateQueries({ queryKey: ['encounter'] });
}
export function useCreateWare(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: NewWare) => (await apiClient.post<{ ware: SaleOffer }>(`/campaigns/${campaignId}/wares`, data)).data.ware,
onSuccess: () => invalidateWares(qc, campaignId),
});
}
// The price and what is left can change; what is sold, and by whom, cannot.
export function usePatchWare(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: { id: string; price?: number; currency?: Currency; stock?: number | null }) =>
(await apiClient.patch<{ ware: SaleOffer }>(`/campaigns/${campaignId}/wares/${id}`, data)).data.ware,
onSuccess: () => invalidateWares(qc, campaignId),
});
}
export function useDeleteWare(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (wareId: string) => apiClient.delete(`/campaigns/${campaignId}/wares/${wareId}`),
onSuccess: () => invalidateWares(qc, campaignId),
});
}
// Both change what a character holds, so the sheet and the party list refresh too.
function invalidateAfterTrade(qc: QueryClient, encounterId: string) {
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['campaign'] });
}
// Changes gold into cash or cash into gold with an NPC in the scene, at their rate.
export function useExchangeWithNpc(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { characterId: string; npcId: string; from: 'GOLD' | 'CASH'; amount: number }) =>
(await apiClient.post<{ received: number; currency: Currency }>(`/encounters/${encounterId}/exchange`, data)).data,
onSuccess: () => invalidateAfterTrade(qc, encounterId),
});
}
export function useBuyWare(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ wareId, ...data }: { wareId: string; characterId: string; quantity?: number }) =>
(await apiClient.post(`/encounters/${encounterId}/wares/${wareId}/buy`, data)).data,
onSuccess: () => invalidateAfterTrade(qc, encounterId),
});
}
// --------------------------------------------------------------------------
// Notes
// --------------------------------------------------------------------------
+89 -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,22 @@ export interface CampaignMemberSummary {
role: 'DM' | 'PLAYER';
}
// The coins a character carries. 1 gold = 10 silver = 100 copper.
export type Coin = 'GOLD' | 'SILVER' | 'COPPER';
// Coins, plus cash ($): modern money for crossover worlds, with no worth in coins.
export type Currency = Coin | 'CASH';
// Where a character stands at 0 HP. UP: on their feet. DYING: rolling death
// saves, three of either kind settles it. STABLE: at 0 HP but no longer
// rolling. DEAD: three failures, or one blow too big to survive.
export type LifeState = 'UP' | 'DYING' | 'STABLE' | 'DEAD';
export interface DeathSaves {
state: LifeState;
successes: number;
failures: number;
}
export interface CharacterSummary {
id: string;
campaignId: string;
@@ -117,7 +136,12 @@ export interface CharacterSummary {
buildStage: BuildStage;
currentHp: number;
tempHp: number;
gold: number;
deathSaves: DeathSaves;
// The purse; all four are null for another player's character.
gold: number | null;
silver: number | null;
copper: number | null;
cash: number | null;
maxHp: number | null;
armorClass: number | null;
currentLocationId: string | null;
@@ -194,6 +218,7 @@ export interface Spell {
description: string | null;
damageDice: string | null;
isStartingOption: boolean;
revives: boolean; // brings back the dead: lets its caster revive a dead ally in a battle
}
export interface CharacterSpell {
@@ -232,8 +257,9 @@ export interface InventoryItem {
export interface GoldTransaction {
id: string;
characterId: string;
currency: Currency; // which coin this entry is in
amount: number; // signed: positive = gained, negative = spent
balanceAfter: number;
balanceAfter: number; // that coin's balance afterwards
reason: string | null;
createdAt: string;
createdBy: { displayName: string } | null;
@@ -266,6 +292,7 @@ export interface CharacterDetail {
rolledScores: Record<Ability, number>;
currentHp: number;
tempHp: number;
deathSaves: DeathSaves;
armorBonus: number;
specialAbilities: string | null;
background: string | null;
@@ -276,7 +303,13 @@ export interface CharacterDetail {
diceMode: DiceMode;
currentLocationId: string | null;
currentLocation: Location | null;
gold: number;
gold: number | null;
silver: number | null;
copper: number | null;
cash: number | null;
// True when this is another player's character: no gold, inventory or
// location is sent, only what the party can see across the table.
restricted: boolean;
buildStage: BuildStage;
buildState: Record<string, unknown> | null;
skillProficiencies: SkillProficiency[];
@@ -311,6 +344,23 @@ export type EncounterStatus = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
export type EncounterMode = 'BATTLE' | 'QUEST';
export type NpcDisposition = 'FRIENDLY' | 'NEUTRAL' | 'HOSTILE';
// Something for sale: a weapon or item from the campaign pools, at a price.
// It is an NPC's (on offer wherever they are met) or an encounter's own.
export interface SaleOffer {
id: string;
campaignId: string;
npcId: string | null;
encounterId: string | null;
weaponId: string | null;
itemId: string | null;
price: number; // for one, in `currency`
currency: Currency;
level: number; // the gear level it is handed over at
stock: number | null; // how many are left; null is no limit
weapon: Weapon | null;
item: Item | null;
}
// What players see of an NPC in an encounter.
export interface NpcPublic {
id: string;
@@ -318,6 +368,11 @@ export interface NpcPublic {
role: string | null;
disposition: NpcDisposition;
description: string | null;
// A money changer's rate: exchangeGold gold for exchangeCash cash, either
// way. Both null for an NPC who does not change money.
exchangeGold: number | null;
exchangeCash: number | null;
wares?: SaleOffer[]; // what they have for sale
}
// The DM's full record. dmNotes and the combat stats never reach a player.
@@ -333,7 +388,8 @@ export interface EncounterReward {
id: string;
encounterId: string;
type: 'GOLD' | 'WEAPON' | 'ITEM';
gold: number | null;
gold: number | null; // for a GOLD reward: how many coins...
currency: Currency; // ...and which coin
weaponId: string | null;
itemId: string | null;
quantity: number;
@@ -372,6 +428,8 @@ export interface CharacterCombatInfo {
characterClass: CharacterClass | null;
armorClass: number;
tempHp: number;
deathSaves: DeathSaves;
canRevive: boolean; // has a spell that brings back the dead, at a level they can cast
speed: number;
initiativeBonus: number;
passivePerception: number;
@@ -390,14 +448,24 @@ export interface EncounterParticipant {
characterId: string | null;
monsterId: string | null;
displayName: string;
// The d20 rolled for initiative (1–20), null until rolled. `initiative` is
// that roll plus the combatant's bonus, and is what the order sorts on.
initiativeRoll: number | null;
initiative: number;
currentHp: number;
maxHp: number;
initiativeBonus?: number; // a monster's or NPC's is sent to the DM only
// Null for a player when the campaign hides monster hit points; hpPercent
// (0–100) is sent in their place to draw the health bar.
currentHp: number | null;
maxHp: number | null;
hpPercent?: number | null;
conditions: string[];
isMonster: boolean;
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
// Out of the fight (a dead character, or a monster or NPC at 0 HP) and so
// hidden from the players. Only the DM is ever sent one of these.
hidden: boolean;
npcId: string | null;
character?: { id: string; name: string } | null;
character?: { id: string; name: string; userId: string | null } | null;
// The DM gets the whole stat block; players only the id and name.
monster?: ({ id: string; name: string } & Partial<Monster>) | null;
npc?: (NpcPublic & Partial<Npc>) | null;
@@ -412,13 +480,23 @@ export interface Encounter {
status: EncounterStatus;
currentRound: number;
activeParticipantId: string | null;
// The combatants marked as targets right now, for everyone to see, in the
// order they were picked. More than one is an area attack.
targetParticipantIds: string[];
description: string | null;
dmNotes?: string | null; // DM only
// Where the scene is. A player is only told once their character has been there.
locationId: string | null;
location?: Pick<Location, 'id' | 'name' | 'description'> | null;
archivedAt: string | null; // archived encounters only reach the DM's list
sortOrder: number; // place in the campaign's own order, lowest first
createdAt: string;
participants?: EncounterParticipant[];
participants?: EncounterParticipant[]; // a player's list leaves out the fallen
// The dead characters in the scene. Players do not get their cards, only
// this, so that an ally can try to revive them.
fallen?: { participantId: string; characterId: string; name: string }[];
rewards?: EncounterReward[]; // players only receive the awarded ones
wares?: SaleOffer[]; // for sale at the scene itself, not by an NPC in it
}
export interface Note {
@@ -454,6 +532,7 @@ export interface LibrarySpell {
duration: string | null;
description: string | null;
damageDice?: string; // default damage; absent for spells that deal none
revives?: boolean; // brings back the dead
classes: CharacterClass[];
}
@@ -500,6 +579,7 @@ export interface QuickStats {
currentHp: number;
maxHp: number;
tempHp: number;
deathSaves: DeathSaves;
armorClass: number;
initiative: number;
speed: number;
+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>
);
}
+38 -4
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,10 +87,13 @@ export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?
>
ⓘ
</button>
{open && (
<span role="tooltip" id={id} className="info-tip-bubble">
{open &&
place &&
createPortal(
<span role="tooltip" id={id} className="info-tip-bubble" style={place}>
{text}
</span>
</span>,
document.body,
)}
</span>
);
+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">
+196
View File
@@ -0,0 +1,196 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useBuyWare, useExchangeWithNpc } from '../api/queries';
import { NumberInput } from './NumberInput';
import { WaresEditor } from './Wares';
import { stockLabel, wareName } from '../lib/wares';
import { formatMoney, formatPurse } from '../lib/format';
import type { CharacterSummary, Encounter, Item, SaleOffer, Weapon } from '../api/types';
type ExchangeSide = 'GOLD' | 'CASH';
// Trade in a quest: changing gold and cash with an NPC who does that, at
// their own rate, and buying what the NPCs in the scene — or the scene itself
// — have for sale. Everyone sees what is on offer; a player trades for their
// own character, the DM for anyone. Trading is open only while the quest is in
// progress, and there is none in a battle. The DM sets it all up: an NPC's
// rate and wares on the campaign page, the scene's own wares here.
export function TradePanel({
encounter,
isDM,
userId,
characters,
weapons,
items,
onError,
}: {
encounter: Encounter;
isDM: boolean;
userId: string | undefined;
characters: CharacterSummary[];
weapons: Weapon[];
items: Item[];
onError: (err: unknown) => void;
}) {
const exchange = useExchangeWithNpc(encounter.id);
const buy = useBuyWare(encounter.id);
const [traderId, setTraderId] = useState('');
const [amounts, setAmounts] = useState<Record<string, string>>({});
const [sides, setSides] = useState<Record<string, ExchangeSide>>({});
const [done, setDone] = useState<string | null>(null);
if (encounter.mode !== 'QUEST') return null;
const here = (encounter.participants ?? []).filter((p) => !p.hidden);
const npcs = here.flatMap((p) => (p.npc ? [p.npc] : []));
const changers = npcs.filter((npc) => npc.exchangeGold && npc.exchangeCash);
const stalls: [string, string, SaleOffer[]][] = [
...npcs.map((npc): [string, string, SaleOffer[]] => [npc.id, `${npc.name} sells`, npc.wares ?? []]),
['here', 'For sale here', encounter.wares ?? []],
];
const openStalls = stalls.filter(([, , wares]) => wares.length > 0);
if (!isDM && changers.length === 0 && openStalls.length === 0) return null;
// Who can do the trading: a player's own characters in the scene, or for the DM anyone's.
const traders = here.flatMap((p) => (p.character && (isDM || p.character.userId === userId) ? [p.character] : []));
const trader = traders.find((c) => c.id === traderId) ?? traders[0];
const purse = characters.find((c) => c.id === trader?.id);
const open = encounter.status === 'ACTIVE';
const busy = exchange.isPending || buy.isPending;
function handleExchange(npc: (typeof changers)[number]) {
const from = sides[npc.id] ?? 'GOLD';
const amount = Math.floor(Number(amounts[npc.id]));
if (!trader || !(amount > 0)) return;
setDone(null);
exchange.mutate(
{ characterId: trader.id, npcId: npc.id, from, amount },
{
onError,
onSuccess: ({ received, currency }) => {
setDone(`${trader.name} changed ${formatMoney(amount, from, true)} for ${formatMoney(received, currency, true)} with ${npc.name}.`);
setAmounts((a) => ({ ...a, [npc.id]: '' }));
},
}
);
}
function handleBuy(ware: SaleOffer) {
if (!trader) return;
setDone(null);
buy.mutate(
{ wareId: ware.id, characterId: trader.id },
{ onError, onSuccess: () => setDone(`${trader.name} bought ${(ware.weapon ?? ware.item)?.name} for ${formatMoney(ware.price, ware.currency, true)}.`) }
);
}
return (
<div className="card">
<div className="card-title">Trade</div>
{!open && (
<p className="combatant-sub" style={{ marginTop: 0 }}>
{encounter.status === 'PLANNING' ? 'Trading opens once the quest is in progress.' : 'The quest is over; trading is closed.'}
</p>
)}
{traders.length > 0 && (
<div className="flex-row flex-wrap gap-sm">
<select aria-label="Who is trading" value={trader?.id ?? ''} onChange={(e) => setTraderId(e.target.value)} style={{ flex: 1, minWidth: 110 }}>
{traders.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{purse && purse.gold !== null && <span className="mono">{formatPurse(purse)}</span>}
</div>
)}
{traders.length === 0 && (changers.length > 0 || openStalls.length > 0) && (
<p className="combatant-sub">{isDM ? 'Add a character to the scene to trade for them.' : 'Your character has to be in the scene to trade.'}</p>
)}
{done && <p className="combatant-sub">✓ {done}</p>}
{changers.map((npc) => {
const from = sides[npc.id] ?? 'GOLD';
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
const rate = { GOLD: npc.exchangeGold ?? 1, CASH: npc.exchangeCash ?? 1 };
const amount = Math.floor(Number(amounts[npc.id]));
const whole = amount > 0 && amount % rate[from] === 0;
return (
<div key={npc.id} className="reward-row">
<strong>💱 {npc.name}</strong>
<span className="combatant-sub">
{' '}
changes {formatMoney(rate.GOLD, 'GOLD', true)} for {formatMoney(rate.CASH, 'CASH')}, either way. No silver or copper.
</span>
<div className="flex-row flex-wrap gap-sm mt-sm">
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label={`How much to change with ${npc.name}`}
value={amounts[npc.id] ?? ''}
onChange={(e) => setAmounts((a) => ({ ...a, [npc.id]: e.target.value }))}
style={{ width: 90 }}
/>
<select
aria-label={`What to hand ${npc.name}`}
value={from}
onChange={(e) => setSides((s) => ({ ...s, [npc.id]: e.target.value as ExchangeSide }))}
>
<option value="GOLD">gold into cash</option>
<option value="CASH">cash into gold</option>
</select>
<button className="btn" disabled={!open || !trader || !whole || busy} onClick={() => handleExchange(npc)}>
{whole ? `Get ${formatMoney((amount / rate[from]) * rate[to], to, true)}` : 'Exchange'}
</button>
</div>
{amount > 0 && !whole && (
<div className="combatant-sub">
Hand over {from.toLowerCase()} in multiples of {rate[from]}.
</div>
)}
</div>
);
})}
{openStalls.map(([key, label, wares]) => (
<div key={key} className="mt-sm">
<div className="section-label">🛒 {label}</div>
{wares.map((ware) => (
<div key={ware.id} className="reward-row">
<div className="flex-between">
<span>
<strong>{wareName(ware)}</strong>
<span className="combatant-sub"> · {stockLabel(ware)}</span>
</span>
<button className="btn" disabled={!open || !trader || ware.stock === 0 || busy} onClick={() => handleBuy(ware)}>
Buy · {formatMoney(ware.price, ware.currency)}
</button>
</div>
{ware.item?.description && <div className="combatant-sub">{ware.item.description}</div>}
</div>
))}
</div>
))}
{isDM && (
<div className="mt-md">
<div className="section-label">Set up: for sale at this encounter</div>
<p className="combatant-sub" style={{ marginTop: 0 }}>
For a stall or a machine with nobody behind it. An NPC's wares and exchange rate are set under "Trade" on the{' '}
<Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link>, and follow the NPC into every quest.
</p>
<WaresEditor
campaignId={encounter.campaignId}
seller={{ encounterId: encounter.id }}
wares={encounter.wares ?? []}
weapons={weapons}
items={items}
onError={onError}
/>
</div>
)}
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
import { useState } from 'react';
import { useCreateWare, useDeleteWare, usePatchWare } from '../api/queries';
import { NumberInput } from './NumberInput';
import { COIN_LABELS, CURRENCIES, formatMoney } from '../lib/format';
import { scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import { stockLabel, wareName } from '../lib/wares';
import type { Currency, Item, SaleOffer, Weapon } from '../api/types';
// One ware in the DM's list. "Edit" swaps the line for its price and stock.
function WareRow({ campaignId, ware, onError }: { campaignId: string; ware: SaleOffer; onError: (err: unknown) => void }) {
const patchWare = usePatchWare(campaignId);
const deleteWare = useDeleteWare(campaignId);
const [editing, setEditing] = useState(false);
const [price, setPrice] = useState('');
const [currency, setCurrency] = useState<Currency>(ware.currency);
const [stock, setStock] = useState('');
function startEditing() {
setPrice(String(ware.price));
setCurrency(ware.currency);
setStock(ware.stock === null ? '' : String(ware.stock));
setEditing(true);
}
function handleSave() {
const amount = Math.floor(Number(price));
if (!(amount > 0)) return;
patchWare.mutate({ id: ware.id, price: amount, currency, stock: stock === '' ? null : Number(stock) }, { onError, onSuccess: () => setEditing(false) });
}
return (
<div style={{ padding: '0.3rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div className="flex-between">
<span>
{wareName(ware)} — <strong className="mono">{formatMoney(ware.price, ware.currency)}</strong>
<span className="combatant-sub"> · {stockLabel(ware)}</span>
</span>
<span className="flex-row">
<button className="btn btn-ghost" aria-label={`Edit the price and stock of ${wareName(ware)}`} onClick={() => (editing ? setEditing(false) : startEditing())}>
{editing ? 'Cancel' : 'Edit'}
</button>
<button className="btn btn-ghost" aria-label={`Stop selling ${wareName(ware)}`} onClick={() => deleteWare.mutate(ware.id, { onError })}>
✕
</button>
</span>
</div>
{editing && (
<div className="flex-row flex-wrap gap-sm mt-sm">
<NumberInput min={1} max={1000000} placeholder="Price" aria-label="Price for one" value={price} onChange={(e) => setPrice(e.target.value)} style={{ width: 90 }} />
<select aria-label="Price is in" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
<NumberInput
min={0}
max={9999}
placeholder="Stock (empty = no limit)"
aria-label="How many are in stock"
title="How many can be bought. Leave empty for no limit."
value={stock}
onChange={(e) => setStock(e.target.value)}
style={{ flex: 1, minWidth: 120 }}
/>
<button className="btn btn-primary" onClick={handleSave} disabled={!price || patchWare.isPending}>
Save
</button>
</div>
)}
</div>
);
}
// The DM's list of what one seller has for sale — an NPC, or an encounter
// itself — with a form to put something from the campaign pools up for sale.
export function WaresEditor({
campaignId,
seller,
wares,
weapons,
items,
onError,
}: {
campaignId: string;
seller: { npcId: string } | { encounterId: string };
wares: SaleOffer[];
weapons: Weapon[];
items: Item[];
onError: (err: unknown) => void;
}) {
const createWare = useCreateWare(campaignId);
const [kind, setKind] = useState<'item' | 'weapon'>('item');
const [poolId, setPoolId] = useState('');
const [level, setLevel] = useState('');
const [price, setPrice] = useState('');
const [currency, setCurrency] = useState<Currency>('GOLD');
const [stock, setStock] = useState('');
const pool: (Weapon | Item)[] = kind === 'weapon' ? weapons : items;
const picked = pool.find((entry) => entry.id === poolId);
function handleAdd() {
const amount = Math.floor(Number(price));
if (!picked || !(amount > 0)) return;
createWare.mutate(
{
...seller,
...(kind === 'weapon' ? { weaponId: picked.id } : { itemId: picked.id }),
price: amount,
currency,
level: level ? Number(level) : undefined,
stock: stock === '' ? null : Number(stock),
},
{
onError,
onSuccess: () => {
setPoolId('');
setLevel('');
setPrice('');
setStock('');
},
}
);
}
return (
<div>
{wares.length === 0 && (
<p className="combatant-sub" style={{ marginTop: 0 }}>
Nothing for sale yet.
</p>
)}
{wares.map((ware) => (
<WareRow key={ware.id} campaignId={campaignId} ware={ware} onError={onError} />
))}
<div className="flex-row flex-wrap gap-sm mt-sm">
<select
aria-label="What kind of thing to sell"
value={kind}
onChange={(e) => {
setKind(e.target.value as typeof kind);
setPoolId('');
setLevel('');
}}
>
<option value="item">Item</option>
<option value="weapon">Weapon</option>
</select>
<select
aria-label={kind === 'weapon' ? 'Weapon to sell' : 'Item to sell'}
value={poolId}
onChange={(e) => {
setPoolId(e.target.value);
setLevel('');
}}
style={{ flex: 1, minWidth: 110 }}
>
<option value="">{pool.length ? 'Choose…' : 'The pool is empty'}</option>
{pool.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</select>
{picked && (
<select aria-label="Level" value={level || picked.minLevel} onChange={(e) => setLevel(e.target.value)}>
{Array.from({ length: MAX_GEAR_LEVEL - picked.minLevel + 1 }, (_, i) => picked.minLevel + i).map((l) => (
<option key={l} value={l}>
Lv {l}
{picked.damageDice ? ` · ${scaledDamageDice(picked.damageDice, l, picked.damageScaling, picked.minLevel)}` : ''}
</option>
))}
</select>
)}
</div>
<div className="flex-row flex-wrap gap-sm mt-sm">
<NumberInput min={1} max={1000000} placeholder="Price" aria-label="Price for one" value={price} onChange={(e) => setPrice(e.target.value)} style={{ width: 90 }} />
<select aria-label="Price is in" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
<NumberInput
min={0}
max={9999}
placeholder="Stock (empty = no limit)"
aria-label="How many are in stock"
title="How many can be bought. Leave empty for no limit."
value={stock}
onChange={(e) => setStock(e.target.value)}
style={{ flex: 1, minWidth: 120 }}
/>
<button className="btn btn-primary" onClick={handleAdd} disabled={!picked || !price || createWare.isPending}>
Put up for sale
</button>
</div>
</div>
);
}
+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;
+34
View File
@@ -1,3 +1,5 @@
import type { Coin, Currency } from '../api/types';
export const SPECIES_LABELS: Record<string, string> = {
HUMAN: 'Human',
HALF_ELF: 'Half-Elf',
@@ -70,6 +72,38 @@ export function hpBarClass(current: number, max: number): string {
return 'hp-bar-fill';
}
// Coins. Silver is worth 0.1 gold and copper 0.01 gold.
export const COINS: Coin[] = ['GOLD', 'SILVER', 'COPPER'];
// Everything a purse can hold: the coins, plus cash ($) for crossover worlds.
// Cash has no worth in coins, so it is never exchanged or used to make change.
export const CURRENCIES: Currency[] = [...COINS, 'CASH'];
export const COIN_LABELS: Record<Currency, string> = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper', CASH: 'Cash ($)' };
export const COIN_FIELD: Record<Currency, 'gold' | 'silver' | 'copper' | 'cash'> = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' };
// What one coin is worth, counted in copper.
export const COIN_VALUE: Record<Coin, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
const COIN_SHORT: Record<Coin, string> = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' };
// "12 gp", "5 sp", "$40". With `long`, coins are spelled out: "12 gold".
export function formatMoney(amount: number, currency: Currency, long = false): string {
if (currency === 'CASH') return `$${amount}`;
return `${amount} ${long ? COIN_LABELS[currency].toLowerCase() : COIN_SHORT[currency]}`;
}
type Purse = { gold: number | null; silver: number | null; copper: number | null; cash: number | null };
// "12 gp · 5 sp · 3 cp · $40". Gold always shows; the rest only when held.
export function formatPurse(purse: Purse): string {
return CURRENCIES.filter((c) => c === 'GOLD' || (purse[COIN_FIELD[c]] ?? 0) > 0)
.map((c) => formatMoney(purse[COIN_FIELD[c]] ?? 0, c))
.join(' · ');
}
// The purse's coins as one figure in gold, e.g. 12.65. Cash is not counted.
export function purseWorthInGold(purse: Purse): number {
const copper = COINS.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
return copper / COIN_VALUE.GOLD;
}
export function extractErrorMessage(err: unknown): string {
const anyErr = err as { response?: { data?: { error?: unknown } } };
const apiError = anyErr?.response?.data?.error;
+14
View File
@@ -0,0 +1,14 @@
import { scaledDamageDice } from './previewCalc';
import type { SaleOffer } from '../api/types';
// "🗡 Longsword · Lv 2 · 1d10": what a ware is, for anyone looking at it.
export function wareName(ware: SaleOffer): string {
const entry = ware.weapon ?? ware.item;
if (!entry) return 'Removed from the pool';
const dice = entry.damageDice ? ` · ${scaledDamageDice(entry.damageDice, ware.level, entry.damageScaling, entry.minLevel)}` : '';
return `${ware.weapon ? '🗡' : '🎒'} ${entry.name} · Lv ${ware.level}${dice}`;
}
export function stockLabel(ware: SaleOffer): string {
return ware.stock === null ? 'always in stock' : ware.stock === 0 ? 'sold out' : `${ware.stock} left`;
}
+372 -52
View File
@@ -1,4 +1,4 @@
import { useState, type FormEvent } from 'react';
import { useState, type CSSProperties, type FormEvent, type ReactNode } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
useCampaign,
@@ -12,6 +12,7 @@ import {
useDeleteEncounter,
useCampaignMonsters,
useCreateMonster,
useUpdateMonster,
useDeleteMonster,
useCampaignWeapons,
useCreateWeapon,
@@ -40,11 +41,14 @@ import {
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { LifeBadge } from '../components/DeathSaves';
import { usePagination, Pagination } from '../components/Paginated';
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, spellLevelLabel, spellRequirementLabel } from '../lib/format';
import { WaresEditor } from '../components/Wares';
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel, spellRequirementLabel } from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
import { DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type { CharacterSummary, Encounter, EncounterMode, LibraryMonster, LibrarySpell, LibraryWeapon, NpcDisposition } from '../api/types';
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type { CharacterSummary, Encounter, EncounterMode, Item, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Weapon } from '../api/types';
// How the encounter list can be ordered. "custom" is the campaign's own order,
// which the DM arranges; the rest only change what this browser shows.
@@ -101,7 +105,7 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
<div className="flex-row">
{!inProgress && (
<div style={{ textAlign: 'right', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
<LifeBadge deathSaves={character.deathSaves} /> HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
</div>
)}
<Link to={inProgress ? `/characters/${character.id}/build` : `/characters/${character.id}`} className="btn">
@@ -234,6 +238,214 @@ function SpellDamageSelect({
);
}
type MonsterInput = Omit<Monster, 'id' | 'campaignId'>;
type AbilityScores = Pick<Monster, 'strScore' | 'dexScore' | 'conScore' | 'intScore' | 'wisScore' | 'chaScore'>;
const scoreField = (ability: string) => `${ability.toLowerCase()}Score` as keyof AbilityScores;
// "STR 8 · DEX 14 · …", in stat-block order.
function abilityLine(scores: AbilityScores): string {
return ABILITY_ORDER.map((a) => `${a} ${scores[scoreField(a)]}`).join(' · ');
}
// The whole stat block, field for field as a printed one gives it. Adds a
// monster, or edits one when `monster` is passed (its values fill the form).
function MonsterForm({
monster,
saving,
onSave,
onCancel,
}: {
monster?: Monster;
saving: boolean;
// Rejects if the save failed, so the form keeps what was typed.
onSave: (data: MonsterInput) => Promise<void>;
onCancel?: () => void;
}) {
const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => (
<div className="field" style={{ marginBottom: 0, ...style }}>
<label htmlFor={`monster-${name}`}>{label}</label>
{input}
</div>
);
const number = (name: keyof MonsterInput, label: string, min: number, max: number, fallback?: number) =>
field(
name,
label,
<NumberInput
id={`monster-${name}`}
name={name}
min={min}
max={max}
defaultValue={(monster?.[name] as number | undefined) ?? fallback}
keepValue={fallback !== undefined}
required
/>
);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const fd = new FormData(form);
const text = (key: string) => String(fd.get(key) || '').trim() || null;
const num = (key: string) => Number(fd.get(key));
try {
await onSave({
name: String(fd.get('name')).trim(),
challengeRating: text('challengeRating'),
ac: num('ac'),
maxHp: num('maxHp'),
speed: num('speed'),
attackBonus: num('attackBonus'),
damageDice: text('damageDice'),
strScore: num('strScore'),
dexScore: num('dexScore'),
conScore: num('conScore'),
intScore: num('intScore'),
wisScore: num('wisScore'),
chaScore: num('chaScore'),
specialTrait: text('specialTrait'),
notes: text('notes'),
});
if (!monster) form.reset();
} catch {
// The page shows the error; what was typed stays.
}
}}
>
<div className="grid grid-cols-3">
{field('name', 'Name', <input id="monster-name" name="name" defaultValue={monster?.name} required maxLength={80} />)}
{field(
'challengeRating',
'CR (Challenge Rating)',
<input id="monster-challengeRating" name="challengeRating" defaultValue={monster?.challengeRating ?? ''} placeholder="e.g. 1/4 or 3" maxLength={10} />
)}
{number('ac', 'AC (Armor Class)', 0, 50)}
{number('maxHp', 'HP (Hit Points)', 1, 100000)}
{number('speed', 'Speed (feet)', 0, 1000, 30)}
{number('attackBonus', 'Attack bonus', -20, 50, 0)}
{field(
'damageDice',
'Damage',
<input id="monster-damageDice" name="damageDice" defaultValue={monster?.damageDice ?? ''} placeholder="e.g. 1d6+2 necrotic" maxLength={60} />
)}
</div>
<div className="grid mt-sm" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))' }}>
{ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))}
</div>
{field(
'specialTrait',
'Special trait',
<input
id="monster-specialTrait"
name="specialTrait"
defaultValue={monster?.specialTrait ?? ''}
placeholder="The one thing that makes it different"
maxLength={500}
/>,
{ marginTop: '0.5rem' }
)}
{field(
'notes',
'Notes',
<textarea
id="monster-notes"
name="notes"
rows={3}
className="textarea-block"
defaultValue={monster?.notes ?? ''}
placeholder="Everything else: other speeds (fly 30, swim 40), extra attacks, spells, tactics, resistances, senses, what it drops"
maxLength={2000}
/>,
{ marginTop: '0.5rem' }
)}
<div className="flex-row mt-sm">
<button type="submit" className="btn btn-primary" disabled={saving}>
{monster ? 'Save Changes' : 'Add Monster'}
</button>
{onCancel && (
<button type="button" className="btn btn-ghost" onClick={onCancel}>
Cancel
</button>
)}
</div>
</form>
);
}
// "1 gp = $5": the rate an NPC changes gold and cash at, or null if they don't.
function rateLabel(npc: Pick<Npc, 'exchangeGold' | 'exchangeCash'>): string | null {
if (!npc.exchangeGold || !npc.exchangeCash) return null;
return `${formatMoney(npc.exchangeGold, 'GOLD')} = ${formatMoney(npc.exchangeCash, 'CASH')}`;
}
// What an NPC trades: the rate they change gold and cash at, and their wares.
// Players meet both on the encounter page, in a quest the NPC is part of.
function NpcTrade({
campaignId,
npc,
weapons,
items,
onError,
}: {
campaignId: string;
npc: Npc;
weapons: Weapon[];
items: Item[];
onError: (err: unknown) => void;
}) {
const patchNpc = usePatchPoolEntry(campaignId, 'npcs');
const [gold, setGold] = useState(npc.exchangeGold ? String(npc.exchangeGold) : '');
const [cash, setCash] = useState(npc.exchangeCash ? String(npc.exchangeCash) : '');
const changesMoney = Boolean(rateLabel(npc));
return (
<div className="card mt-sm" style={{ background: 'var(--bg-inset)' }}>
<div className="section-label">Changes money</div>
<p className="combatant-sub" style={{ marginTop: 0 }}>
Set a rate and the party can change gold into cash, or cash into gold, with {npc.name}. Silver and copper are not
taken. Leave it empty for someone who does not change money.
</p>
<div className="flex-row flex-wrap gap-sm">
<NumberInput min={1} max={1000000} aria-label="Gold side of the rate" placeholder="Gold" value={gold} onChange={(e) => setGold(e.target.value)} style={{ width: 90 }} />
<span>gold =</span>
<span>$</span>
<NumberInput min={1} max={1000000} aria-label="Cash side of the rate" placeholder="Cash" value={cash} onChange={(e) => setCash(e.target.value)} style={{ width: 90 }} />
<button
className="btn"
disabled={!gold || !cash || patchNpc.isPending}
onClick={() => patchNpc.mutate({ id: npc.id, exchangeGold: Number(gold), exchangeCash: Number(cash) }, { onError })}
>
Save rate
</button>
{changesMoney && (
<button
className="btn btn-ghost"
disabled={patchNpc.isPending}
onClick={() =>
patchNpc.mutate(
{ id: npc.id, exchangeGold: null, exchangeCash: null },
{
onError,
onSuccess: () => {
setGold('');
setCash('');
},
}
)
}
>
Stop changing money
</button>
)}
</div>
<div className="section-label mt-md">For sale</div>
<WaresEditor campaignId={campaignId} seller={{ npcId: npc.id }} wares={npc.wares ?? []} weapons={weapons} items={items} onError={onError} />
</div>
);
}
function LibraryPicker<T extends { key: string; name: string }>({
items,
onAdd,
@@ -293,7 +505,7 @@ export function CampaignHomePage() {
const { data: npcs } = useCampaignNpcs(campaignId, data?.campaign.role === 'DM');
const { data: notes } = useCampaignNotes(campaignId);
const { data: locations } = useCampaignLocations(campaignId);
const { data: library } = useCampaignLibrary(campaignId);
const { data: library } = useCampaignLibrary(campaignId, data?.campaign.role === 'DM');
const createCharacter = useCreateCharacter(campaignId);
const deleteCharacter = useDeleteCharacter(campaignId);
@@ -302,6 +514,7 @@ export function CampaignHomePage() {
const setEncounterArchived = useSetEncounterArchived(campaignId);
const deleteEncounter = useDeleteEncounter(campaignId);
const createMonster = useCreateMonster(campaignId);
const updateMonster = useUpdateMonster(campaignId);
const deleteMonster = useDeleteMonster(campaignId);
const createWeapon = useCreateWeapon(campaignId);
const createSpell = useCreateSpell(campaignId);
@@ -331,6 +544,7 @@ export function CampaignHomePage() {
const [encounterSort, setEncounterSort] = useState<EncounterSort>(storedEncounterSort);
const [showArchivedEncounters, setShowArchivedEncounters] = useState(false);
const [showNpcForm, setShowNpcForm] = useState(false);
const [tradeNpcId, setTradeNpcId] = useState<string | null>(null);
const [noteBody, setNoteBody] = useState('');
const [dmNoteBody, setDmNoteBody] = useState('');
const [newLocationName, setNewLocationName] = useState('');
@@ -339,6 +553,7 @@ export function CampaignHomePage() {
const [showDelete, setShowDelete] = useState(false);
const [deleteConfirmName, setDeleteConfirmName] = useState('');
const [showMonsterForm, setShowMonsterForm] = useState(false);
const [editingMonsterId, setEditingMonsterId] = useState<string | null>(null);
const [showWeaponForm, setShowWeaponForm] = useState(false);
const [showSpellForm, setShowSpellForm] = useState(false);
const [showItemForm, setShowItemForm] = useState(false);
@@ -351,6 +566,7 @@ export function CampaignHomePage() {
const itemsPage = usePagination(items, 8);
const npcsPage = usePagination(npcs, 8);
const monstersPage = usePagination(monsters, 8);
const editingMonster = monsters?.find((m) => m.id === editingMonsterId);
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
@@ -486,27 +702,46 @@ export function CampaignHomePage() {
startingItemCount: Number(fd.get('startingItemCount')),
startingSpellCount: Number(fd.get('startingSpellCount')),
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
showMonsterHp: fd.get('showMonsterHp') === 'on',
areaAttackAcPenalty: Number(fd.get('areaAttackAcPenalty')),
});
}}
>
<div className="grid grid-cols-3">
<div className="field">
<label>Starting gold</label>
<input name="startingGold" type="number" min={0} defaultValue={campaign.startingGold} />
<NumberInput name="startingGold" min={0} max={1000000} keepValue defaultValue={campaign.startingGold} />
</div>
<div className="field">
<label>Starting items (weapons + items)</label>
<input name="startingItemCount" type="number" min={0} defaultValue={campaign.startingItemCount} />
<NumberInput name="startingItemCount" min={0} max={50} keepValue defaultValue={campaign.startingItemCount} />
</div>
<div className="field">
<label>Starting spells</label>
<input name="startingSpellCount" type="number" min={0} defaultValue={campaign.startingSpellCount} />
<NumberInput name="startingSpellCount" min={0} max={50} keepValue defaultValue={campaign.startingSpellCount} />
</div>
</div>
<label className="checkbox-row">
<input type="checkbox" name="skipMotivationRoll" defaultChecked={campaign.skipMotivationRoll} />
Skip the motivation step when building a character
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
</label>
<label className="flex-row flex-wrap gap-sm mt-sm">
Area attack AC penalty
<NumberInput
name="areaAttackAcPenalty"
min={0}
max={10}
keepValue
defaultValue={campaign.areaAttackAcPenalty}
style={{ width: 70 }}
title="An attack on several targets is harder to land: each target's AC counts as this much higher for every target beyond the first. 0 turns the penalty off."
/>
<span className="combatant-sub">added to each target's AC for every target beyond the first (0 for none)</span>
</label>
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
Save Settings
</button>
@@ -750,6 +985,18 @@ export function CampaignHomePage() {
)}
</div>
{!isDM && locations && locations.length > 0 && (
<div className="mt-lg card">
<div className="card-title">Places You've Been</div>
{locations.map((loc) => (
<div key={loc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<strong>📍 {loc.name}</strong>
{loc.description && <p className="subtitle" style={{ margin: '0.2rem 0 0', whiteSpace: 'pre-wrap' }}>{loc.description}</p>}
</div>
))}
</div>
)}
{isDM && (
<div className="mt-lg card">
<div className="card-title">
@@ -760,7 +1007,8 @@ export function CampaignHomePage() {
</div>
<p className="subtitle" style={{ marginTop: 0 }}>
The people your party meets. Add them to an encounter to bring them into a scene. Players never see your notes or an
NPC's stats.
NPC's stats. "Trade" sets what an NPC sells and the rate they change gold and cash at; players see both when they
meet the NPC in a quest.
</p>
{showNpcForm && (
<form
@@ -800,9 +1048,9 @@ export function CampaignHomePage() {
<textarea name="description" rows={2} className="textarea-block mt-sm" placeholder="What the party sees and knows (shown to players)" />
<textarea name="dmNotes" rows={2} className="textarea-block mt-sm" placeholder="Secrets and motives (only you see this)" />
<div className="grid grid-cols-3 mt-sm">
<input name="ac" type="number" min={0} placeholder="AC (optional)" />
<input name="maxHp" type="number" min={1} placeholder="HP (optional)" />
<input name="attackBonus" type="number" placeholder="Attack bonus (optional)" />
<NumberInput name="ac" min={0} max={50} placeholder="AC (optional)" />
<NumberInput name="maxHp" min={1} max={100000} placeholder="HP (optional)" />
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus (optional)" />
<input name="damageDice" placeholder="Damage (optional, e.g. 1d6)" maxLength={20} />
<button type="submit" className="btn btn-primary" disabled={createNpc.isPending}>Add NPC</button>
</div>
@@ -811,14 +1059,21 @@ export function CampaignHomePage() {
<div className="mt-sm">
{npcs?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No NPCs yet.</p>}
{npcsPage.pageItems.map((npc) => (
<div key={npc.id} className="flex-between" style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div key={npc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div className="flex-between">
<div>
<strong>{npc.name}</strong>
{npc.role && <span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>{npc.role}</span>}
<span className={`badge badge-${npc.disposition.toLowerCase()}`} style={{ marginLeft: 8 }}>{npc.disposition}</span>
{rateLabel(npc) && <span className="badge" style={{ marginLeft: 8 }}>💱 {rateLabel(npc)}</span>}
{(npc.wares?.length ?? 0) > 0 && <span className="badge" style={{ marginLeft: 8 }}>🛒 {npc.wares?.length} for sale</span>}
{npc.description && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{npc.description}</div>}
{npc.dmNotes && <div style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>🔒 {npc.dmNotes}</div>}
</div>
<div className="flex-row">
<button className="btn btn-ghost" onClick={() => setTradeNpcId(tradeNpcId === npc.id ? null : npc.id)}>
{tradeNpcId === npc.id ? 'Close' : 'Trade'}
</button>
<button
className="btn btn-ghost"
aria-label={`Remove ${npc.name}`}
@@ -827,6 +1082,17 @@ export function CampaignHomePage() {
✕
</button>
</div>
</div>
{tradeNpcId === npc.id && (
<NpcTrade
campaignId={campaignId}
npc={npc}
weapons={weapons ?? []}
items={items ?? []}
onError={(err) => setFormError(extractErrorMessage(err))}
/>
)}
</div>
))}
</div>
<Pagination page={npcsPage.page} pageCount={npcsPage.pageCount} onPrev={npcsPage.prevPage} onNext={npcsPage.nextPage} />
@@ -838,7 +1104,10 @@ export function CampaignHomePage() {
<div className="card-title">
<span>Locations</span>
</div>
<p className="subtitle" style={{ marginTop: 0 }}>Track where each character currently is.</p>
<p className="subtitle" style={{ marginTop: 0 }}>
Track where each character currently is. Give an encounter a location and its characters move there when it
begins. Players only see the places their own characters have been.
</p>
<form onSubmit={handleCreateLocation} className="flex-row flex-wrap">
<input
placeholder="Location name (e.g. The Rusty Tankard)"
@@ -1093,6 +1362,7 @@ export function CampaignHomePage() {
description: String(fd.get('description') || '').trim() || null,
damageDice: String(fd.get('damageDice') || '') || null,
isStartingOption: fd.get('isStartingOption') === 'on',
revives: fd.get('revives') === 'on',
});
(e.target as HTMLFormElement).reset();
}}
@@ -1113,6 +1383,10 @@ export function CampaignHomePage() {
<input type="checkbox" name="isStartingOption" />
Starting option
</label>
<label className="checkbox-row" title="A character who can cast it may revive a dead ally on their turn in a battle">
<input type="checkbox" name="revives" />
Revives the dead
</label>
<button type="submit" className="btn btn-primary">Add</button>
</form>
)}
@@ -1123,6 +1397,7 @@ export function CampaignHomePage() {
<th>Spell</th>
<th>Level</th>
<th>Damage</th>
<th title="A character who can cast it may revive a dead ally on their turn in a battle">Revives</th>
<th>Starting</th>
<th />
</tr>
@@ -1138,6 +1413,14 @@ export function CampaignHomePage() {
<td>
<SpellDamageSelect spellName={s.name} value={s.damageDice} onChange={(damageDice) => patchSpell.mutate({ id: s.id, damageDice })} />
</td>
<td>
<input
type="checkbox"
aria-label={`${s.name} revives the dead`}
checked={s.revives}
onChange={(e) => patchSpell.mutate({ id: s.id, revives: e.target.checked })}
/>
</td>
<PoolRowControls
entry={s}
onToggleStarting={(isStartingOption) => patchSpell.mutate({ id: s.id, isStartingOption })}
@@ -1158,8 +1441,14 @@ export function CampaignHomePage() {
<button className="btn btn-ghost" onClick={() => setShowMonsterLibrary((s) => !s)}>
{showMonsterLibrary ? 'Close Library' : '📚 From Library'}
</button>
<button className="btn btn-ghost" onClick={() => setShowMonsterForm((s) => !s)}>
{showMonsterForm ? 'Close' : '+ Add Monster'}
<button
className="btn btn-ghost"
onClick={() => {
setShowMonsterForm(!(showMonsterForm || editingMonster));
setEditingMonsterId(null);
}}
>
{showMonsterForm || editingMonster ? 'Close' : '+ Add Monster'}
</button>
</div>
</div>
@@ -1169,62 +1458,93 @@ export function CampaignHomePage() {
adding={addMonsterFromLibrary.isPending}
onAdd={(key) => addMonsterFromLibrary.mutate(key)}
placeholder="Search the monster library…"
renderMeta={(m) => `CR ${m.challengeRating} · AC ${m.ac} · HP ${m.maxHp} · attack +${m.attackBonus}${m.damageDice ? ` for ${m.damageDice}` : ''}`}
renderMeta={(m) =>
`CR ${m.challengeRating ?? '—'} · AC ${m.ac} · HP ${m.maxHp} · speed ${m.speed} · attack ${fmtMod(m.attackBonus)}${m.damageDice ? ` for ${m.damageDice}` : ''} · ${abilityLine(m)}`
}
renderDetail={(m) => [m.specialTrait, m.notes].filter(Boolean).join(' — ') || null}
/>
)}
{showMonsterForm && (
<form
onSubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
await createMonster.mutateAsync({
name: String(fd.get('name')),
ac: Number(fd.get('ac')),
maxHp: Number(fd.get('maxHp')),
speed: 30,
strScore: 10,
dexScore: 10,
conScore: 10,
intScore: 10,
wisScore: 10,
chaScore: 10,
attackBonus: Number(fd.get('attackBonus') || 0),
damageDice: String(fd.get('damageDice') || '') || null,
challengeRating: null,
specialTrait: null,
notes: null,
});
(e.target as HTMLFormElement).reset();
{(showMonsterForm || editingMonster) && (
<div className="card mt-sm" style={{ background: 'var(--bg-inset)' }}>
{editingMonster && <div className="section-label">Editing {editingMonster.name}</div>}
<MonsterForm
key={editingMonster?.id ?? 'new'}
monster={editingMonster}
saving={createMonster.isPending || updateMonster.isPending}
onCancel={editingMonster ? () => setEditingMonsterId(null) : undefined}
onSave={async (stats) => {
setFormError(null);
try {
if (editingMonster) {
await updateMonster.mutateAsync({ id: editingMonster.id, ...stats });
setEditingMonsterId(null);
} else {
await createMonster.mutateAsync(stats);
}
} catch (err) {
setFormError(extractErrorMessage(err));
throw err;
}
}}
className="grid grid-cols-3"
>
<input name="name" placeholder="Name" required />
<input name="ac" type="number" placeholder="AC" required />
<input name="maxHp" type="number" placeholder="Max HP" required />
<input name="attackBonus" type="number" placeholder="Attack bonus" />
<input name="damageDice" placeholder="Damage dice" />
<button type="submit" className="btn btn-primary">Add</button>
</form>
/>
</div>
)}
<div className="table-wrap mt-sm">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>CR</th>
<th>AC</th>
<th>HP</th>
<th>Speed</th>
<th>Attack</th>
<th>Damage</th>
<th />
<th />
</tr>
</thead>
<tbody>
{monstersPage.pageItems.map((m) => (
<tr key={m.id}>
<td>{m.name}</td>
<td>
<strong>{m.name}</strong>
<div className="mono" style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{abilityLine(m)}</div>
{m.specialTrait && (
<div style={{ fontSize: '0.9rem' }}>
<strong>Trait:</strong> {m.specialTrait}
</div>
)}
{m.notes && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>{m.notes}</div>}
</td>
<td>{m.challengeRating || '—'}</td>
<td>{m.ac}</td>
<td>{m.maxHp}</td>
<td>{m.speed}</td>
<td>{fmtMod(m.attackBonus)}</td>
<td>{m.damageDice || '—'}</td>
<td>
<button className="btn btn-ghost" onClick={() => deleteMonster.mutate(m.id)}>✕</button>
<button
className="btn btn-ghost"
aria-label={`Edit ${m.name}`}
onClick={() => {
setEditingMonsterId(m.id);
setShowMonsterForm(false);
}}
>
Edit
</button>
</td>
<td>
<button
className="btn btn-ghost"
aria-label={`Remove ${m.name}`}
onClick={() =>
window.confirm(`Remove ${m.name}? Copies already in encounters stay, but lose their stat block.`) && deleteMonster.mutate(m.id)
}
>
✕
</button>
</td>
</tr>
))}
+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}
+153 -31
View File
@@ -17,6 +17,7 @@ import {
useRemoveCharacterSpell,
useGoldLedger,
useAdjustGold,
useExchangeCoins,
useCampaignCharacters,
useGive,
type Gift,
@@ -30,14 +31,33 @@ import {
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
import { DeathSaves } from '../components/DeathSaves';
import { QuickStatsFab } from '../components/QuickStatsDrawer';
import { InfoTip } from '../components/InfoTip';
import { usePagination, Pagination } from '../components/Paginated';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, fmtMod, hpBarClass, extractErrorMessage, spellLevelLabel } from '../lib/format';
import {
ABILITY_LABELS,
CLASS_LABELS,
COINS,
COIN_FIELD,
COIN_LABELS,
COIN_VALUE,
CURRENCIES,
SKILL_LABELS,
SPECIES_LABELS,
fmtMod,
extractErrorMessage,
formatMoney,
formatPurse,
purseWorthInGold,
spellLevelLabel,
} from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import { CLASS_HIT_DIE } from '../lib/classData';
import type { Ability, CharacterDetail } from '../api/types';
import type { Ability, CharacterDetail, Coin, Currency } from '../api/types';
// The DM's level picker for one held weapon or item, from the level the gear
// starts at up to the maximum. Each option shows what the dice become at that
@@ -87,19 +107,26 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
const others = characters?.filter((c) => c.id !== character.id && c.buildStage === 'COMPLETE') ?? [];
if (others.length === 0) return null;
// "kind:id" identifies the thing picked; gold has no id.
// "kind:id" identifies the thing picked; for coins the id is which coin.
const [kind, id] = what.split(':');
const coin = (kind === 'gold' ? id : 'GOLD') as Currency;
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
const heldItem = character.inventoryItems.find((i) => i.itemId === id);
const max = kind === 'gold' ? character.gold : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
// A gift of coins is paid from the whole purse, with change, so any coin
// the purse is worth at least one of can be given. Cash is only paid in cash.
const held = (c: Currency) => character[COIN_FIELD[c]] ?? 0;
const purseInCopper = COINS.reduce((sum, c) => sum + held(c) * COIN_VALUE[c], 0);
const affordable = (c: Currency) => (c === 'CASH' ? held(c) : Math.floor(purseInCopper / COIN_VALUE[c]));
const coinsHeld = CURRENCIES.filter((c) => affordable(c) > 0);
const max = kind === 'gold' ? affordable(coin) : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
const count = Math.floor(Number(amount));
const countOk = kind === 'spell' || (count >= 1 && count <= max);
const nothingToGive = character.gold === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
const nothingToGive = coinsHeld.length === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
function handleGive() {
const gift: Gift | null =
kind === 'gold'
? { kind, amount: count }
? { kind, amount: count, currency: coin }
: kind === 'weapon' || kind === 'item'
? { kind, id, quantity: count }
: kind === 'spell'
@@ -142,7 +169,11 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
style={{ flex: 2, minWidth: 160 }}
>
<option value="">What to give…</option>
{character.gold > 0 && <option value="gold:">Gold ({character.gold} gp)</option>}
{coinsHeld.map((c) => (
<option key={c} value={`gold:${c}`}>
{COIN_LABELS[c]} ({formatMoney(held(c), c)} held)
</option>
))}
{character.weapons.length > 0 && (
<optgroup label="Weapons">
{character.weapons.map((w) => (
@@ -172,12 +203,11 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
)}
</select>
{what && kind !== 'spell' && (
<input
type="number"
<NumberInput
min={1}
max={max}
aria-label={kind === 'gold' ? 'How much gold' : 'How many'}
title={kind === 'gold' ? 'How much gold' : `How many (up to ${max})`}
aria-label={kind === 'gold' ? 'How many coins' : 'How many'}
title={`How many (up to ${max})`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
style={{ width: 90 }}
@@ -212,7 +242,7 @@ export function CharacterSheetPage() {
const { data: campaignWeapons } = useCampaignWeapons(character?.campaignId);
const { data: campaignSpells } = useCampaignSpells(character?.campaignId);
const { data: campaignItems } = useCampaignItems(character?.campaignId);
const { data: goldLedger } = useGoldLedger(characterId);
const { data: goldLedger } = useGoldLedger(characterId, Boolean(character) && !character?.restricted);
const { data: locations } = useCampaignLocations(character?.campaignId);
const { data: notes } = useCharacterNotes(characterId);
@@ -226,6 +256,7 @@ export function CharacterSheetPage() {
const patchSpell = usePatchCharacterSpell(characterId);
const removeSpell = useRemoveCharacterSpell(characterId);
const adjustGold = useAdjustGold(characterId);
const exchangeCoins = useExchangeCoins(characterId);
const createNote = useCreateNote();
const { data: campaigns } = useCampaigns();
const copyCharacter = useCopyCharacter(characterId);
@@ -235,6 +266,10 @@ export function CharacterSheetPage() {
const [pickedItemId, setPickedItemId] = useState('');
const [goldAmount, setGoldAmount] = useState('');
const [goldReason, setGoldReason] = useState('');
const [goldCurrency, setGoldCurrency] = useState<Currency>('GOLD');
const [exchangeAmount, setExchangeAmount] = useState('');
const [exchangeFrom, setExchangeFrom] = useState<Coin>('GOLD');
const [exchangeTo, setExchangeTo] = useState<Coin>('SILVER');
const [noteBody, setNoteBody] = useState('');
const [pickedWeaponId, setPickedWeaponId] = useState('');
const [pickedSpellId, setPickedSpellId] = useState('');
@@ -271,7 +306,9 @@ export function CharacterSheetPage() {
const amount = Number(hpDelta);
if (!amount) return;
const next = character!.currentHp + sign * amount;
patchCharacter.mutate({ currentHp: next });
setFormError(null);
// Healing is refused for a character who has died.
patchCharacter.mutate({ currentHp: next }, { onError: (err) => setFormError(extractErrorMessage(err)) });
setHpDelta('');
}
@@ -298,7 +335,7 @@ export function CharacterSheetPage() {
if (!(amount > 0)) return;
setFormError(null);
try {
await adjustGold.mutateAsync({ amount: sign * amount, reason: goldReason.trim() || undefined });
await adjustGold.mutateAsync({ amount: sign * amount, currency: goldCurrency, reason: goldReason.trim() || undefined });
setGoldAmount('');
setGoldReason('');
} catch (err) {
@@ -306,6 +343,18 @@ export function CharacterSheetPage() {
}
}
async function handleExchange() {
const amount = Math.floor(Number(exchangeAmount));
if (!(amount > 0) || exchangeFrom === exchangeTo) return;
setFormError(null);
try {
await exchangeCoins.mutateAsync({ from: exchangeFrom, to: exchangeTo, amount });
setExchangeAmount('');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleAddNote(e: FormEvent) {
e.preventDefault();
if (!noteBody.trim()) return;
@@ -412,15 +461,23 @@ export function CharacterSheetPage() {
</div>
</div>
<div className="hp-bar-track mt-sm">
<div className={hpBarClass(character.currentHp, sheet.hp.max)} style={{ width: `${Math.max(0, Math.min(100, (character.currentHp / sheet.hp.max) * 100))}%` }} />
</div>
<HpBar current={character.currentHp} max={sheet.hp.max} temp={character.tempHp} />
<DeathSaves
characterId={characterId}
name={character.name}
deathSaves={character.deathSaves}
canAct={canEdit}
isDM={isDM}
onError={(err) => setFormError(extractErrorMessage(err))}
/>
{canEdit && (
<div className="flex-row mt-sm flex-wrap">
<input
type="number"
<NumberInput
min={1}
max={9999}
placeholder="Amount"
aria-label="HP change"
value={hpDelta}
onChange={(e) => setHpDelta(e.target.value)}
style={{ width: 100 }}
@@ -433,9 +490,11 @@ export function CharacterSheetPage() {
</button>
<label className="flex-row gap-sm" style={{ marginLeft: '1rem' }}>
Temp HP <InfoTip term="TEMP_HP" />
<input
type="number"
<NumberInput
key={`temp:${character.tempHp}`}
min={0}
max={9999}
keepValue
defaultValue={character.tempHp}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ tempHp: Number(e.target.value) })}
@@ -443,9 +502,11 @@ export function CharacterSheetPage() {
</label>
<label className="flex-row gap-sm">
Armor Bonus <InfoTip term="ARMOR_BONUS" />
<input
type="number"
<NumberInput
key={`armor:${character.armorBonus}`}
min={0}
max={30}
keepValue
defaultValue={character.armorBonus}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ armorBonus: Number(e.target.value) })}
@@ -458,10 +519,11 @@ export function CharacterSheetPage() {
<div className="flex-row mt-sm flex-wrap">
<label className="flex-row gap-sm">
Level
<input
type="number"
<NumberInput
key={`level:${character.level}`}
min={1}
max={20}
keepValue
defaultValue={character.level}
style={{ width: 60 }}
onBlur={(e) => patchCharacter.mutate({ level: Number(e.target.value) })}
@@ -671,6 +733,15 @@ export function CharacterSheetPage() {
</div>
)}
{character.restricted && (
<p className="subtitle mt-md">
This is another player's character. You can see what the party sees across the table; what they carry and how
much gold they have is theirs to share.
</p>
)}
{!character.restricted && (
<>
<div className="card mt-md">
<div className="card-title">Inventory</div>
{itemsPage.pageItems.map((held) => (
@@ -754,19 +825,31 @@ export function CharacterSheetPage() {
<div className="card mt-md">
<div className="card-title">
<span>Money</span>
<span className="mono">{character.gold} gp</span>
<span className="mono">{formatPurse(character)}</span>
</div>
<p className="subtitle" style={{ marginTop: 0 }}>
1 gold = 10 silver = 100 copper. Worth {purseWorthInGold(character)} gold in all. Spending and giving make
change for you: if you are short of a coin, a bigger one is broken. Cash ($) is kept apart: it is never
exchanged for coins.
</p>
{canEdit && (
<div className="flex-row flex-wrap">
<input
type="number"
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="Gold amount"
aria-label="Coin amount"
value={goldAmount}
onChange={(e) => setGoldAmount(e.target.value)}
style={{ width: 100 }}
/>
<select aria-label="Coin" value={goldCurrency} onChange={(e) => setGoldCurrency(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
<input
placeholder="What for? (optional)"
aria-label="Reason"
@@ -783,24 +866,63 @@ export function CharacterSheetPage() {
</button>
</div>
)}
{canEdit && (
<div className="flex-row flex-wrap mt-sm">
<span className="subtitle">Exchange</span>
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="How many coins to exchange"
value={exchangeAmount}
onChange={(e) => setExchangeAmount(e.target.value)}
style={{ width: 100 }}
/>
<select aria-label="Exchange from" value={exchangeFrom} onChange={(e) => setExchangeFrom(e.target.value as Coin)}>
{COINS.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
<span className="subtitle">into</span>
<select aria-label="Exchange into" value={exchangeTo} onChange={(e) => setExchangeTo(e.target.value as Coin)}>
{COINS.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
<button className="btn" onClick={handleExchange} disabled={!exchangeAmount || exchangeFrom === exchangeTo || exchangeCoins.isPending}>
Exchange
</button>
</div>
)}
<div className="mt-sm">
{goldLedger?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No transactions yet.</p>}
{ledgerPage.pageItems.map((t) => (
<div key={t.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong className="mono">{t.amount > 0 ? `+${t.amount}` : `−${Math.abs(t.amount)}`}</strong>
<strong className="mono">
{t.amount > 0 ? '+' : '−'}
{formatMoney(Math.abs(t.amount), t.currency ?? 'GOLD')}
</strong>
<span style={{ marginLeft: 8 }}>{t.reason || (t.amount > 0 ? 'Gained' : 'Spent')}</span>
<div style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
{t.createdBy ? `${t.createdBy.displayName} · ` : ''}
{new Date(t.createdAt).toLocaleString()}
</div>
</div>
<span className="mono" style={{ color: 'var(--text-muted)' }}>{t.balanceAfter} gp</span>
<span className="mono" style={{ color: 'var(--text-muted)' }}>
{formatMoney(t.balanceAfter, t.currency ?? 'GOLD')}
</span>
</div>
))}
</div>
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} />
</div>
</>
)}
{/* Composed by the character builder from the player's picks. Players
read them; only the DM can rewrite them (new abilities on level-up,
+418 -51
View File
@@ -8,11 +8,14 @@ import {
useCampaignNpcs,
useCampaignWeapons,
useCampaignItems,
useCampaignLocations,
useAddParticipant,
usePatchParticipant,
useRemoveParticipant,
useAdvanceRound,
useNextTurn,
useSetTargets,
useReviveParticipant,
usePatchEncounter,
useSetEncounterArchived,
useDeleteEncounter,
@@ -22,12 +25,18 @@ import {
type NewReward,
} from '../api/queries';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { useAuth } from '../auth/AuthContext';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { ABILITY_LABELS, CLASS_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, hpBarClass, spellLevelLabel } from '../lib/format';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
import { DeathSaves } from '../components/DeathSaves';
import { TradePanel } from '../components/TradePanel';
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel } from '../lib/format';
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type {
Ability,
CharacterSummary,
Currency,
Encounter,
EncounterMode,
EncounterParticipant,
@@ -56,6 +65,11 @@ const CONDITIONS = [
'Concentrating',
];
// An attack on more than one target is an area attack, and harder to land:
// every target's AC counts as `step` higher for each target beyond the first.
// The step is the campaign's "area attack AC penalty" setting.
const areaPenalty = (targetCount: number, step: number) => Math.max(0, targetCount - 1) * step;
const KIND_LABELS = { CHARACTER: 'PC', MONSTER: 'Monster', NPC: 'NPC' } as const;
const KIND_BADGES = { CHARACTER: 'badge-player', MONSTER: 'badge-hostile', NPC: 'badge-friendly' } as const;
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
@@ -90,17 +104,32 @@ function subline(p: EncounterParticipant): string {
function CombatantCard({
participant: p,
isDM,
mode,
isActive,
onPatch,
canRollInitiative,
targetedBy,
acPenalty,
onTarget,
onRemove,
onError,
}: {
participant: EncounterParticipant;
isDM: boolean;
mode: EncounterMode;
isActive: boolean;
onPatch: (data: Partial<EncounterParticipant>) => void;
// The DM for anyone; a player for their own character. The same goes for
// that character's death saves.
canRollInitiative: boolean;
// Set while this combatant is a target: who is going for them, or '' when
// nobody is on turn.
targetedBy?: string;
// What an area attack adds to this target's AC; 0 when they are the only one.
acPenalty: number;
// Marks this combatant as a target, or unmarks them. Absent for someone who
// may not pick targets right now, and on the card of whoever is on turn.
onTarget?: () => void;
onRemove: () => void;
onError: (err: unknown) => void;
}) {
const [hpDelta, setHpDelta] = useState('');
// Details start open for the DM (who runs the scene from this screen) and
@@ -112,9 +141,16 @@ function CombatantCard({
const info = p.characterCombatInfo;
const monster = p.monster;
const npc = p.npc;
const tracksHp = p.maxHp > 0;
const isDown = tracksHp && p.currentHp <= 0;
const pct = tracksHp ? Math.max(0, Math.min(100, (p.currentHp / p.maxHp) * 100)) : 0;
// A player may get a percentage in place of a monster's or NPC's numbers.
const currentHp = p.currentHp ?? 0;
const maxHp = p.maxHp ?? 0;
const barOnly = p.maxHp == null && p.hpPercent != null;
const tracksHp = barOnly || maxHp > 0;
const pct = barOnly ? (p.hpPercent ?? 0) : tracksHp ? Math.max(0, Math.min(100, (currentHp / maxHp) * 100)) : 0;
const isDown = tracksHp && pct <= 0;
// A dying character still has something to do each turn, so its card stays lit.
const isDying = info?.deathSaves.state === 'DYING';
const isTargeted = targetedBy !== undefined;
// Players get a monster's name and health bar only; the stat block is the DM's.
const monsterStats = monster && monster.ac !== undefined ? monster : null;
const hasDetails = Boolean(info || monsterStats || npc?.dmNotes);
@@ -122,7 +158,7 @@ function CombatantCard({
function applyDelta(sign: 1 | -1) {
const amount = Number(hpDelta);
if (!amount) return;
onPatch({ currentHp: p.currentHp + sign * amount });
onPatch({ currentHp: currentHp + sign * amount });
setHpDelta('');
}
@@ -133,10 +169,16 @@ function CombatantCard({
}
return (
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown ? ' is-down' : ''}`}>
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown && !isDying ? ' is-down' : ''}${isTargeted ? ' is-targeted' : ''}`}>
<div className="flex-between flex-wrap">
<div>
{isActive && <div className="turn-marker">▶ On turn</div>}
{isTargeted && (
<div className="target-marker">
🎯 Targeted{targetedBy && ` by ${targetedBy}`}
{acPenalty > 0 && ` · area attack, AC +${acPenalty}`}
</div>
)}
<div className="flex-row flex-wrap">
<span className={`badge ${KIND_BADGES[p.kind]}`}>{KIND_LABELS[p.kind]}</span>
{p.character ? (
@@ -147,31 +189,56 @@ function CombatantCard({
<span className="combatant-name">{p.displayName}</span>
)}
{npc && <span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</span>}
{isDown && <span className="badge badge-hostile">Down</span>}
{isDown && !info && <span className="badge badge-hostile">Down</span>}
{p.hidden && (
<span className="badge" title="Out of the fight. Players no longer see this card, and it has no turn.">
Hidden from players
</span>
)}
{onTarget && (
<button
className={`btn btn-target${isTargeted ? ' is-on' : ''}`}
aria-pressed={isTargeted}
title={isTargeted ? 'Stop targeting' : `Mark ${p.displayName} as a target. Marking more than one makes it an area attack.`}
onClick={onTarget}
>
🎯 {isTargeted ? 'Targeted' : 'Target'}
</button>
)}
</div>
<div className="combatant-sub">{subline(p)}</div>
</div>
{mode === 'BATTLE' &&
(isDM ? (
<label className="flex-row gap-sm">
Initiative
<input
key={`${p.id}:${p.initiative}`}
type="number"
defaultValue={p.initiative}
{/* Battles and quests both take initiative: who speaks or acts first matters in either. */}
{canRollInitiative ? (
<label className="flex-row gap-sm" title="Type what the d20 showed (1 to 20). The initiative bonus is added for you.">
Initiative roll
<NumberInput
key={`${p.id}:${p.initiativeRoll}`}
min={1}
max={20}
placeholder="d20"
aria-label={`Initiative roll for ${p.displayName}`}
defaultValue={p.initiativeRoll ?? ''}
style={{ width: 64 }}
onBlur={(e) => Number(e.target.value) !== p.initiative && onPatch({ initiative: Number(e.target.value) })}
onBlur={(e) => {
const roll = e.target.value === '' ? null : Number(e.target.value);
if (roll !== p.initiativeRoll) onPatch({ initiativeRoll: roll });
}}
/>
<span className="mono">
{p.initiativeBonus !== undefined && fmtMod(p.initiativeBonus)}
{p.initiativeRoll != null && ` = ${p.initiative}`}
</span>
</label>
) : (
<StatChip label="Initiative">{p.initiative}</StatChip>
))}
<StatChip label="Initiative">{p.initiativeRoll == null ? '—' : p.initiative}</StatChip>
)}
</div>
<div className="stat-chips mt-sm">
{tracksHp && (
{tracksHp && !barOnly && (
<StatChip label="HP">
{p.currentHp}/{p.maxHp}
{currentHp}/{maxHp}
{info && info.tempHp > 0 && ` +${info.tempHp}`}
</StatChip>
)}
@@ -203,14 +270,12 @@ function CombatantCard({
{tracksHp && (
<>
<div className="hp-bar-track mt-sm">
<div className={hpBarClass(p.currentHp, p.maxHp)} style={{ width: `${pct}%` }} />
</div>
<HpBar current={pct} max={100} temp={info && maxHp > 0 ? (info.tempHp / maxHp) * 100 : 0} />
{isDM && (
<div className="flex-row gap-sm mt-sm">
<input
type="number"
<NumberInput
min={1}
max={99999}
placeholder="Amount"
aria-label={`HP change for ${p.displayName}`}
value={hpDelta}
@@ -225,6 +290,16 @@ function CombatantCard({
</button>
</div>
)}
{info && p.character && (
<DeathSaves
characterId={p.character.id}
name={p.displayName}
deathSaves={info.deathSaves}
canAct={canRollInitiative}
isDM={isDM}
onError={onError}
/>
)}
</>
)}
@@ -361,8 +436,178 @@ function CombatantCard({
);
}
// What a combatant hits with, one line per attack: a character's weapons
// (the equipped ones, when any are) and damaging spells, or the single attack
// of a monster or NPC.
function attackLines(p: EncounterParticipant): string[] {
const info = p.characterCombatInfo;
if (info) {
const equipped = info.weapons.filter((w) => w.equipped);
const weapons = (equipped.length ? equipped : info.weapons).map(
(w) => `${w.name}: ${fmtMod(w.attackBonus)} to hit · ${w.damageDice} ${fmtMod(w.damageBonus)} ${w.damageType}`
);
const spells = info.spells
.filter((s) => s.damageDice)
.map((s) => `${s.name}: ${s.damageDice}${info.spellcasting ? ` · ${fmtMod(info.spellcasting.attackBonus)} to hit / DC ${info.spellcasting.saveDC}` : ''}`);
return [...weapons, ...spells];
}
const stats = p.monster ?? p.npc;
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
return [[stats.attackBonus != null && `${fmtMod(stats.attackBonus)} to hit`, stats.damageDice].filter(Boolean).join(' · ')];
}
type AreaMode = 'SPLIT' | 'EACH';
// How `amount` lands on `count` targets: all of it on each, or shared out
// evenly, any remainder going one point each to the first targets picked
// (7 between three is 3, 2 and 2).
function shares(amount: number, count: number, mode: AreaMode): number[] {
if (mode === 'EACH') return Array.from({ length: count }, () => amount);
const base = Math.floor(amount / count);
return Array.from({ length: count }, (_, i) => base + (i < amount % count ? 1 : 0));
}
// The DM's shortcut once targets are marked: who is attacking whom, the
// numbers needed to call the hit, and a box to apply the damage without
// hunting for each target's card. With more than one target it is an area
// attack: each AC is shown with the penalty added, the DM ticks who was hit,
// and the amount is split between them or dealt to each in full.
function AttackBar({
attacker,
targets,
acStep,
onApply,
onRemove,
onClear,
}: {
attacker: EncounterParticipant | undefined;
targets: EncounterParticipant[];
acStep: number; // the campaign's area attack AC penalty
onApply: (changes: { participantId: string; currentHp: number }[]) => void;
onRemove: (participantId: string) => void;
onClear: () => void;
}) {
const [amount, setAmount] = useState('');
const [mode, setMode] = useState<AreaMode>('SPLIT');
const [missed, setMissed] = useState<string[]>([]);
const isArea = targets.length > 1;
const penalty = areaPenalty(targets.length, acStep);
const tracksHp = (t: EncounterParticipant) => (t.maxHp ?? 0) > 0;
const hit = targets.filter((t) => tracksHp(t) && !missed.includes(t.id));
const value = Number(amount) || 0;
const portions = shares(value, hit.length, isArea ? mode : 'EACH');
const attacks = attacker ? attackLines(attacker) : [];
function apply(sign: 1 | -1) {
if (!value || hit.length === 0) return;
onApply(hit.map((t, i) => ({ participantId: t.id, currentHp: (t.currentHp ?? 0) + sign * portions[i] })));
setAmount('');
setMissed([]);
}
return (
<div className="card attack-bar">
<div className="flex-between flex-wrap">
<div className="attack-bar-title">
{attacker && <>{attacker.displayName} → </>}🎯 {isArea ? `${targets.length} targets` : targets[0].displayName}
</div>
<button className="btn btn-ghost" onClick={onClear}>
✕ Clear {isArea ? 'targets' : 'target'}
</button>
</div>
{isArea && (
<p className="combatant-sub" style={{ margin: '0.25rem 0 0' }}>
Area attack{penalty > 0 && `: every target's AC counts as ${penalty} higher (+${acStep} for each target beyond the first)`}. Untick anyone the
roll missed.
</p>
)}
{targets.map((t) => {
const info = t.characterCombatInfo;
const ac = info?.armorClass ?? t.monster?.ac ?? t.npc?.ac;
const portion = portions[hit.indexOf(t)];
return (
<div key={t.id} className="attack-target">
{isArea && (
<label className="flex-row gap-sm">
<input
type="checkbox"
aria-label={`${t.displayName} was hit`}
disabled={!tracksHp(t)}
checked={hit.includes(t)}
onChange={(e) => setMissed(e.target.checked ? missed.filter((id) => id !== t.id) : [...missed, t.id])}
/>
<span className="attack-target-name">{t.displayName}</span>
</label>
)}
<div className="stat-chips">
{ac != null && (
<StatChip label={isArea ? 'AC to beat' : 'Target AC'}>
{ac + penalty}
{penalty > 0 && ` (${ac} +${penalty})`}
</StatChip>
)}
{tracksHp(t) ? (
<StatChip label={isArea ? 'HP' : 'Target HP'}>
{t.currentHp ?? 0}/{t.maxHp}
{info && info.tempHp > 0 && ` +${info.tempHp}`}
</StatChip>
) : (
<span className="combatant-sub">No hit points to track.</span>
)}
{isArea && value > 0 && portion !== undefined && <StatChip label="Share">{portion}</StatChip>}
</div>
{isArea && (
<button className="btn btn-ghost" aria-label={`Stop targeting ${t.displayName}`} onClick={() => onRemove(t.id)}>
✕
</button>
)}
</div>
);
})}
{attacks.map((line) => (
<div key={line} className="combatant-sub mt-sm">
⚔ {line}
</div>
))}
{targets.some(tracksHp) && (
<div className="flex-row flex-wrap gap-sm mt-sm">
{/* Remounted when the targets change, so the cursor lands here as soon as one is marked. */}
<NumberInput
key={targets.map((t) => t.id).join()}
autoFocus
min={1}
max={99999}
placeholder={isArea ? 'Total' : 'Amount'}
aria-label={isArea ? 'HP change for the targets hit' : `HP change for ${targets[0].displayName}`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && apply(-1)}
style={{ width: 90 }}
/>
{isArea && (
<select aria-label="How the amount is shared" value={mode} onChange={(e) => setMode(e.target.value as AreaMode)} style={{ width: 'auto' }}>
<option value="SPLIT">Split between those hit</option>
<option value="EACH">Full amount to each</option>
</select>
)}
<button className="btn btn-danger" onClick={() => apply(-1)} disabled={!value || hit.length === 0}>
− Damage
</button>
<button className="btn" onClick={() => apply(1)} disabled={!value || hit.length === 0}>
+ Heal
</button>
</div>
)}
</div>
);
}
function rewardLabel(r: EncounterReward): string {
if (r.type === 'GOLD') return `${r.gold} gold`;
if (r.type === 'GOLD') return formatMoney(r.gold ?? 0, r.currency ?? 'GOLD', true);
const entry = r.type === 'WEAPON' ? r.weapon : r.item;
return `${entry?.name ?? 'Removed from the pool'}${r.quantity > 1 ? ` ×${r.quantity}` : ''} · Lv ${r.level}`;
}
@@ -388,6 +633,7 @@ function RewardsPanel({
const [type, setType] = useState<NewReward['type']>('GOLD');
const [gold, setGold] = useState('');
const [currency, setCurrency] = useState<Currency>('GOLD');
const [poolId, setPoolId] = useState('');
const [quantity, setQuantity] = useState('1');
const [level, setLevel] = useState('');
@@ -407,7 +653,7 @@ function RewardsPanel({
if (type === 'GOLD') {
const amount = Math.floor(Number(gold));
if (!(amount > 0)) return;
reward = { type, gold: amount, ...shared };
reward = { type, gold: amount, currency, ...shared };
} else {
if (!picked) return;
const gear = { quantity: Math.max(1, Math.floor(Number(quantity)) || 1), level: level ? Number(level) : undefined, ...shared };
@@ -430,7 +676,7 @@ function RewardsPanel({
<div className="card-title">Rewards</div>
{isDM && rewards.length === 0 && (
<p className="combatant-sub" style={{ marginTop: 0 }}>
Line up gold, weapons and items here. Players only see a reward once you award it.
Line up coins, weapons and items here. Players only see a reward once you award it.
</p>
)}
{rewards.map((r) => (
@@ -500,20 +746,29 @@ function RewardsPanel({
setLevel('');
}}
>
<option value="GOLD">Gold</option>
<option value="GOLD">Coins</option>
<option value="WEAPON">Weapon</option>
<option value="ITEM">Item</option>
</select>
{type === 'GOLD' ? (
<input
type="number"
<>
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="Gold amount"
aria-label="Coin amount"
value={gold}
onChange={(e) => setGold(e.target.value)}
style={{ flex: 1, minWidth: 90 }}
/>
<select aria-label="Coin" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
</>
) : (
<>
<select
@@ -542,9 +797,9 @@ function RewardsPanel({
</option>
))}
</select>
<input
type="number"
<NumberInput
min={1}
max={999}
aria-label="Quantity"
title="Quantity"
value={quantity}
@@ -577,6 +832,7 @@ function RewardsPanel({
export function EncounterPage() {
const { encounterId = '' } = useParams();
const { user } = useAuth();
const { data: encounter, isLoading, error } = useEncounter(encounterId);
useCampaignSocket(encounter?.campaignId);
@@ -587,12 +843,15 @@ export function EncounterPage() {
const { data: npcs } = useCampaignNpcs(encounter?.campaignId, isDM);
const { data: weapons } = useCampaignWeapons(encounter?.campaignId);
const { data: items } = useCampaignItems(encounter?.campaignId);
const { data: locations } = useCampaignLocations(encounter?.campaignId);
const addParticipant = useAddParticipant(encounterId);
const patchParticipant = usePatchParticipant(encounterId);
const removeParticipant = useRemoveParticipant(encounterId);
const advanceRound = useAdvanceRound(encounterId);
const nextTurn = useNextTurn(encounterId);
const setTargets = useSetTargets(encounterId);
const revive = useReviveParticipant(encounterId);
const patchEncounter = usePatchEncounter(encounterId);
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
@@ -602,20 +861,39 @@ export function EncounterPage() {
const [pickId, setPickId] = useState('');
const [initiative, setInitiative] = useState('');
const [formError, setFormError] = useState<string | null>(null);
// The DM's own screen leaves the fallen out too, until asked for.
const [showDead, setShowDead] = useState(false);
if (isLoading) return <Spinner label="Setting the scene…" />;
if (error || !encounter) return <ErrorBanner message="Could not load this encounter." />;
const isBattle = encounter.mode === 'BATTLE';
const participants = encounter.participants ?? []; // already in initiative order
// The fallen: dead characters, and monsters and NPCs at 0 HP. Only the DM is
// sent their cards, and they sit apart from those still in the fight.
const standing = participants.filter((p) => !p.hidden);
const outOfTheFight = participants.filter((p) => p.hidden);
const fallen = encounter.fallen ?? [];
const active = isBattle && encounter.status === 'ACTIVE' ? participants.find((p) => p.id === encounter.activeParticipantId) : undefined;
const targetIds = encounter.targetParticipantIds ?? [];
const targets = targetIds.map((id) => participants.find((p) => p.id === id)).filter((p): p is EncounterParticipant => p !== undefined);
// The DM marks targets whenever they like; a player while their own character is on turn.
const acStep = campaignData?.campaign.areaAttackAcPenalty ?? 0;
const canTarget = isDM || (active?.character != null && active.character.userId === user?.id);
// A player whose own character is on turn, on their feet and able to raise the dead.
const canTryRevive =
active?.character != null &&
active.character.userId === user?.id &&
active.characterCombatInfo?.deathSaves.state === 'UP' &&
active.characterCombatInfo.canRevive;
const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? [];
const onError = (err: unknown) => setFormError(extractErrorMessage(err));
const fail = { onError };
const pickOptions: { id: string; name: string }[] =
pickType === 'character'
? builtCharacters.filter((c) => !participants.some((p) => p.characterId === c.id))
? // The dead join no new scene until they are revived.
builtCharacters.filter((c) => c.deathSaves.state !== 'DEAD' && !participants.some((p) => p.characterId === c.id))
: pickType === 'monster'
? (monsters ?? [])
: (npcs ?? []).filter((n) => !participants.some((p) => p.npcId === n.id));
@@ -624,7 +902,7 @@ export function EncounterPage() {
if (!pickId) return;
setFormError(null);
addParticipant.mutate(
{ [`${pickType}Id`]: pickId, initiative: Number(initiative) || 0 },
{ [`${pickType}Id`]: pickId, initiativeRoll: initiative ? Number(initiative) : undefined },
{ onError, onSuccess: () => setPickId('') }
);
}
@@ -634,21 +912,42 @@ export function EncounterPage() {
key={p.id}
participant={p}
isDM={isDM}
mode={encounter.mode}
isActive={p.id === active?.id}
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
canRollInitiative={isDM || (p.character != null && p.character.userId === user?.id)}
targetedBy={targetIds.includes(p.id) ? (active?.displayName ?? '') : undefined}
acPenalty={areaPenalty(targets.length, acStep)}
onTarget={
// Nobody targets themselves: the combatant on turn has no button.
canTarget && p.id !== active?.id
? () => setTargets.mutate(targetIds.includes(p.id) ? targetIds.filter((id) => id !== p.id) : [...targetIds, p.id], fail)
: undefined
}
onRemove={() => removeParticipant.mutate(p.id, fail)}
onError={onError}
/>
);
// A quest scene is grouped by who is there rather than by initiative.
// In a battle under way the turn passes down the initiative order, so
// everyone above whoever is on turn has had their go this round. They drop
// to "Next Round" at the bottom, which leaves the combatant on turn at the
// top with those still to act beneath them. A new round puts everyone back.
const turnIndex = active ? participants.indexOf(active) : -1;
const hasActed = (p: EncounterParticipant) => participants.indexOf(p) < turnIndex;
// A quest scene is grouped by who is there; within each group the order is
// still initiative, highest first.
const groups: [string, EncounterParticipant[]][] = isBattle
? [['Initiative order', participants]]
? [
['Initiative order', standing.filter((p) => !hasActed(p))],
['Next Round', standing.filter(hasActed)],
]
: [
['People you meet', participants.filter((p) => p.kind === 'NPC')],
['The party', participants.filter((p) => p.kind === 'CHARACTER')],
['Creatures', participants.filter((p) => p.kind === 'MONSTER')],
['People you meet', standing.filter((p) => p.kind === 'NPC')],
['The party', standing.filter((p) => p.kind === 'CHARACTER')],
['Creatures', standing.filter((p) => p.kind === 'MONSTER')],
];
if (showDead) groups.push(['Out of the fight, hidden from players', outOfTheFight]);
return (
<div>
@@ -661,6 +960,7 @@ export function EncounterPage() {
<span className="badge badge-dm">{MODE_LABELS[encounter.mode]}</span>
<span className={`badge ${encounter.status === 'ACTIVE' ? 'badge-live' : ''}`}>{STATUS_LABELS[encounter.status]}</span>
{isBattle && <span className="badge">Round {encounter.currentRound}</span>}
{encounter.location && <span className="badge">📍 {encounter.location.name}</span>}
{encounter.archivedAt && <span className="badge">Archived</span>}
{active && <span className="turn-marker">▶ {active.displayName}'s turn</span>}
</div>
@@ -679,6 +979,19 @@ export function EncounterPage() {
<option value="BATTLE">Battle</option>
<option value="QUEST">NPC quest</option>
</select>
<select
aria-label="Location"
title="Where this scene takes place. Its characters move here when it begins."
value={encounter.locationId ?? ''}
onChange={(e) => patchEncounter.mutate({ locationId: e.target.value || null }, fail)}
>
<option value="">No location</option>
{locations?.map((loc) => (
<option key={loc.id} value={loc.id}>
📍 {loc.name}
</option>
))}
</select>
{encounter.status === 'PLANNING' && (
<button className="btn btn-primary" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' }, fail)}>
{isBattle ? 'Begin Battle' : 'Begin Quest'}
@@ -757,7 +1070,51 @@ export function EncounterPage() {
<div className="encounter-layout mt-md">
<div>
{participants.length === 0 && (
{isDM && targets.length > 0 && (
<AttackBar
attacker={active}
targets={targets}
acStep={acStep}
onApply={(changes) => Promise.all(changes.map((change) => patchParticipant.mutateAsync(change))).catch(onError)}
onRemove={(id) => setTargets.mutate(targetIds.filter((x) => x !== id), fail)}
onClear={() => setTargets.mutate([], fail)}
/>
)}
{!isDM && fallen.length > 0 && (
<div className="card fallen-bar">
<div className="card-title">☠ Fallen</div>
{fallen.map((f) => (
<div key={f.participantId} className="flex-between flex-wrap">
<span className="combatant-name">{f.name}</span>
{canTryRevive && (
<button
className="btn"
title={`${active?.displayName} tries to bring ${f.name} back, at 1 HP.`}
disabled={revive.isPending}
onClick={() => {
setFormError(null);
revive.mutate(f.participantId, fail);
}}
>
Try revive
</button>
)}
</div>
))}
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>
{canTryRevive
? `${active?.displayName} has a spell that can bring back the dead.`
: 'A character with a spell that brings back the dead can try to revive them on their own turn.'}
</p>
</div>
)}
{isDM && outOfTheFight.length > 0 && (
<label className="checkbox-row mt-md" title="Dead characters, and monsters and NPCs at 0 HP. Players never see them.">
<input type="checkbox" checked={showDead} onChange={(e) => setShowDead(e.target.checked)} />
Show dead ({outOfTheFight.length})
</label>
)}
{participants.length === 0 && fallen.length === 0 && (
<div className="empty-state card">
{isDM
? 'Nobody here yet. Add characters, monsters or NPCs from the panel alongside.'
@@ -803,16 +1160,16 @@ export function EncounterPage() {
</select>
</div>
<div className="flex-row gap-sm mt-sm">
{isBattle && (
<input
type="number"
placeholder="Initiative"
aria-label="Initiative"
<NumberInput
min={1}
max={20}
placeholder="Initiative roll (d20)"
title="What the d20 showed, 1 to 20. Leave empty to roll later; the bonus is added for you."
aria-label="Initiative roll"
value={initiative}
onChange={(e) => setInitiative(e.target.value)}
style={{ flex: 1, minWidth: 0 }}
/>
)}
<button className="btn btn-primary" onClick={handleAdd} disabled={!pickId || addParticipant.isPending}>
Add
</button>
@@ -825,6 +1182,16 @@ export function EncounterPage() {
</div>
)}
<TradePanel
encounter={encounter}
isDM={isDM}
userId={user?.id}
characters={builtCharacters}
weapons={weapons ?? []}
items={items ?? []}
onError={onError}
/>
<RewardsPanel
encounter={encounter}
isDM={isDM}
+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.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.0.0",
"version": "1.10.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.0.0",
"version": "1.10.0",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "0.0.0",
"version": "1.10.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.0.0",
"version": "1.10.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [
@@ -10,6 +10,7 @@
"scripts": {
"dev:backend": "npm run dev --workspace backend",
"dev:frontend": "npm run dev --workspace frontend",
"test:backend": "npm run test --workspace backend"
"test:backend": "npm run test --workspace backend",
"seed:hollowcrown": "npm run seed:hollowcrown --workspace backend"
}
}