Compare commits

...
16 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 0124e3bc53 V1.11.0: gear that applies itself, equipping that matters, a character panel
- Weapons and items can carry effects: bonuses and penalties to AC, speed,
  initiative, max HP, passive Perception, ability scores, saving throws
  and skills, plus a note. They apply on their own while equipped and stop
  when the thing comes off. Armor is an item with an AC effect. The DM
  sets them with "Effects" on a row in the weapon or item pool.
- The sheet's "armor bonus" is now "Extra AC", the DM's own adjustment on
  top of gear, and only the DM can set it.
- Only what is equipped counts: a weapon has to be equipped to be used in
  an encounter. A character can have two things equipped at once, one of
  them a weapon (campaign settings). Equipping at the limit is refused
  with a message, and can be changed at any time, mid-encounter included.
- A built-in item library: armor, shields, worn gear and supplies, with
  their effects set. The weapon library gains paired weapons (a pair of
  daggers is one weapon).
- A floating character button on every page opens a panel with a
  character's numbers and everything they hold, with equip ticks. Players
  get their own characters, the DM everyone's, and each character card in
  an encounter has an Inventory button. It replaces the quick stats drawer.
- A player's own characters stand out: marked and highlighted in
  encounters, with a button that jumps to their card; highlighted on the
  party screen; and listed once, not twice, on the campaign page.
- All packages at version 1.11.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:03:53 +02:00
joshuaandClaude Opus 5.5 2dcb41ae4a V1.10.1: a phone-friendly layout, easier navigation, paged encounters
- The main destinations are always on screen: Campaigns, Campaign and
  Party (for the campaign last visited, from any page) and Account. They
  are links along the top bar on a wide screen, and a bar along the
  bottom on a phone or small tablet (900px or less).
- Nothing floats over the page on a phone: the dice roller is the last
  button of the bottom bar, and quick stats sits in the top bar.
- The campaign page and the character sheet start with a row of links to
  their sections.
- On a small screen every table is a stack of labelled cards, rows of
  controls wrap, fields shrink to fit, and things to tap are bigger.
- The encounter list is paged, ten to a page, as is the archived list.
  Moving an encounter still works across pages.
- Text boxes with no type given, and native checkboxes and dropdowns, now
  match the dark theme. Long monster notes and item descriptions wrap
  inside their table cell.
- All packages at version 1.10.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:37:31 +02:00
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
joshuaandClaude Opus 5.5 28e1a84b64 V1.2.0: archive/delete campaigns and encounters, encounter sorting, change password, fuller seed
- Campaigns: DM can archive/restore; owner can delete. Archived campaigns
  move off the main list and stop accepting invite codes.
- Encounters: DM can archive/restore and delete, and arrange the campaign's
  own order; the list can also be sorted by name, status, type or date.
  Archived encounters are hidden from players' lists.
- Account page with change password (POST /auth/password).
- Seed is now additive (safe on a live database; --reset for the old wipe)
  and builds a fuller Aldenfell: earned weapons, item pool, spells, NPCs,
  and 28 battle/quest scenes with descriptions, DM notes and rewards.
- Vite preview host config for the deployed site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:58:54 +02:00
79 changed files with 13204 additions and 884 deletions
+1
View File
@@ -6,3 +6,4 @@ build/
*.log
.DS_Store
.idea/
temp/
+52 -8
View File
@@ -2,7 +2,7 @@
A web app for a D&D 5e Dungeon Master and their players to run a campaign
together: campaign management, a guided dice-roll character builder, live
character sheets, a mid-combat "quick stats" panel, weapons/inventory/spells,
character sheets, a character panel on every page, weapons/inventory/spells,
monsters, and a real-time encounter/combat tracker.
## How it works
@@ -12,12 +12,12 @@ monsters, and a real-time encounter/combat tracker.
| Document | Covers |
|---|---|
| `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/WEAPONS.md` | The weapon pool and library, equipping, 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 and library, armor and other gear effects, what can be equipped, 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
@@ -25,6 +25,27 @@ version was built from. The app has since moved on in places (gear levels,
the builder's story steps, picking a motivation); the documents above are the
current behaviour.
## Getting around
The same four destinations are always on screen: **Campaigns** (the list),
**Campaign** and **Party** (for the campaign you were last in, from any page,
including a character sheet or an encounter) and **Account**.
- On a wide screen they are links along the top bar, and the dice roller
floats at the bottom left.
- On a phone or small tablet (900px wide or less) they are a bar along the
bottom of the screen, with the **dice roller** as its last button. Nothing
floats over the page: the 🧙 character button sits in the top bar.
- The **🧙 character button** is on every page (floating at the bottom right
on a wide screen). It opens a panel with your character's numbers and
everything they hold; the DM can open it for any character.
- The campaign page and the character sheet are long, so each starts with a
row of **section links** (Characters, Encounters, NPCs, Monsters...) that
jump straight to that part of the page.
- On a small screen every table (weapons, items, spells, monsters, the party
overview) is shown as a stack of cards, one per row, with each figure
labelled, and rows of controls wrap rather than run off the side.
## Stack
- **Backend:** Node.js + Express, JavaScript, Prisma ORM, PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation.
@@ -44,7 +65,7 @@ npm install # installs both workspaces (backend + frontend)
cd backend
cp .env.example .env # edit DATABASE_URL and JWT_SECRET
npx prisma migrate dev --name init
npx prisma db seed # creates a demo campaign + demo users (see output for login/invite code)
npx prisma db seed # adds the admin account + the Aldenfell campaign if missing (see output for login/invite code)
# Frontend
cd ../frontend
@@ -60,10 +81,33 @@ npm run dev:backend # Express + Socket.io on http://localhost:4000
npm run dev:frontend # Vite dev server, prints its own port (5173 by default)
```
Open the frontend URL, register an account (or log in with the seeded demo
users — see the seed script's console output for credentials and invite
Open the frontend URL, register an account (or log in with the seeded admin
account — see the seed script's console output for credentials and invite
code), and start playing.
The seed only adds: it never touches an existing account or campaign, so it is
safe to run on a server with real data. It skips the campaign if the admin
already has an active "Aldenfell"; archive or delete that one in the app
(Campaign Settings) and run it again for a fresh copy. `SEED_ADMIN_EMAIL`,
`SEED_ADMIN_PASSWORD` and `SEED_ADMIN_NAME` set the account it creates.
`npx prisma db seed -- --reset` wipes every user and campaign first.
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
("Account" in the navigation).
## Tests
```bash
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.0.0",
"version": "1.11.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,2 @@
-- AlterTable: a campaign can be put away without deleting it
ALTER TABLE "campaigns" ADD COLUMN "archivedAt" TIMESTAMP(3);
@@ -0,0 +1,12 @@
-- AlterTable: an encounter can be put away, and has a place in the campaign's own order
ALTER TABLE "encounters" ADD COLUMN "archivedAt" TIMESTAMP(3);
ALTER TABLE "encounters" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
-- Existing encounters keep the order the list has always shown: newest first.
UPDATE "encounters" AS e
SET "sortOrder" = r.pos
FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY "campaignId" ORDER BY "createdAt" DESC, id) - 1 AS pos
FROM "encounters"
) AS r
WHERE e.id = r.id;
@@ -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;
@@ -0,0 +1,7 @@
-- AlterTable: a weapon or item can change the numbers on the sheet of whoever
-- has it equipped. `effects` is a list of { stat, amount }; `effectNote` is
-- for what it does that is not a number ("Disadvantage on Stealth").
ALTER TABLE "weapons" ADD COLUMN "effects" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "weapons" ADD COLUMN "effectNote" TEXT;
ALTER TABLE "items" ADD COLUMN "effects" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "items" ADD COLUMN "effectNote" TEXT;
@@ -0,0 +1,4 @@
-- AlterTable: how much a character can have equipped at once. Only equipped
-- gear can be used in an encounter or gives its effects.
ALTER TABLE "campaigns" ADD COLUMN "maxEquipped" INTEGER NOT NULL DEFAULT 2;
ALTER TABLE "campaigns" ADD COLUMN "maxEquippedWeapons" INTEGER NOT NULL DEFAULT 1;
+125 -4
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,7 +168,21 @@ 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)
// How much a character can have equipped at once: weapons and items
// together, and weapons alone. Only equipped gear can be used in an
// encounter or gives its effects, so this is what a character can bring to
// bear at one time. A pair of daggers is one weapon, made as such.
maxEquipped Int @default(2)
maxEquippedWeapons Int @default(1)
ownerId String
// Set while the campaign is archived: kept in full, but moved off the main
// list and closed to new members until the DM restores it.
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -170,6 +194,7 @@ model Campaign {
monsters Monster[]
npcs Npc[]
encounters Encounter[]
saleOffers SaleOffer[]
notes Note[]
locations Location[]
@@ -185,10 +210,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
@@ -233,13 +276,23 @@ model Character {
currentHp Int @default(0)
tempHp Int @default(0)
armorBonus Int @default(0) // flat AC bonus from equipped armor/shield
// 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) // a flat AC adjustment the DM sets by hand, on top of what equipped gear gives
speedOverride Int?
// The next three are composed by the builder from the player's picks
// (game/data/characterOptions.js). After that only the DM can edit them.
specialAbilities String? // shown in the quick-stats panel
// The purse: each coin (and cash) is counted on its own and only changes through the
// money ledger (GoldTransaction).
gold Int @default(0)
silver Int @default(0)
copper Int @default(0)
cash Int @default(0) // whole dollars
background String?
appearance String?
@@ -268,6 +321,7 @@ model Character {
spells CharacterSpell[]
notes Note[]
encounterEntries EncounterParticipant[]
locationVisits CharacterLocationVisit[]
@@map("characters")
}
@@ -320,11 +374,17 @@ model Weapon {
// Offered in the character builder's starting-equipment step. Off by default:
// the DM opts entries in, so the pool can hold gear that must be earned.
isStartingOption Boolean @default(false)
// What it does to the sheet of whoever has it equipped: a list of
// { stat, amount } (game/calculations.js, GEAR_STATS), and a note for
// anything that is not a number.
effects Json @default("[]")
effectNote String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
assigned CharacterWeapon[]
rewards EncounterReward[]
offers SaleOffer[]
@@map("weapons")
}
@@ -354,11 +414,16 @@ model Item {
damageScaling Int @default(1)
minLevel Int @default(1)
isStartingOption Boolean @default(false)
// As on a weapon: what it does to whoever has it equipped. Armor is an
// item with an AC effect.
effects Json @default("[]")
effectNote String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
held InventoryItem[]
rewards EncounterReward[]
offers SaleOffer[]
@@map("items")
}
@@ -379,12 +444,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())
@@ -412,6 +479,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[]
@@ -475,10 +545,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")
}
@@ -492,14 +567,28 @@ 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?
// Place in the campaign's own order, lowest first. The DM rearranges it.
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
participants EncounterParticipant[]
rewards EncounterReward[]
wares SaleOffer[]
@@map("encounters")
}
@@ -511,7 +600,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)
@@ -528,6 +618,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
@@ -540,6 +657,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],
});
+25 -103
View File
@@ -1,110 +1,32 @@
// Resets the database and seeds the Aldenfell campaign (see
// docs/Aldenfell - Full Campaign Book.docx and seed-data/aldenfell.js).
// 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.
//
// DESTRUCTIVE: every user, campaign, character, note, etc. is deleted first.
// npx prisma db seed add what is missing
// npx prisma db seed -- --reset DESTRUCTIVE: delete every user, campaign,
// character, note, etc. first
//
// 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 weapons = require(path.join(__dirname, '..', 'seed-data', 'weapons.json'));
const aldenfell = require(path.join(__dirname, '..', 'seed-data', 'aldenfell.js'));
const ADMIN_EMAIL = 'joshua@crosscode.co.za';
const ADMIN_PASSWORD = '12345678';
async function resetDatabase() {
// Truncating the two roots cascades through every table that references them.
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
}
async function main() {
await resetDatabase();
const admin = await prisma.user.create({
data: {
email: ADMIN_EMAIL,
passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12),
displayName: 'Joshua',
},
});
const campaign = await prisma.campaign.create({
data: {
...aldenfell.campaign,
inviteCode: generateInviteCode(),
ownerId: admin.id,
members: { create: { userId: admin.id, role: 'DM' } },
},
});
await prisma.weapon.createMany({ data: weapons.map((w) => ({ ...w, isStartingOption: true, campaignId: campaign.id })) });
await prisma.location.createMany({
data: aldenfell.locations.map((l) => ({ ...l, campaignId: campaign.id })),
});
const monsterByName = {};
for (const monster of aldenfell.monsters) {
const created = await prisma.monster.create({ data: { ...monster, campaignId: campaign.id } });
monsterByName[created.name] = created;
}
for (const enc of aldenfell.encounters) {
const participants = [];
for (const [name, count] of enc.monsters) {
const monster = monsterByName[name];
if (!monster) throw new Error(`Encounter "${enc.name}" references unknown monster "${name}"`);
for (let i = 1; i <= count; i++) {
participants.push({
monsterId: monster.id,
displayName: count > 1 ? `${monster.name} #${i}` : monster.name,
currentHp: monster.maxHp,
maxHp: monster.maxHp,
isMonster: true,
});
}
}
await prisma.encounter.create({
data: { campaignId: campaign.id, name: enc.name, participants: { create: participants } },
});
}
// Notes list newest-first, so stamp them in reverse to keep book order on screen.
const allNotes = [
...aldenfell.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
...aldenfell.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
];
const now = Date.now();
await prisma.note.createMany({
data: allNotes.map((n, i) => ({
...n,
campaignId: campaign.id,
authorId: admin.id,
createdAt: new Date(now - i * 1000),
})),
});
console.log('Seed complete.');
console.log(` Admin/DM login: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
console.log(
` ${weapons.length} weapons, ${aldenfell.locations.length} locations, ${aldenfell.monsters.length} monsters, ` +
`${aldenfell.encounters.length} encounters, ${allNotes.length} notes`,
);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
run(
{
...aldenfell,
// Everyday weapons are what a new character picks from; the rest are earned.
weapons: [...defaultWeapons.map((w) => ({ ...w, isStartingOption: true })), ...aldenfell.weapons],
items: aldenfell.items.map(({ starting, ...item }) => ({ ...item, isStartingOption: Boolean(starting) })),
spells: [
...aldenfell.spells.starting.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: true })),
...aldenfell.spells.earned.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: false })),
],
},
{ 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 };
+27 -3
View File
@@ -1,4 +1,4 @@
// Writes the full lists (every library spell, weapon and monster, and every
// Writes the full lists (every library spell, weapon, item and monster, and every
// option the character builder offers) into the docs, straight from the data
// files the app itself uses, so the docs cannot drift from the app.
//
@@ -10,7 +10,7 @@
// is written by hand.
const fs = require('fs');
const path = require('path');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../src/game/data/library');
const { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../src/game/data/library');
const options = require('../src/game/data/characterOptions');
const { SPECIES } = require('../src/game/data/species');
const { CLASSES } = require('../src/game/data/classes');
@@ -49,8 +49,31 @@ function weaponLibrary() {
ATTACK_WITH[w.attackAbility],
w.properties.join(', '),
w.rangeNormal ? `${w.rangeNormal}/${w.rangeLong} ft` : null,
[effectList(w.effects), w.effectNote].filter(Boolean).join('. ') || null,
]);
return `### ${category} (${weapons.length})\n\n${table(['Weapon', 'Base damage', 'Type', 'Attacks with', 'Properties', 'Range'], rows)}`;
return `### ${category} (${weapons.length})\n\n${table(['Weapon', 'Base damage', 'Type', 'Attacks with', 'Properties', 'Range', 'While equipped'], rows)}`;
})
.join('\n\n');
}
// "+6 AC, −10 Speed": what a library entry does while equipped.
const STAT_NAMES = { AC: 'AC', SPEED: 'Speed', INITIATIVE: 'Initiative', MAX_HP: 'Max HP', PASSIVE_PERCEPTION: 'Passive Perception', SAVES: 'all saves', SKILLS: 'all skill checks' };
const title = (key) => key.toLowerCase().split('_').map((w, i) => (i > 0 && w === 'of' ? w : w[0].toUpperCase() + w.slice(1))).join(' ');
function statName(stat) {
if (STAT_NAMES[stat]) return STAT_NAMES[stat];
if (stat.startsWith('SAVE_')) return `${stat.slice(5)} saves`;
if (stat.startsWith('SKILL_')) return title(stat.slice(6));
return stat;
}
const effectList = (effects = []) => effects.map((e) => `${signed(e.amount).replace('-', '−')} ${statName(e.stat)}`).join(', ');
function itemLibrary() {
const categories = [...new Set(ITEM_LIBRARY.map((i) => i.category))];
return categories
.map((category) => {
const items = ITEM_LIBRARY.filter((i) => i.category === category);
const rows = items.map((i) => [i.name, effectList(i.effects), i.effectNote, i.damageDice, i.description]);
return `### ${category} (${items.length})\n\n${table(['Item', 'While equipped', 'Note', 'Dice', 'Description'], rows)}`;
})
.join('\n\n');
}
@@ -94,6 +117,7 @@ function builderLists() {
const SECTIONS = {
'SPELLS.md': { 'spell-library': spellLibrary },
'WEAPONS.md': { 'weapon-library': weaponLibrary },
'ITEMS.md': { 'item-library': itemLibrary },
'MONSTERS.md': { 'monster-library': monsterLibrary },
'CHARACTERS.md': { 'builder-lists': builderLists },
};
+409 -20
View File
@@ -1,6 +1,8 @@
// Aldenfell campaign content, transcribed (and condensed where a note would
// otherwise be unreadable) from docs/Aldenfell - Full Campaign Book.docx.
// Consumed by prisma/seed.js. Shapes match the Prisma models minus ids.
// Aldenfell campaign content: the gazetteer, bestiary and notes are condensed
// from the Aldenfell Campaign Book; the gear, item pool, NPC list and scenes
// are built out from it to use everything a campaign can hold.
// Consumed by prisma/seed.js. Shapes match the Prisma models minus ids, except
// where a section's comment says otherwise.
const campaign = {
name: 'Aldenfell',
@@ -129,28 +131,415 @@ const monsters = [
specialTrait: 'Undead — vulnerable to bludgeoning',
notes: 'Ordinary restless dead for the Sunken Vaults’ Restless Ossuary (Room 4). Not tied to the Hollow King; DC 12 Religion identifies that proper deconsecration would settle them.',
},
{
name: 'Feral Guard Dog', challengeRating: '1/8', ac: 12, maxHp: 5, speed: 40,
strScore: 13, dexScore: 14, conScore: 12, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 3, damageDice: '1d6+1',
specialTrait: 'Keen hearing and smell — advantage on Perception checks that rely on them.',
notes: 'Medium beast, 25 XP. Bite +3 (1d6+1 piercing, DC 11 Str or prone). A chapel mastiff gone half-wild in the Sunken Vaults’ Old Crypt Hall (Room 2). Starving, not vicious — DC 12 Animal Handling or a thrown ration ends the fight.',
},
];
// ---------------------------------------------------------------------------
// Encounters — pre-built (PLANNING) for the combat beats of the Campaign Book.
// Each participant is [monster name, count]; instances get "#n" suffixes.
// Weapons the party earns. The everyday weapons (seed-data/weapons.json) are
// seeded as starting options; these are not, so they only reach a character as
// a reward or a gift. damageDice is the damage at minLevel, and a character
// must be at least a held copy's level to use it. damageScaling: 1 standard,
// 2 powerful, 3 legendary.
// ---------------------------------------------------------------------------
const weapons = [
{ name: 'Masterwork Dagger', damageDice: '1d6', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'thrown'], rangeNormal: 20, rangeLong: 60, minLevel: 2, damageScaling: 1 },
{ name: 'Smuggler’s Cutlass', damageDice: '1d8', damageType: 'slashing', attackAbility: 'FINESSE', properties: ['finesse'], rangeNormal: null, rangeLong: null, minLevel: 2, damageScaling: 1 },
{ name: 'Warden’s Longbow', damageDice: '1d10', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'heavy', 'two-handed'], rangeNormal: 150, rangeLong: 600, minLevel: 2, damageScaling: 2 },
{ name: 'Grey Dawn Longsword', damageDice: '1d10', damageType: 'slashing', attackAbility: 'STR', properties: ['versatile (1d12)'], rangeNormal: null, rangeLong: null, minLevel: 3, damageScaling: 2 },
{ name: 'Greyford Halberd', damageDice: '1d12', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'reach', 'two-handed'], rangeNormal: null, rangeLong: null, minLevel: 3, damageScaling: 1 },
{ name: 'Stonejaw Maul', damageDice: '2d8', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['heavy', 'two-handed'], rangeNormal: null, rangeLong: null, minLevel: 4, damageScaling: 2 },
{ name: 'Threndir’s Fang', damageDice: '1d8', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light'], rangeNormal: null, rangeLong: null, minLevel: 5, damageScaling: 2 },
{ name: 'Hollowbane', damageDice: '2d8', damageType: 'radiant', attackAbility: 'FINESSE', properties: ['finesse', 'versatile (2d10)'], rangeNormal: null, rangeLong: null, minLevel: 7, damageScaling: 3 },
];
// ---------------------------------------------------------------------------
// Item pool. Items are descriptive: holding one never changes a number on the
// sheet. `starting: true` offers the item in the character builder.
// ---------------------------------------------------------------------------
const items = [
// Starting kit
{ name: 'Potion of Healing', description: 'A red liquid that glimmers when shaken. Drink it (an action) to regain the hit points shown.', damageDice: '2d4+2', starting: true },
{ name: 'Traveller’s Pack', description: 'Bedroll, mess kit, tinderbox, waterskin and five days of rations.', starting: true },
{ name: 'Rope (50 ft)', description: 'Hempen rope. Holds a climber; DC 17 Strength to burst.', starting: true },
{ name: 'Torches (bundle of 5)', description: 'Each burns for an hour: bright light for 20 feet, dim for 20 more.', starting: true },
{ name: 'Healer’s Kit', description: 'Ten uses. Spend one to stabilise a creature at 0 hit points without a Medicine check.', starting: true },
{ name: 'Thieves’ Tools', description: 'Picks, a small file and a mirror on a handle. Needed to pick a lock or disarm a trap.', starting: true },
{ name: 'Shield', description: 'A wooden or steel shield, +2 Armor Class while carried. Ask the DM to add it to your armor bonus.', starting: true },
{ name: 'Leather Armor', description: 'Boiled leather, Armor Class 11 + Dexterity. Ask the DM to set your armor bonus.', starting: true },
{ name: 'Spellcasting Focus', description: 'A wand, holy symbol, crystal or carved staff that your spells are cast through.', starting: true },
{ name: 'Founding Fair Token', description: 'A stamped brass token from the Lantern Market. Good for one drink at any stall, or a small favour from whoever ran the game you won it at.', starting: true },
// Found and earned
{ name: 'Potion of Greater Healing', description: 'A deeper red than the common potion. Drink it (an action) to regain the hit points shown.', damageDice: '4d4+4', minLevel: 3 },
{ name: 'Alchemist’s Fire', description: 'A sticky flask that ignites in air. Throw it up to 20 feet: the target burns for the damage shown at the start of each of its turns until someone spends an action putting it out.', damageDice: '1d4' },
{ name: 'Silver Locket', description: 'A tarnished locket from the Old Crypt Hall, worth about 15 gp. The portrait inside has been scratched out.' },
{ name: 'Smugglers’ Ledger Page', description: 'A torn page from a crate in the Sunken Vaults, stamped with a merchant sigil nobody at the Concord Exchange recognises. Half a name survives in the margin.' },
{ name: 'Bell-Ringer’s Keepsake', description: 'A worn brass bell-clapper charm, pressed into your hand with more gratitude than its owner could put into words.' },
{ name: 'Grey Dawn Signet', description: 'An iron ring bearing the Order’s rising sun. Shown at Grey Dawn Hall or to any patrol, it marks you as someone the Order vouches for.' },
{ name: 'Concord Writ of Passage', description: 'A sealed Merchant Concord contract. Waives tolls and gate fees across Aldenfell while the Concord still considers you good for business.' },
{ name: 'Warden’s Charm', description: 'A knot of greywood bark and red thread. The Wardens will know you by it, and the forest’s paths seem a little easier to find while you carry it.' },
{ name: 'Stonejaw Oath-Token', description: 'A river stone the size of a fist, carved with the Stonejaw clan mark. Proof that your word was tested and held.' },
{ name: 'Half-Burned Letter', description: 'Found among a missing person’s belongings in the Root. Most of it is ash. What is left mentions payments, a shrine, and a family crest.' },
{ name: 'Silver Shrine Relic', description: 'A small silver figure lifted from black water beneath the Greywood, worth about 75 gp. Its style is older than the kingdom.' },
{ name: 'Sealed Letter', description: 'Heavy paper, an unbroken wax seal, and a servant’s whispered warning about who must never know you have it.' },
{ name: 'Root-Fiber Scrap', description: 'A length of pale root that is still, very slightly, moving. It is cold, and it does not rot.' },
{ name: 'Seal-Stone Fragment', description: 'A shard of worked stone from the edge of the breach. Script runs along one face. It is warm, and holding it makes the bell script easier to look at.' },
];
// ---------------------------------------------------------------------------
// Spells, pulled from the default library (src/game/data/spellsLibrary.js) by
// key. A new character can only pick starting spells of their own level or
// lower, so the starting set is cantrips and 1st level; the rest are for the
// DM to teach later.
// ---------------------------------------------------------------------------
const spells = {
starting: [
'chill-touch', 'eldritch-blast', 'fire-bolt', 'guidance', 'light', 'mage-hand', 'minor-illusion',
'prestidigitation', 'produce-flame', 'ray-of-frost', 'sacred-flame', 'shocking-grasp', 'spare-the-dying',
'thorn-whip', 'vicious-mockery',
'bless', 'burning-hands', 'charm-person', 'command', 'cure-wounds', 'detect-magic', 'entangle', 'faerie-fire',
'guiding-bolt', 'healing-word', 'mage-armor', 'magic-missile', 'shield', 'sleep', 'thunderwave',
],
earned: [
'hold-person', 'invisibility', 'lesser-restoration', 'misty-step', 'moonbeam', 'scorching-ray', 'shatter',
'spiritual-weapon',
'counterspell', 'daylight', 'dispel-magic', 'fireball', 'remove-curse', 'revivify',
'banishment',
],
};
// ---------------------------------------------------------------------------
// NPCs. `description` is what the party sees once the NPC is in a scene;
// `dmNotes` never leaves the DM's screen. Combat stats only where a fight is
// plausible.
// ---------------------------------------------------------------------------
const npcs = [
{
name: 'King Aldric Veyrmont', role: 'King of Aldenfell', disposition: 'NEUTRAL',
description: 'An aging king with a soldier’s posture and tired eyes. Courteous, proud, and quick to steer talk away from anything that sounds like bad news.',
dmNotes: 'Tired, proud, in denial — cornered, not ill. Pays well for quiet problem-solving and reacts badly to blunt truth. An audience needs a sponsor (a knight, a noble, a Concord representative). The crown fractures publicly at Stage 3.',
},
{
name: 'Princess Isolde Veyrmont', role: 'Heir to the throne', disposition: 'FRIENDLY',
description: 'Sharp, pragmatic and well liked. She keeps a small private study at the palace and has a habit of asking capable outsiders exactly the right question.',
dmNotes: 'The best long-term ally, and she recruits outsiders on purpose. Already suspects something is deeply wrong, so she is the most likely to believe the party early. Reporting to her at E10B sets up Act 3’s politics. Rumour says she will move against her father — misleading.',
},
{
name: 'Ser Kavan Thorne', role: 'Knight-Commander of the Grey Dawn', disposition: 'FRIENDLY',
description: 'Rigid, honorable and loyal to a fault. He runs Grey Dawn Hall by the book and judges people by whether they do what they said they would.',
dmNotes: 'Recruits the party (E4A → Grey Dawn track). SECRET: the Order’s founding is tied to the original sacrifice, and Thorne does not know. He struggles when evidence points at the crown; whether and how the party tells him is theirs to choose. If Ressa Vane escorted them (E9C) he already suspects by E10B.',
ac: 18, maxHp: 65, attackBonus: 6, damageDice: '1d8+4',
},
{
name: 'Mireille Costa', role: 'Merchant Concord representative', disposition: 'NEUTRAL',
description: 'Charming and transactional, usually found at the Concord Exchange with a contract already drafted. She always has a job, and she always pays what she promised.',
dmNotes: 'Source of paying side work (E4B → The Smugglers’ Tithe). The Concord is neutral and pragmatic, and drops anyone who becomes bad for business — including the party.',
},
{
name: 'Ashwen', role: 'Warden of the Greywood', disposition: 'NEUTRAL',
description: 'Terse and watchful, with a longbow that never seems to be out of reach. She answers to the forest, not to the crown.',
dmNotes: 'Suspicious, not hostile — a slow-burn ally. The first to notice the old shrines acting strangely (E4C → The Warden’s Silence). After that quest she is convinced something is deeply wrong, whatever happened to her Warden.',
ac: 15, maxHp: 44, attackBonus: 6, damageDice: '1d8+3',
},
{
name: 'Threndir Ashclaw', role: 'Old copper dragon of the Spine', disposition: 'NEUTRAL',
description: 'Vast, scarred and weary, coiled in an eyrie high on the eastern peaks. He grants an audience to anyone who approaches openly, and he prefers words to fire.',
dmNotes: 'Blunt, and wants the kingdom to survive. He wants proof the crown can still be trusted — the intended “win” is a negotiation (Persuasion DC 15–18). Always offers to talk unless attacked first. If it does come to a fight, use the “Threndir Ashclaw (old copper dragon)” monster stat block.',
},
{
name: 'Korgath Stonejaw', role: 'Chieftain of the Stonejaw clan', disposition: 'NEUTRAL',
description: 'A hill giant chieftain with a voice like a rockslide and no patience for politics. Outsiders in his hold are tested, not attacked.',
dmNotes: 'Honor-bound: trials, contests and bargains, never ambushes. Offers the Trial of Stone instead of an agreement; success is a standing alliance (Spine Clans track), failure earns a second, harder trial. Nothing sinister here — it is a genuine test of character.',
ac: 13, maxHp: 105, attackBonus: 8, damageDice: '3d8+5',
},
{
name: 'Vessa Hollow', role: 'A voice in the dark', disposition: 'NEUTRAL',
description: 'A woman’s voice — calm, patient, almost kind. She does not blink. She speaks as though she has been waiting a very long time and can wait a little longer.',
dmNotes: 'SECRET: one of the people Highcrown’s founders sacrificed, and the Hollow King’s sympathetic “face”. She believes waking the rest is justice. Unsettling, never mustache-twirling; DC 16 Insight confirms she believes what she says. Use her in dreams and omens long before she appears. Negotiating with her is a legitimate outcome. She does not fight — any combat near her uses Hollowed guardians.',
},
{
name: 'Corporal Ressa Vane', role: 'Grey Dawn warden escort', disposition: 'FRIENDLY',
description: 'A steady, plain-spoken corporal of the Grey Dawn, assigned to see you safely through the tunnels. She writes things down.',
dmNotes: 'Only appears if the party took Grey Dawn recruitment (E4A → E9C). She lets them skip to Root Room 2, and she reports everything back to Grey Dawn Hall — which matters at E10B and E15.',
ac: 16, maxHp: 27, attackBonus: 4, damageDice: '1d8+2',
},
{
name: 'The Bell-Ringer', role: 'Bell-ringer of the Founding Chapel', disposition: 'FRIENDLY',
description: 'A stooped, soft-spoken man who has rung the chapel bell every hour for most of his life. Badly shaken, and very grateful to see a friendly face.',
dmNotes: 'Found alive in Vault Room 5 (E3). He saw “someone who looked like his neighbor, except wrong”. Returns in The Bell-Ringer’s Debt: he owes a Highcrown lender who operates under the corrupted house’s protection (not knowable yet). A loyal low-key ally either way.',
},
{
name: 'A Frightened Local', role: 'Witness outside the chapel', disposition: 'NEUTRAL',
description: 'A market porter who was passing the chapel when the bell stopped. Pale, talking too fast, and not keen to go back inside.',
dmNotes: 'DC 12 Persuasion or Insight gets the account of “a woman who wasn’t blinking”. If the party talks to the crowd first (E2C) they get three witness accounts but no script lead, and their E10 Insight check is at advantage.',
},
{
name: 'The Lender', role: 'Highcrown moneylender', disposition: 'HOSTILE',
description: 'A neat, unhurried man who keeps his ledgers tidier than his conscience. He never raises his voice; he has collectors for that.',
dmNotes: 'Holds the bell-ringer’s debt. Operates under the corrupted noble house’s protection — digging into who backs him is harder but plants a clue toward the house before Act 2. Can be paid off, intimidated, or exposed.',
ac: 12, maxHp: 22, attackBonus: 3, damageDice: '1d4+1',
},
{
name: 'Greyford’s Commander', role: 'Commander of Greyford Keep', disposition: 'NEUTRAL',
description: 'An exhausted officer holding a border fortress with half the troops and a third of the supplies it needs. Blunt, overworked, and out of patience.',
dmNotes: 'Wants the Sorrow Camp “moved along” — and is not wrong about the strategic liability. No clean villain in Refugees of the Marches. Whether Greyford is supplied (The Smugglers’ Tithe outcome) decides rescue vs recovery at E11 and whether the keep holds at E16.',
ac: 17, maxHp: 52, attackBonus: 5, damageDice: '1d8+3',
},
];
// ---------------------------------------------------------------------------
// 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.
// 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.
// npcs — NPC names.
// rewards — lined up unawarded, so they stay the DM's secret until
// handed out. { gold } | { weapon } | { item }, with optional
// quantity, level (defaults to the entry's starting level) and
// note.
// ---------------------------------------------------------------------------
const encounters = [
{ name: 'E3 — Sunken Vaults: The Restless Ossuary', monsters: [['Skeleton', 2]] },
{ name: 'E6 — The Bell-Ringer’s Debt: Collectors', monsters: [['Highcrown Cutthroat', 3]] },
{ name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', monsters: [['Bandit Captain', 1], ['Highcrown Cutthroat', 4]] },
{ name: 'Side Quest — The Warden’s Silence: Direwolf Pack', monsters: [['Greywood Direwolf', 3]] },
{ name: 'Side Quest — The Warden’s Silence: The Shrine', monsters: [['Hollowed', 1]] },
{ name: 'Side Quest — What the Tide Brought In: The Reef', monsters: [['Reef Terror', 1]] },
{ name: 'E9 — The Root: Weeping Passage', monsters: [['Bellcrawler', 1]] },
{ name: 'E9 — The Root: Hollowed Nest', monsters: [['Hollowed', 2]] },
{ name: 'E9 — The Root: Root Stalker’s Warren', monsters: [['Root Stalker', 1]] },
{ name: 'E9 — The Root: Bellcrawler Gallery', monsters: [['Bellcrawler', 3]] },
{ name: 'E12 — A Line Thins', monsters: [['Root Stalker', 1]] },
{ name: 'E14 — The Break', monsters: [['Hollowed', 4]] },
{ name: 'Mid-campaign scare — The Hollow King’s Shadow', monsters: [['The Hollow King’s Shadow', 1]] },
{ name: 'E25 — The Last Approach', monsters: [['Root Stalker', 1], ['Bellcrawler', 2]] },
{
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', 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', 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', 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', 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'],
rewards: [
{ gold: 40, note: 'Room 3 — trade goods from the smugglers’ cache' },
{ item: 'Smugglers’ Ledger Page', note: 'Room 3 — DC 14 Investigation' },
],
},
{
name: 'E4 — Aftermath & Faction Hooks', mode: 'QUEST',
description: 'Word travels fast in Highcrown. By the next morning three different people have sent for you, and each of them has work.',
dmNotes: 'All open doors; they can run in parallel.\n• E4A Grey Dawn recruitment (Kavan) → Grey Dawn ending track; unlocks Ressa Vane at E9C.\n• E4B Concord job board (Mireille) → The Smugglers’ Tithe.\n• E4C Warden contact (Ashwen) → The Warden’s Silence.\n• E4D Caravan to the Spine → Trial of Stone → Spine Clans track.\n• E4E Investigate independently (DC 15 Investigation) → Independent track.\nAfter two or more threads and one E5 omen, advance the clock to Stage 2 (E8).',
npcs: ['Ser Kavan Thorne', 'Mireille Costa', 'Ashwen'],
rewards: [
{ item: 'Grey Dawn Signet', note: 'E4A — if they accept recruitment' },
{ item: 'Concord Writ of Passage', note: 'E4B — on taking Mireille’s contract' },
],
},
{
name: 'E6 — The Bell-Ringer’s Debt: Collectors', mode: 'BATTLE',
description: 'Three hard-faced men are waiting outside the bell-ringer’s door when you arrive. They are not here to hear him ring.',
dmNotes: 'Optional side quest, Lv 1–2. The collectors are ordinary desperate people: kill them, scare them off, or find out why they are desperate. Beats: hear the bell-ringer out → track the collectors → pay the debt, intimidate the lender, or dig into who backs him (harder, more rewarding — a clue toward the corrupted house before Act 2).',
monsters: [['Highcrown Cutthroat', 3]],
npcs: ['The Bell-Ringer', 'The Lender'],
rewards: [
{ gold: 30, note: 'The collectors’ take for the week' },
{ item: 'Bell-Ringer’s Keepsake', note: 'Given once the debt is dealt with' },
],
},
{
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', 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]],
rewards: [
{ gold: 60, note: 'The tithe chest' },
{ weapon: 'Smuggler’s Cutlass', note: 'The Captain’s own blade' },
],
},
{
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', 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']],
rewards: [
{ item: 'Warden’s Charm', note: 'From Ashwen, whatever the outcome' },
{ weapon: 'Warden’s Longbow', note: 'The missing Warden’s bow — Ashwen says it should be used' },
],
},
{
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']],
npcs: ['Korgath Stonejaw'],
rewards: [
{ item: 'Stonejaw Oath-Token', note: 'On passing the trial' },
{ weapon: 'Stonejaw Maul', note: 'Feast-gift for whoever wrestled the champion' },
],
},
{
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]],
npcs: ['Greyford’s Commander'],
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', 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]],
rewards: [
{ gold: 80, note: 'Tidewatch’s collection for whoever cleared the reef' },
{ item: 'Potion of Healing', quantity: 2, note: 'From the harbour apothecary' },
],
},
{
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', 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', 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]],
rewards: [
{ gold: 20, note: 'Personal effects' },
{ item: 'Half-Burned Letter', note: 'DC 14 Investigation' },
],
},
{
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]],
rewards: [
{ gold: 60, note: 'From what the roots grew around' },
{ weapon: 'Masterwork Dagger', note: 'Still in a dead hand' },
],
},
{
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]],
rewards: [
{ gold: 90, note: 'Scattered along the ledges' },
{ item: 'Potion of Healing', note: 'In a fallen pack' },
],
},
{
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', 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'],
rewards: [
{ gold: 70, note: 'Trade goods in the keep’s stores (2d6×10 gp)' },
{ weapon: 'Greyford Halberd', note: 'From the armoury' },
],
},
{
name: 'E12 — A Line Thins', mode: 'BATTLE',
description: 'The ground splits somewhere you thought was safe, and something comes up through it that should have stayed far below.',
dmNotes: 'A Root Stalker surfaces near a location the party did not secure — pick it from what they left undone. DC 15 Arcana/Religion: this is deliberate erosion from below. The root-fiber scrap is a key item (needed at E14/E18/E19). Then E13: the clock turns to Stage 3.',
monsters: [['Root Stalker', 1]],
rewards: [{ item: 'Root-Fiber Scrap', note: 'Key item — cut from the Stalker' }],
},
{
name: 'E14 — The Break', mode: 'BATTLE',
description: 'The crack has become a breach. People are coming out of it — neighbours, guards, people you have met — walking calmly, smiling, and not blinking.',
dmNotes: 'Two to four Hollowed (scale to the party). DC 15 Arcana: the breach is shaped for something to come through.\n• E14A seal it (DC 17 Arcana or the chapel ritual): closes, but a debt comes due at E17.\n• E14B evacuate instead: no deaths, but the breach stays open, worsening omens until E18.\nThe seal-stone fragment is a key item (needed at E19).',
monsters: [['Hollowed', 4]],
rewards: [{ item: 'Seal-Stone Fragment', note: 'Key item — from the breach’s edge' }],
},
{
name: 'Mid-campaign scare — The Hollow King’s Shadow', mode: 'BATTLE',
description: 'The light goes thin and the air goes cold. Something tall is standing where the wall should be, and the wall does not seem to mind.',
dmNotes: 'DO NOT run as a fair fight. At 0 HP it disperses for the scene and never dies; treat its HP as a ticking clock. Give the party a way out: close a ritual circle, reach sunlight or a warded threshold, or survive N rounds. Echoing dread: DC 15 Wis or frightened within 30 ft. Save its true, killable form for Act 4.',
monsters: [['The Hollow King’s Shadow', 1]],
},
{
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'],
},
{
name: 'E21 — Rally Point', mode: 'QUEST',
description: 'One last gathering before the descent. Whoever is going to stand with you is here now — and they do not all agree about what happens next.',
dmNotes: 'Who shows up depends on the branch list — remove anyone the party lost or alienated.\n• E21A Grey Dawn track: Kavan and his wardens, by the book.\n• E21B Spine Clans track: Korgath’s war-band; advantage on the first Survival check at E22.\n• E21C Independent: no reinforcements; every choice stays theirs.\n• Several tracks: the groups arrive and disagree — the party mediates.',
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne', 'Korgath Stonejaw', 'Ashwen'],
rewards: [{ item: 'Potion of Greater Healing', quantity: 2, note: 'Going-away gift from whoever stands with them' }],
},
{
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', 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]],
},
];
// ---------------------------------------------------------------------------
@@ -414,4 +803,4 @@ The Salt Coast: 1 smugglers wary of witnesses · 2 short-handed fishing crew ·
The Bramble Marches: 1 Bandit Captain’s crew · 2 refugees on the road · 3 tense border patrol · 4 signs of the neighboring power · 5 a skirmish in progress · 6 watchful quiet.`,
];
module.exports = { campaign, locations, monsters, encounters, sharedNotes, dmNotes };
module.exports = { campaign, locations, monsters, weapons, items, spells, npcs, encounters, sharedNotes, dmNotes };
File diff suppressed because one or more lines are too long
+65 -8
View File
@@ -121,12 +121,60 @@ function getClassSpellcastingAbility(characterClass) {
return data ? data.spellcastingAbility : null;
}
// ---------------------------------------------------------------------------
// Gear effects (house rule). A weapon or item in the campaign pools can carry
// effects: signed amounts added to one of the numbers on the sheet. They count
// while a character has the thing equipped and is high enough level to use it;
// holding two of something does not double it.
// ---------------------------------------------------------------------------
// What an effect can change. An ability effect changes the score itself, so
// everything worked out from that ability moves with it.
const GEAR_STATS = [
'AC',
'SPEED',
'INITIATIVE',
'MAX_HP',
'PASSIVE_PERCEPTION',
...ABILITIES,
'SAVES', // every saving throw
...ABILITIES.map((ability) => `SAVE_${ability}`),
'SKILLS', // every skill check
...Object.keys(SKILLS).map((skill) => `SKILL_${skill}`),
];
// The effects in force for a character, each with the name of what gives it:
// [{ stat, amount, source }]. Takes a character with `inventoryItems` (each
// with its item) and `weapons` (each with its weapon) loaded; without them
// there are none. `notes` are the things gear does that are not a number.
function activeGear(character) {
const level = character.level || 1;
const held = [
...(character.inventoryItems || []).map((ii) => ({ ...ii, entry: ii.item })),
...(character.weapons || []).map((cw) => ({ ...cw, entry: cw.weapon })),
].filter((h) => h.entry && h.equipped && canUseGear(level, h.level));
const effects = [];
const notes = [];
for (const { entry } of held) {
for (const effect of Array.isArray(entry.effects) ? entry.effects : []) {
if (GEAR_STATS.includes(effect.stat) && Number.isInteger(effect.amount) && effect.amount !== 0) {
effects.push({ stat: effect.stat, amount: effect.amount, source: entry.name });
}
}
if (entry.effectNote) notes.push({ source: entry.name, note: entry.effectNote });
}
return { effects, notes };
}
/**
* Assembles the full derived character sheet from raw stored data. This is
* the single function the API and quick-stats view should call rather than
* re-deriving any of the above by hand.
*
* @param {object} character - rolled scores, species/class, level, armorBonus, etc.
* With `inventoryItems` and `weapons` loaded (each with its pool entry),
* the effects of equipped gear are applied; see activeGear.
* @param {Array<{skill: string, proficient: boolean, expertise: boolean}>} skillProficiencies
* @param {Array<{ability: string, proficient: boolean}>} saveProficiencies
*/
@@ -150,9 +198,14 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
const halfElfBonusAbilities = [halfElfBonusAbility1, halfElfBonusAbility2].filter(Boolean);
const rolled = { STR: strScore, DEX: dexScore, CON: conScore, INT: intScore, WIS: wisScore, CHA: chaScore };
const gear = activeGear(character);
// The total equipped gear adds to (or takes from) one number.
const fromGear = (...stats) => gear.effects.filter((e) => stats.includes(e.stat)).reduce((sum, e) => sum + e.amount, 0);
const abilities = {};
for (const ability of ABILITIES) {
const final = finalAbilityScore(rolled[ability], ability, species, halfElfBonusAbilities);
// A score never drops below 1, whatever is weighing the character down.
const final = Math.max(1, finalAbilityScore(rolled[ability], ability, species, halfElfBonusAbilities) + fromGear(ability));
abilities[ability] = {
rolled: rolled[ability],
final,
@@ -166,7 +219,7 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
for (const ability of ABILITIES) {
const entry = saveProficiencies.find((s) => s.ability === ability);
const proficient = entry ? entry.proficient : false;
saves[ability] = { proficient, bonus: saveBonus(abilities[ability].modifier, proficient, profBonus) };
saves[ability] = { proficient, bonus: saveBonus(abilities[ability].modifier, proficient, profBonus) + fromGear('SAVES', `SAVE_${ability}`) };
}
const skills = {};
@@ -179,7 +232,7 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
ability,
proficient,
expertise,
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise),
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise) + fromGear('SKILLS', `SKILL_${skillKey}`),
};
}
@@ -189,14 +242,16 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
return {
abilities,
proficiencyBonus: profBonus,
armorClass: armorClass(abilities.DEX.modifier, armorBonus),
initiative: initiative(abilities.DEX.modifier),
speed: effectiveSpeed(species, speedOverride),
hp: { max: hpMax(level, hitDieSize, abilities.CON.modifier) },
armorClass: armorClass(abilities.DEX.modifier, armorBonus) + fromGear('AC'),
initiative: initiative(abilities.DEX.modifier) + fromGear('INITIATIVE'),
speed: Math.max(0, effectiveSpeed(species, speedOverride) + fromGear('SPEED')),
hp: { max: Math.max(1, hpMax(level, hitDieSize, abilities.CON.modifier) + fromGear('MAX_HP')) },
hitDice: { size: hitDieSize, count: level },
saves,
skills,
passivePerception: passivePerception(skills.PERCEPTION.bonus),
passivePerception: passivePerception(skills.PERCEPTION.bonus) + fromGear('PASSIVE_PERCEPTION'),
// What equipped gear is doing to the numbers above, and what else it does.
gear,
spellcasting: spellcastingAbility
? {
ability: spellcastingAbility,
@@ -350,6 +405,8 @@ module.exports = {
getClassSaveProficiencies,
getClassHitDieSize,
getClassSpellcastingAbility,
GEAR_STATS,
activeGear,
computeCharacterSheet,
computeWeaponStats,
MAX_GEAR_LEVEL,
+78
View File
@@ -0,0 +1,78 @@
// Default item library — armor, shields, a few worn pieces and common
// supplies, offered to any campaign so a DM isn't hand-typing them. Shape
// matches the `Item` Prisma model minus `id`/`campaignId`, plus a stable `key`
// for "add to campaign" lookups and a `category` for the library picker.
//
// Armor here is a flat bonus to AC on top of 10 + Dexterity, because that is
// how gear effects work (game/calculations.js, GEAR_STATS): the standard
// rules' "replaces 10 + DEX" and "DEX bonus capped at 2" are not modelled.
// Each suit's bonus is its standard AC less 10, and what the bonus cannot say
// is in the note. A DM who wants it stricter edits the copy in their campaign.
const STEALTH = 'Disadvantage on Stealth checks.';
const armor = (key, name, category, ac, description, note = null, more = []) => ({
key,
name,
category,
description,
damageDice: null,
effects: [{ stat: 'AC', amount: ac }, ...more],
effectNote: note,
});
const gear = (key, name, category, description, effects = [], effectNote = null, damageDice = null) => ({
key,
name,
category,
description,
damageDice,
effects,
effectNote,
});
const ITEM_LIBRARY = [
// Light armor
armor('padded-armor', 'Padded Armor', 'Light Armor', 1, 'Quilted layers of cloth and batting.', STEALTH),
armor('leather-armor', 'Leather Armor', 'Light Armor', 1, 'Boiled leather breastplate and shoulder guards over soft leather.'),
armor('studded-leather', 'Studded Leather', 'Light Armor', 2, 'Tough leather set with close rivets and spikes.'),
// Medium armor
armor('hide-armor', 'Hide Armor', 'Medium Armor', 2, 'Thick furs and pelts.', 'Standard rules: counts at most +2 from Dexterity.'),
armor('chain-shirt', 'Chain Shirt', 'Medium Armor', 3, 'Interlocking rings worn between layers of clothing.', 'Standard rules: counts at most +2 from Dexterity.'),
armor('scale-mail', 'Scale Mail', 'Medium Armor', 4, 'A coat and leggings of leather covered in overlapping metal scales.', `${STEALTH} Standard rules: counts at most +2 from Dexterity.`),
armor('breastplate', 'Breastplate', 'Medium Armor', 4, 'A fitted metal chest piece worn with supple leather.', 'Standard rules: counts at most +2 from Dexterity.'),
armor('half-plate', 'Half Plate', 'Medium Armor', 5, 'Shaped metal plates covering most of the body.', `${STEALTH} Standard rules: counts at most +2 from Dexterity.`),
// Heavy armor: slow, loud, and in the standard rules it ignores Dexterity.
armor('ring-mail', 'Ring Mail', 'Heavy Armor', 4, 'Leather armor with heavy rings sewn into it.', `${STEALTH} Standard rules: adds no Dexterity.`),
armor('chain-mail', 'Chain Mail', 'Heavy Armor', 6, 'Interlocking metal rings over a layer of quilted fabric, with gauntlets.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 13.`, [{ stat: 'SPEED', amount: -10 }]),
armor('splint-armor', 'Splint Armor', 'Heavy Armor', 7, 'Narrow vertical strips of metal riveted to a leather backing.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
armor('plate-armor', 'Plate Armor', 'Heavy Armor', 8, 'Shaped, interlocking metal plates covering the whole body.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
// Shields
armor('shield', 'Shield', 'Shields', 2, 'Wood or metal, carried in one hand.'),
armor('tower-shield', 'Tower Shield', 'Shields', 3, 'A wall of oak and iron, as tall as its bearer.', null, [{ stat: 'SPEED', amount: -5 }]),
// Worn gear
gear('ring-of-protection', 'Ring of Protection', 'Worn Gear', 'A plain band that turns blows aside.', [{ stat: 'AC', amount: 1 }, { stat: 'SAVES', amount: 1 }]),
gear('cloak-of-protection', 'Cloak of Protection', 'Worn Gear', 'A heavy cloak that seems to catch what was meant for you.', [{ stat: 'AC', amount: 1 }, { stat: 'SAVES', amount: 1 }]),
gear('cloak-of-elvenkind', 'Cloak of Elvenkind', 'Worn Gear', 'With the hood up, its colour shifts to match what is behind it.', [{ stat: 'SKILL_STEALTH', amount: 5 }], 'With the hood up, others have disadvantage on Perception checks to see you.'),
gear('boots-of-striding', 'Boots of Striding', 'Worn Gear', 'Light, tireless boots.', [{ stat: 'SPEED', amount: 10 }]),
gear('gauntlets-of-might', 'Gauntlets of Might', 'Worn Gear', 'Iron-banded gauntlets that lend a giant’s grip.', [{ stat: 'STR', amount: 2 }]),
gear('gloves-of-thievery', 'Gloves of Thievery', 'Worn Gear', 'Thin grey gloves that all but vanish when worn.', [{ stat: 'SKILL_SLEIGHT_OF_HAND', amount: 5 }]),
gear('amulet-of-health', 'Amulet of Health', 'Worn Gear', 'A red stone on a golden chain, warm against the skin.', [{ stat: 'CON', amount: 2 }]),
gear('headband-of-intellect', 'Headband of Intellect', 'Worn Gear', 'A silver circlet set with a pale blue stone.', [{ stat: 'INT', amount: 2 }]),
gear('sentinel-charm', 'Sentinel Charm', 'Worn Gear', 'A brass eye on a cord that never quite closes.', [{ stat: 'INITIATIVE', amount: 2 }, { stat: 'PASSIVE_PERCEPTION', amount: 5 }]),
gear('stone-of-good-luck', 'Stone of Good Luck', 'Worn Gear', 'A polished agate that sits well in a pocket.', [{ stat: 'SAVES', amount: 1 }, { stat: 'SKILLS', amount: 1 }]),
// Supplies: nothing to equip, but every pack has them.
gear('healing-potion', 'Healing Potion', 'Supplies', 'A red liquid that glimmers when shaken. Drink it to regain hit points.', [], null, '2d4+2'),
gear('rope', 'Rope (50 ft)', 'Supplies', 'Hempen rope. Holds a climber.'),
gear('torch', 'Torch', 'Supplies', 'Burns for an hour, lighting 20 feet brightly.', [], 'Held in a hand to give light.'),
gear('lantern', 'Hooded Lantern', 'Supplies', 'Bright light for 30 feet; the hood dims it to a glow.', [], 'Held in a hand to give light.'),
gear('rations', 'Rations (1 day)', 'Supplies', 'Dried meat, hard biscuit, nuts.'),
gear('bedroll', 'Bedroll', 'Supplies', 'A blanket roll for sleeping rough.'),
gear('thieves-tools', 'Thieves’ Tools', 'Supplies', 'A file, picks, a small mirror, narrow scissors and pliers.'),
gear('healers-kit', 'Healer’s Kit', 'Supplies', 'Bandages, salves and splints. Steadies a dying creature without a check.'),
];
module.exports = { ITEM_LIBRARY };
+5 -3
View File
@@ -1,7 +1,9 @@
// Combined default-content library — see weaponsLibrary.js / spellsLibrary.js
// / monstersLibrary.js for the individual sets and their shape.
// Combined default-content library — see weaponsLibrary.js / itemsLibrary.js
// / spellsLibrary.js / monstersLibrary.js for the individual sets and their
// shape.
const { WEAPON_LIBRARY } = require('./weaponsLibrary');
const { ITEM_LIBRARY } = require('./itemsLibrary');
const { SPELL_LIBRARY } = require('./spellsLibrary');
const { MONSTER_LIBRARY } = require('./monstersLibrary');
module.exports = { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY };
module.exports = { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY };
+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'] },
];
+9
View File
@@ -3,6 +3,9 @@
// matches the `Weapon` Prisma model minus `id`/`campaignId`, plus a stable
// `key` for "add to campaign" lookups and a `category` for the library
// picker's filter. attackAbility follows the schema's STR|DEX|FINESSE.
//
// A character has one weapon equipped at a time (a campaign setting), so a
// weapon fought with in each hand is a single entry of its own: see "Paired".
const WEAPON_LIBRARY = [
// Simple Melee
@@ -49,6 +52,12 @@ const WEAPON_LIBRARY = [
{ key: 'heavy-crossbow', name: 'Heavy Crossbow', category: 'Martial Ranged', damageDice: '1d10', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'heavy', 'loading', 'two-handed'], rangeNormal: 100, rangeLong: 400 },
{ key: 'longbow', name: 'Longbow', category: 'Martial Ranged', damageDice: '1d8', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'heavy', 'two-handed'], rangeNormal: 150, rangeLong: 600 },
{ key: 'net', name: 'Net', category: 'Martial Ranged', damageDice: '-', damageType: 'special', attackAbility: 'DEX', properties: ['special', 'thrown'], rangeNormal: 5, rangeLong: 15 },
// Paired: one in each hand, as a single weapon. The damage is both blades.
{ key: 'paired-daggers', name: 'Paired Daggers', category: 'Paired', damageDice: '2d4', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'paired'], rangeNormal: null, rangeLong: null, effectNote: 'A dagger in each hand: takes both hands.' },
{ key: 'paired-shortswords', name: 'Paired Shortswords', category: 'Paired', damageDice: '2d6', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'paired'], rangeNormal: null, rangeLong: null, effectNote: 'A shortsword in each hand: takes both hands.' },
{ key: 'paired-scimitars', name: 'Paired Scimitars', category: 'Paired', damageDice: '2d6', damageType: 'slashing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'paired'], rangeNormal: null, rangeLong: null, effectNote: 'A scimitar in each hand: takes both hands.' },
{ key: 'paired-handaxes', name: 'Paired Handaxes', category: 'Paired', damageDice: '2d6', damageType: 'slashing', attackAbility: 'STR', properties: ['light', 'paired'], rangeNormal: null, rangeLong: null, effectNote: 'A handaxe in each hand: takes both hands.' },
];
module.exports = { WEAPON_LIBRARY };
File diff suppressed because it is too large Load Diff
+29
View File
@@ -18,6 +18,11 @@ const loginSchema = z.object({
password: z.string().min(1),
});
const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
});
function issueToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, displayName: user.displayName },
@@ -76,4 +81,28 @@ router.get('/me', requireAuth, async (req, res, next) => {
}
});
// Tokens already issued stay valid until they expire; this only changes what
// the next login needs.
router.post('/password', requireAuth, async (req, res, next) => {
try {
const parsed = changePasswordSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { currentPassword, newPassword } = parsed.data;
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (!user) return res.status(404).json({ error: 'User not found' });
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) return res.status(400).json({ error: 'Your current password is incorrect' });
if (newPassword === currentPassword) {
return res.status(400).json({ error: 'Your new password must be different from the current one' });
}
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: await bcrypt.hash(newPassword, 12) } });
return res.status(204).end();
} catch (err) {
return next(err);
}
});
module.exports = router;
+287 -30
View File
@@ -4,8 +4,12 @@ const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { requireCampaignMember, requireDM } = require('../middleware/campaignAccess');
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 { computeCharacterSheet, GEAR_STATS, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
const { SHEET_INCLUDE, settleHp } = require('../utils/sheet');
const { WEAPON_LIBRARY, ITEM_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,12 +20,18 @@ 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,
maxEquipped: campaign.maxEquipped,
maxEquippedWeapons: campaign.maxEquippedWeapons,
ownerId: campaign.ownerId,
archivedAt: campaign.archivedAt,
role,
createdAt: campaign.createdAt,
};
@@ -44,10 +54,17 @@ 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(),
// How many weapons and items a character can have equipped together, and
// how many of those can be weapons. Lowering them takes nothing off anyone:
// they are checked when something is put on.
maxEquipped: z.number().int().min(1).max(20).optional(),
maxEquippedWeapons: z.number().int().min(0).max(20).optional(),
});
// POST /campaigns — create, creator becomes DM/owner
@@ -84,6 +101,7 @@ router.post('/join/:inviteCode', async (req, res, next) => {
try {
const campaign = await prisma.campaign.findUnique({ where: { inviteCode: req.params.inviteCode } });
if (!campaign) return res.status(404).json({ error: 'Invalid invite code' });
if (campaign.archivedAt) return res.status(403).json({ error: 'That campaign is archived and is not taking new members' });
const existing = await prisma.campaignMember.findUnique({
where: { campaignId_userId: { campaignId: campaign.id, userId: req.user.id } },
@@ -112,20 +130,41 @@ router.get('/:id', requireCampaignMember(), async (req, res, next) => {
}
});
const updateCampaignSchema = createCampaignSchema.partial();
// `archived` puts the campaign away (true) or brings it back (false).
const updateCampaignSchema = createCampaignSchema.partial().extend({ archived: z.boolean().optional() });
router.patch('/:id', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = updateCampaignSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const campaign = await prisma.campaign.update({ where: { id: req.campaign.id }, data: parsed.data });
const { archived, ...data } = parsed.data;
// Re-archiving keeps the original date.
if (archived === true && !req.campaign.archivedAt) data.archivedAt = new Date();
if (archived === false) data.archivedAt = null;
const campaign = await prisma.campaign.update({ where: { id: req.campaign.id }, data });
res.json({ campaign: campaignSummary(campaign, 'DM') });
} catch (err) {
next(err);
}
});
// Deleting takes everything in the campaign with it — characters, pools,
// encounters, notes — so it is the owner's call alone, not any DM's.
router.delete('/:id', requireCampaignMember(), async (req, res, next) => {
try {
if (req.campaign.ownerId !== req.user.id) {
return res.status(403).json({ error: 'Only the campaign owner can delete it' });
}
await prisma.campaign.delete({ where: { id: req.campaign.id } });
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('campaign:deleted', { campaignId: req.campaign.id });
res.status(204).end();
} catch (err) {
next(err);
}
});
router.post('/:id/invite', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const regenerate = req.body && req.body.regenerate === true;
@@ -167,7 +206,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,
@@ -179,10 +222,22 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
try {
const characters = await prisma.character.findMany({
where: { campaignId: req.campaign.id },
include: { skillProficiencies: true, saveProficiencies: true, currentLocation: true },
include: { ...SHEET_INCLUDE, 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);
}
@@ -229,7 +284,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);
@@ -237,18 +292,53 @@ 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);
}
});
// What a weapon or item does to the sheet of whoever has it equipped: signed
// amounts on the numbers in GEAR_STATS (armor is an item with an AC effect),
// and a note for what is not a number.
const gearEffects = {
effects: z
.array(z.object({ stat: z.enum(GEAR_STATS), amount: z.number().int().min(-100).max(100).refine((n) => n !== 0, 'An effect of 0 does nothing') }))
.max(12)
.optional(),
effectNote: z.string().trim().max(300).optional().nullable(),
};
// A change to what a pool entry does reaches everyone who has it equipped:
// their maximum HP may have dropped, and every open sheet should refetch.
async function gearChanged(req, heldModel, poolKey, poolId, holderIds) {
const holders = holderIds || (await prisma[heldModel].findMany({ where: { [poolKey]: poolId }, select: { characterId: true } })).map((h) => h.characterId);
await settleHp(prisma, holders);
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:updated', {});
}
const weaponSchema = z.object({
name: z.string().min(1).max(80),
damageDice: z.string().min(1).max(20),
@@ -260,6 +350,7 @@ const weaponSchema = z.object({
damageScaling: z.number().int().min(1).max(MAX_DAMAGE_SCALING).optional(),
minLevel: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
isStartingOption: z.boolean().optional(),
...gearEffects,
});
// Raising a pool entry's starting level lifts every held copy that is now
@@ -304,6 +395,7 @@ router.patch('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), asy
const weapon = await updatePoolEntry('weapon', req.params.weaponId, req.campaign.id, parsed.data);
if (!weapon) return res.status(404).json({ error: 'Weapon not found in this campaign' });
await liftHeldCopies('characterWeapon', 'weaponId', weapon.id, parsed.data.minLevel);
await gearChanged(req, 'characterWeapon', 'weaponId', weapon.id);
res.json({ weapon });
} catch (err) {
next(err);
@@ -312,9 +404,11 @@ router.patch('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), asy
router.delete('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const holders = (await prisma.characterWeapon.findMany({ where: { weaponId: req.params.weaponId }, select: { characterId: true } })).map((h) => h.characterId);
if (!(await deletePoolEntry('weapon', req.params.weaponId, req.campaign.id))) {
return res.status(404).json({ error: 'Weapon not found in this campaign' });
}
await gearChanged(req, 'characterWeapon', 'weaponId', req.params.weaponId, holders);
res.status(204).end();
} catch (err) {
next(err);
@@ -327,7 +421,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);
@@ -344,6 +438,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) => {
@@ -386,7 +481,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);
@@ -400,6 +495,7 @@ const itemSchema = z.object({
damageScaling: z.number().int().min(1).max(MAX_DAMAGE_SCALING).optional(),
minLevel: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
isStartingOption: z.boolean().optional(),
...gearEffects,
});
router.post('/:id/items', requireCampaignMember(), requireDM(), async (req, res, next) => {
@@ -420,6 +516,7 @@ router.patch('/:id/items/:itemId', requireCampaignMember(), requireDM(), async (
const item = await updatePoolEntry('item', req.params.itemId, req.campaign.id, parsed.data);
if (!item) return res.status(404).json({ error: 'Item not found in this campaign' });
await liftHeldCopies('inventoryItem', 'itemId', item.id, parsed.data.minLevel);
await gearChanged(req, 'inventoryItem', 'itemId', item.id);
res.json({ item });
} catch (err) {
next(err);
@@ -428,9 +525,11 @@ router.patch('/:id/items/:itemId', requireCampaignMember(), requireDM(), async (
router.delete('/:id/items/:itemId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const holders = (await prisma.inventoryItem.findMany({ where: { itemId: req.params.itemId }, select: { characterId: true } })).map((h) => h.characterId);
if (!(await deletePoolEntry('item', req.params.itemId, req.campaign.id))) {
return res.status(404).json({ error: 'Item not found in this campaign' });
}
await gearChanged(req, 'inventoryItem', 'itemId', req.params.itemId, holders);
res.status(204).end();
} catch (err) {
next(err);
@@ -453,8 +552,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(),
@@ -462,8 +561,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(),
@@ -510,9 +609,12 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(),
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
try {
const isDM = req.membership.role === 'DM';
// 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 },
orderBy: { createdAt: 'desc' },
where: { campaignId: req.campaign.id, ...(isDM ? {} : mine) },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
});
// dmNotes never leaves the server for a player.
res.json({ encounters: isDM ? encounters : encounters.map(({ dmNotes, ...encounter }) => encounter) });
@@ -525,19 +627,53 @@ 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() });
const encounter = await prisma.encounter.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
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({
data: { ...parsed.data, campaignId: req.campaign.id, sortOrder: (first._min.sortOrder ?? 1) - 1 },
});
res.status(201).json({ encounter });
} catch (err) {
next(err);
}
});
const encounterOrderSchema = z.object({ encounterIds: z.array(z.string()).min(1).max(1000) });
// Sets the campaign's own encounter order: the ids given take positions
// 0, 1, 2... in the order sent.
router.put('/:id/encounters/order', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = encounterOrderSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { encounterIds } = parsed.data;
const owned = await prisma.encounter.count({ where: { id: { in: encounterIds }, campaignId: req.campaign.id } });
if (new Set(encounterIds).size !== encounterIds.length || owned !== encounterIds.length) {
return res.status(400).json({ error: 'Send each encounter of this campaign once.' });
}
await prisma.$transaction(
encounterIds.map((id, sortOrder) => prisma.encounter.update({ where: { id }, data: { sortOrder } }))
);
res.status(204).end();
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// NPCs (DM only — players meet them through encounters, without the DM's
// notes or stats)
@@ -545,7 +681,11 @@ router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req,
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);
@@ -558,16 +698,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) {
@@ -579,8 +738,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);
@@ -624,12 +785,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);
@@ -675,15 +841,92 @@ 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 });
res.json({ weapons: WEAPON_LIBRARY, items: ITEM_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY });
} catch (err) {
next(err);
}
@@ -703,6 +946,20 @@ router.post('/:id/weapons/from-library', requireCampaignMember(), requireDM(), a
}
});
router.post('/:id/items/from-library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = z.object({ key: z.string() }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const template = ITEM_LIBRARY.find((i) => i.key === parsed.data.key);
if (!template) return res.status(404).json({ error: 'Unknown library item.' });
const { key, category, ...data } = template;
const item = await prisma.item.create({ data: { ...data, campaignId: req.campaign.id } });
res.status(201).json({ item });
} catch (err) {
next(err);
}
});
router.post('/:id/spells/from-library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = z.object({ key: z.string() }).safeParse(req.body);
+288 -49
View File
@@ -29,7 +29,10 @@ 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 { SHEET_INCLUDE, settleHp } = require('../utils/sheet');
const { absorbDamage, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
const router = express.Router();
@@ -51,7 +54,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 +86,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 +141,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 +150,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(-30).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(),
@@ -151,8 +166,9 @@ const patchCharacterSchema = z.object({
// builder composed (special abilities, background, appearance): a player
// picks those from lists and cannot type their own. The owning player can
// edit the rest of their own sheet. Gold is not patchable at all — it only
// moves through the ledger (POST /:id/gold).
const DM_ONLY_PATCH_FIELDS = ['level', 'xp', 'currentLocationId', 'speedOverride', 'specialAbilities', 'background', 'appearance'];
// moves through the ledger (POST /:id/gold). The armor bonus is the DM's
// adjustment on top of what equipped gear gives, so it is the DM's too.
const DM_ONLY_PATCH_FIELDS = ['level', 'xp', 'currentLocationId', 'speedOverride', 'armorBonus', 'specialAbilities', 'background', 'appearance'];
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
@@ -160,20 +176,39 @@ 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() });
if (!req.isDM && DM_ONLY_PATCH_FIELDS.some((field) => parsed.data[field] !== undefined)) {
return res.status(403).json({ error: 'Only the DM can change level, XP, speed, location, special abilities, background or appearance.' });
return res.status(403).json({ error: 'Only the DM can change level, XP, speed, armor bonus, location, special abilities, background or appearance.' });
}
// Current HP stays within 0..max, including when a level change moves max.
if (req.character.buildStage === 'COMPLETE' && (parsed.data.currentHp !== undefined || parsed.data.level !== undefined)) {
const maxHp = computeCharacterSheet({ ...req.character, ...parsed.data }).hp.max;
parsed.data.currentHp = clamp(parsed.data.currentHp ?? req.character.currentHp, 0, maxHp);
// Damage is soaked by temp HP before it reaches real HP — unless this
// same request sets temp HP outright.
const settingHp = parsed.data.currentHp !== undefined;
const settingTempHp = parsed.data.tempHp !== undefined;
if (req.character.buildStage === 'COMPLETE' && (settingHp || parsed.data.level !== undefined)) {
// Current HP stays within 0..max, including when a level change moves max.
const geared = await prisma.character.findUnique({ where: { id: req.character.id }, include: SHEET_INCLUDE });
const maxHp = computeCharacterSheet({ ...geared, ...parsed.data }, geared.skillProficiencies, geared.saveProficiencies).hp.max;
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 +226,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 } };
@@ -200,13 +236,70 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
where: { characterId: updated.id, spell: { level: { gt: updated.level } } },
data: { prepared: false },
});
// Gear that came off may have been holding the maximum HP up.
await settleHp(prisma, [updated.id]);
}
const full = await loadFullCharacter(updated.id);
// 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 +429,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 +447,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 +463,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 +880,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);
@@ -799,32 +898,72 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
return res.status(400).json({ error: 'Character build is not finished yet.' });
}
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
// The pack, the spell list and the purse are the owner's and the DM's to
// see, as on the sheet; anyone else gets what is visible across the table.
const restricted = !req.isOwner && !req.isDM;
const effectsOf = (entry) => ({ effects: Array.isArray(entry.effects) ? entry.effects : [], effectNote: entry.effectNote || null });
const weapons = character.weapons
.filter((cw) => canUseGear(character.level, cw.level))
.map((cw) => ({
characterWeaponId: cw.id,
weaponId: cw.weaponId,
name: cw.weapon.name,
level: cw.level,
equipped: cw.equipped,
quantity: cw.quantity,
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true, cw.level),
}));
// Everything held is listed, usable or not: `usable` says whether the
// character is high enough level for it.
const weapons = character.weapons.map((cw) => ({
characterWeaponId: cw.id,
weaponId: cw.weaponId,
name: cw.weapon.name,
level: cw.level,
equipped: cw.equipped,
quantity: cw.quantity,
usable: canUseGear(character.level, cw.level),
...effectsOf(cw.weapon),
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true, cw.level),
}));
const inventory = character.inventoryItems.map((ii) => ({
inventoryItemId: ii.id,
itemId: ii.itemId,
name: ii.item.name,
description: ii.item.description,
level: ii.level,
equipped: ii.equipped,
quantity: ii.quantity,
usable: canUseGear(character.level, ii.level),
damageDice: ii.item.damageDice ? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel) : null,
...effectsOf(ii.item),
}));
const spells = character.spells.map((cs) => ({
characterSpellId: cs.id,
name: cs.spell.name,
level: cs.spell.level,
damageDice: cs.spell.damageDice,
description: cs.spell.description,
prepared: cs.prepared,
usable: canUseGear(character.level, cs.spell.level),
}));
res.json({
quickStats: {
id: character.id,
name: character.name,
level: character.level,
species: character.species,
characterClass: character.characterClass,
currentHp: character.currentHp,
maxHp: sheet.hp.max,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
armorClass: sheet.armorClass,
initiative: sheet.initiative,
speed: sheet.speed,
proficiencyBonus: sheet.proficiencyBonus,
spellcasting: sheet.spellcasting,
passivePerception: sheet.passivePerception,
abilities: sheet.abilities,
specialAbilities: character.specialAbilities,
// What equipped gear is adding and taking away, and what else it does.
gear: sheet.gear,
weapons,
restricted,
inventory: restricted ? [] : inventory,
spells: restricted ? [] : spells,
purse: restricted ? null : { gold: character.gold, silver: character.silver, copper: character.copper, cash: character.cash },
},
});
} catch (err) {
@@ -838,14 +977,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 +1008,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);
}
@@ -911,6 +1112,29 @@ const GEAR = [
const gearLevelSchema = z.number().int().min(1).max(MAX_GEAR_LEVEL);
// A character can only have so much equipped at once (campaign settings:
// weapons and items together, and weapons alone). Returns why `adding` — a
// weapon or an item, not yet counted — cannot go on as well, or null if it
// can. `exceptPoolId` is the pool entry being equipped, in case it already is.
async function equipBlocked(req, kind, exceptPoolId) {
const { maxEquipped, maxEquippedWeapons } = req.campaign;
const where = { characterId: req.character.id, equipped: true };
const [weapons, items] = await Promise.all([
prisma.characterWeapon.findMany({ where: { ...where, ...(kind === 'weapon' ? { weaponId: { not: exceptPoolId } } : {}) }, include: { weapon: true } }),
prisma.inventoryItem.findMany({ where: { ...where, ...(kind === 'item' ? { itemId: { not: exceptPoolId } } : {}) }, include: { item: true } }),
]);
const names = (rows, key) => rows.map((row) => row[key].name).join(', ');
if (kind === 'weapon' && weapons.length >= maxEquippedWeapons) {
if (maxEquippedWeapons === 0) return 'Nobody can equip a weapon in this campaign.';
return `${req.character.name} already has ${names(weapons, 'weapon')} equipped, and can hold ${maxEquippedWeapons === 1 ? 'one weapon' : `${maxEquippedWeapons} weapons`} at a time. Unequip it first.`;
}
if (weapons.length + items.length >= maxEquipped) {
const on = [names(weapons, 'weapon'), names(items, 'item')].filter(Boolean).join(', ');
return `${req.character.name} already has ${maxEquipped === 1 ? 'something' : `${maxEquipped} things`} equipped (${on}). Unequip one first.`;
}
return null;
}
for (const gear of GEAR) {
const uniqueKey = `characterId_${gear.key}`;
const whereHeld = (req) => ({ [uniqueKey]: { characterId: req.character.id, [gear.key]: req.params.poolId } });
@@ -948,6 +1172,10 @@ for (const gear of GEAR) {
if (parsed.data[gear.flag] && !canUseGear(req.character.level, requiredLevel(parsed.data, entry))) {
return res.status(400).json({ error: tooHighMessage(requiredLevel(parsed.data, entry)) });
}
if (gear.ownLevel && parsed.data.equipped) {
const blocked = await equipBlocked(req, gear.pool, poolId);
if (blocked) return res.status(400).json({ error: blocked });
}
// Giving something the character already holds adds to the stack (the
// stack keeps the level it has).
@@ -957,6 +1185,7 @@ for (const gear of GEAR) {
update: gear.ownLevel ? { quantity: { increment: parsed.data.quantity } } : { [gear.flag]: parsed.data[gear.flag] },
include,
});
if (gear.ownLevel) await settleHp(prisma, [req.character.id]);
emitCharacterUpdated(req);
res.status(201).json({ [gear.held]: row });
} catch (err) {
@@ -988,8 +1217,13 @@ for (const gear of GEAR) {
if (data[gear.flag]) return res.status(400).json({ error: tooHighMessage(needed) });
data[gear.flag] = false; // levelled past its holder: it comes off
}
if (gear.ownLevel && data.equipped && !existing.equipped) {
const blocked = await equipBlocked(req, gear.pool, req.params.poolId);
if (blocked) return res.status(400).json({ error: blocked });
}
const row = await prisma[gear.held].update({ where: whereHeld(req), data, include });
if (gear.ownLevel) await settleHp(prisma, [req.character.id]);
emitCharacterUpdated(req);
res.json({ [gear.held]: row });
} catch (err) {
@@ -1004,6 +1238,7 @@ for (const gear of GEAR) {
where: { characterId: req.character.id, [gear.key]: req.params.poolId },
});
if (count === 0) return res.status(404).json({ error: `This character doesn't have that ${gear.label}.` });
if (gear.ownLevel) await settleHp(prisma, [req.character.id]);
emitCharacterUpdated(req);
res.status(204).end();
} catch (err) {
@@ -1020,7 +1255,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 +1282,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,12 +1319,14 @@ 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;
}
// What was handed over may have been equipped, and holding the giver's maximum HP up.
await settleHp(prisma, [giver.id]);
const io = req.app.get('io');
for (const characterId of [giver.id, recipient.id]) {
io.to(`campaign:${giver.campaignId}`).emit('character:updated', { characterId });
+477 -54
View File
@@ -3,8 +3,11 @@ 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, scaledDamageDice, canUseGear, abilityModifier, MAX_GEAR_LEVEL } = require('../game/calculations');
const { CURRENCIES, InsufficientGoldError, adjustGold, spendCoins, giveGear } = require('../utils/grants');
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
const { SHEET_INCLUDE } = require('../utils/sheet');
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
const router = express.Router();
@@ -18,9 +21,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,20 +51,82 @@ 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
// sheet's headline numbers plus what it has in hand — the weapons and items
// it has equipped — and the spells it can cast, so the party can plan around
// each other mid-fight, not only the DM. A weapon has to be equipped to be
// used; what is still in the pack is in the character panel, for the
// character's own player and the DM. Uses the
// same calculation-engine functions as the sheet, never re-deriving formulas.
function characterCombatInfo(character) {
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
// Gear and spells above the character's level are held but can't be used,
// so they stay out of the fight.
// so they stay out of the fight; so does anything not equipped.
const inHand = (held) => held.equipped && canUseGear(character.level, held.level);
const items = character.inventoryItems.filter(inHand).map((ii) => ({
inventoryItemId: ii.id,
name: ii.item.name,
level: ii.level,
quantity: ii.quantity,
damageDice: ii.item.damageDice ? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel) : null,
}));
const weapons = character.weapons
.filter((cw) => canUseGear(character.level, cw.level))
.filter(inHand)
.map((cw) => ({
characterWeaponId: cw.id,
name: cw.weapon.name,
@@ -76,6 +152,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,
@@ -83,48 +161,73 @@ function characterCombatInfo(character) {
abilities: sheet.abilities,
saves: sheet.saves,
specialAbilities: character.specialAbilities,
// What equipped gear is adding and taking away. The numbers above already include it.
gear: sheet.gear,
weapons,
items,
spells,
spellcasting: sheet.spellcasting,
};
}
// 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: {
include: {
skillProficiencies: true,
saveProficiencies: true,
weapons: { include: { weapon: true } },
...SHEET_INCLUDE,
spells: { include: { spell: true } },
},
},
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 +235,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 +269,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,21 +305,32 @@ 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)
});
router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const parsed = patchEncounterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const data = { ...parsed.data };
const { archived, ...data } = parsed.data;
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) {
@@ -196,6 +338,18 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
}
});
// Deleting removes the scene, its combatant rows and its reward list. What
// was already handed out stays with the characters, as does their HP.
router.delete('/:id', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
await prisma.encounter.delete({ where: { id: req.encounter.id } });
emitEncounterUpdated(req);
res.status(204).end();
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Combatants
// ---------------------------------------------------------------------------
@@ -206,7 +360,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',
@@ -216,21 +370,27 @@ 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({
where: { id: characterId },
include: { skillProficiencies: true, saveProficiencies: true },
include: SHEET_INCLUDE,
});
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) {
@@ -239,6 +399,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) {
@@ -257,7 +418,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),
@@ -272,34 +434,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: SHEET_INCLUDE }, 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) {
@@ -317,15 +498,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);
@@ -334,25 +520,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 } } : {}),
},
});
@@ -364,21 +643,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);
}
@@ -389,18 +670,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(),
}),
@@ -466,7 +747,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 },
@@ -495,7 +776,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';
@@ -515,4 +796,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 };
+35
View File
@@ -0,0 +1,35 @@
// Loading a character so that its sheet comes out right. The sheet depends on
// what the character has equipped (game/calculations.js, activeGear), so every
// query that feeds computeCharacterSheet includes the same relations.
const { computeCharacterSheet } = require('../game/calculations');
const SHEET_INCLUDE = {
skillProficiencies: true,
saveProficiencies: true,
inventoryItems: { include: { item: true } },
weapons: { include: { weapon: true } },
};
function sheetOf(character) {
return computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
}
// Equipping, unequipping or changing gear can lower a character's maximum HP.
// Brings current HP back down to the new maximum where it is now above it,
// and returns the ids of the characters that changed. Takes a Prisma client or
// transaction client as `db`.
async function settleHp(db, characterIds) {
const changed = [];
const characters = await db.character.findMany({ where: { id: { in: [...new Set(characterIds)] }, buildStage: 'COMPLETE' }, include: SHEET_INCLUDE });
for (const character of characters) {
const max = sheetOf(character).hp.max;
if (character.currentHp > max) {
await db.character.update({ where: { id: character.id }, data: { currentHp: max } });
changed.push(character.id);
}
}
return changed;
}
module.exports = { SHEET_INCLUDE, sheetOf, settleHp };
+137 -33
View File
@@ -137,7 +137,7 @@ this order:
These are table rules written in plain words for the DM to apply. The app
does not calculate anything from them: Rage does not add damage by itself, and
Unarmored Defense does not change AC unless the DM sets the armor bonus.
Unarmored Defense does not change AC unless the DM sets the extra AC.
**Background** is five lines, one per question:
@@ -325,21 +325,75 @@ 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 |
|---|---|
| Armor Class | 10 + DEX modifier + armor bonus |
| Initiative | DEX modifier |
| Speed | Species speed, unless a speed override is set |
| Passive Perception | 10 + Perception skill bonus |
| Armor Class | 10 + DEX modifier + equipped gear + the DM's extra AC |
| Initiative | DEX modifier + equipped gear |
| Speed | Species speed, unless a speed override is set, + equipped gear |
| Passive Perception | 10 + Perception skill bonus + equipped gear |
| Hit dice | One per level, of the class's die size (shown only; not spent or tracked) |
| Spell save DC | 8 + proficiency bonus + spellcasting ability modifier |
| Spell attack bonus | Proficiency bonus + spellcasting ability modifier |
**Armor bonus** is a single number typed in on the sheet to stand for armor and
shield. There are no armor items, and equipping an inventory item does not
change AC.
**Equipped gear.** Armor, shields and other gear are items (and weapons) with
**effects**: bonuses and penalties to AC, speed, initiative, max HP, passive
Perception, ability scores, saving throws and skills. They apply on their own
while the thing is equipped, and stop when it comes off. Every number on the
sheet already includes them, and the "From Equipped Gear" card says which
item is doing what. An effect on an ability score changes the score itself,
so everything in sections 5 to 10 that is built on that ability moves too.
The full rules are in [ITEMS.md](ITEMS.md), section 5.
**Extra AC** is a number only the DM can set on the sheet, added to AC on top
of gear, for anything an item does not cover. It can be negative. (It used to
be called "armor bonus" and stood in for all armor; characters keep whatever
it was set to.)
**Speed override** exists in the data and API (DM only) but has no field on
the sheet.
@@ -383,7 +437,10 @@ character.
characters from the pool, changes how many a character has, levels them up,
and takes them away.
- **The owning player** can tick and untick Equipped (weapons, items) and
Prepared (spells) on gear their character can use, and can **give** what
Prepared (spells) on gear their character can use, at any time, including
mid-encounter. Only so much can be equipped at once: by default two things,
one of them a weapon (a campaign setting; [ITEMS.md](ITEMS.md), section 3).
A weapon has to be equipped to be used in an encounter, and can **give** what
their character holds to another character (section 12).
- Removing an entry from a pool removes it from every character holding it.
@@ -418,8 +475,9 @@ starting level, copies below it are lifted to it and unequipped.
| … | … |
Gear above the character's level can still be **held**, but it shows
"Requires level N", cannot be equipped, and is left out of quick stats and
encounters. If the DM levels a piece of gear past its holder, or lowers the
"Requires level N", cannot be equipped, gives none of its effects, and is
left out of the character's encounter card. The character panel still lists
it, marked "needs level N". If the DM levels a piece of gear past its holder, or lowers the
character's level, anything now out of reach is unequipped automatically.
**How damage grows.** The dice in the pool are the damage at the gear's
@@ -475,6 +533,10 @@ value, which is fixed.
## 11. Weapons and spells
A character holds weapons, each with a quantity, a level and an equipped flag.
Only an **equipped** weapon is in hand: it is the one shown on the character's
encounter card and offered in the DM's attack bar. One weapon can be equipped
at a time unless the campaign allows more, so a weapon for each hand is one
weapon made as a pair ([WEAPONS.md](WEAPONS.md), sections 3 and 4).
| Number | Formula |
|---|---|
@@ -505,21 +567,56 @@ A character knows spells, each with a prepared flag.
**Inventory** is the items a character holds from the campaign's item pool,
each with a quantity, a level and an equipped flag. Giving an item the
character already has adds to the stack. Items have no effect on any stat. An
character already has adds to the stack. An item can have **effects** that
change the sheet while it is equipped: this is how armor works
([ITEMS.md](ITEMS.md), section 5). 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 +649,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:
@@ -565,11 +662,11 @@ character in the same campaign.
| Screen | What it shows |
|---|---|
| Campaign page | Character list: species, class, level, HP, AC, location. Players see their own first, then the rest of the party. |
| Party overview | One table of every character with an HP bar, AC, gold and location. |
| Campaign page | Character list: species, class, level, HP, AC, location. A player's own characters are under "Your Characters" and nowhere else; "Rest of the Party" is everyone else's. |
| Party overview | One table of every character with an HP bar, AC, gold and location. A player's own characters are highlighted and marked "★ Yours". |
| Character sheet | Everything in this document. |
| Character builder | A character that is still being built (section 2). |
| Quick stats drawer | A combat summary: HP, AC, initiative, speed, proficiency bonus, spellcasting numbers, special abilities, and each weapon's attack and damage. |
| Character panel (the floating 🧙 button, on every page: bottom right, or in the top bar on a phone) | One character at a glance: HP, AC, initiative, speed, proficiency bonus, passive Perception, ability scores, what equipped gear is doing, spellcasting numbers and special abilities; then **everything the character holds**: every weapon with its attack and damage, every item, every spell and the purse, with what is equipped and what each thing does. Gear can be equipped and unequipped from here. A player gets their own characters in the campaign they are in; the DM gets every character, with a menu to switch. Another player's panel shows the numbers and weapons only. It replaced the quick stats drawer. |
| Encounter page | Characters taking part in a battle or quest (next section). |
Changes are pushed live to everyone who has the campaign open, so sheets and
@@ -620,7 +717,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.
@@ -670,14 +769,19 @@ Another Campaign" card on its sheet.
| View the sheet and quick stats | Yes | Yes | Yes |
| View private notes | Yes | No | No |
| Run the builder | Yes | Yes | No |
| Change HP, temp HP, armor bonus | Yes | Yes | No |
| Change HP, temp HP | Yes | Yes | No |
| Set the extra AC | No | Yes | No |
| Open the character panel with the full inventory | Yes | Yes | No (numbers and weapons only) |
| 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 |
@@ -704,16 +808,16 @@ calculations for them.
subclasses and multiclassing
- Spell slots, spell preparation limits and concentration
- Ability score increases and feats on level-up
- Armor and shields as items (only the single armor bonus number)
- Armor rules beyond a flat bonus to AC (no "replaces 10 + DEX", no cap on DEX)
- Weapon proficiency (everyone is proficient with everything)
- 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
bottom left of every page, or the last button of the bottom bar on a phone,
is a plain roller that knows nothing about the sheet)
- 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
+292 -28
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.
@@ -61,7 +67,40 @@ notes) works the same in both.
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
deleted or renamed from the screen.
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
the DM arranges with the ▲ and ▼ buttons and everyone sees. A new encounter
goes to the top. The "Sort by" menu re-sorts the list by name, status, type or
date instead; that choice is remembered per browser and changes nothing for
anyone else. ▲ and ▼ only show while sorting by campaign order.
The list shows **ten encounters a page**, with Prev and Next beneath it, and
the DM's archived list is paged the same way. The order runs on across pages:
moving the last encounter on a page down carries it to the top of the next.
**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; 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
confirmation. There is no undo. Rewards already handed out stay with the
characters, and characters keep their current HP.
## 4. The scene and DM notes
@@ -78,16 +117,33 @@ 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 |
**Finding your own character.** A player's own characters stand out: their
cards have a blue border and background and say "★ Your character", and a row
of buttons at the top of the page names each one with its HP ("★ Aria · 12/12
HP", plus "your turn" when it is) and scrolls to its card. The DM sees the
same for any character of their own.
**Inventory.** A character's card has an "🎒 Inventory" button for the DM and
for that character's own player. It opens the character panel on that
character: everything they hold, what is equipped and what each thing does
([CHARACTERS.md](CHARACTERS.md), section 14). The floating 🧙 button opens the
same panel from anywhere. A card's details also list what the character's
equipped gear is doing; the AC, speed and other numbers on the card already
include it.
Each has a card. The card's left edge is coloured by kind, and it shows:
- **Characters:** level, species and class; HP, AC, speed, initiative bonus,
passive perception, proficiency bonus and spell save DC. Under "details":
ability scores, saves, the weapons and spells the character can use (with
attack and damage numbers), and special abilities. The name links to the
ability scores, saves, the weapon and items the character has **equipped**
(with attack and damage numbers), the spells they can cast, and special
abilities. A weapon that is not equipped is not on the card and cannot be
used; "No weapon equipped" shows when there is none. Equipping is changed
from "🎒 Inventory", at any point in the scene. The name links to the
character sheet.
- **Monsters:** for the DM, AC, speed, attack and damage, and under "details"
ability scores, trait and notes.
@@ -98,6 +154,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.
@@ -112,23 +172,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 (none,
if nothing is 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
@@ -148,7 +353,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 |
@@ -159,9 +364,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
@@ -172,44 +377,103 @@ 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 |
|---|---|---|
| Scene description | Yes | Yes |
| DM notes | Yes | Never |
| Characters | Full card | Full card |
| A character's inventory, spells and purse | Yes, for everyone | Their own characters only |
| 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)
left of every page, or in the bottom bar on a phone)
- 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
- Deleting or renaming an encounter from the screen
- 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
+187 -10
View File
@@ -14,10 +14,12 @@ level up the same way weapons do), [SPELLS.md](SPELLS.md),
3. [A character's inventory](#3-a-characters-inventory)
4. [Item levels](#4-item-levels)
5. [Dice on items](#5-dice-on-items)
- [Effects: armor and other gear that changes the sheet](#effects-armor-and-other-gear-that-changes-the-sheet)
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)
10. [The complete item library](#10-the-complete-item-library)
---
@@ -27,7 +29,8 @@ level up the same way weapons do), [SPELLS.md](SPELLS.md),
anything that is not a weapon or a spell.
- A character's **inventory** is the items it holds from that pool. Players
cannot type in items of their own.
- The DM creates items and hands them out. Players can only tick Equipped.
- The DM creates items and hands them out. Players can only tick Equipped,
and only so much can be equipped at once (section 3).
- Items are **descriptive**. Holding or equipping one never changes a number
on the sheet.
@@ -43,8 +46,16 @@ The pool is the "Items Pool" card on the campaign page (DM only).
| Starts at level | 1 to 9. The lowest level this item can be (section 4) |
| Growth | Standard, Powerful or Legendary. Only matters if the item has dice |
| Starting option | Whether a brand-new character may pick it in the builder (section 6) |
| Effects | What it does to whoever has it equipped: bonuses and penalties to the sheet's numbers, and a note (section 5) |
- **Adding:** the "+ Add Item" form. There is no built-in library of items.
- **Adding:** by hand with the "+ Add Item" form, or with "📚 From Library",
which offers 32 ready-made items: light, medium and heavy armor, shields,
worn gear such as a Ring of Protection, and common supplies. Each is copied
into the pool with its effects already set (Chain Mail: +6 AC, −10 Speed,
and a note about Stealth), at starting level 1 and not ticked as a starting
option. The copy is yours to change. The full list is in section 10.
- **Effects:** the "Effects" button on an item's row. The row shows what the
item does under its name.
- **Changing:** starting level, growth (when the item has dice) and the
starting-option tick box can be changed in the table. Name, description and
dice cannot be changed on screen once created (the API accepts it); remove
@@ -67,8 +78,39 @@ On a character's sheet, the DM picks an item from the pool and clicks Give.
- Quantity cannot go below 1; to take the last one away the DM uses ✕.
- **Using up** an item (drinking a potion) is not automatic: the DM lowers the
quantity or removes it.
- **Equipped** is a label. It does not change Armor Class or anything else;
armor is represented by the single "armor bonus" number on the sheet.
- **Equipped** is what switches an item's effects on (section 5), and what
shows the item on the character's encounter card.
- **Only what is equipped counts.** A weapon has to be equipped to be used
in an encounter, and gear only gives its effects while equipped. What is
not equipped stays in the pack: still the character's, still listed on the
sheet and in the character panel, but not in hand.
- **There is a limit on how much can be equipped at once**, set per campaign
in Campaign Settings ("Equipped at once"):
| Setting | Starts at | Means |
|---|---|---|
| Things equipped at once | 2 | Weapons and items together |
| Of which weapons | 1 | So: one weapon, or one weapon and one item, or two items |
Armor and shields are items and count toward the limit. With the starting
numbers a character can have a weapon and a shield, or a weapon and armor,
but not all three; a DM who wants armor, shield and weapon together sets
the first number to 3.
- **Putting something on when the limit is reached is refused**, with a
message naming what is already equipped. Take something off first.
- **Equipping can be changed at any time, including in the middle of an
encounter**, by the owning player or the DM: on the sheet, or in the
character panel (the 🧙 button, or "🎒 Inventory" on the character's
encounter card).
- Lowering the limits takes nothing off anyone. A character with more on
than the new limit keeps it until they change what they have equipped.
- Everything arrives unequipped: gear from the DM, from a reward, bought, or
given by another character.
- The whole pack is always listed: on the sheet, and in the **character
panel** (the floating 🧙 button, on every page), which the owning player and
the DM can open from anywhere, including from the character's card in an
encounter. Things the character is too low level to use are shown and
marked "needs level N", not hidden.
**Passing items on.** A character's owner (or the DM) can give some or all of
a held stack to another character in the campaign from the "Give to Another
@@ -111,6 +153,48 @@ rungs per level depending on Growth. The full ladder is in
The dice are shown beside the item in the inventory. Nothing is rolled or
applied automatically; use the dice roller.
### Effects: armor and other gear that changes the sheet
Armor, shields, rings, cloaks and the like are ordinary items that have
**effects**. An effect is a whole number, positive for a bonus and negative
for a penalty, on one of the sheet's numbers:
| It can change | Examples |
|---|---|
| AC | Chain mail +6, a shield +2, a cursed cloak −1 |
| Speed | Heavy armor −10, boots of speed +10. Speed never goes below 0 |
| Initiative | +2 |
| Max HP | +5. Max HP never goes below 1 |
| Passive Perception | +5 |
| An ability score (STR, DEX, CON, INT, WIS, CHA) | Gauntlets +2 STR. The score changes, so its modifier and everything built on it (saves, skills, attacks, HP from CON, AC from DEX) change with it. A score never goes below 1 |
| All saving throws, or one ability's saves | A ring: +1 to all saves |
| All skill checks, or one skill | Noisy armor: −2 Stealth |
An item can have up to 12 effects, one per number, each from −100 to +100,
plus a **note** for what is not a number ("Disadvantage on Stealth",
"Resistance to fire").
- **They apply on their own.** While a character has the item **equipped**
and is high enough level to use it, its effects are part of every number
the app shows for that character: the sheet, the character panel, the party
overview and their encounter card. Nothing has to be typed in.
- **Unequipping takes them away again**, as does giving the item away, the DM
removing it, or the character dropping below its level.
- **Effects add up.** Two different items with +1 AC give +2. Carrying two of
the same item does not double it: a stack counts once.
- **The sheet says where each change comes from.** A "From Equipped Gear"
card lists every item in force with what it is doing, and its note.
- **Max HP can move.** If taking something off lowers a character's maximum
HP below their current HP, current HP comes down to the new maximum.
- **Only the DM sets effects**, with the "Effects" button on the item's row in
the pool. Changing them changes the sheet of everyone wearing the item,
straight away.
- The DM also has an **Extra AC** number on each sheet, added on top of gear,
for anything an item does not cover. It can be negative.
Weapons can carry effects in exactly the same way
([WEAPONS.md](WEAPONS.md), section 2).
## 6. Starting items
In the builder's equipment step a new character can pick items that are:
@@ -121,31 +205,124 @@ 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 |
## 9. What is not modelled
- Any effect of an item on stats (AC, speed, abilities)
- Armor and shields as items
- Armor rules beyond a flat bonus: armor does not replace 10 + DEX, cap the
DEX bonus or need a Strength score (set the AC effect to match)
- Effects on attack rolls, damage, spell save DC or proficiency bonus
- Effects that depend on something (only at night, only against undead), or
that grow with the item's level
- Advantage and disadvantage as rules (write them in the note)
- 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
- Hands and body slots: the limit is a plain count, so nothing stops two
suits of armor being equipped if the count allows it
- Swapping automatically: equipping at the limit is refused, not exchanged
## 10. The complete item library
Everything "📚 From Library" offers. "While equipped" is what the item does
to the sheet of whoever has it on.
Armor is a flat bonus to AC on top of 10 + Dexterity: each suit's bonus is its
standard AC less 10. What a flat bonus cannot say (heavy armor ignoring
Dexterity, a Strength requirement, disadvantage on Stealth) is in the note,
for the DM to apply or to build into the numbers of their own copy.
This list is written from the app's own data by `npm run docs:lists` (in `/backend`). Re-run it after changing the data; do not edit the list by hand.
<!-- BEGIN GENERATED: item-library -->
### Light Armor (3)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Padded Armor | +1 AC | Disadvantage on Stealth checks. | — | Quilted layers of cloth and batting. |
| Leather Armor | +1 AC | — | — | Boiled leather breastplate and shoulder guards over soft leather. |
| Studded Leather | +2 AC | — | — | Tough leather set with close rivets and spikes. |
### Medium Armor (5)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Hide Armor | +2 AC | Standard rules: counts at most +2 from Dexterity. | — | Thick furs and pelts. |
| Chain Shirt | +3 AC | Standard rules: counts at most +2 from Dexterity. | — | Interlocking rings worn between layers of clothing. |
| Scale Mail | +4 AC | Disadvantage on Stealth checks. Standard rules: counts at most +2 from Dexterity. | — | A coat and leggings of leather covered in overlapping metal scales. |
| Breastplate | +4 AC | Standard rules: counts at most +2 from Dexterity. | — | A fitted metal chest piece worn with supple leather. |
| Half Plate | +5 AC | Disadvantage on Stealth checks. Standard rules: counts at most +2 from Dexterity. | — | Shaped metal plates covering most of the body. |
### Heavy Armor (4)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Ring Mail | +4 AC | Disadvantage on Stealth checks. Standard rules: adds no Dexterity. | — | Leather armor with heavy rings sewn into it. |
| Chain Mail | +6 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 13. | — | Interlocking metal rings over a layer of quilted fabric, with gauntlets. |
| Splint Armor | +7 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 15. | — | Narrow vertical strips of metal riveted to a leather backing. |
| Plate Armor | +8 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 15. | — | Shaped, interlocking metal plates covering the whole body. |
### Shields (2)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Shield | +2 AC | — | — | Wood or metal, carried in one hand. |
| Tower Shield | +3 AC, −5 Speed | — | — | A wall of oak and iron, as tall as its bearer. |
### Worn Gear (10)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Ring of Protection | +1 AC, +1 all saves | — | — | A plain band that turns blows aside. |
| Cloak of Protection | +1 AC, +1 all saves | — | — | A heavy cloak that seems to catch what was meant for you. |
| Cloak of Elvenkind | +5 Stealth | With the hood up, others have disadvantage on Perception checks to see you. | — | With the hood up, its colour shifts to match what is behind it. |
| Boots of Striding | +10 Speed | — | — | Light, tireless boots. |
| Gauntlets of Might | +2 STR | — | — | Iron-banded gauntlets that lend a giant’s grip. |
| Gloves of Thievery | +5 Sleight of Hand | — | — | Thin grey gloves that all but vanish when worn. |
| Amulet of Health | +2 CON | — | — | A red stone on a golden chain, warm against the skin. |
| Headband of Intellect | +2 INT | — | — | A silver circlet set with a pale blue stone. |
| Sentinel Charm | +2 Initiative, +5 Passive Perception | — | — | A brass eye on a cord that never quite closes. |
| Stone of Good Luck | +1 all saves, +1 all skill checks | — | — | A polished agate that sits well in a pocket. |
### Supplies (8)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Healing Potion | — | — | 2d4+2 | A red liquid that glimmers when shaken. Drink it to regain hit points. |
| Rope (50 ft) | — | — | — | Hempen rope. Holds a climber. |
| Torch | — | Held in a hand to give light. | — | Burns for an hour, lighting 20 feet brightly. |
| Hooded Lantern | — | Held in a hand to give light. | — | Bright light for 30 feet; the hood dims it to a glow. |
| Rations (1 day) | — | — | — | Dried meat, hard biscuit, nuts. |
| Bedroll | — | — | — | A blanket roll for sleeping rough. |
| Thieves’ Tools | — | — | — | A file, picks, a small mirror, narrow scissors and pliers. |
| Healer’s Kit | — | — | — | Bandages, salves and splints. Steadies a dying creature without a check. |
<!-- END GENERATED: item-library -->
+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 |
+114 -55
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)
@@ -49,8 +49,12 @@ The pool is the "Weapons Pool" card on the campaign page (DM only).
| Growth | Standard, Powerful or Legendary: how fast the dice climb per level (section 6) |
| Starting option | Whether a brand-new character may pick it in the builder (section 8) |
| Properties, range | Stored for library weapons (light, thrown, 20/60 ft…) but not shown or used anywhere |
| Effects | What it does to whoever has it equipped: bonuses and penalties to the sheet's numbers, and a note. Works exactly as for items ([ITEMS.md](ITEMS.md), section 5) |
- **Adding:** by hand with the "+ Add Weapon" form, or from the library.
- **Effects:** the "Effects" button on a weapon's row. A defending blade can
give +1 AC, a heavy maul −5 speed. They count only while the weapon is
equipped, and do not change its own attack or damage.
- **Changing:** starting level, growth and the starting-option tick box can be
changed in the table. Name, damage, type and ability cannot be changed on
screen once created (the API accepts it); remove and re-add instead.
@@ -61,7 +65,7 @@ The pool is the "Weapons Pool" card on the campaign page (DM only).
## 3. The built-in library
"📚 From Library" offers 37 standard weapons to copy into the pool:
"📚 From Library" offers 41 weapons to copy into the pool:
| Category | Count | Examples |
|---|---|---|
@@ -69,6 +73,13 @@ The pool is the "Weapons Pool" card on the campaign page (DM only).
| Simple ranged | 4 | Shortbow, Light Crossbow, Dart, Sling |
| Martial melee | 18 | Longsword 1d8, Rapier 1d8, Greataxe 1d12, Greatsword 2d6 |
| Martial ranged | 5 | Longbow, Heavy Crossbow, Hand Crossbow |
| Paired | 4 | Paired Daggers 2d4, Paired Shortswords 2d6, Paired Scimitars 2d6, Paired Handaxes 2d6 |
**Paired weapons.** A character has one weapon equipped at a time (section
4), so fighting with a weapon in each hand is not two weapons: it is **one
weapon made as a pair**, whose damage is both blades. The library has four,
and a DM makes others the same way with "+ Add Weapon" ("Twin Hammers",
2d4 bludgeoning).
A weapon added from the library arrives with starting level 1, Standard
growth, and **not** ticked as a starting option. Change those in the table
@@ -84,8 +95,33 @@ On a character's sheet, the DM picks a weapon from the pool and clicks Give.
keeps the level it has. A character holds one stack per weapon, so two
copies of the same weapon at different levels is not possible.
- The DM can take a weapon away with ✕ on the sheet.
- The owning player ticks **Equipped**. Equipped is a label only: unequipped
weapons still show their numbers, and nothing limits how many are equipped.
- The owning player, or the DM, ticks **Equipped**.
- **Only what is equipped counts.** A weapon has to be equipped to be used
in an encounter, and gear only gives its effects while equipped. What is
not equipped stays in the pack: still the character's, still listed on the
sheet and in the character panel, but not in hand.
- **There is a limit on how much can be equipped at once**, set per campaign
in Campaign Settings ("Equipped at once"):
| Setting | Starts at | Means |
|---|---|---|
| Things equipped at once | 2 | Weapons and items together |
| Of which weapons | 1 | So: one weapon, or one weapon and one item, or two items |
Armor and shields are items and count toward the limit. With the starting
numbers a character can have a weapon and a shield, or a weapon and armor,
but not all three; a DM who wants armor, shield and weapon together sets
the first number to 3.
- **Putting something on when the limit is reached is refused**, with a
message naming what is already equipped. Take something off first.
- **Equipping can be changed at any time, including in the middle of an
encounter**, by the owning player or the DM: on the sheet, or in the
character panel (the 🧙 button, or "🎒 Inventory" on the character's
encounter card).
- Lowering the limits takes nothing off anyone. A character with more on
than the new limit keeps it until they change what they have equipped.
- Everything arrives unequipped: gear from the DM, from a reward, bought, or
given by another character.
**Passing a weapon on.** A character's owner (or the DM) can give a held
weapon to another character in the campaign from the "Give to Another
@@ -115,7 +151,8 @@ This is a house rule of this app, not standard D&D.
| 9 and above | 1–9 |
A weapon above its holder's level is still held, but it shows "Requires level
N", cannot be equipped, and is left out of quick stats and encounters. If the
N", cannot be equipped, and so is never on its holder's encounter card. The
character panel lists it, marked "needs level N". If the
DM levels a weapon past its holder, or lowers the character's level, it is
unequipped automatically.
@@ -185,21 +222,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 |
@@ -207,74 +254,86 @@ on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
## 11. What is not modelled
- Weapon proficiency by class (everyone is proficient with everything)
- Properties: two-handed, versatile, light, reach, loading, thrown
- Properties: two-handed, versatile, light, reach, loading, thrown (a
two-handed weapon counts as one thing equipped, like any other)
- 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
- Magic bonuses to a weapon's own attack or damage, such as +1 weapons (use a
higher level or growth instead)
- 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
Every weapon "📚 From Library" offers. Base damage is the damage at level 1.
Properties and range are carried into the campaign but not shown or used.
"While equipped" is the weapon's effects and note, which are carried in too.
This list is written from the app's own data by `npm run docs:lists` (in `/backend`). Re-run it after changing the data; do not edit the list by hand.
<!-- BEGIN GENERATED: weapon-library -->
### Simple Melee (10)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Club | 1d4 | bludgeoning | Strength | light | — |
| Dagger | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, light, thrown | 20/60 ft |
| Greatclub | 1d8 | bludgeoning | Strength | two-handed | — |
| Handaxe | 1d6 | slashing | Strength | light, thrown | 20/60 ft |
| Javelin | 1d6 | piercing | Strength | thrown | 30/120 ft |
| Light Hammer | 1d4 | bludgeoning | Strength | light, thrown | 20/60 ft |
| Mace | 1d6 | bludgeoning | Strength | — | — |
| Quarterstaff | 1d6 | bludgeoning | Strength | versatile (1d8) | — |
| Sickle | 1d4 | slashing | Strength | light | — |
| Spear | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft |
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Club | 1d4 | bludgeoning | Strength | light | — | — |
| Dagger | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, light, thrown | 20/60 ft | — |
| Greatclub | 1d8 | bludgeoning | Strength | two-handed | — | — |
| Handaxe | 1d6 | slashing | Strength | light, thrown | 20/60 ft | — |
| Javelin | 1d6 | piercing | Strength | thrown | 30/120 ft | — |
| Light Hammer | 1d4 | bludgeoning | Strength | light, thrown | 20/60 ft | — |
| Mace | 1d6 | bludgeoning | Strength | — | — | — |
| Quarterstaff | 1d6 | bludgeoning | Strength | versatile (1d8) | — | — |
| Sickle | 1d4 | slashing | Strength | light | — | — |
| Spear | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft | — |
### Simple Ranged (4)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Light Crossbow | 1d8 | piercing | Dexterity | ammunition, loading, two-handed | 80/320 ft |
| Dart | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, thrown | 20/60 ft |
| Shortbow | 1d6 | piercing | Dexterity | ammunition, two-handed | 80/320 ft |
| Sling | 1d4 | bludgeoning | Dexterity | ammunition | 30/120 ft |
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Light Crossbow | 1d8 | piercing | Dexterity | ammunition, loading, two-handed | 80/320 ft | — |
| Dart | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, thrown | 20/60 ft | — |
| Shortbow | 1d6 | piercing | Dexterity | ammunition, two-handed | 80/320 ft | — |
| Sling | 1d4 | bludgeoning | Dexterity | ammunition | 30/120 ft | — |
### Martial Melee (18)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Battleaxe | 1d8 | slashing | Strength | versatile (1d10) | — |
| Flail | 1d8 | bludgeoning | Strength | — | — |
| Glaive | 1d10 | slashing | Strength | heavy, reach, two-handed | — |
| Greataxe | 1d12 | slashing | Strength | heavy, two-handed | — |
| Greatsword | 2d6 | slashing | Strength | heavy, two-handed | — |
| Halberd | 1d10 | slashing | Strength | heavy, reach, two-handed | — |
| Lance | 1d12 | piercing | Strength | reach, special | — |
| Longsword | 1d8 | slashing | Strength | versatile (1d10) | — |
| Maul | 2d6 | bludgeoning | Strength | heavy, two-handed | — |
| Morningstar | 1d8 | piercing | Strength | — | — |
| Pike | 1d10 | piercing | Strength | heavy, reach, two-handed | — |
| Rapier | 1d8 | piercing | Strength or Dexterity (finesse) | finesse | — |
| Scimitar | 1d6 | slashing | Strength or Dexterity (finesse) | finesse, light | — |
| Shortsword | 1d6 | piercing | Strength or Dexterity (finesse) | finesse, light | — |
| Trident | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft |
| War Pick | 1d8 | piercing | Strength | — | — |
| Warhammer | 1d8 | bludgeoning | Strength | versatile (1d10) | — |
| Whip | 1d4 | slashing | Strength or Dexterity (finesse) | finesse, reach | — |
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Battleaxe | 1d8 | slashing | Strength | versatile (1d10) | — | — |
| Flail | 1d8 | bludgeoning | Strength | — | — | — |
| Glaive | 1d10 | slashing | Strength | heavy, reach, two-handed | — | — |
| Greataxe | 1d12 | slashing | Strength | heavy, two-handed | — | — |
| Greatsword | 2d6 | slashing | Strength | heavy, two-handed | — | — |
| Halberd | 1d10 | slashing | Strength | heavy, reach, two-handed | — | — |
| Lance | 1d12 | piercing | Strength | reach, special | — | — |
| Longsword | 1d8 | slashing | Strength | versatile (1d10) | — | — |
| Maul | 2d6 | bludgeoning | Strength | heavy, two-handed | — | — |
| Morningstar | 1d8 | piercing | Strength | — | — | — |
| Pike | 1d10 | piercing | Strength | heavy, reach, two-handed | — | — |
| Rapier | 1d8 | piercing | Strength or Dexterity (finesse) | finesse | — | — |
| Scimitar | 1d6 | slashing | Strength or Dexterity (finesse) | finesse, light | — | — |
| Shortsword | 1d6 | piercing | Strength or Dexterity (finesse) | finesse, light | — | — |
| Trident | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft | — |
| War Pick | 1d8 | piercing | Strength | — | — | — |
| Warhammer | 1d8 | bludgeoning | Strength | versatile (1d10) | — | — |
| Whip | 1d4 | slashing | Strength or Dexterity (finesse) | finesse, reach | — | — |
### Martial Ranged (5)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Blowgun | 1 | piercing | Dexterity | ammunition, loading | 25/100 ft |
| Hand Crossbow | 1d6 | piercing | Dexterity | ammunition, light, loading | 30/120 ft |
| Heavy Crossbow | 1d10 | piercing | Dexterity | ammunition, heavy, loading, two-handed | 100/400 ft |
| Longbow | 1d8 | piercing | Dexterity | ammunition, heavy, two-handed | 150/600 ft |
| Net | - | special | Dexterity | special, thrown | 5/15 ft |
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Blowgun | 1 | piercing | Dexterity | ammunition, loading | 25/100 ft | — |
| Hand Crossbow | 1d6 | piercing | Dexterity | ammunition, light, loading | 30/120 ft | — |
| Heavy Crossbow | 1d10 | piercing | Dexterity | ammunition, heavy, loading, two-handed | 100/400 ft | — |
| Longbow | 1d8 | piercing | Dexterity | ammunition, heavy, two-handed | 150/600 ft | — |
| Net | - | special | Dexterity | special, thrown | 5/15 ft | — |
### Paired (4)
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Paired Daggers | 2d4 | piercing | Strength or Dexterity (finesse) | finesse, light, paired | — | A dagger in each hand: takes both hands. |
| Paired Shortswords | 2d6 | piercing | Strength or Dexterity (finesse) | finesse, light, paired | — | A shortsword in each hand: takes both hands. |
| Paired Scimitars | 2d6 | slashing | Strength or Dexterity (finesse) | finesse, light, paired | — | A scimitar in each hand: takes both hands. |
| Paired Handaxes | 2d6 | slashing | Strength | light, paired | — | A handaxe in each hand: takes both hands. |
<!-- END GENERATED: weapon-library -->
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "1.11.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -9,6 +9,7 @@ import { PartyOverviewPage } from './pages/PartyOverviewPage';
import { CharacterSheetPage } from './pages/CharacterSheetPage';
import { CharacterBuilderPage } from './pages/CharacterBuilderPage';
import { EncounterPage } from './pages/EncounterPage';
import { AccountPage } from './pages/AccountPage';
function App() {
return (
@@ -24,6 +25,7 @@ function App() {
<Route path="/characters/:characterId" element={<CharacterSheetPage />} />
<Route path="/characters/:characterId/build" element={<CharacterBuilderPage />} />
<Route path="/encounters/:encounterId" element={<EncounterPage />} />
<Route path="/account" element={<AccountPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+246 -14
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,16 +7,19 @@ import type {
CampaignMemberSummary,
CharacterDetail,
CharacterSummary,
Currency,
DiceMode,
Encounter,
EncounterMode,
EncounterParticipant,
GearEffect,
GoldTransaction,
Item,
Location,
Monster,
Note,
Npc,
SaleOffer,
QuickStats,
Spell,
Weapon,
@@ -62,7 +65,7 @@ export function useCreateCampaign() {
export function useUpdateCampaign(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Partial<Campaign>) =>
mutationFn: async (data: Partial<Campaign> & { archived?: boolean }) =>
(await apiClient.patch<{ campaign: Campaign }>(`/campaigns/${campaignId}`, data)).data.campaign,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
@@ -71,6 +74,31 @@ export function useUpdateCampaign(campaignId: string) {
});
}
// Archived campaigns stay whole; they only move off the main list.
export function useSetCampaignArchived() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ campaignId, archived }: { campaignId: string; archived: boolean }) =>
(await apiClient.patch<{ campaign: Campaign }>(`/campaigns/${campaignId}`, { archived })).data.campaign,
onSuccess: (campaign) => {
qc.invalidateQueries({ queryKey: ['campaign', campaign.id] });
qc.invalidateQueries({ queryKey: ['campaigns'] });
},
});
}
// Permanent: takes every character, encounter and note in the campaign with it.
export function useDeleteCampaign() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (campaignId: string) => apiClient.delete(`/campaigns/${campaignId}`),
onSuccess: (_res, campaignId) => {
qc.removeQueries({ queryKey: ['campaign', campaignId] });
qc.invalidateQueries({ queryKey: ['campaigns'] });
},
});
}
export function useJoinCampaign() {
const qc = useQueryClient();
return useMutation({
@@ -137,6 +165,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({
@@ -147,7 +197,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 };
@@ -186,6 +236,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
// --------------------------------------------------------------------------
@@ -348,10 +408,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,
@@ -362,8 +423,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
});
}
@@ -404,12 +465,22 @@ 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;
effects?: GearEffect[];
effectNote?: string | null;
}) =>
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, pool] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, pool] });
// What an entry does while equipped changes the sheet of whoever holds it.
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
},
});
}
@@ -477,6 +548,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({
@@ -489,10 +574,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,
});
@@ -508,6 +593,14 @@ export function useAddWeaponFromLibrary(campaignId: string) {
});
}
export function useAddItemFromLibrary(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) => (await apiClient.post<{ item: Item }>(`/campaigns/${campaignId}/items/from-library`, { key })).data.item,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'items'] }),
});
}
export function useAddSpellFromLibrary(campaignId: string) {
const qc = useQueryClient();
return useMutation({
@@ -579,6 +672,51 @@ export function useCreateEncounter(campaignId: string) {
});
}
// Sets the campaign's own encounter order to the ids given, first to last.
export function useReorderEncounters(campaignId: string) {
const qc = useQueryClient();
const key = ['campaign', campaignId, 'encounters'];
return useMutation({
mutationFn: async (encounterIds: string[]) => apiClient.put(`/campaigns/${campaignId}/encounters/order`, { encounterIds }),
// Show the move straight away; the refetch below confirms or undoes it.
onMutate: (encounterIds) => {
qc.setQueryData<Encounter[]>(key, (old) => {
if (!old) return old;
const byId = new Map(old.map((e) => [e.id, e]));
return encounterIds.flatMap((id, sortOrder) => {
const encounter = byId.get(id);
return encounter ? [{ ...encounter, sortOrder }] : [];
});
});
},
onSettled: () => qc.invalidateQueries({ queryKey: key }),
});
}
export function useSetEncounterArchived(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ encounterId, archived }: { encounterId: string; archived: boolean }) =>
(await apiClient.patch(`/encounters/${encounterId}`, { archived })).data,
onSuccess: (_data, { encounterId }) => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
},
});
}
// Permanent. Rewards already handed out stay with the characters.
export function useDeleteEncounter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (encounterId: string) => apiClient.delete(`/encounters/${encounterId}`),
onSuccess: (_res, encounterId) => {
qc.removeQueries({ queryKey: ['encounter', encounterId] });
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
},
});
}
export function useEncounter(encounterId: string | undefined) {
return useQuery({
queryKey: ['encounter', encounterId],
@@ -590,7 +728,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] }),
});
@@ -631,16 +769,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({
@@ -654,7 +820,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 };
@@ -710,6 +876,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
// --------------------------------------------------------------------------
+183 -11
View File
@@ -87,12 +87,20 @@ 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;
// How many weapons and items a character can have equipped together, and
// how many of those can be weapons. Only equipped gear is used in a fight.
maxEquipped: number;
maxEquippedWeapons: number;
ownerId: string;
archivedAt: string | null; // set while the campaign is archived
role: 'DM' | 'PLAYER';
createdAt: string;
}
@@ -103,6 +111,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;
@@ -116,7 +140,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;
@@ -137,6 +166,21 @@ export interface AbilityDetail {
modifier: number;
}
// One thing a weapon or item does to whoever has it equipped: a signed amount
// on one of the sheet's numbers ("AC", "SPEED", "STR", "SAVE_DEX",
// "SKILL_STEALTH"...). The full list is GEAR_STATS on the server.
export interface GearEffect {
stat: string;
amount: number;
}
// What a character's equipped gear is doing right now. The sheet's numbers
// already include it; this says where each change comes from.
export interface ActiveGear {
effects: (GearEffect & { source: string })[];
notes: { source: string; note: string }[];
}
export interface CharacterSheet {
abilities: Record<Ability, AbilityDetail>;
proficiencyBonus: number;
@@ -149,6 +193,7 @@ export interface CharacterSheet {
skills: Record<string, { ability: Ability; proficient: boolean; expertise: boolean; bonus: number }>;
passivePerception: number;
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
gear: ActiveGear;
}
export interface Weapon {
@@ -167,6 +212,8 @@ export interface Weapon {
damageScaling: number;
minLevel: number;
isStartingOption: boolean;
effects: GearEffect[]; // what it does to whoever has it equipped
effectNote: string | null; // and what it does that is not a number
}
export interface CharacterWeapon {
@@ -193,6 +240,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 {
@@ -214,6 +262,8 @@ export interface Item {
damageScaling: number;
minLevel: number;
isStartingOption: boolean;
effects: GearEffect[]; // armor is an item with an AC effect
effectNote: string | null;
}
export interface InventoryItem {
@@ -231,8 +281,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;
@@ -265,6 +316,7 @@ export interface CharacterDetail {
rolledScores: Record<Ability, number>;
currentHp: number;
tempHp: number;
deathSaves: DeathSaves;
armorBonus: number;
specialAbilities: string | null;
background: string | null;
@@ -275,7 +327,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[];
@@ -310,6 +368,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;
@@ -317,6 +392,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.
@@ -332,7 +412,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;
@@ -356,6 +437,15 @@ export interface CombatWeapon {
damageType: string;
}
// An item a character has equipped, as everyone in an encounter sees it.
export interface CombatItem {
inventoryItemId: string;
name: string;
level: number;
quantity: number;
damageDice: string | null;
}
export interface CombatSpell {
characterSpellId: string;
name: string;
@@ -371,6 +461,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;
@@ -378,7 +470,9 @@ export interface CharacterCombatInfo {
abilities: Record<Ability, AbilityDetail>;
saves: Record<Ability, { proficient: boolean; bonus: number }>;
specialAbilities: string | null;
weapons: CombatWeapon[];
gear: ActiveGear;
weapons: CombatWeapon[]; // the equipped ones: a weapon has to be in hand to be used
items: CombatItem[]; // likewise
spells: CombatSpell[];
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
}
@@ -389,14 +483,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;
@@ -411,11 +515,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 {
@@ -439,6 +555,18 @@ export interface LibraryWeapon {
properties: string[];
rangeNormal: number | null;
rangeLong: number | null;
effects?: GearEffect[];
effectNote?: string | null;
}
export interface LibraryItem {
key: string;
name: string;
category: string;
description: string | null;
damageDice: string | null;
effects: GearEffect[];
effectNote: string | null;
}
export interface LibrarySpell {
@@ -451,6 +579,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[];
}
@@ -475,6 +604,7 @@ export interface LibraryMonster {
export interface CampaignLibrary {
weapons: LibraryWeapon[];
items: LibraryItem[];
spells: LibrarySpell[];
monsters: LibraryMonster[];
}
@@ -486,22 +616,64 @@ export interface QuickStatsWeapon {
level: number;
equipped: boolean;
quantity: number;
usable: boolean; // false while the character is below this copy's level
effects: GearEffect[];
effectNote: string | null;
attackBonus: number;
damageBonus: number;
damageDice: string;
damageType: string;
}
export interface QuickStats {
export interface QuickStatsItem {
inventoryItemId: string;
itemId: string;
name: string;
description: string | null;
level: number;
equipped: boolean;
quantity: number;
usable: boolean;
damageDice: string | null; // at this copy's level
effects: GearEffect[];
effectNote: string | null;
}
export interface QuickStatsSpell {
characterSpellId: string;
name: string;
level: number;
damageDice: string | null;
description: string | null;
prepared: boolean;
usable: boolean;
}
// A character at a glance, with everything it holds. The pack, the spell
// list and the purse are only sent to the owning player and the DM
// (`restricted` is true for anyone else).
export interface QuickStats {
id: string;
name: string;
level: number;
species: Species | null;
characterClass: CharacterClass | null;
currentHp: number;
maxHp: number;
tempHp: number;
deathSaves: DeathSaves;
armorClass: number;
initiative: number;
speed: number;
proficiencyBonus: number;
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
passivePerception: number;
abilities: Record<Ability, AbilityDetail>;
specialAbilities: string | null;
gear: ActiveGear;
weapons: QuickStatsWeapon[];
restricted: boolean;
inventory: QuickStatsItem[];
spells: QuickStatsSpell[];
purse: { gold: number; silver: number; copper: number; cash: number } | null;
}
+298
View File
@@ -0,0 +1,298 @@
import { Link, useMatch } from 'react-router-dom';
import { useCampaign, useCampaignCharacters, usePatchCharacterWeapon, usePatchInventoryItem, useQuickStats } from '../api/queries';
import { useAuth } from '../auth/AuthContext';
import { useLastCampaignId } from '../lib/lastCampaign';
import { closeCharacterPanel, openCharacterPanel, useCharacterPanelId } from '../lib/characterPanel';
import { CLASS_LABELS, SPECIES_LABELS, fmtMod, formatPurse, spellLevelLabel } from '../lib/format';
import { effectLabel, effectsSummary } from '../lib/gear';
import { speciesIcon } from '../lib/speciesIcons';
import { Spinner, ErrorBanner } from './Spinner';
import { HpBar } from './HpBar';
import { DeathSaves } from './DeathSaves';
import { useState } from 'react';
import { extractErrorMessage } from '../lib/format';
import type { ActiveGear } from '../api/types';
// What equipped gear is doing, one line per thing: "Chain Mail: +6 AC · −10 Speed".
export function GearInForce({ gear }: { gear: ActiveGear }) {
const sources = [...new Set([...gear.effects.map((e) => e.source), ...gear.notes.map((n) => n.source)])];
if (sources.length === 0) return null;
return (
<>
{sources.map((source) => (
<div key={source} className="detail-row">
<strong>{source}</strong>
<span>
{[...gear.effects.filter((e) => e.source === source).map(effectLabel), ...gear.notes.filter((n) => n.source === source).map((n) => n.note)].join(' · ')}
</span>
</div>
))}
</>
);
}
function Flags({ equipped, usable, level }: { equipped: boolean; usable: boolean; level: number }) {
return (
<>
{equipped && <span className="badge badge-live" style={{ marginLeft: 6 }}>equipped</span>}
{!usable && <span className="badge badge-hostile" style={{ marginLeft: 6 }}>needs level {level}</span>}
</>
);
}
// One character at a glance: the numbers for a fight, and everything they
// hold. The pack, spells and purse are there for the character's own player
// and the DM, who can also put gear on and take it off from here.
function PanelBody({ characterId }: { characterId: string }) {
const { data, isLoading, error } = useQuickStats(characterId, true);
const patchWeapon = usePatchCharacterWeapon(characterId);
const patchItem = usePatchInventoryItem(characterId);
// Why something could not be equipped: too much on already.
const [equipError, setEquipError] = useState<string | null>(null);
const equip = { onError: (err: unknown) => setEquipError(extractErrorMessage(err)), onSuccess: () => setEquipError(null) };
if (isLoading) return <Spinner label="Reading the sheet…" />;
if (error || !data) return <ErrorBanner message="Could not load this character." />;
const canEquip = !data.restricted;
const stats: [string, string | number][] = [
['AC', data.armorClass],
['Initiative', fmtMod(data.initiative)],
['Speed', data.speed],
['Prof.', fmtMod(data.proficiencyBonus)],
['Passive Perc.', data.passivePerception],
];
return (
<div className="mt-md">
<div className="flex-between flex-wrap">
<div>
<h3 style={{ margin: 0 }}>
<span aria-hidden="true">{speciesIcon(data.species)}</span> {data.name}
</h3>
<div className="combatant-sub">
{data.species && SPECIES_LABELS[data.species]} {data.characterClass && CLASS_LABELS[data.characterClass]} · Level {data.level}
</div>
</div>
<Link to={`/characters/${data.id}`} className="btn" onClick={closeCharacterPanel}>
Full sheet
</Link>
</div>
<div className="stat-chips mt-sm">
<span className="stat-chip">
<span>HP</span>
<b>
{data.currentHp}/{data.maxHp}
{data.tempHp > 0 && ` +${data.tempHp}`}
</b>
</span>
{stats.map(([label, value]) => (
<span key={label} className="stat-chip">
<span>{label}</span>
<b>{value}</b>
</span>
))}
</div>
<div className="mt-sm">
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
</div>
{/* 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} />
<div className="stat-chips mt-sm">
{(Object.keys(data.abilities) as (keyof typeof data.abilities)[]).map((a) => (
<span key={a} className="stat-chip">
<span>{a}</span>
<b>
{data.abilities[a].final} ({fmtMod(data.abilities[a].modifier)})
</b>
</span>
))}
</div>
{(data.gear.effects.length > 0 || data.gear.notes.length > 0) && (
<div className="card mt-md">
<div className="card-title">From equipped gear</div>
<GearInForce gear={data.gear} />
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>Already counted in the numbers above.</p>
</div>
)}
{data.spellcasting && (
<div className="card mt-md">
<div className="card-title">Spellcasting</div>
<p style={{ margin: 0 }}>
Save DC {data.spellcasting.saveDC} · Attack {fmtMod(data.spellcasting.attackBonus)}
</p>
</div>
)}
{canEquip && (
<p className="combatant-sub mt-md" style={{ marginBottom: 0 }}>
Tick what is equipped. Only equipped weapons can be used in an encounter, and only equipped gear gives its effects.
</p>
)}
{equipError && <ErrorBanner message={equipError} />}
<div className="card mt-md">
<div className="card-title">Weapons · {data.weapons.length}</div>
{data.weapons.length === 0 && <p className="combatant-sub" style={{ margin: 0 }}>No weapons.</p>}
{data.weapons.map((w) => (
<div key={w.characterWeaponId} className="reward-row">
<div className="flex-between">
<div>
<strong>{w.name}</strong>
{w.quantity > 1 && <span className="combatant-sub"> ×{w.quantity}</span>}
<Flags equipped={w.equipped} usable={w.usable} level={w.level} />
<div className="combatant-sub">
Lv {w.level} · attack {fmtMod(w.attackBonus)} · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
</div>
{effectsSummary(w.effects, w.effectNote) && <div className="combatant-sub">✦ {effectsSummary(w.effects, w.effectNote)}</div>}
</div>
{canEquip && (
<input
type="checkbox"
aria-label={`${w.name} is equipped`}
title="Equipped"
checked={w.equipped}
disabled={!w.usable || patchWeapon.isPending}
onChange={(e) => patchWeapon.mutate({ weaponId: w.weaponId, equipped: e.target.checked }, equip)}
/>
)}
</div>
</div>
))}
</div>
{data.restricted ? (
<p className="combatant-sub mt-md">Their pack, spells and purse are their own business.</p>
) : (
<>
<div className="card mt-md">
<div className="card-title">Inventory · {data.inventory.length}</div>
{data.inventory.length === 0 && <p className="combatant-sub" style={{ margin: 0 }}>Nothing carried.</p>}
{data.inventory.map((item) => (
<div key={item.inventoryItemId} className="reward-row">
<div className="flex-between">
<div>
<strong>{item.name}</strong>
<span className="combatant-sub"> ×{item.quantity}</span>
<Flags equipped={item.equipped} usable={item.usable} level={item.level} />
<div className="combatant-sub">
Lv {item.level}
{item.damageDice && ` · ${item.damageDice}`}
</div>
{effectsSummary(item.effects, item.effectNote) && <div className="combatant-sub">✦ {effectsSummary(item.effects, item.effectNote)}</div>}
{item.description && <div className="combatant-sub">{item.description}</div>}
</div>
<input
type="checkbox"
aria-label={`${item.name} is equipped`}
title="Equipped"
checked={item.equipped}
disabled={!item.usable || patchItem.isPending}
onChange={(e) => patchItem.mutate({ itemId: item.itemId, equipped: e.target.checked }, equip)}
/>
</div>
</div>
))}
</div>
<div className="card mt-md">
<div className="card-title">
<span>Money</span>
{data.purse && <span className="mono">{formatPurse(data.purse)}</span>}
</div>
</div>
{data.spells.length > 0 && (
<div className="card mt-md">
<div className="card-title">Spells · {data.spells.length}</div>
{data.spells.map((s) => (
<div key={s.characterSpellId} className="reward-row">
<strong>{s.name}</strong>
<span className="combatant-sub">
{' '}
{spellLevelLabel(s.level)}
{s.damageDice && ` · ${s.damageDice}`}
</span>
{s.prepared && <span className="badge badge-live" style={{ marginLeft: 6 }}>prepared</span>}
{!s.usable && <span className="badge badge-hostile" style={{ marginLeft: 6 }}>needs level {s.level}</span>}
{s.description && <div className="combatant-sub">{s.description}</div>}
</div>
))}
</div>
)}
</>
)}
{data.specialAbilities && (
<div className="card mt-md">
<div className="card-title">Special Abilities</div>
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{data.specialAbilities}</p>
</div>
)}
</div>
);
}
// The floating character button, on every page, and the panel it opens. A
// player gets their own characters in the campaign they are in; the DM gets
// everyone's. Other pages can open the panel for a particular character with
// openCharacterPanel (lib/characterPanel.ts).
export function CharacterPanel() {
const { user } = useAuth();
const campaignId = useLastCampaignId() ?? undefined;
const { data: campaignData } = useCampaign(campaignId);
const { data: characters } = useCampaignCharacters(campaignId);
const onSheet = useMatch('/characters/:characterId');
const openId = useCharacterPanelId();
const isDM = campaignData?.campaign.role === 'DM';
const choices = (characters ?? []).filter((c) => c.buildStage === 'COMPLETE' && (isDM || c.userId === user?.id));
if (choices.length === 0 && !openId) return null;
// On a character's own sheet the button opens that character; otherwise the first of yours.
const fallback = choices.find((c) => c.id === onSheet?.params.characterId) ?? choices.find((c) => c.userId === user?.id) ?? choices[0];
const shown = openId ?? undefined;
return (
<>
{fallback && (
<button
className="fab-quickstats"
onClick={() => openCharacterPanel(fallback.id)}
title={isDM ? 'Characters: stats and inventory' : 'Your character: stats and inventory'}
aria-label="Open the character panel"
>
🧙
</button>
)}
{shown && (
<>
<div className="drawer-backdrop" onClick={closeCharacterPanel} />
<div className="drawer" role="dialog" aria-label="Character panel">
<div className="flex-between">
{choices.length > 1 && choices.some((c) => c.id === shown) ? (
<select aria-label="Which character" value={shown} onChange={(e) => openCharacterPanel(e.target.value)} style={{ flex: 1, minWidth: 0 }}>
{choices.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{isDM && c.userId === user?.id ? ' (yours)' : ''}
</option>
))}
</select>
) : (
<h2 style={{ margin: 0 }}>Character</h2>
)}
<button className="btn btn-ghost" onClick={closeCharacterPanel} aria-label="Close the character panel">
✕
</button>
</div>
<PanelBody key={shown} characterId={shown} />
</div>
</>
)}
</>
);
}
+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>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useState } from 'react';
import { NumberInput } from './NumberInput';
import { GEAR_STAT_GROUPS, effectLabel, gearStatLabel } from '../lib/gear';
import type { GearEffect } from '../api/types';
// The DM's editor for what a weapon or item does to whoever has it equipped:
// a list of signed amounts on the sheet's numbers (armor is "+6 AC"), and a
// note for anything that is not a number. It holds nothing itself: the list
// and the note live with whoever renders it.
export function EffectsEditor({
effects,
note,
onChange,
}: {
effects: GearEffect[];
note: string;
onChange: (effects: GearEffect[], note: string) => void;
}) {
const [stat, setStat] = useState('AC');
const [amount, setAmount] = useState('');
function handleAdd() {
const value = Math.trunc(Number(amount));
if (!value) return;
// One line per number: adding to one already listed replaces it.
onChange([...effects.filter((e) => e.stat !== stat), { stat, amount: value }], note);
setAmount('');
}
return (
<div>
<div className="section-label">While equipped</div>
{effects.length === 0 && (
<p className="combatant-sub" style={{ marginTop: 0 }}>
No effects. Add a bonus (armor: +AC) or a penalty (heavy armor: −Speed).
</p>
)}
{effects.length > 0 && (
<div className="stat-chips" style={{ marginBottom: '0.5rem' }}>
{effects.map((effect) => (
<span key={effect.stat} className={`condition-chip ${effect.amount > 0 ? 'is-bonus' : ''}`}>
{effectLabel(effect)}
<button type="button" aria-label={`Remove the ${gearStatLabel(effect.stat)} effect`} onClick={() => onChange(effects.filter((e) => e !== effect), note)}>
✕
</button>
</span>
))}
</div>
)}
<div className="flex-row flex-wrap gap-sm">
<select aria-label="What it changes" value={stat} onChange={(e) => setStat(e.target.value)} style={{ flex: 1, minWidth: 130 }}>
{GEAR_STAT_GROUPS.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.stats.map((s) => (
<option key={s} value={s}>
{gearStatLabel(s)}
</option>
))}
</optgroup>
))}
</select>
<NumberInput
min={-100}
max={100}
placeholder="+ or −"
aria-label="By how much (negative for a penalty)"
title="By how much. A negative number is a penalty."
value={amount}
onChange={(e) => setAmount(e.target.value)}
style={{ width: 90 }}
/>
<button type="button" className="btn" onClick={handleAdd} disabled={!Math.trunc(Number(amount))}>
Add effect
</button>
</div>
<input
className="mt-sm"
style={{ width: '100%' }}
placeholder="Anything else it does (e.g. Disadvantage on Stealth)"
aria-label="What else it does, in words"
maxLength={300}
value={note}
onChange={(e) => onChange(effects, e.target.value)}
/>
</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>
);
}
+41 -7
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.',
@@ -14,7 +16,7 @@ export const STAT_HELP: Record<string, string> = {
SKILL_BONUS: 'Skill Bonus — added when you roll to use a skill. The governing ability modifier, plus your proficiency bonus if you are proficient (doubled if you have expertise).',
EXPERTISE: 'Expertise — doubles your proficiency bonus for this skill, on top of being proficient.',
ABILITY_MODIFIER: 'Ability Modifier — the number added to rolls that use this ability. floor((score − 10) / 2).',
ARMOR_BONUS: 'Armor Bonus — a flat bonus to AC from equipped armor or a shield, added on top of 10 + Dex modifier.',
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item.',
SPEED: 'Speed — how far you can move on your turn, in feet. Set by your species, unless overridden.',
SPELL_SAVE_DC: 'Spell Save DC — the difficulty a target must beat to resist your spell. 8 + proficiency bonus + your spellcasting ability modifier.',
SPELL_ATTACK_BONUS: 'Spell Attack Bonus — added when you make a spell attack roll. Proficiency bonus + your spellcasting ability modifier.',
@@ -38,8 +40,36 @@ export const STAT_HELP: Record<string, string> = {
// (a species, a skill, an ability) without adding it to the shared list.
export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?: keyof typeof STAT_HELP; text?: string; label?: string }) {
const [open, setOpen] = useState(false);
const [place, setPlace] = useState<CSSProperties | null>(null);
const trigger = useRef<HTMLButtonElement>(null);
const id = useId();
const text = custom ?? (term ? STAT_HELP[term] : undefined);
// The bubble is drawn on the page itself, fixed to where the ⓘ is on
// screen, rather than inside whatever holds the ⓘ: a scrolling table or a
// drawer would otherwise cut it off. It sits above the ⓘ when there is room
// and below when there isn't, and never runs off the sides.
useEffect(() => {
if (!open || !trigger.current) return;
const rect = trigger.current.getBoundingClientRect();
const halfWidth = Math.min(130, window.innerWidth / 2 - 8);
const centre = Math.min(Math.max(rect.left + rect.width / 2, halfWidth + 8), window.innerWidth - halfWidth - 8);
const above = rect.top > 160;
setPlace({
left: centre,
...(above ? { bottom: window.innerHeight - rect.top + 8 } : { top: rect.bottom + 8 }),
});
// Its position is only right until the page moves.
const close = () => setOpen(false);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
return () => {
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
setPlace(null); // so the next opening is measured afresh, not shown where the last one was
};
}, [open]);
if (!text) return null;
return (
@@ -49,6 +79,7 @@ export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?
className="info-tip-trigger"
aria-describedby={id}
aria-label={label}
ref={trigger}
onMouseEnter={() => setOpen(true)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
@@ -56,11 +87,14 @@ export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?
>
ⓘ
</button>
{open && (
<span role="tooltip" id={id} className="info-tip-bubble">
{text}
</span>
)}
{open &&
place &&
createPortal(
<span role="tooltip" id={id} className="info-tip-bubble" style={place}>
{text}
</span>,
document.body,
)}
</span>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
// A row of links to the sections of a long page. It finds them itself: any
// element with a `data-jump="Label"` attribute that is on the page right now
// gets a link, so sections only some people see need no special handling.
export function JumpNav() {
const [labels, setLabels] = useState<string[]>([]);
// Sections come and go as data loads, so the page is watched for them.
useEffect(() => {
const look = () => {
const found = [...document.querySelectorAll<HTMLElement>('[data-jump]')].map((el) => el.dataset.jump ?? '');
setLabels((current) => (current.join('|') === found.join('|') ? current : found));
};
const observer = new MutationObserver(look);
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-jump'] });
look();
return () => observer.disconnect();
}, []);
if (labels.length < 3) return null;
return (
<nav className="jump-nav" aria-label="Sections of this page">
{labels.map((label) => (
<button
key={label}
type="button"
className="jump-link"
onClick={() => document.querySelector(`[data-jump="${label}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
>
{label}
</button>
))}
</nav>
);
}
+43 -6
View File
@@ -1,10 +1,40 @@
import { Link, Outlet } from 'react-router-dom';
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
import { DiceRoller } from './DiceRoller';
import { CharacterPanel } from './CharacterPanel';
export function Layout() {
const { user, logout } = useAuth();
// The campaign pages say which campaign they are in their address; sheets
// and encounters report theirs themselves (useRememberCampaign).
const onCampaign = useMatch('/campaigns/:campaignId/*');
useRememberCampaign(onCampaign?.params.campaignId);
const campaignId = useLastCampaignId();
// The same destinations twice: along the top bar on a wide screen, and as a
// bar along the bottom, in reach of a thumb, on a phone.
const destinations = [
{ to: '/campaigns', end: true, icon: '🗺', label: 'Campaigns' },
...(campaignId
? [
{ to: `/campaigns/${campaignId}`, end: true, icon: '🏰', label: 'Campaign' },
{ to: `/campaigns/${campaignId}/party`, end: true, icon: '👥', label: 'Party' },
]
: []),
{ to: '/account', end: true, icon: '👤', label: 'Account' },
];
const links = (className: string) =>
destinations.map((d) => (
<NavLink key={d.to} to={d.to} end={d.end} className={({ isActive }) => `${className}${isActive ? ' is-active' : ''}`}>
<span className="nav-icon" aria-hidden>
{d.icon}
</span>
<span>{d.label}</span>
</NavLink>
));
return (
<div className="app-shell">
<header className="topbar">
@@ -14,21 +44,28 @@ export function Layout() {
</span>
The Sunken Reach
</Link>
<nav className="topbar-nav">
{user && (
{user && (
<nav className="topbar-nav" aria-label="Main">
<div className="topbar-links">{links('topbar-link')}</div>
<div className="topbar-user">
<span>{user.displayName}</span>
<span className="topbar-name">{user.displayName}</span>
<button className="btn btn-ghost" onClick={logout}>
Log out
</button>
</div>
)}
</nav>
</nav>
)}
</header>
<main className="page">
<Outlet />
</main>
{user && (
<nav className="bottom-nav" aria-label="Main">
{links('bottom-nav-link')}
</nav>
)}
<DiceRoller />
{user && <CharacterPanel />}
</div>
);
}
+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} />;
}
@@ -1,96 +0,0 @@
import { useState } from 'react';
import { useQuickStats } from '../api/queries';
import { Spinner, ErrorBanner } from './Spinner';
import { fmtMod, hpBarClass } from '../lib/format';
export function QuickStatsFab({ characterId }: { characterId: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button className="fab-quickstats" onClick={() => setOpen(true)} title="Quick Stats" aria-label="Open quick stats">
⚡
</button>
{open && <QuickStatsDrawer characterId={characterId} onClose={() => setOpen(false)} />}
</>
);
}
export function QuickStatsDrawer({ characterId, onClose }: { characterId: string; onClose: () => void }) {
const { data, isLoading, error } = useQuickStats(characterId, true);
return (
<>
<div className="drawer-backdrop" onClick={onClose} />
<div className="drawer">
<div className="flex-between">
<h2 style={{ margin: 0 }}>Quick Stats</h2>
<button className="btn btn-ghost" onClick={onClose}>
✕
</button>
</div>
{isLoading && <Spinner label="Reading the sheet…" />}
{error && <ErrorBanner message="Could not load quick stats." />}
{data && (
<div className="mt-md">
<h3 style={{ margin: 0 }}>{data.name}</h3>
<div className="grid grid-cols-3 mt-sm">
<div className="stat-block">
<span className="stat-label">HP</span>
<span className="stat-value">
{data.currentHp}/{data.maxHp}
</span>
{data.tempHp > 0 && <span className="stat-sub">+{data.tempHp} temp</span>}
</div>
<div className="stat-block">
<span className="stat-label">AC</span>
<span className="stat-value">{data.armorClass}</span>
</div>
<div className="stat-block">
<span className="stat-label">Initiative</span>
<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>
{data.spellcasting && (
<div className="card mt-md">
<div className="card-title">Spellcasting</div>
<p style={{ margin: 0 }}>
Save DC {data.spellcasting.saveDC} · Attack {fmtMod(data.spellcasting.attackBonus)}
</p>
</div>
)}
<div className="card mt-md">
<div className="card-title">Weapons</div>
{data.weapons.length === 0 && <p style={{ color: 'var(--text-muted)', margin: 0 }}>No weapons equipped.</p>}
{data.weapons.map((w) => (
<div key={w.characterWeaponId} className="flex-between mt-sm">
<div>
<strong>{w.name}</strong>
{w.equipped && <span className="badge" style={{ marginLeft: 6 }}>equipped</span>}
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Lv {w.level} · {w.damageDice} {w.damageType} {fmtMod(w.damageBonus)}
</p>
</div>
<span className="stat-value" style={{ fontSize: '1.1rem' }}>
{fmtMod(w.attackBonus)}
</span>
</div>
))}
</div>
{data.specialAbilities && (
<div className="card mt-md">
<div className="card-title">Special Abilities</div>
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{data.specialAbilities}</p>
</div>
)}
</div>
)}
</div>
</>
);
}
+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>
);
}
+10
View File
@@ -29,21 +29,30 @@ 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'] });
};
const onCampaignDeleted = () => {
qc.invalidateQueries({ queryKey: ['campaigns'] });
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
};
socket.on('character:updated', onCharacterUpdated);
socket.on('character:created', onCharacterCreated);
socket.on('encounter:participant-updated', onParticipantUpdated);
socket.on('encounter:round-advanced', onRoundAdvanced);
socket.on('campaign:deleted', onCampaignDeleted);
return () => {
socket.emit('campaign:leave', campaignId);
@@ -51,6 +60,7 @@ export function useCampaignSocket(campaignId: string | undefined) {
socket.off('character:created', onCharacterCreated);
socket.off('encounter:participant-updated', onParticipantUpdated);
socket.off('encounter:round-advanced', onRoundAdvanced);
socket.off('campaign:deleted', onCampaignDeleted);
};
}, [campaignId, token, qc]);
}
+575 -8
View File
@@ -1,4 +1,6 @@
:root {
/* Dark throughout, so the browser draws its own controls dark too. */
color-scheme: dark;
--bg: #131009;
--bg-elevated: #1b160e;
--bg-card: #221b12;
@@ -347,10 +349,8 @@ textarea {
color: var(--text-muted);
}
input[type='text'],
input[type='email'],
input[type='password'],
input[type='number'],
/* Every text-like field, including an <input> with no type given. */
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']),
select,
textarea {
background: var(--bg-inset);
@@ -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;
@@ -1186,3 +1290,466 @@ table.data-table tbody tr:hover {
margin: 0;
white-space: pre-wrap;
}
/* ---------------------------------------------------------------------- */
/* Navigation: links along the top bar on a wide screen, a bar along the */
/* bottom on a phone; and a row of links to the sections of a long page */
/* ---------------------------------------------------------------------- */
.topbar-links {
display: flex;
align-items: center;
gap: 0.25rem;
}
.topbar-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border-radius: 6px;
border: 1px solid transparent;
color: var(--text-muted);
font-family: var(--font-display);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.topbar-link:hover {
text-decoration: none;
color: var(--accent-strong);
border-color: var(--border);
}
.topbar-link.is-active {
color: var(--accent-strong);
border-color: var(--accent);
background: var(--accent-soft);
}
.topbar-name {
max-width: 14ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bottom-nav {
display: none;
}
.jump-nav {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 1.25rem;
}
.jump-link {
font-family: var(--font-display);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.4rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-inset);
color: var(--text-muted);
cursor: pointer;
}
.jump-link:hover {
border-color: var(--accent);
color: var(--accent-strong);
}
/* A section jumped to stops below the sticky top bar, not under it. */
[data-jump] {
scroll-margin-top: 4.5rem;
}
/* A table cell that holds a sentence rather than a figure may wrap. */
table.data-table td.cell-title,
table.data-table td.cell-wide {
white-space: normal;
min-width: 12rem;
}
/* ---------------------------------------------------------------------- */
/* Small screens. Nothing may be wider than the screen: rows of controls */
/* wrap, fields shrink to fit, tables become stacked cards, and the main */
/* destinations move to a bar along the bottom. */
/* ---------------------------------------------------------------------- */
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body,
#root,
.app-shell {
min-height: 100dvh;
}
/* A field never forces its row wider than the space there is. */
input,
select,
textarea {
max-width: 100%;
min-width: 0;
}
@media (max-width: 900px) {
:root {
--bottom-nav-height: calc(3.6rem + env(safe-area-inset-bottom, 0px));
--dice-slot: 4.2rem;
}
body {
overflow-wrap: break-word;
}
.page {
padding: 1rem 0.85rem calc(var(--bottom-nav-height) + 1.5rem);
}
.topbar {
padding: 0.5rem 0.85rem;
gap: 0.5rem;
}
.topbar-brand {
font-size: 1rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar-links,
.topbar-name {
display: none;
}
.topbar-nav {
flex-wrap: nowrap;
}
.bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 70;
display: flex;
height: var(--bottom-nav-height);
padding-bottom: env(safe-area-inset-bottom, 0px);
background: linear-gradient(180deg, #1e1810 0%, #120e08 100%);
border-top: 1px solid var(--border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.45);
}
.bottom-nav-link {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
color: var(--text-muted);
font-family: var(--font-display);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border-top: 2px solid transparent;
}
.bottom-nav-link:hover {
text-decoration: none;
}
.bottom-nav-link .nav-icon {
font-size: 1.25rem;
line-height: 1.2;
}
.bottom-nav-link.is-active {
color: var(--accent-strong);
border-top-color: var(--accent-strong);
background: var(--accent-soft);
}
/* Nothing floats over the page on a phone. The dice roller takes the last
place in the bottom bar, and quick stats a place in the top bar. */
.bottom-nav {
padding-right: var(--dice-slot);
}
.fab-dice {
left: auto;
right: 0;
bottom: 0;
z-index: 71;
width: var(--dice-slot);
height: var(--bottom-nav-height);
padding: 0 0 env(safe-area-inset-bottom, 0px);
flex-direction: column;
gap: 0.1rem;
border: none;
border-top: 2px solid transparent;
border-radius: 0;
background: transparent;
box-shadow: none;
font-size: 1.25rem;
line-height: 1.2;
color: var(--text-muted);
}
.fab-dice::after {
content: 'Dice';
font-family: var(--font-display);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.fab-dice:hover {
transform: none;
}
.dice-panel {
left: 0.85rem;
bottom: calc(var(--bottom-nav-height) + 0.6rem);
width: calc(100vw - 1.7rem);
max-height: calc(100dvh - var(--bottom-nav-height) - 5rem);
}
.fab-quickstats {
top: 0.3rem;
bottom: auto;
right: 6.4rem;
z-index: 31;
width: 40px;
height: 40px;
font-size: 1.15rem;
box-shadow: none;
}
/* The brand makes room for it. */
.app-shell:has(.fab-quickstats) .topbar-brand {
max-width: calc(100vw - 11rem);
}
.drawer {
width: 100vw;
padding: 1rem 0.85rem calc(var(--bottom-nav-height) + 1rem);
z-index: 95;
}
.drawer-backdrop {
z-index: 94;
}
h1 {
font-size: 1.5rem;
}
.card {
padding: 0.9rem 0.85rem;
}
.card-title {
flex-wrap: wrap;
}
.page-header {
margin-bottom: 1rem;
}
/* Rows of controls wrap instead of running off the side, and what is in
them may shrink: an inline min-width never beats the screen. */
.flex-row,
.flex-between,
.detail-row {
flex-wrap: wrap;
}
.flex-row > *,
.flex-between > * {
max-width: 100%;
}
.flex-row > input,
.flex-row > select,
.flex-row > textarea,
.flex-row > a,
.flex-between > a {
min-width: min(100%, 8rem) !important;
}
.btn {
white-space: normal;
padding: 0.6rem 0.9rem;
}
.grid-cols-2 {
grid-template-columns: minmax(0, 1fr);
}
.grid-cols-3 {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 130px), 1fr));
}
.choice-grid {
grid-template-columns: 1fr;
}
.btn-roll {
font-size: 1.1rem;
padding: 1.1rem 1.25rem;
max-width: 100%;
}
.step-footer > * {
flex: 1 1 auto;
}
/* Things to tap are big enough to hit with a thumb. */
input[type='checkbox'],
input[type='radio'] {
width: 22px;
height: 22px;
flex: none;
}
.death-pip {
width: 1.5rem;
height: 1.5rem;
}
.btn-target {
padding: 0.4rem 0.75rem;
}
.condition-chip {
padding: 0.3rem 0.6rem;
}
.condition-chip button {
padding: 0.15rem 0.35rem;
font-size: 1rem;
}
.info-tip-trigger {
font-size: 1.05em;
padding: 0.2rem;
}
.pick-chip {
padding: 0.5rem 0.9rem;
}
.jump-link {
padding: 0.5rem 0.8rem;
}
.attack-bar {
top: 3.4rem;
max-height: 55dvh;
overflow-y: auto;
}
[data-jump] {
scroll-margin-top: 3.8rem;
}
/* Tables: every row becomes a card. The first cell is its heading; the
rest carry their column's name from data-label. */
.table-wrap {
overflow: visible;
border: none;
}
table.data-table,
table.data-table tbody {
display: block;
}
table.data-table thead {
display: none;
}
table.data-table tbody tr {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.9rem;
padding: 0.7rem 0.75rem;
margin-bottom: 0.6rem;
background: var(--bg-inset);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
}
table.data-table tbody tr:last-child {
border-bottom: 1px solid var(--border-soft);
margin-bottom: 0;
}
table.data-table tbody tr:hover {
background: var(--bg-inset);
}
table.data-table td {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0;
white-space: normal;
min-width: 0;
}
table.data-table td:empty {
display: none;
}
table.data-table td.cell-title,
table.data-table td.cell-wide {
display: block;
flex: 1 1 100%;
min-width: 0;
}
table.data-table td.cell-title {
font-size: 1.05rem;
}
table.data-table td[data-label]::before {
content: attr(data-label);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
flex: none;
}
table.data-table td.cell-actions {
margin-left: auto;
}
}
/* The narrowest phones: five places in the bottom bar still fit. */
@media (max-width: 380px) {
:root {
--dice-slot: 3.4rem;
}
.bottom-nav-link,
.fab-dice::after {
font-size: 0.58rem;
letter-spacing: 0;
}
}
/* Between a phone and a full desktop the top bar drops the name to stay on one line. */
@media (max-width: 1080px) {
.topbar-name {
display: none;
}
.topbar-nav {
flex-wrap: nowrap;
}
}
/* ---------------------------------------------------------------------- */
/* "This one is yours": a player's own character, wherever it is listed */
/* ---------------------------------------------------------------------- */
.badge-mine {
border-color: var(--arcane-strong);
color: var(--arcane-strong);
background: rgba(92, 127, 176, 0.18);
}
.mine-marker {
color: var(--arcane-strong);
font-family: var(--font-display);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.combatant.is-mine {
border-color: var(--arcane-strong);
background: linear-gradient(160deg, #1d2431 0%, var(--bg-elevated) 100%);
box-shadow: 0 0 0 1px var(--arcane-strong), 0 0 20px rgba(127, 163, 214, 0.22), var(--shadow-deep);
}
/* Whose turn it is and who is targeted still win the border. */
.combatant.is-mine.is-active {
border-color: var(--accent-strong);
box-shadow: 0 0 0 1px var(--accent-strong), 0 0 22px rgba(201, 162, 39, 0.25), var(--shadow-deep);
}
.combatant.is-mine.is-targeted {
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);
}
.jump-link.is-mine {
border-color: var(--arcane-strong);
color: var(--arcane-strong);
background: rgba(92, 127, 176, 0.18);
}
table.data-table tbody tr.is-mine,
table.data-table tbody tr.is-mine:hover {
background: rgba(92, 127, 176, 0.16);
box-shadow: inset 4px 0 0 var(--arcane-strong);
}
/* A bonus from gear reads green; a penalty keeps the red of a condition. */
.condition-chip.is-bonus {
border-color: var(--success);
color: var(--success-strong);
background: rgba(95, 138, 79, 0.18);
}
+29
View File
@@ -0,0 +1,29 @@
import { useSyncExternalStore } from 'react';
// Which character the floating character panel is showing, if it is open.
// Kept outside React so that any page — an encounter card, say — can open the
// panel for a particular character without being wired to the layout.
let openId: string | null = null;
const listeners = new Set<() => void>();
function set(id: string | null) {
openId = id;
listeners.forEach((listener) => listener());
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function openCharacterPanel(characterId: string) {
set(characterId);
}
export function closeCharacterPanel() {
set(null);
}
export function useCharacterPanelId(): string | null {
return useSyncExternalStore(subscribe, () => openId);
}
+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;
+43
View File
@@ -0,0 +1,43 @@
import { ABILITY_LABELS, SKILL_LABELS, fmtMod } from './format';
import type { GearEffect } from '../api/types';
// What a weapon or item can change on the sheet of whoever has it equipped.
// Mirrors GEAR_STATS in backend/src/game/calculations.js; the server is what
// applies them, this is only for naming them and offering them in a form.
const ABILITIES = Object.keys(ABILITY_LABELS);
const SKILLS = Object.keys(SKILL_LABELS);
export const GEAR_STAT_GROUPS: { label: string; stats: string[] }[] = [
{ label: 'Combat', stats: ['AC', 'SPEED', 'INITIATIVE', 'MAX_HP', 'PASSIVE_PERCEPTION'] },
{ label: 'Ability scores', stats: ABILITIES },
{ label: 'Saving throws', stats: ['SAVES', ...ABILITIES.map((a) => `SAVE_${a}`)] },
{ label: 'Skills', stats: ['SKILLS', ...SKILLS.map((s) => `SKILL_${s}`)] },
];
const FIXED_LABELS: Record<string, string> = {
AC: 'AC',
SPEED: 'Speed',
INITIATIVE: 'Initiative',
MAX_HP: 'Max HP',
PASSIVE_PERCEPTION: 'Passive Perception',
SAVES: 'All saving throws',
SKILLS: 'All skill checks',
};
export function gearStatLabel(stat: string): string {
if (FIXED_LABELS[stat]) return FIXED_LABELS[stat];
if (stat.startsWith('SAVE_')) return `${ABILITY_LABELS[stat.slice(5)] ?? stat} saves`;
if (stat.startsWith('SKILL_')) return SKILL_LABELS[stat.slice(6)] ?? stat;
return ABILITY_LABELS[stat] ?? stat;
}
// "+6 AC", "−10 Speed".
export function effectLabel(effect: GearEffect): string {
return `${fmtMod(effect.amount).replace('-', '−')} ${gearStatLabel(effect.stat)}`;
}
// "+6 AC · −10 Speed · Disadvantage on Stealth", or null when it does nothing.
export function effectsSummary(effects: GearEffect[] | undefined, note?: string | null): string | null {
const parts = [...(effects ?? []).map(effectLabel), ...(note ? [note] : [])];
return parts.length ? parts.join(' · ') : null;
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useSyncExternalStore } from 'react';
// The campaign the user was last in, so the navigation can always offer a way
// back to it — from a character sheet or an encounter, whose addresses don't
// say which campaign they belong to. Kept per browser.
const KEY = 'dnd_last_campaign';
const listeners = new Set<() => void>();
function read(): string | null {
return localStorage.getItem(KEY);
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function forgetCampaign() {
localStorage.removeItem(KEY);
listeners.forEach((listener) => listener());
}
export function useLastCampaignId(): string | null {
return useSyncExternalStore(subscribe, read);
}
// Call from any page that knows which campaign it is showing.
export function useRememberCampaign(campaignId: string | null | undefined) {
useEffect(() => {
if (!campaignId || read() === campaignId) return;
localStorage.setItem(KEY, campaignId);
listeners.forEach((listener) => listener());
}, [campaignId]);
}
+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`;
}
+96
View File
@@ -0,0 +1,96 @@
import { useState, type FormEvent } from 'react';
import { apiClient } from '../api/client';
import { useAuth } from '../auth/AuthContext';
import { extractErrorMessage } from '../lib/format';
import { ErrorBanner } from '../components/Spinner';
export function AccountPage() {
const { user } = useAuth();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSaved(false);
if (newPassword !== confirmPassword) {
setError('The two new passwords do not match.');
return;
}
setSubmitting(true);
try {
await apiClient.post('/auth/password', { currentPassword, newPassword });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setSaved(true);
} catch (err) {
setError(extractErrorMessage(err));
} finally {
setSubmitting(false);
}
}
return (
<div style={{ maxWidth: 480 }}>
<div className="page-header">
<div>
<h1>Your Account</h1>
<p className="subtitle">
{user?.displayName} · {user?.email}
</p>
</div>
</div>
<div className="card">
<div className="card-title">Change Password</div>
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="currentPassword">Current password</label>
<input
id="currentPassword"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="newPassword">New password (at least 8 characters)</label>
<input
id="newPassword"
type="password"
autoComplete="new-password"
required
minLength={8}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="confirmPassword">New password again</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
required
minLength={8}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{error && <ErrorBanner message={error} />}
{saved && <p className="subtitle">Password changed. Use the new one next time you log in.</p>}
<button type="submit" className="btn btn-primary mt-md" disabled={submitting}>
{submitting ? 'Saving…' : 'Change Password'}
</button>
</form>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+46 -4
View File
@@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useCampaigns, useCreateCampaign, useJoinCampaign } from '../api/queries';
import { useCampaigns, useCreateCampaign, useJoinCampaign, useSetCampaignArchived } from '../api/queries';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { extractErrorMessage } from '../lib/format';
@@ -8,6 +8,7 @@ export function CampaignListPage() {
const { data: campaigns, isLoading, error } = useCampaigns();
const createCampaign = useCreateCampaign();
const joinCampaign = useJoinCampaign();
const setArchived = useSetCampaignArchived();
const navigate = useNavigate();
const [showCreate, setShowCreate] = useState(false);
@@ -15,6 +16,10 @@ export function CampaignListPage() {
const [description, setDescription] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const activeCampaigns = campaigns?.filter((c) => !c.archivedAt);
const archivedCampaigns = campaigns?.filter((c) => c.archivedAt) || [];
async function handleCreate(e: FormEvent) {
e.preventDefault();
@@ -89,11 +94,15 @@ export function CampaignListPage() {
<div className="mt-lg">
{isLoading && <Spinner label="Consulting the tavern board…" />}
{error && <ErrorBanner message="Could not load your campaigns." />}
{campaigns && campaigns.length === 0 && (
<div className="empty-state">No campaigns yet — found one, or join with an invite code above.</div>
{activeCampaigns && activeCampaigns.length === 0 && (
<div className="empty-state">
{archivedCampaigns.length > 0
? 'No active campaigns — found one, join with an invite code above, or restore one from the archive below.'
: 'No campaigns yet — found one, or join with an invite code above.'}
</div>
)}
<div className="grid grid-cols-2">
{campaigns?.map((c) => (
{activeCampaigns?.map((c) => (
<Link key={c.id} to={`/campaigns/${c.id}`} className="card" style={{ display: 'block' }}>
<div className="card-title">
<span>{c.name}</span>
@@ -104,6 +113,39 @@ export function CampaignListPage() {
))}
</div>
</div>
{archivedCampaigns.length > 0 && (
<div className="mt-lg">
<div className="flex-between">
<h2 className="section-label" style={{ margin: 0 }}>Archived ({archivedCampaigns.length})</h2>
<button className="btn btn-ghost" onClick={() => setShowArchived((s) => !s)}>
{showArchived ? 'Hide' : 'Show'}
</button>
</div>
{showArchived && (
<div className="grid grid-cols-2 mt-sm">
{archivedCampaigns.map((c) => (
<div key={c.id} className="card">
<div className="card-title">
<Link to={`/campaigns/${c.id}`}>{c.name}</Link>
<span className={`badge ${c.role === 'DM' ? 'badge-dm' : 'badge-player'}`}>{c.role}</span>
</div>
<p style={{ color: 'var(--text-muted)', marginTop: 0 }}>{c.description || 'No description yet.'}</p>
{c.role === 'DM' && (
<button
className="btn"
disabled={setArchived.isPending}
onClick={() => setArchived.mutate({ campaignId: c.id, archived: false })}
>
Restore
</button>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
+5 -4
View File
@@ -1,7 +1,9 @@
import { useState, type ReactNode } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useRememberCampaign } from '../lib/lastCampaign';
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';
@@ -94,6 +96,7 @@ export function CharacterBuilderPage() {
const { characterId = '' } = useParams();
const navigate = useNavigate();
const { data: character, isLoading, error } = useCharacter(characterId);
useRememberCampaign(character?.campaignId);
const roll = useBuildRoll(characterId);
const choose = useBuildChoose(characterId);
const finalize = useBuildFinalize(characterId);
@@ -898,8 +901,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 +1093,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}
+208 -52
View File
@@ -17,6 +17,7 @@ import {
useRemoveCharacterSpell,
useGoldLedger,
useAdjustGold,
useExchangeCoins,
useCampaignCharacters,
useGive,
type Gift,
@@ -30,14 +31,36 @@ import {
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { QuickStatsFab } from '../components/QuickStatsDrawer';
import { NumberInput } from '../components/NumberInput';
import { JumpNav } from '../components/JumpNav';
import { useRememberCampaign } from '../lib/lastCampaign';
import { HpBar } from '../components/HpBar';
import { DeathSaves } from '../components/DeathSaves';
import { GearInForce } from '../components/CharacterPanel';
import { effectsSummary } from '../lib/gear';
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 +110,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 +172,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 +206,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 }}
@@ -205,6 +238,7 @@ export function CharacterSheetPage() {
const { characterId = '' } = useParams();
const { user } = useAuth();
const { data: character, isLoading, error } = useCharacter(characterId);
useRememberCampaign(character?.campaignId);
const patchCharacter = usePatchCharacter(characterId);
useCampaignSocket(character?.campaignId);
@@ -212,7 +246,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 +260,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 +270,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 +310,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('');
}
@@ -293,12 +334,22 @@ export function CharacterSheetPage() {
}
}
// Equipping can be refused (too much on already). The reason goes in the
// banner at the top, so the page goes there too.
const equipResult = {
onSuccess: () => setFormError(null),
onError: (err: unknown) => {
setFormError(extractErrorMessage(err));
window.scrollTo({ top: 0, behavior: 'smooth' });
},
};
async function handleGold(sign: 1 | -1) {
const amount = Math.floor(Number(goldAmount));
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 +357,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;
@@ -344,7 +407,7 @@ export function CharacterSheetPage() {
}
}
const otherCampaigns = campaigns?.filter((c) => c.id !== character.campaignId) ?? [];
const otherCampaigns = campaigns?.filter((c) => c.id !== character.campaignId && !c.archivedAt) ?? [];
const assignedWeaponIds = new Set(character.weapons.map((w) => w.weaponId));
const assignedSpellIds = new Set(character.spells.map((s) => s.spellId));
@@ -412,15 +475,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,24 +504,31 @@ 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) })}
/>
</label>
<label className="flex-row gap-sm">
Armor Bonus <InfoTip term="ARMOR_BONUS" />
<input
type="number"
min={0}
defaultValue={character.armorBonus}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ armorBonus: Number(e.target.value) })}
/>
</label>
{/* Armor is an equipped item now; this is the DM's own adjustment on top. */}
{isDM && (
<label className="flex-row gap-sm" title="Added to AC on top of what equipped gear gives. Can be negative.">
Extra AC (DM) <InfoTip term="ARMOR_BONUS" />
<NumberInput
key={`armor:${character.armorBonus}`}
min={-30}
max={30}
keepValue
defaultValue={character.armorBonus}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ armorBonus: Number(e.target.value) })}
/>
</label>
)}
</div>
)}
@@ -458,10 +536,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) })}
@@ -490,7 +569,19 @@ export function CharacterSheetPage() {
<p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p>
)}
<div className="card mt-md">
{(sheet.gear.effects.length > 0 || sheet.gear.notes.length > 0) && (
<div className="card mt-md">
<div className="card-title">From Equipped Gear</div>
<GearInForce gear={sheet.gear} />
<p className="subtitle" style={{ margin: '0.4rem 0 0' }}>
Already counted in every number on this sheet. Take something off and its line goes.
</p>
</div>
)}
<JumpNav />
<div data-jump="Abilities" className="card mt-md">
<div className="card-title">Ability Scores</div>
<div className="grid grid-cols-3">
{(Object.keys(sheet.abilities) as Ability[]).map((ability) => (
@@ -506,7 +597,7 @@ export function CharacterSheetPage() {
</div>
<div className="grid grid-cols-2 mt-md">
<div className="card">
<div data-jump="Saves" className="card">
<div className="card-title">
Saving Throws <InfoTip term="SAVING_THROW" />
</div>
@@ -519,7 +610,7 @@ export function CharacterSheetPage() {
</div>
))}
</div>
<div className="card">
<div data-jump="Skills" className="card">
<div className="card-title">
Skills <InfoTip term="SKILL_BONUS" />
</div>
@@ -553,7 +644,7 @@ export function CharacterSheetPage() {
</div>
)}
<div className="card mt-md">
<div data-jump="Weapons" className="card mt-md">
<div className="card-title">
Weapons <InfoTip term="ATTACK_BONUS" />
</div>
@@ -565,6 +656,9 @@ export function CharacterSheetPage() {
Lv {cw.level} · {cw.scaledDamageDice} {cw.weapon.damageType} · x{cw.quantity}
</span>
{!cw.usable && <RequiresLevel level={cw.level} />}
{effectsSummary(cw.weapon.effects, cw.weapon.effectNote) && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>✦ While equipped: {effectsSummary(cw.weapon.effects, cw.weapon.effectNote)}</div>
)}
</div>
{canEdit && (
<div className="flex-row gap-sm">
@@ -574,7 +668,7 @@ export function CharacterSheetPage() {
type="checkbox"
checked={cw.equipped}
disabled={!cw.usable}
onChange={(e) => patchWeapon.mutate({ weaponId: cw.weaponId, equipped: e.target.checked })}
onChange={(e) => patchWeapon.mutate({ weaponId: cw.weaponId, equipped: e.target.checked }, equipResult)}
/>
</label>
{isDM && (
@@ -616,7 +710,7 @@ export function CharacterSheetPage() {
</div>
{campaignSpells && campaignSpells.length > 0 && (
<div className="card mt-md">
<div data-jump="Spells" className="card mt-md">
<div className="card-title">Spells Known</div>
{spellsPage.pageItems.map((cs) => (
<div key={cs.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
@@ -671,7 +765,16 @@ export function CharacterSheetPage() {
</div>
)}
<div className="card mt-md">
{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 data-jump="Inventory" className="card mt-md">
<div className="card-title">Inventory</div>
{itemsPage.pageItems.map((held) => (
<div key={held.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
@@ -682,6 +785,9 @@ export function CharacterSheetPage() {
{held.scaledDamageDice && ` · ${held.scaledDamageDice}`} · x{held.quantity}
</span>
{!held.usable && <RequiresLevel level={held.level} />}
{effectsSummary(held.item.effects, held.item.effectNote) && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>✦ While equipped: {effectsSummary(held.item.effects, held.item.effectNote)}</div>
)}
{held.item.description && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{held.item.description}</div>
)}
@@ -694,7 +800,7 @@ export function CharacterSheetPage() {
type="checkbox"
checked={held.equipped}
disabled={!held.usable}
onChange={(e) => patchItem.mutate({ itemId: held.itemId, equipped: e.target.checked })}
onChange={(e) => patchItem.mutate({ itemId: held.itemId, equipped: e.target.checked }, equipResult)}
/>
</label>
{isDM && (
@@ -751,22 +857,34 @@ export function CharacterSheetPage() {
)}
</div>
<div className="card mt-md">
<div data-jump="Money" 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 +901,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,
@@ -877,7 +1034,7 @@ export function CharacterSheetPage() {
</div>
)}
<div className="card mt-md">
<div data-jump="Notes" className="card mt-md">
<div className="card-title">Notes</div>
<form onSubmit={handleAddNote} className="flex-row flex-wrap">
<textarea
@@ -904,7 +1061,6 @@ export function CharacterSheetPage() {
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} />
</div>
<QuickStatsFab characterId={characterId} />
</div>
);
}
+500 -64
View File
@@ -1,5 +1,5 @@
import { useState, type ReactNode } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
useEncounter,
useCampaign,
@@ -8,24 +8,38 @@ import {
useCampaignNpcs,
useCampaignWeapons,
useCampaignItems,
useCampaignLocations,
useAddParticipant,
usePatchParticipant,
useRemoveParticipant,
useAdvanceRound,
useNextTurn,
useSetTargets,
useReviveParticipant,
usePatchEncounter,
useSetEncounterArchived,
useDeleteEncounter,
useAddReward,
useDeleteReward,
useAwardReward,
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 { useRememberCampaign } from '../lib/lastCampaign';
import { openCharacterPanel } from '../lib/characterPanel';
import { GearInForce } from '../components/CharacterPanel';
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,
@@ -54,6 +68,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' };
@@ -88,17 +107,35 @@ function subline(p: EncounterParticipant): string {
function CombatantCard({
participant: p,
isDM,
mode,
isMine,
isActive,
onPatch,
canRollInitiative,
targetedBy,
acPenalty,
onTarget,
onRemove,
onError,
}: {
participant: EncounterParticipant;
isDM: boolean;
mode: EncounterMode;
// The viewer's own character: its card is marked so it is easy to find.
isMine: boolean;
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
@@ -110,9 +147,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);
@@ -120,7 +164,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('');
}
@@ -131,10 +175,20 @@ function CombatantCard({
}
return (
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown ? ' is-down' : ''}`}>
<div
id={`combatant-${p.id}`}
className={`card combatant combatant-${p.kind.toLowerCase()}${isMine ? ' is-mine' : ''}${isActive ? ' is-active' : ''}${isDown && !isDying ? ' is-down' : ''}${isTargeted ? ' is-targeted' : ''}`}
>
<div className="flex-between flex-wrap">
<div>
{isMine && <div className="mine-marker">★ Your character</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 ? (
@@ -145,31 +199,65 @@ 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>}
{p.character && (isDM || isMine) && (
<button
className="btn btn-target"
title={`Everything ${p.displayName} holds: weapons, items, spells and money`}
onClick={() => p.character && openCharacterPanel(p.character.id)}
>
🎒 Inventory
</button>
)}
{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>
)}
@@ -201,14 +289,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}
@@ -223,6 +309,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}
/>
)}
</>
)}
@@ -289,11 +385,19 @@ function CombatantCard({
{ABILITIES.map((a) => `${a} ${fmtMod(info.saves[a].bonus)}${info.saves[a].proficient ? '*' : ''}`).join(' · ')}{' '}
<span title="Proficient">(* proficient)</span>
</p>
{info.weapons.length === 0 && info.spells.length === 0 && <p className="combatant-sub">No usable weapons or spells.</p>}
<GearInForce gear={info.gear} />
{info.weapons.length === 0 && (
<p className="combatant-sub">No weapon equipped. A weapon has to be equipped to be used: open 🎒 Inventory to put one on.</p>
)}
{info.items.length > 0 && (
<p className="combatant-sub">
Equipped: {info.items.map((i) => `${i.name}${i.damageDice ? ` (${i.damageDice})` : ''}`).join(' · ')}
</p>
)}
{info.weapons.map((w) => (
<div key={w.characterWeaponId} className="detail-row">
<span>
{w.name} {w.equipped && <span className="badge">equipped</span>}
{w.name}
<span className="combatant-sub" style={{ marginLeft: 6 }}>
Lv {w.level} · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
</span>
@@ -359,8 +463,177 @@ 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 weapons = 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}`;
}
@@ -386,6 +659,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('');
@@ -405,7 +679,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 };
@@ -428,7 +702,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) => (
@@ -498,20 +772,29 @@ function RewardsPanel({
setLevel('');
}}
>
<option value="GOLD">Gold</option>
<option value="GOLD">Coins</option>
<option value="WEAPON">Weapon</option>
<option value="ITEM">Item</option>
</select>
{type === 'GOLD' ? (
<input
type="number"
min={1}
placeholder="Amount"
aria-label="Gold amount"
value={gold}
onChange={(e) => setGold(e.target.value)}
style={{ flex: 1, minWidth: 90 }}
/>
<>
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="Coin amount"
value={gold}
onChange={(e) => setGold(e.target.value)}
style={{ flex: 1, minWidth: 90 }}
/>
<select aria-label="Coin" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
</>
) : (
<>
<select
@@ -540,9 +823,9 @@ function RewardsPanel({
</option>
))}
</select>
<input
type="number"
<NumberInput
min={1}
max={999}
aria-label="Quantity"
title="Quantity"
value={quantity}
@@ -575,8 +858,10 @@ function RewardsPanel({
export function EncounterPage() {
const { encounterId = '' } = useParams();
const { user } = useAuth();
const { data: encounter, isLoading, error } = useEncounter(encounterId);
useCampaignSocket(encounter?.campaignId);
useRememberCampaign(encounter?.campaignId);
const { data: campaignData } = useCampaign(encounter?.campaignId);
const isDM = campaignData?.campaign.role === 'DM';
@@ -585,32 +870,59 @@ 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 ?? '');
const navigate = useNavigate();
const [pickType, setPickType] = useState<'character' | 'monster' | 'npc'>('character');
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;
// The viewer's own characters here, to mark and to jump to.
const mine = participants.filter((p) => p.character != null && p.character.userId === user?.id);
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));
@@ -619,7 +931,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('') }
);
}
@@ -629,21 +941,43 @@ export function EncounterPage() {
key={p.id}
participant={p}
isDM={isDM}
mode={encounter.mode}
isMine={p.character != null && p.character.userId === user?.id}
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>
@@ -656,6 +990,8 @@ 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>
</div>
@@ -673,6 +1009,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'}
@@ -698,6 +1047,24 @@ export function EncounterPage() {
Reopen
</button>
)}
<button
className="btn btn-ghost"
title={encounter.archivedAt ? 'Put it back on the campaign list' : 'Take it off the campaign list without deleting it'}
disabled={setArchived.isPending}
onClick={() => setArchived.mutate({ encounterId, archived: !encounter.archivedAt }, fail)}
>
{encounter.archivedAt ? 'Restore' : 'Archive'}
</button>
<button
className="btn btn-ghost"
disabled={deleteEncounter.isPending}
onClick={() =>
window.confirm(`Delete "${encounter.name}" for good? Rewards already handed out stay with the characters.`) &&
deleteEncounter.mutate(encounterId, { ...fail, onSuccess: () => navigate(`/campaigns/${encounter.campaignId}`) })
}
>
Delete
</button>
</>
)}
</div>
@@ -705,6 +1072,21 @@ export function EncounterPage() {
{formError && <ErrorBanner message={formError} />}
{mine.length > 0 && (
<nav className="jump-nav" aria-label="Your characters in this scene">
{mine.map((p) => (
<button
key={p.id}
type="button"
className="jump-link is-mine"
onClick={() => document.getElementById(`combatant-${p.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })}
>
★ {p.displayName} · {p.currentHp}/{p.maxHp} HP{p.id === active?.id ? ' · your turn' : ''}
</button>
))}
</nav>
)}
{(isDM || encounter.description) && (
<div className="card">
<div className="card-title">{isBattle ? 'The Scene' : 'Quest'}</div>
@@ -733,7 +1115,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.'
@@ -779,16 +1205,16 @@ export function EncounterPage() {
</select>
</div>
<div className="flex-row gap-sm mt-sm">
{isBattle && (
<input
type="number"
placeholder="Initiative"
aria-label="Initiative"
value={initiative}
onChange={(e) => setInitiative(e.target.value)}
style={{ flex: 1, minWidth: 0 }}
/>
)}
<NumberInput
min={1}
max={20}
placeholder="Initiative roll (d20)"
title="What the d20 showed, 1 to 20. Leave empty to roll later; the bonus is added for you."
aria-label="Initiative roll"
value={initiative}
onChange={(e) => setInitiative(e.target.value)}
style={{ flex: 1, minWidth: 0 }}
/>
<button className="btn btn-primary" onClick={handleAdd} disabled={!pickId || addParticipant.isPending}>
Add
</button>
@@ -801,6 +1227,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}
+20 -19
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>
@@ -64,34 +66,33 @@ export function PartyOverviewPage() {
const inProgress = c.buildStage !== 'COMPLETE';
const isMine = c.userId === user?.id;
return (
<tr key={c.id}>
<td>
<tr key={c.id} className={isMine ? 'is-mine' : undefined}>
<td className="cell-title">
<Link to={inProgress ? `/characters/${c.id}/build` : `/characters/${c.id}`}>
<span aria-hidden="true">{speciesIcon(c.species)}</span> {c.name}
</Link>
{isMine && <span className="badge" style={{ marginLeft: 6 }}>you</span>}
{isMine && <span className="badge badge-mine" style={{ marginLeft: 6 }}>★ Yours</span>}
</td>
<td>{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
<td>{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
<td>{c.level}</td>
<td>
<td data-label="Species">{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
<td data-label="Class">{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
<td data-label="Level">{c.level}</td>
<td data-label="HP">
{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>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
<td data-label="AC">{c.armorClass ?? '—'}</td>
<td data-label="Coins" className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
<td data-label="Location">{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
</tr>
);
})}
+6
View File
@@ -4,4 +4,10 @@ import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
preview: {
host: true,
port: 5173,
allowedHosts: ['the-sunken-reach.crosscode.co.za'],
},
})
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.0.0",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.0.0",
"version": "1.11.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.0.0",
"version": "1.11.0",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "0.0.0",
"version": "1.11.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.11.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"
}
}