Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37c561c5d3 | ||
|
|
7b3d121151 | ||
|
|
f04e3497f4 | ||
|
|
0ddf9846a2 | ||
|
|
9c97d61ce5 | ||
|
|
b82084276a | ||
|
|
0124e3bc53 | ||
|
|
2dcb41ae4a | ||
|
|
c852ecb2e4 | ||
|
|
bdce3399ee | ||
|
|
f764878960 | ||
|
|
6b466d497e | ||
|
|
f8462a6e31 | ||
|
|
754eb0ee68 | ||
|
|
f8cee43ca2 | ||
|
|
e4456b0df2 |
@@ -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,13 @@ 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 |
|
||||
| `docs/SEEDS.md` | World seeds: ready-made campaigns a DM starts from a code, and how to add one |
|
||||
|
||||
`CLAUDE_BUILD_PROMPT.md`, `docs/GAME_RULES_REFERENCE.md` and
|
||||
`docs/ROLL_YOUR_CHARACTER.md` are the original specifications the first
|
||||
@@ -25,14 +26,55 @@ 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 shows one part at a time**, picked from a row of
|
||||
buttons under its name: Overview (characters and encounters), NPCs,
|
||||
Locations, Weapons, Items, Spells, Monsters, Notes and Settings for the DM;
|
||||
Overview, Places and Notes for a player. Each button shows how many things
|
||||
are in that part. On a phone the row is one line that scrolls sideways. The
|
||||
part showing is in the address (`?view=weapons`), so it can be bookmarked
|
||||
and the back button steps through the parts.
|
||||
- **Search this campaign**, the box above that row, looks through everything
|
||||
that has been added to the campaign at once: characters, encounters,
|
||||
locations and notes, and for the DM also NPCs, weapons, items, spells and
|
||||
monsters. It matches names and descriptions, every word typed, in any
|
||||
order. A result opens that thing. Each of the DM's lists also has a search
|
||||
box of its own.
|
||||
- The DM's lists (NPCs, weapons, items, spells, monsters) are short rows: a
|
||||
name and a few words. **Clicking a row opens a pop-out** with everything
|
||||
about it and everything that can be changed. Adding one, by hand or from the
|
||||
library, is a pop-out too. A pop-out is a window over the page on a wide
|
||||
screen and the whole screen on a phone; ✕, Escape or a click outside it
|
||||
closes it.
|
||||
- Every paged list has Prev and Next, and with three pages or more the page
|
||||
number is a menu to **jump straight to any page**.
|
||||
- The character sheet is long, so it starts with a row of **section links**
|
||||
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.
|
||||
- **Backend:** Node.js + Express, JavaScript, Prisma ORM (with the `pg` driver adapter), PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation.
|
||||
- **Frontend:** React + Vite + TypeScript, React Router, TanStack Query, socket.io-client.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+ (tested on Node 22)
|
||||
- Node.js 20.19+ (tested on Node 24)
|
||||
- A running PostgreSQL instance (local install, Docker, or a hosted service like Neon/Supabase)
|
||||
|
||||
## First-time setup
|
||||
@@ -44,7 +86,8 @@ 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 # adds the admin account + the Aldenfell campaign if missing (see output for login/invite code)
|
||||
npx prisma generate # migrate no longer does this itself
|
||||
npx prisma db seed # adds the admin account if it is missing (see output for the login and the world codes)
|
||||
|
||||
# Frontend
|
||||
cd ../frontend
|
||||
@@ -61,31 +104,38 @@ 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 admin
|
||||
account — see the seed script's console output for credentials and invite
|
||||
code), and start playing.
|
||||
account — see the seed script's console output for the credentials), 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.
|
||||
The database seed creates the admin account and nothing else. It only adds: an
|
||||
existing account, and its password, is left alone, so it is safe to run on a
|
||||
server with real data. `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):
|
||||
## Worlds
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run seed:hollowcrown
|
||||
```
|
||||
Campaigns are created in the app: blank, or from a **world seed** by its
|
||||
code. On the Campaigns page click "+ New Campaign" and enter a code under "Or
|
||||
start from a ready-made world"; the whole world is built for you as its DM.
|
||||
|
||||
On a server, after pulling a new version, run `npm run deploy` in `backend`
|
||||
| Code | World |
|
||||
|---|---|
|
||||
| `ALDENF` | Aldenfell |
|
||||
| `HOLLOW` | Hollowcrown: The Rift Between Worlds |
|
||||
|
||||
Each world is one file in `/backend/seeds`. Drop another file in and its code
|
||||
works straight away, with no restart; `npm run seeds:check` (from the repo
|
||||
root or `backend`) checks every file and says what is wrong with one.
|
||||
`docs/SEEDS.md` has the format.
|
||||
|
||||
On a server, after pulling a new version, run `npm install` in the repo root
|
||||
(when dependencies changed) and then `npm run deploy` in `backend`
|
||||
and then restart the backend. It applies new migrations **and** regenerates
|
||||
the Prisma client; without the second half the server keeps running with the
|
||||
old data model and pages that use anything new fail to load (for example
|
||||
"Could not load this encounter"). Change your password from the account page
|
||||
(click your name in the top bar).
|
||||
("Account" in the navigation).
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -94,8 +144,9 @@ cd backend
|
||||
npm test
|
||||
```
|
||||
|
||||
The calculation engine (`src/game/calculations.js`) is fully unit tested and
|
||||
runs with no external dependencies. The route-level integration tests in
|
||||
The calculation engine (`src/game/calculations.js`) and the world seeds
|
||||
(`src/seeds`, which also checks every file in `/backend/seeds`) are unit
|
||||
tested and run with no external dependencies. The route-level integration tests in
|
||||
`src/routes/api.integration.test.js` need a real, migrated database reachable
|
||||
via `DATABASE_URL` — they skip themselves gracefully (with a console warning)
|
||||
if the DB isn't reachable, so `npm test` still passes in an environment with
|
||||
@@ -105,14 +156,17 @@ no database configured.
|
||||
|
||||
```
|
||||
/backend — Express + Prisma API, Socket.io server
|
||||
/prisma — schema.prisma, seed.js
|
||||
/seed-data — reference JSON (weapons seeded per-campaign; species/classes/skills
|
||||
are ported into src/game/data/*.js and used directly by the
|
||||
calculation engine, not stored as DB rows)
|
||||
prisma.config.js — where the Prisma CLI finds the schema, the database and the seed
|
||||
/prisma — schema.prisma, migrations, seed.js (the admin account only)
|
||||
/seeds — world seeds: one file per ready-made campaign, created by code
|
||||
/seed-data — reference JSON (species/classes/skills/weapons, ported into
|
||||
src/game/data/*.js and used directly by the calculation engine,
|
||||
not stored as DB rows)
|
||||
/src
|
||||
/game — calculations.js (the ONLY place D&D formulas live) + buildFlow.js
|
||||
/middleware — auth + campaign/character authorization
|
||||
/routes — auth, campaigns, characters, encounters, notes
|
||||
/seeds — finds, checks and creates campaigns from the world seeds
|
||||
/sockets — Socket.io room wiring
|
||||
/frontend — React + Vite + TypeScript app
|
||||
/docs — game rules & character-builder specs (reference material)
|
||||
|
||||
+12
-13
@@ -1,37 +1,36 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.5.0",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
"prisma": {
|
||||
"seed": "node prisma/seed.js"
|
||||
},
|
||||
"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",
|
||||
"seeds:check": "node scripts/check-seeds.js",
|
||||
"deploy": "prisma migrate deploy && prisma generate",
|
||||
"seed:hollowcrown": "node prisma/seed-hollowcrown.js",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"compression": "^1.8.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
"dotenv": "^18.0.6",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"socket.io": "^4.8.0",
|
||||
"zod": "^3.23.8"
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^5.20.0",
|
||||
"jest": "^30.5.2",
|
||||
"prisma": "^7.10.0",
|
||||
"supertest": "^7.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
// Prisma CLI configuration: where the schema and migrations live, which
|
||||
// database to run them against, and the command behind `prisma db seed`.
|
||||
require('dotenv').config({ quiet: true });
|
||||
const { defineConfig, env } = require('prisma/config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
seed: 'node prisma/seed.js',
|
||||
},
|
||||
datasource: {
|
||||
url: env('DATABASE_URL'),
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateIndex: the columns every list is looked up by. Postgres does not
|
||||
-- index a foreign key on its own.
|
||||
CREATE INDEX "locations_campaignId_idx" ON "locations"("campaignId");
|
||||
CREATE INDEX "campaign_members_userId_idx" ON "campaign_members"("userId");
|
||||
CREATE INDEX "characters_campaignId_idx" ON "characters"("campaignId");
|
||||
CREATE INDEX "characters_userId_idx" ON "characters"("userId");
|
||||
CREATE INDEX "weapons_campaignId_idx" ON "weapons"("campaignId");
|
||||
CREATE INDEX "items_campaignId_idx" ON "items"("campaignId");
|
||||
CREATE INDEX "spells_campaignId_idx" ON "spells"("campaignId");
|
||||
CREATE INDEX "monsters_campaignId_idx" ON "monsters"("campaignId");
|
||||
CREATE INDEX "npcs_campaignId_idx" ON "npcs"("campaignId");
|
||||
CREATE INDEX "encounters_campaignId_idx" ON "encounters"("campaignId");
|
||||
CREATE INDEX "encounter_rewards_encounterId_idx" ON "encounter_rewards"("encounterId");
|
||||
CREATE INDEX "sale_offers_campaignId_idx" ON "sale_offers"("campaignId");
|
||||
CREATE INDEX "sale_offers_npcId_idx" ON "sale_offers"("npcId");
|
||||
CREATE INDEX "sale_offers_encounterId_idx" ON "sale_offers"("encounterId");
|
||||
CREATE INDEX "encounter_participants_encounterId_idx" ON "encounter_participants"("encounterId");
|
||||
CREATE INDEX "encounter_participants_characterId_idx" ON "encounter_participants"("characterId");
|
||||
CREATE INDEX "notes_campaignId_idx" ON "notes"("campaignId");
|
||||
CREATE INDEX "notes_characterId_idx" ON "notes"("characterId");
|
||||
@@ -1,7 +1,8 @@
|
||||
// D&D Campaign Manager — Prisma schema
|
||||
// Postgres. Run `npx prisma migrate dev --name init` after `DATABASE_URL` is set,
|
||||
// then `npx prisma db seed` using the seed script built from /seed-data/*.json
|
||||
// (see CLAUDE_BUILD_PROMPT.md, "Database" section).
|
||||
// Postgres. The connection (DATABASE_URL) is set in prisma.config.js, not here.
|
||||
// Run `npx prisma migrate dev` then `npx prisma generate`; `npx prisma db seed`
|
||||
// adds the admin account (prisma/seed.js). Worlds are created in the app from
|
||||
// the seeds in /backend/seeds.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
@@ -9,7 +10,6 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -118,12 +118,14 @@ enum RewardType {
|
||||
ITEM
|
||||
}
|
||||
|
||||
// The coins a character carries. Their worth is fixed in utils/grants.js:
|
||||
// 1 gold = 10 silver = 100 copper.
|
||||
// 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 {
|
||||
@@ -168,6 +170,15 @@ model Campaign {
|
||||
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.
|
||||
@@ -183,6 +194,7 @@ model Campaign {
|
||||
monsters Monster[]
|
||||
npcs Npc[]
|
||||
encounters Encounter[]
|
||||
saleOffers SaleOffer[]
|
||||
notes Note[]
|
||||
locations Location[]
|
||||
|
||||
@@ -201,6 +213,7 @@ model Location {
|
||||
encounters Encounter[]
|
||||
visits CharacterLocationVisit[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("locations")
|
||||
}
|
||||
|
||||
@@ -231,6 +244,7 @@ model CampaignMember {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([campaignId, userId])
|
||||
@@index([userId])
|
||||
@@map("campaign_members")
|
||||
}
|
||||
|
||||
@@ -269,17 +283,18 @@ model Character {
|
||||
deathSaveSuccesses Int @default(0)
|
||||
deathSaveFailures Int @default(0)
|
||||
stable Boolean @default(false) // at 0 HP but no longer rolling
|
||||
armorBonus Int @default(0) // flat AC bonus from equipped armor/shield
|
||||
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 is counted on its own and only changes through the
|
||||
// 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?
|
||||
@@ -310,6 +325,8 @@ model Character {
|
||||
encounterEntries EncounterParticipant[]
|
||||
locationVisits CharacterLocationVisit[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([userId])
|
||||
@@map("characters")
|
||||
}
|
||||
|
||||
@@ -361,12 +378,19 @@ 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[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("weapons")
|
||||
}
|
||||
|
||||
@@ -395,12 +419,18 @@ 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[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("items")
|
||||
}
|
||||
|
||||
@@ -455,10 +485,14 @@ 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[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("spells")
|
||||
}
|
||||
|
||||
@@ -501,6 +535,7 @@ model Monster {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
encounterAppearances EncounterParticipant[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("monsters")
|
||||
}
|
||||
|
||||
@@ -518,11 +553,17 @@ 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[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("npcs")
|
||||
}
|
||||
|
||||
@@ -535,6 +576,10 @@ 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
|
||||
@@ -552,7 +597,9 @@ model Encounter {
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
participants EncounterParticipant[]
|
||||
rewards EncounterReward[]
|
||||
wares SaleOffer[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("encounters")
|
||||
}
|
||||
|
||||
@@ -578,9 +625,40 @@ model EncounterReward {
|
||||
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
|
||||
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([encounterId])
|
||||
@@map("encounter_rewards")
|
||||
}
|
||||
|
||||
// Something for sale: a weapon or item from the campaign pools, at a price.
|
||||
// It belongs to an NPC (and is on offer wherever that NPC is met) 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)
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([npcId])
|
||||
@@index([encounterId])
|
||||
@@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
|
||||
@@ -608,6 +686,8 @@ model EncounterParticipant {
|
||||
monster Monster? @relation(fields: [monsterId], references: [id], onDelete: SetNull)
|
||||
npc Npc? @relation(fields: [npcId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([encounterId])
|
||||
@@index([characterId])
|
||||
@@map("encounter_participants")
|
||||
}
|
||||
|
||||
@@ -628,5 +708,7 @@ model Note {
|
||||
character Character? @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([characterId])
|
||||
@@map("notes")
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
// 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],
|
||||
});
|
||||
+48
-26
@@ -1,32 +1,54 @@
|
||||
// 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.
|
||||
// The database seed: makes sure the admin account exists, and nothing else.
|
||||
//
|
||||
// npx prisma db seed add what is missing
|
||||
// npx prisma db seed create the admin account if it 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).
|
||||
// It only ever adds, so it is safe against a live database: an existing
|
||||
// account, and its password, is left alone. SEED_ADMIN_EMAIL /
|
||||
// SEED_ADMIN_PASSWORD / SEED_ADMIN_NAME override the account that is created.
|
||||
//
|
||||
// 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 { 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');
|
||||
// Worlds are not seeded here. A campaign is created in the app, blank or from
|
||||
// one of the world seeds in /backend/seeds by its code (docs/SEEDS.md).
|
||||
//
|
||||
// Species/classes/skills are not rows either: they are fixed game-rule tables
|
||||
// in src/game/data/*.js, as is the library DMs pull weapons, items, spells
|
||||
// and monsters from.
|
||||
const bcrypt = require('bcrypt');
|
||||
const prisma = require('../src/db');
|
||||
const { loadSeeds } = require('../src/seeds');
|
||||
|
||||
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') },
|
||||
);
|
||||
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';
|
||||
|
||||
async function seed({ reset = false } = {}) {
|
||||
if (reset) {
|
||||
// Truncating the two roots cascades through every table that references them.
|
||||
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
|
||||
console.log('Database reset: every user and campaign was deleted.');
|
||||
}
|
||||
|
||||
if (await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } })) {
|
||||
console.log(`Admin account ${ADMIN_EMAIL} already exists — left as it is.`);
|
||||
} else {
|
||||
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 ("Account" in the navigation) once you are logged in.');
|
||||
}
|
||||
|
||||
const worlds = loadSeeds().filter((world) => !world.error);
|
||||
if (worlds.length) {
|
||||
console.log('Worlds to start a campaign from (Campaigns → New Campaign → world code):');
|
||||
for (const { code, seed: world } of worlds) console.log(` ${code} ${world.campaign.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
seed({ reset: process.argv.includes('--reset') })
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
// 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 };
|
||||
@@ -0,0 +1,27 @@
|
||||
// Checks every world seed in /backend/seeds and says what each one holds, or
|
||||
// what is wrong with it. Run it after dropping in or editing a seed file:
|
||||
//
|
||||
// npm run seeds:check
|
||||
const { loadSeeds, SEEDS_DIR } = require('../src/seeds');
|
||||
|
||||
const results = loadSeeds();
|
||||
if (results.length === 0) console.log(`No seed files in ${SEEDS_DIR}.`);
|
||||
|
||||
for (const { file, code, seed, error } of results) {
|
||||
if (error) {
|
||||
console.error(`✗ ${file}\n${error.replace(/^/gm, ' ')}`);
|
||||
process.exitCode = 1;
|
||||
continue;
|
||||
}
|
||||
const starting = (rows) => rows.filter((row) => row.isStartingOption).length;
|
||||
const count = (rows, of) => rows.reduce((sum, row) => sum + (row[of] || []).length, 0);
|
||||
console.log(`✓ ${file} — code ${code} — ${seed.campaign.name}`);
|
||||
console.log(
|
||||
` ${seed.weapons.length} weapons (${starting(seed.weapons)} starting), ${seed.items.length} items (${starting(seed.items)} starting), ` +
|
||||
`${seed.spells.length} spells (${starting(seed.spells)} starting), ${seed.locations.length} locations`,
|
||||
);
|
||||
console.log(
|
||||
` ${seed.monsters.length} monsters, ${seed.npcs.length} NPCs (${count(seed.npcs, 'wares')} wares), ${seed.encounters.length} encounters ` +
|
||||
`(${count(seed.encounters, 'rewards')} rewards, ${count(seed.encounters, 'wares')} wares), ${seed.sharedNotes.length + seed.dmNotes.length} notes`,
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// 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.
|
||||
// Aldenfell: a world seed (docs/SEEDS.md). A DM starts a campaign from it by
|
||||
// entering its code.
|
||||
//
|
||||
// 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.
|
||||
|
||||
const campaign = {
|
||||
name: 'Aldenfell',
|
||||
@@ -137,17 +138,30 @@ const monsters = [
|
||||
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.',
|
||||
},
|
||||
{
|
||||
name: 'The Hollow King', challengeRating: '10', ac: 16, maxHp: 190, speed: 30,
|
||||
strScore: 18, dexScore: 14, conScore: 18, intScore: 18, wisScore: 18, chaScore: 20, attackBonus: 9, damageDice: '4d8',
|
||||
specialTrait: 'Many, not one — it is the will of everyone the founders sacrificed, so it cannot be charmed or frightened, and it speaks in several voices. Echoing dread — starting a turn within 30 ft: DC 16 Wis or frightened. What the Court remembers (recharge 5–6): each creature within 30 ft, DC 16 Wis or 6d6 psychic and lose its reaction (half on success).',
|
||||
notes: 'A STARTING POINT, NOT THE BOOK’S: the Campaign Book gives no stat block for E26 and tells you to build one to your party’s level and the resources they brought. This one suits four level 7–8 characters; change the numbers freely. Fly 30 ft (hover). Multiattack (two Chilling Touch +9, 4d8 necrotic). Three legendary actions a round: move without provoking, one Chilling Touch, or (2 actions) pull a creature 20 ft toward the seal (DC 16 Str). The fight should never be only a sword fight — what the party does with the truth (E19, E22) matters more than its hit points.',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.
|
||||
// Weapons. The everyday ones are copies from the site library and are what a
|
||||
// new character picks from. The rest are not starting options, so they only
|
||||
// reach a character as a reward, a gift or a purchase. 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 everydayWeapons = [
|
||||
'dagger', 'shortsword', 'rapier', 'longsword', 'greatsword', 'battleaxe', 'handaxe', 'warhammer', 'mace',
|
||||
'quarterstaff', 'spear', 'shortbow', 'longbow', 'light-crossbow', 'dart',
|
||||
];
|
||||
|
||||
const weapons = [
|
||||
...everydayWeapons.map((library) => ({ library, isStartingOption: true })),
|
||||
{ name: 'Unarmed Strike', damageDice: '1', damageType: 'bludgeoning', attackAbility: 'STR', properties: [], isStartingOption: true },
|
||||
{ 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 },
|
||||
@@ -159,38 +173,47 @@ const weapons = [
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item pool. Items are descriptive: holding one never changes a number on the
|
||||
// sheet. `starting: true` offers the item in the character builder.
|
||||
// Item pool. Most items are descriptive. The ones with `effects` change the
|
||||
// sheet of whoever has them equipped (armor is an item with an AC effect), and
|
||||
// `effectNote` says what a number cannot. `isStartingOption: 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 },
|
||||
{ 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', isStartingOption: true },
|
||||
{ name: 'Traveller’s Pack', description: 'Bedroll, mess kit, tinderbox, waterskin and five days of rations.', isStartingOption: true },
|
||||
{ name: 'Rope (50 ft)', description: 'Hempen rope. Holds a climber; DC 17 Strength to burst.', isStartingOption: true },
|
||||
{ name: 'Torches (bundle of 5)', description: 'Each burns for an hour: bright light for 20 feet, dim for 20 more.', isStartingOption: true },
|
||||
{ name: 'Healer’s Kit', description: 'Ten uses. Spend one to stabilise a creature at 0 hit points without a Medicine check.', isStartingOption: true },
|
||||
{ name: 'Thieves’ Tools', description: 'Picks, a small file and a mirror on a handle. Needed to pick a lock or disarm a trap.', isStartingOption: true },
|
||||
{ name: 'Shield', description: 'A wooden or steel shield carried on one arm.', isStartingOption: true, effects: [{ stat: 'AC', amount: 2 }], effectNote: 'Takes a hand: no two-handed weapon while it is up.' },
|
||||
{ name: 'Leather Armor', description: 'Boiled leather, light enough to sleep and sneak in.', isStartingOption: true, effects: [{ stat: 'AC', amount: 1 }] },
|
||||
{ name: 'Spellcasting Focus', description: 'A wand, holy symbol, crystal or carved staff that your spells are cast through.', isStartingOption: 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.', isStartingOption: 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 },
|
||||
{ library: 'studded-leather' },
|
||||
{ library: 'chain-shirt' },
|
||||
{ library: 'chain-mail' },
|
||||
{ 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: '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.', effects: [{ stat: 'SKILL_SURVIVAL', amount: 1 }] },
|
||||
{ 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.' },
|
||||
{ name: 'Seized House Documents', description: 'Ledgers and letters taken from the noble house after it was confronted. No value in coin; a great deal of value to anyone building a case.' },
|
||||
{ name: 'House Records', description: 'The fallen house’s private records: names, payments and meeting places for its contacts across Aldenfell.' },
|
||||
{ name: 'Masterwork Chain Mail', description: 'Close-linked mail from the Grey Dawn’s own armoury, fitted rather than issued.', minLevel: 5, effects: [{ stat: 'AC', amount: 7 }, { stat: 'SPEED', amount: -10 }], effectNote: 'Disadvantage on Stealth checks.' },
|
||||
{ name: 'The Seal-Stone', description: 'The stone at the heart of the seal, whole again and quiet. It is heavier than it looks, and it is never quite cold.', minLevel: 7, effects: [{ stat: 'SAVE_WIS', amount: 2 }], effectNote: 'Grows warm near anything of the Hollow Below. Its other properties are the DM’s to decide: something tied to binding, warding or detecting.' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -200,7 +223,7 @@ const items = [
|
||||
// DM to teach later.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const spells = {
|
||||
const spellKeys = {
|
||||
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',
|
||||
@@ -216,6 +239,11 @@ const spells = {
|
||||
],
|
||||
};
|
||||
|
||||
const spells = [
|
||||
...spellKeys.starting.map((library) => ({ library, isStartingOption: true })),
|
||||
...spellKeys.earned,
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
@@ -242,7 +270,17 @@ const npcs = [
|
||||
{
|
||||
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.',
|
||||
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. She also sells: Concord goods at Concord prices, in any scene she is in (DC 12 Persuasion to haggle, per the DM Tables).',
|
||||
wares: [
|
||||
{ item: 'Potion of Healing', price: 50 },
|
||||
{ item: 'Potion of Greater Healing', price: 150 },
|
||||
{ item: 'Alchemist’s Fire', price: 50 },
|
||||
{ item: 'Healer’s Kit', price: 5 },
|
||||
{ item: 'Thieves’ Tools', price: 25 },
|
||||
{ item: 'Studded Leather', price: 45 },
|
||||
{ item: 'Chain Shirt', price: 50 },
|
||||
{ item: 'Chain Mail', price: 75 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Ashwen', role: 'Warden of the Greywood', disposition: 'NEUTRAL',
|
||||
@@ -311,6 +349,8 @@ const npcs = [
|
||||
// handed out. { gold } | { weapon } | { item }, with optional
|
||||
// quantity, level (defaults to the entry's starting level) and
|
||||
// note.
|
||||
// wares — for sale at the scene itself (a stall), as on an NPC:
|
||||
// { weapon | item, price, currency?, level?, stock? }.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const encounters = [
|
||||
@@ -319,6 +359,18 @@ const encounters = [
|
||||
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'],
|
||||
// The market stalls: everyday kit at everyday prices.
|
||||
wares: [
|
||||
{ item: 'Traveller’s Pack', price: 2 },
|
||||
{ item: 'Rope (50 ft)', price: 1 },
|
||||
{ item: 'Torches (bundle of 5)', price: 5, currency: 'COPPER' },
|
||||
{ item: 'Spellcasting Focus', price: 10 },
|
||||
{ item: 'Shield', price: 10 },
|
||||
{ item: 'Leather Armor', price: 10 },
|
||||
{ weapon: 'Dagger', price: 2 },
|
||||
{ weapon: 'Shortbow', price: 25 },
|
||||
{ weapon: 'Longsword', price: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E2 — The Bell That Wasn’t Ringing', mode: 'QUEST', location: 'Highcrown — The Founding Chapel',
|
||||
@@ -329,9 +381,9 @@ const encounters = [
|
||||
{
|
||||
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.',
|
||||
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 Investigation spots the locket.',
|
||||
monsters: [['Feral Guard Dog', 1]],
|
||||
rewards: [{ item: 'Silver Locket', note: 'Room 2 — DC 12 Perception' }],
|
||||
rewards: [{ item: 'Silver Locket', note: 'Room 2 — DC 12 Investigation' }],
|
||||
},
|
||||
{
|
||||
name: 'E3 — Sunken Vaults: The Restless Ossuary', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
|
||||
@@ -360,6 +412,17 @@ const encounters = [
|
||||
{ item: 'Concord Writ of Passage', note: 'E4B — on taking Mireille’s contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E5 — Omens Montage', mode: 'QUEST', location: 'Greywood — The Old Shrine Sites',
|
||||
description: 'Small things, weeks apart. A farmer’s cattle gone dry overnight out past the shrine road. A stranger at the next table describing, detail for detail, the dream you had last night. None of it is an attack. All of it is wrong.',
|
||||
dmNotes: 'Pure atmosphere, no treasure. Run two or three as short scenes spread across sessions, never back to back:\n• Livestock and crops failing near an old shrine.\n• Shared nightmares around Highcrown — DC 12 Insight for a PC to realise their own dream matched a stranger’s account.\n• A minor noble behaving with odd, unearned loyalty to no one in particular (the corrupted house — a background detail, not a spotlight).\nThe Drowned Shrine (Root Room 5) is a good place to point an omen at. → E6 whenever it feels earned.',
|
||||
},
|
||||
{
|
||||
name: 'E7 — A Closer Look', mode: 'QUEST',
|
||||
description: 'One noble house keeps turning up at the edge of things: generous, quiet, a little too pleased with itself. Asking after it gets polite answers and closed doors.',
|
||||
dmNotes: 'If the party actively investigates the odd house from E5 they find only dead ends at this stage — by design. Stage 1 omens accumulate; they do not resolve.\nException: a party that took E2C makes a DC 13 Insight check (not 15) to catch one real thread — a servant who flinched at the mention of the house’s name. Note what they learn for E10.\n→ E8: once they have engaged with two or more E4 threads and one full E5 omen, advance the clock to Stage 2 and move to Act 2.',
|
||||
npcs: ['King Aldric Veyrmont', 'Princess Isolde Veyrmont'],
|
||||
},
|
||||
{
|
||||
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.',
|
||||
@@ -440,6 +503,23 @@ const encounters = [
|
||||
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: 'The Spine — Wyvern Pass', mode: 'BATTLE', location: 'The Spine — Ironvein',
|
||||
description: 'The pass above Ironvein narrows to a ledge, and the ledge runs under a shelf of rock streaked white with droppings. Something large shifts up there and looks down at you.',
|
||||
dmNotes: 'Optional travel encounter (Random Encounters, the Spine). Territorial, not evil: it attacks anything that lingers near the nest and ignores a party that keeps moving — DC 13 Stealth or Survival to pass without a fight. Stinger: DC 15 Con or poisoned for a minute. Ironvein below is the resupply stop, with its own rumours (miners going quiet, deeper tunnels avoided).',
|
||||
monsters: [['Spine Wyvern', 1]],
|
||||
},
|
||||
{
|
||||
name: 'The Spine — Sevrisk’s Hollow', mode: 'BATTLE', location: 'The Spine — Sevrisk’s Hollow',
|
||||
description: 'A low, wet cave mouth breathing green-tinged mist. The bones outside it have been arranged, not dropped. From inside comes a voice like a blade being drawn: slow, amused, and very sure of itself.',
|
||||
dmNotes: 'Optional, and dangerous: Sevrisk is the straightforward dragon fight, in contrast to Threndir. Territorial and opportunistic — she will bargain only to gain an edge, and breaks a bargain the moment it stops paying. Poison breath (recharge 5–6): 30-ft cone, DC 14 Con, 8d6. CR 5: too much for a party below level 4 without help or a plan. Her hoard is the treasure.',
|
||||
monsters: [['Sevrisk (young green dragon)', 1, 'Sevrisk']],
|
||||
rewards: [
|
||||
{ gold: 400, note: 'Sevrisk’s hoard — coin and plate' },
|
||||
{ item: 'Potion of Greater Healing', quantity: 2, note: 'In the hoard' },
|
||||
{ weapon: 'Grey Dawn Longsword', note: 'In the hoard — a knight came here once and did not leave' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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.',
|
||||
@@ -467,6 +547,12 @@ const encounters = [
|
||||
{ weapon: 'Masterwork Dagger', note: 'Still in a dead hand' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E9 — The Root: The Drowned Shrine', mode: 'QUEST', location: 'Hollow Below — The Root (Layer 2)',
|
||||
description: 'A shrine older than anything in the vaults above, half sunk in still black water. The altar is whole. The symbol carved into it belongs to no faith anyone above has practised in a very long time.',
|
||||
dmNotes: 'Root Room 5. A pre-kingdom holy site, connected to the Sunken Library in the Greywood. DC 16 History or Religion: the symbol is older than Aldenfell itself — a thread toward the Hollow King’s origin, without the full truth. A good place for a vision or omen (E5 can point here).\nThe relic: DC 13 Athletics or Investigation to lift it without disturbing the water. Disturbing it summons nothing, only ripples that spread wrong. Exits: back to Room 2, or a narrow crawl to Room 6.',
|
||||
rewards: [{ item: 'Silver Shrine Relic', note: 'In the water — DC 13 Athletics or Investigation' }],
|
||||
},
|
||||
{
|
||||
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.',
|
||||
@@ -474,7 +560,7 @@ const encounters = [
|
||||
monsters: [['Bellcrawler', 3]],
|
||||
rewards: [
|
||||
{ gold: 90, note: 'Scattered along the ledges' },
|
||||
{ item: 'Potion of Healing', note: 'In a fallen pack' },
|
||||
{ item: 'Potion of Healing', note: 'In a fallen pack — the book has the coin or the potion, DM’s choice' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -482,7 +568,10 @@ const encounters = [
|
||||
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' }],
|
||||
rewards: [
|
||||
{ item: 'Sealed Letter', note: 'E10C only — the servant’s evidence' },
|
||||
{ item: 'Seized House Documents', note: 'E10A only — taken at the confrontation' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E11 — Silence on the Border', mode: 'QUEST', location: 'Bramble Marches — Greyford Keep',
|
||||
@@ -508,6 +597,27 @@ const encounters = [
|
||||
monsters: [['Hollowed', 4]],
|
||||
rewards: [{ item: 'Seal-Stone Fragment', note: 'Key item — from the breach’s edge' }],
|
||||
},
|
||||
{
|
||||
name: 'E15 — The House Falls (or Doesn’t)', mode: 'BATTLE',
|
||||
description: 'Word travels faster than truth in Highcrown. By the time you reach the house, half the city already has an opinion about what happened there — and most of them are wrong.',
|
||||
dmNotes: 'Plays very differently by Act 2 route.\n• E15A (took E10A): the house has spent Act 2 preparing — hired guards, an alibi, perhaps a scapegoat servant. DC 18 to break the cover story, or this fight: house guards and one Hollowed-touched retainer.\n• E15B (took E10B): a tense public tribunal, no fight. DC 16 Persuasion or Insight steers it toward exile rather than execution (matters for a returning villain).\n• E15C (took E10C): the house suspects nothing. Move on its masters — the cult in Highcrown. Only this route reaches E15C-1: a cult lieutenant taken alive, who gives up the Hollow Court’s surface entrance (→ E18B).\nRemove the monsters for E15B and E15C. → E16.',
|
||||
monsters: [['Bandit Captain', 2, 'House Guard Sergeant'], ['Highcrown Cutthroat', 4, 'House Guard'], ['Hollowed', 1, 'The Retainer']],
|
||||
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne'],
|
||||
rewards: [{ item: 'House Records', note: 'Names other cult contacts across Aldenfell — seeds for a side quest, or safely ignored' }],
|
||||
},
|
||||
{
|
||||
name: 'E16 — What the Marches Cost', mode: 'QUEST', location: 'Bramble Marches — Greyford Keep',
|
||||
description: 'Smoke on the horizon means something different out here than it does in Highcrown. Out here, it usually means someone made a choice that can’t be unmade.',
|
||||
dmNotes: 'The consequences of E11. No loot: this is about consequence.\n• E16A — Greyford was reinforced (E11C, Spine Clans allied): the keep holds. A rare unambiguous good outcome; let them feel it. Spine Clan standing rises.\n• E16B — Greyford was under-supplied (Smugglers’ Tithe unresolved): the keep falls or is abandoned for good. The named refugee from Refugees of the Marches is lost unless the party steps in — DC 16 Athletics or Survival chase and rescue.\n→ E17 (pay off any debt the party has run up: the E14A weakening, an unresolved side quest, unchased omens — or a quiet breather with the bell-ringer) → E18.',
|
||||
npcs: ['Greyford’s Commander'],
|
||||
},
|
||||
{
|
||||
name: 'E18 — Into the Hollow Court', mode: 'BATTLE', location: 'Hollow Below — The Hollow Court (Layer 3)',
|
||||
description: 'The stair doesn’t so much end as stop pretending to be a stair. What’s beneath Aldenfell was never built — it grew, the way a wound grows scar tissue, except the scar tissue is watching you.',
|
||||
dmNotes: 'Hollow Court Rooms 1–3. E18A enters by the Bent Stair; E18B (cult lieutenant from E15C-1) skips straight to the Memory Pool.\n• Room 1, The Bent Stair: DC 15 Wis save or lose track of how long the descent took (atmosphere, or 10 minutes lost). No monster.\n• Room 2, The Memory Pool: each PC who looks sees a vision tied to a choice they made — the payoff for the branch points. No damage; DC 17 Insight to understand it.\n• Room 3, The Court of Whispers: Vessa may speak plainly here. DC 16 Insight: she believes what she says. If it comes to a fight it is with these Hollowed guardians, never with her — remove them if the party talks.\n→ E19.',
|
||||
monsters: [['Hollowed', 4, 'Hollowed Guardian']],
|
||||
npcs: ['Vessa Hollow'],
|
||||
},
|
||||
{
|
||||
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.',
|
||||
@@ -525,14 +635,20 @@ const encounters = [
|
||||
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' }],
|
||||
rewards: [
|
||||
{ item: 'Potion of Greater Healing', quantity: 2, note: 'Going-away gift from whoever stands with them' },
|
||||
{ item: 'Masterwork Chain Mail', note: 'E21A — from the Grey Dawn armoury, if the Order 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' }],
|
||||
rewards: [
|
||||
{ weapon: 'Hollowbane', note: 'Drawn from the seal itself — the campaign’s one significant magic item' },
|
||||
{ item: 'The Seal-Stone', note: 'E22A only — the seal, reinforced rather than broken' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E25 — The Last Approach', mode: 'BATTLE', location: 'Hollow Below — The Hollow Court (Layer 3)',
|
||||
@@ -540,6 +656,19 @@ const encounters = [
|
||||
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]],
|
||||
},
|
||||
{
|
||||
name: 'E23–E24 — The Waking', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
|
||||
description: 'The Waking isn’t loud. That’s the wrong word for it. It’s the sound of something enormous remembering it has a name.',
|
||||
dmNotes: 'Hollow Court Room 6. The seal gives way, one way or another.\n• Broke it deliberately (E22B): the party arrives prepared, on their own timing — full resources, no surprise round against them.\n• It failed on its own (E22C): the Hollow King is already partly awake — apply one stage of escalation before the party acts, and E25–E26 are noticeably harder.\nE24, What the Court Remembers: not combat. Its memories bleed into the room, fragments of who it was before. DC 14 Insight recognises a fragment as manipulation rather than truth — it is recruiting sympathy, not confessing. → E25.',
|
||||
npcs: ['Vessa Hollow'],
|
||||
},
|
||||
{
|
||||
name: 'E26 — The Hollow King', mode: 'BATTLE', location: 'Hollow Below — The Hollow Court (Layer 3)',
|
||||
description: 'There is no throne and no crown. There is a shape made of everyone who was put down here, and it has finally turned to look at you.',
|
||||
dmNotes: 'The final confrontation. The book gives no stat block — it asks you to build one to the party’s level and what they brought. The “The Hollow King” monster here is a starting point for four level 7–8 characters: change it.\nThree shapes:\n• Grey Dawn track — disciplined tactics: Kavan’s wardens hold a line while the party lands the killing blow. Add Ser Kavan Thorne.\n• Spine Clans track — terrain and ritual: collapsing passages, the Memory Pool as a weapon. Add Korgath Stonejaw.\n• Independent — hardest: no reinforcements, and every choice in the last round is theirs.\nNegotiating through Vessa is a legitimate ending. → E27, Endings: Sealed, Grey Dawn, Spine Clans or Independent — and close where it opened, at the Lantern Market a year later.',
|
||||
monsters: [['The Hollow King', 1]],
|
||||
npcs: ['Vessa Hollow'],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -803,4 +932,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, weapons, items, spells, npcs, encounters, sharedNotes, dmNotes };
|
||||
module.exports = { code: 'ALDENF', campaign, locations, monsters, weapons, items, spells, npcs, encounters, sharedNotes, dmNotes };
|
||||
File diff suppressed because one or more lines are too long
+8
-1
@@ -1,5 +1,6 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const compression = require('compression');
|
||||
|
||||
const authRoutes = require('./routes/auth.routes');
|
||||
const campaignsRoutes = require('./routes/campaigns.routes');
|
||||
@@ -10,7 +11,13 @@ const notesRoutes = require('./routes/notes.routes');
|
||||
function createApp() {
|
||||
const app = express();
|
||||
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173' }));
|
||||
// The site and the API are on different hosts, so a browser asks leave
|
||||
// before each kind of request. maxAge lets it remember the answer (browsers
|
||||
// cap it at two hours) and not ask again every time.
|
||||
app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173', maxAge: 7200 }));
|
||||
// Nothing in front of the API compresses for it (frp only tunnels), so the
|
||||
// JSON is gzipped here.
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||
|
||||
+6
-1
@@ -1,5 +1,10 @@
|
||||
// Prisma no longer reads .env itself, and this is the first thing to need it.
|
||||
require('dotenv').config({ quiet: true });
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { PrismaPg } = require('@prisma/adapter-pg');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = new PrismaClient({
|
||||
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
||||
});
|
||||
|
||||
module.exports = prisma;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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'] },
|
||||
];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
require('dotenv').config();
|
||||
require('dotenv').config({ quiet: true });
|
||||
const http = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
const { createApp } = require('./app');
|
||||
|
||||
@@ -12,11 +12,18 @@ function requireCampaignMember() {
|
||||
return async (req, res, next) => {
|
||||
const campaignId = req.params.campaignId || req.params.id;
|
||||
try {
|
||||
const campaign = await prisma.campaign.findUnique({ where: { id: campaignId } });
|
||||
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
|
||||
|
||||
const membership = await loadCampaignMembership(req.user.id, campaignId);
|
||||
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
|
||||
// One query for a member, which is nearly every request. Only when it
|
||||
// finds nothing is the campaign looked up, to tell a 404 from a 403.
|
||||
const found = await prisma.campaignMember.findUnique({
|
||||
where: { campaignId_userId: { campaignId, userId: req.user.id } },
|
||||
include: { campaign: true },
|
||||
});
|
||||
if (!found) {
|
||||
const exists = await prisma.campaign.findUnique({ where: { id: campaignId }, select: { id: true } });
|
||||
if (!exists) return res.status(404).json({ error: 'Campaign not found' });
|
||||
return res.status(403).json({ error: 'Not a member of this campaign' });
|
||||
}
|
||||
const { campaign, ...membership } = found;
|
||||
|
||||
req.campaign = campaign;
|
||||
req.membership = membership;
|
||||
|
||||
@@ -311,11 +311,10 @@ describe('copy character to another campaign', () => {
|
||||
await request(app).post(`/characters/${characterId}/weapons`).set(source.dmAuth).send({ weaponId: longsword.body.weapon.id, level: 2 });
|
||||
await request(app).post(`/characters/${characterId}/spells`).set(source.dmAuth).send({ spellId: spell.body.spell.id });
|
||||
await request(app).post(`/characters/${characterId}/inventory`).set(source.dmAuth).send({ itemId: rope.body.item.id, quantity: 2 });
|
||||
await request(app).post(`/characters/${characterId}/gold`).set(playerAuth).send({ amount: 42, reason: 'Savings' });
|
||||
await request(app).post(`/characters/${characterId}/gold`).set(source.dmAuth).send({ amount: 42, reason: 'Savings' });
|
||||
await request(app).patch(`/characters/${characterId}`).set(source.dmAuth).send({ level: 3 });
|
||||
const original = (
|
||||
await request(app).patch(`/characters/${characterId}`).set(playerAuth).send({ currentHp: 1, privateNotes: 'secret' })
|
||||
).body.character;
|
||||
await request(app).patch(`/characters/${characterId}`).set(source.dmAuth).send({ currentHp: 1 });
|
||||
const original = (await request(app).patch(`/characters/${characterId}`).set(playerAuth).send({ privateNotes: 'secret' })).body.character;
|
||||
|
||||
const copyRes = await request(app).post(`/characters/${characterId}/copy`).set(playerAuth).send({ targetCampaignId: target.campaign.id });
|
||||
expect(copyRes.status).toBe(201);
|
||||
@@ -402,6 +401,9 @@ describe('who can change what on a character', () => {
|
||||
const location = await request(app).post(`/campaigns/${campaign.id}/locations`).set(dmAuth).send({ name: 'Keep' });
|
||||
|
||||
const dmOnly = [
|
||||
// A player cannot heal or hurt their own character.
|
||||
{ currentHp: 1 },
|
||||
{ tempHp: 2 },
|
||||
{ level: 20 },
|
||||
{ xp: 9000 },
|
||||
{ currentLocationId: location.body.location.id },
|
||||
@@ -419,7 +421,8 @@ describe('who can change what on a character', () => {
|
||||
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ gold: 999 })).status).toBe(400);
|
||||
|
||||
// The player can still edit the rest of their own sheet.
|
||||
expect((await request(app).patch(`/characters/${characterId}`).set(playerAuth).send({ armorBonus: 2 })).status).toBe(200);
|
||||
expect((await request(app).patch(`/characters/${characterId}`).set(playerAuth).send({ privateNotes: 'Mine.' })).status).toBe(200);
|
||||
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ currentHp: 1, tempHp: 2 })).status).toBe(200);
|
||||
|
||||
const dmRes = await request(app)
|
||||
.patch(`/characters/${characterId}`)
|
||||
@@ -581,12 +584,12 @@ describe('hit points', () => {
|
||||
const patch = (auth, body) => request(app).patch(`/characters/${characterId}`).set(auth).send(body);
|
||||
|
||||
const level5 = (await patch(dmAuth, { level: 5 })).body.character;
|
||||
const full = (await patch(playerAuth, { currentHp: 9999 })).body.character;
|
||||
const full = (await patch(dmAuth, { currentHp: 9999 })).body.character;
|
||||
expect(full.currentHp).toBe(level5.sheet.hp.max);
|
||||
|
||||
expect((await patch(playerAuth, { currentHp: -1 })).body.character.currentHp).toBe(0);
|
||||
expect((await patch(dmAuth, { currentHp: -1 })).body.character.currentHp).toBe(0);
|
||||
|
||||
await patch(playerAuth, { currentHp: 9999 });
|
||||
await patch(dmAuth, { currentHp: 9999 });
|
||||
const level1 = (await patch(dmAuth, { level: 1 })).body.character;
|
||||
expect(level1.sheet.hp.max).toBeLessThan(level5.sheet.hp.max);
|
||||
expect(level1.currentHp).toBe(level1.sheet.hp.max);
|
||||
@@ -609,11 +612,11 @@ describe('hit points', () => {
|
||||
expect(await sheetHp()).toBe(maxHp - 3);
|
||||
|
||||
// Healing on the sheet shows in the tracker.
|
||||
await request(app).patch(`/characters/${characterId}`).set(playerAuth).send({ currentHp: maxHp - 1 });
|
||||
await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ currentHp: maxHp - 1 });
|
||||
expect((await combatant()).currentHp).toBe(maxHp - 1);
|
||||
|
||||
// The tracker is clamped like the sheet, and can't give a character a different max.
|
||||
await request(app).patch(`/encounters/${encounterId}/participants/${participantId}`).set(dmAuth).send({ currentHp: -20, maxHp: 500 });
|
||||
await request(app).patch(`/encounters/${encounterId}/participants/${participantId}`).set(dmAuth).send({ currentHp: -1, maxHp: 500 });
|
||||
expect(await sheetHp()).toBe(0);
|
||||
expect((await combatant()).maxHp).toBe(maxHp);
|
||||
|
||||
@@ -645,14 +648,19 @@ describe('money tracker', () => {
|
||||
expect(reward.status).toBe(201);
|
||||
expect(reward.body.gold).toBe(65);
|
||||
|
||||
const spend = await gold(playerAuth, { amount: -20, reason: 'New boots' });
|
||||
const spend = await gold(dmAuth, { amount: -20, reason: 'New boots' });
|
||||
expect(spend.status).toBe(201);
|
||||
expect(spend.body.gold).toBe(45);
|
||||
|
||||
const overspend = await gold(playerAuth, { amount: -46 });
|
||||
// A player can neither add to their own money nor write it off, in any coin.
|
||||
expect((await gold(playerAuth, { amount: 5, reason: 'Found it' })).status).toBe(403);
|
||||
expect((await gold(playerAuth, { amount: 5, currency: 'SILVER' })).status).toBe(403);
|
||||
expect((await gold(playerAuth, { amount: -5, reason: 'Lost it' })).status).toBe(403);
|
||||
|
||||
const overspend = await gold(dmAuth, { amount: -46 });
|
||||
expect(overspend.status).toBe(400);
|
||||
expect((await gold(playerAuth, { amount: 0 })).status).toBe(400);
|
||||
expect((await gold(playerAuth, { amount: 1.5 })).status).toBe(400);
|
||||
expect((await gold(dmAuth, { amount: 0 })).status).toBe(400);
|
||||
expect((await gold(dmAuth, { amount: 1.5 })).status).toBe(400);
|
||||
|
||||
// Another player can neither write to the ledger nor read it.
|
||||
const stranger = await request(app)
|
||||
@@ -669,11 +677,45 @@ describe('money tracker', () => {
|
||||
expect(ledger.status).toBe(200);
|
||||
expect(ledger.body.gold).toBe(45);
|
||||
expect(ledger.body.transactions.map((t) => [t.amount, t.balanceAfter, t.reason, t.createdBy.displayName])).toEqual([
|
||||
[-20, 45, 'New boots', 'Player'],
|
||||
[-20, 45, 'New boots', 'DM'],
|
||||
[50, 65, 'Bounty on the wolf pack', 'DM'],
|
||||
[15, 15, 'Starting gold', 'Player'],
|
||||
]);
|
||||
});
|
||||
|
||||
itIfDb('keeps cash apart from the coins', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('cash');
|
||||
const create = (name) => request(app).post(`/campaigns/${campaign.id}/characters`).set(playerAuth).send({ name });
|
||||
const characterId = (await create('Drifter')).body.character.id;
|
||||
// Only the DM records a gain or a spend.
|
||||
const money = (body) => request(app).post(`/characters/${characterId}/gold`).set(dmAuth).send(body);
|
||||
|
||||
await money({ amount: 5 });
|
||||
const paid = await money({ amount: 40, currency: 'CASH', reason: 'Odd job' });
|
||||
expect(paid.status).toBe(201);
|
||||
expect(paid.body).toMatchObject({ gold: 5, cash: 40 });
|
||||
expect(paid.body.transaction).toMatchObject({ currency: 'CASH', amount: 40, balanceAfter: 40 });
|
||||
|
||||
// Cash is paid in cash alone: gold doesn't cover it, and it doesn't cover gold.
|
||||
const overspend = await money({ amount: -41, currency: 'CASH' });
|
||||
expect(overspend.status).toBe(400);
|
||||
expect(overspend.body.error).toContain('$40');
|
||||
expect((await money({ amount: -6 })).status).toBe(400);
|
||||
const spent = await money({ amount: -15, currency: 'CASH', reason: 'Diner' });
|
||||
expect(spent.body).toMatchObject({ gold: 5, silver: 0, copper: 0, cash: 25 });
|
||||
expect(spent.body.transactions).toHaveLength(1);
|
||||
|
||||
// It has no worth in coins, so it can't be exchanged, and it only moves through the ledger.
|
||||
const exchange = (body) => request(app).post(`/characters/${characterId}/gold/exchange`).set(playerAuth).send(body);
|
||||
expect((await exchange({ from: 'CASH', to: 'GOLD', amount: 10 })).status).toBe(400);
|
||||
expect((await exchange({ from: 'GOLD', to: 'CASH', amount: 1 })).status).toBe(400);
|
||||
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ cash: 999 })).status).toBe(400);
|
||||
|
||||
const sheet = (await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character;
|
||||
expect(sheet.cash).toBe(25);
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters;
|
||||
expect(listed.find((c) => c.id === characterId).cash).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe('starting options', () => {
|
||||
@@ -762,11 +804,16 @@ describe('gear levels', () => {
|
||||
expect(levelled.body.characterWeapon).toMatchObject({ level: 3, equipped: false });
|
||||
expect(await held('Spear')).toMatchObject({ scaledDamageDice: '1d10', usable: false });
|
||||
|
||||
// Unusable weapons stay out of quick stats and the encounter view.
|
||||
// The character panel lists everything held and marks what is out of reach;
|
||||
// the encounter view leaves unusable weapons out.
|
||||
const quick = async () => (await request(app).get(`${ch}/quick-stats`).set(playerAuth)).body.quickStats.weapons;
|
||||
expect(await quick()).toEqual([]);
|
||||
expect((await quick()).map((w) => [w.name, w.usable]).sort()).toEqual([
|
||||
['Relic Blade', false],
|
||||
['Spear', false],
|
||||
]);
|
||||
|
||||
// At character level 3 both are usable, at their own level's dice.
|
||||
await request(app).patch(c).set(dmAuth).send({ maxEquippedWeapons: 2 });
|
||||
await request(app).patch(ch).set(dmAuth).send({ level: 3 });
|
||||
expect(await held('Spear')).toMatchObject({ usable: true });
|
||||
expect((await request(app).patch(`${ch}/weapons/${spear.id}`).set(playerAuth).send({ equipped: true })).status).toBe(200);
|
||||
@@ -956,7 +1003,11 @@ describe('encounters', () => {
|
||||
|
||||
await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId: heroId });
|
||||
const playerView = byName((await request(app).get(e).set(playerAuth)).body.encounter);
|
||||
expect(playerView['Mayor Orla'].npc).toEqual({ id: mayor.id, name: 'Mayor Orla', role: 'Mayor', disposition: 'HOSTILE', description: 'Tired but kind.' });
|
||||
// What a player gets is exactly this: the public face, plus what the NPC trades (nothing, here).
|
||||
expect(playerView['Mayor Orla'].npc).toEqual({
|
||||
id: mayor.id, name: 'Mayor Orla', role: 'Mayor', disposition: 'HOSTILE', description: 'Tired but kind.',
|
||||
exchangeGold: null, exchangeCash: null, wares: [],
|
||||
});
|
||||
|
||||
// Deleting an NPC takes it out of the scenes it was in.
|
||||
expect((await request(app).delete(`${c}/npcs/${hermit.id}`).set(dmAuth)).status).toBe(204);
|
||||
@@ -1391,6 +1442,7 @@ describe('default content library', () => {
|
||||
expect((await request(app).get(`/campaigns/${campaign.id}/library`).set(playerAuth)).status).toBe(403);
|
||||
const listRes = await request(app).get(`/campaigns/${campaign.id}/library`).set(dmAuth);
|
||||
expect(listRes.status).toBe(200);
|
||||
expect(listRes.headers['content-encoding']).toBe('gzip'); // a big answer goes out compressed
|
||||
expect(listRes.body.weapons.length).toBeGreaterThan(20);
|
||||
expect(listRes.body.spells.length).toBeGreaterThan(50);
|
||||
expect(listRes.body.monsters.length).toBeGreaterThan(20);
|
||||
@@ -1403,6 +1455,12 @@ describe('default content library', () => {
|
||||
expect(addRes.body.weapon.name).toBe('Longsword');
|
||||
expect(addRes.body.weapon.campaignId).toBe(campaign.id);
|
||||
|
||||
// Each library entry goes in once, until its copy is renamed or deleted.
|
||||
const addLongsword = () => request(app).post(`/campaigns/${campaign.id}/weapons/from-library`).set(dmAuth).send({ key: 'longsword' });
|
||||
expect((await addLongsword()).status).toBe(409);
|
||||
await request(app).delete(`/campaigns/${campaign.id}/weapons/${addRes.body.weapon.id}`).set(dmAuth);
|
||||
expect((await addLongsword()).status).toBe(201);
|
||||
|
||||
// Library spells show their level and default damage before they are added,
|
||||
// and carry both into the campaign.
|
||||
const byKey = Object.fromEntries(listRes.body.spells.map((s) => [s.key, s]));
|
||||
@@ -1418,6 +1476,35 @@ describe('default content library', () => {
|
||||
expect((await request(app).get(`/campaigns/${campaign.id}/monsters`).set(dmAuth)).body.monsters).toHaveLength(1);
|
||||
expect((await request(app).get(`/campaigns/${campaign.id}/monsters`).set(playerAuth)).status).toBe(403);
|
||||
|
||||
// Every library monster carries the whole stat block.
|
||||
for (const m of listRes.body.monsters) {
|
||||
expect(m).toMatchObject({
|
||||
challengeRating: expect.any(String),
|
||||
ac: expect.any(Number),
|
||||
maxHp: expect.any(Number),
|
||||
speed: expect.any(Number),
|
||||
attackBonus: expect.any(Number),
|
||||
damageDice: expect.any(String),
|
||||
notes: expect.any(String),
|
||||
});
|
||||
for (const a of ['str', 'dex', 'con', 'int', 'wis', 'cha']) expect(m[`${a}Score`]).toEqual(expect.any(Number));
|
||||
}
|
||||
|
||||
// A hand-made monster takes the whole stat block, and every field can be changed afterwards.
|
||||
const block = {
|
||||
name: 'Gloamling', challengeRating: '1/2', ac: 13, maxHp: 18, speed: 40, attackBonus: 4, damageDice: '1d6+2 necrotic',
|
||||
strScore: 8, dexScore: 16, conScore: 12, intScore: 6, wisScore: 11, chaScore: 5,
|
||||
specialTrait: 'Shadow Step', notes: 'Fly 30. Resists necrotic.',
|
||||
};
|
||||
const made = await request(app).post(`/campaigns/${campaign.id}/monsters`).set(dmAuth).send(block);
|
||||
expect(made.status).toBe(201);
|
||||
expect(made.body.monster).toMatchObject(block);
|
||||
const patchMonster = (auth, body) => request(app).patch(`/campaigns/${campaign.id}/monsters/${made.body.monster.id}`).set(auth).send(body);
|
||||
const edited = await patchMonster(dmAuth, { speed: 20, wisScore: 14, challengeRating: '1', specialTrait: null });
|
||||
expect(edited.status).toBe(200);
|
||||
expect(edited.body.monster).toMatchObject({ ...block, speed: 20, wisScore: 14, challengeRating: '1', specialTrait: null });
|
||||
expect((await patchMonster(playerAuth, { ac: 1 })).status).toBe(403);
|
||||
|
||||
const playerAddRes = await request(app)
|
||||
.post(`/campaigns/${campaign.id}/monsters/from-library`)
|
||||
.set(playerAuth)
|
||||
@@ -1839,7 +1926,9 @@ describe('what a player may see', () => {
|
||||
expect(dm.Ogre.hpPercent).toBeUndefined();
|
||||
|
||||
await request(app).patch(`${e}/participants/${ogreRow.id}`).set(dmAuth).send({ currentHp: 0 });
|
||||
expect((await view(playerAuth)).Ogre.hpPercent).toBe(0);
|
||||
// At 0 HP it is out of the fight, and out of the players' sight.
|
||||
expect((await view(playerAuth)).Ogre).toBeUndefined();
|
||||
expect((await view(dmAuth)).Ogre).toMatchObject({ currentHp: 0, hidden: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1912,6 +2001,68 @@ describe('initiative', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('targets', () => {
|
||||
itIfDb('are picked by the DM or the player on turn, shown to all, and go when the turn does', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('target');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Archer');
|
||||
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster;
|
||||
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Ambush' })).body.encounter.id}`;
|
||||
const add = async (body) => (await request(app).post(`${e}/participants`).set(dmAuth).send(body)).body.participant;
|
||||
const hero = await add({ characterId: heroId, initiativeRoll: 20 });
|
||||
const first = await add({ monsterId: goblin.id, initiativeRoll: 10 });
|
||||
const second = await add({ monsterId: goblin.id, initiativeRoll: 1 });
|
||||
const target = (auth, ...participantIds) => request(app).put(`${e}/targets`).set(auth).send({ participantIds });
|
||||
const seen = async (auth) => (await request(app).get(e).set(auth)).body.encounter.targetParticipantIds;
|
||||
|
||||
// Before the battle only the DM can mark anyone.
|
||||
expect((await target(playerAuth, first.id)).status).toBe(403);
|
||||
expect((await target(dmAuth, first.id)).body.encounter.targetParticipantIds).toEqual([first.id]);
|
||||
expect(await seen(playerAuth)).toEqual([first.id]);
|
||||
expect((await target(dmAuth, first.id, 'nobody')).status).toBe(400);
|
||||
|
||||
// Beginning the battle clears them and puts the hero on turn: now the
|
||||
// player can, and several at once is an area attack, kept in the order picked.
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect(await seen(dmAuth)).toEqual([]);
|
||||
expect((await target(playerAuth, second.id, first.id, second.id)).status).toBe(200);
|
||||
expect(await seen(dmAuth)).toEqual([second.id, first.id]);
|
||||
|
||||
// Removing a target unmarks it and leaves the rest.
|
||||
await request(app).delete(`${e}/participants/${second.id}`).set(dmAuth);
|
||||
expect(await seen(dmAuth)).toEqual([first.id]);
|
||||
|
||||
// The turn passing clears them, and the player is no longer on turn.
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
expect(await seen(dmAuth)).toEqual([]);
|
||||
expect((await target(playerAuth, first.id)).status).toBe(403);
|
||||
|
||||
// Whoever is on turn can't be a target, their own or as part of a group.
|
||||
expect((await target(dmAuth, first.id)).status).toBe(400);
|
||||
expect((await target(dmAuth, hero.id, first.id)).status).toBe(400);
|
||||
|
||||
// The goblin's turn: the DM marks the hero, and can clear it again.
|
||||
expect((await target(dmAuth, hero.id)).body.encounter.targetParticipantIds).toEqual([hero.id]);
|
||||
expect((await target(dmAuth)).body.encounter.targetParticipantIds).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('area attack AC penalty', () => {
|
||||
itIfDb('is a campaign setting the DM changes, 2 unless told otherwise', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('areapenalty');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
expect(campaign.areaAttackAcPenalty).toBe(2);
|
||||
|
||||
expect((await request(app).patch(c).set(playerAuth).send({ areaAttackAcPenalty: 0 })).status).toBe(403);
|
||||
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: 11 })).status).toBe(400);
|
||||
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: -1 })).status).toBe(400);
|
||||
expect((await request(app).patch(c).set(dmAuth).send({ areaAttackAcPenalty: 0 })).body.campaign.areaAttackAcPenalty).toBe(0);
|
||||
|
||||
// Players are told it too: their cards show the penalty on a marked target.
|
||||
expect((await request(app).get(c).set(playerAuth)).body.campaign.areaAttackAcPenalty).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('number limits', () => {
|
||||
itIfDb('are enforced by the server, whatever a form sends', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('limits');
|
||||
@@ -1944,17 +2095,17 @@ describe('temporary hit points', () => {
|
||||
expect((await sheet()).currentHp).toBe(max);
|
||||
|
||||
// On the sheet: 5 temp, 3 damage — all of it soaked.
|
||||
await request(app).patch(ch).set(playerAuth).send({ tempHp: 5 });
|
||||
let now = (await request(app).patch(ch).set(playerAuth).send({ currentHp: max - 3 })).body.character;
|
||||
await request(app).patch(ch).set(dmAuth).send({ tempHp: 5 });
|
||||
let now = (await request(app).patch(ch).set(dmAuth).send({ currentHp: max - 3 })).body.character;
|
||||
expect([now.currentHp, now.tempHp]).toEqual([max, 2]);
|
||||
|
||||
// More damage than is left: the rest reaches real HP.
|
||||
now = (await request(app).patch(ch).set(playerAuth).send({ currentHp: max - 3 })).body.character;
|
||||
now = (await request(app).patch(ch).set(dmAuth).send({ currentHp: max - 3 })).body.character;
|
||||
expect([now.currentHp, now.tempHp]).toEqual([max - 1, 0]);
|
||||
|
||||
// Healing doesn't touch temp HP, and setting temp HP doesn't touch HP.
|
||||
await request(app).patch(ch).set(playerAuth).send({ tempHp: 4 });
|
||||
now = (await request(app).patch(ch).set(playerAuth).send({ currentHp: max })).body.character;
|
||||
await request(app).patch(ch).set(dmAuth).send({ tempHp: 4 });
|
||||
now = (await request(app).patch(ch).set(dmAuth).send({ currentHp: max })).body.character;
|
||||
expect([now.currentHp, now.tempHp]).toEqual([max, 4]);
|
||||
|
||||
// In an encounter the damage lands on the same sheet, the same way.
|
||||
@@ -1966,7 +2117,7 @@ describe('temporary hit points', () => {
|
||||
expect([now.currentHp, now.tempHp]).toEqual([max - 2, 0]);
|
||||
|
||||
// A blow far past zero still only takes what there is.
|
||||
await request(app).patch(ch).set(playerAuth).send({ tempHp: 2 });
|
||||
await request(app).patch(ch).set(dmAuth).send({ tempHp: 2 });
|
||||
await request(app).patch(`${e}/participants/${row.id}`).set(dmAuth).send({ currentHp: -500 });
|
||||
now = await sheet();
|
||||
expect([now.currentHp, now.tempHp]).toEqual([0, 0]);
|
||||
@@ -1986,7 +2137,7 @@ describe('death saves', () => {
|
||||
|
||||
// No saves while standing, and a save is a d20.
|
||||
expect((await save(playerAuth, { action: 'roll', roll: 12 })).status).toBe(400);
|
||||
await hp(playerAuth, 0);
|
||||
await hp(dmAuth, 0);
|
||||
expect((await sheet()).deathSaves).toEqual({ state: 'DYING', successes: 0, failures: 0 });
|
||||
expect((await save(playerAuth, { action: 'roll', roll: 21 })).status).toBe(400);
|
||||
|
||||
@@ -1996,10 +2147,10 @@ describe('death saves', () => {
|
||||
|
||||
// Being hit while down is a failed save; healing clears the lot.
|
||||
expect((await hp(dmAuth, -1)).body.character.deathSaves).toEqual({ state: 'DYING', successes: 1, failures: 2 });
|
||||
expect((await hp(playerAuth, 3)).body.character.deathSaves).toEqual({ state: 'UP', successes: 0, failures: 0 });
|
||||
expect((await hp(dmAuth, 3)).body.character.deathSaves).toEqual({ state: 'UP', successes: 0, failures: 0 });
|
||||
|
||||
// Three successes: stable, at 0 HP, with nothing left to roll.
|
||||
await hp(playerAuth, 0);
|
||||
await hp(dmAuth, 0);
|
||||
for (const roll of [15, 11, 19]) await save(playerAuth, { action: 'roll', roll });
|
||||
let now = await sheet();
|
||||
expect([now.currentHp, now.deathSaves]).toEqual([0, { state: 'STABLE', successes: 0, failures: 0 }]);
|
||||
@@ -2011,7 +2162,7 @@ describe('death saves', () => {
|
||||
expect([now.currentHp, now.deathSaves]).toEqual([1, { state: 'UP', successes: 0, failures: 0 }]);
|
||||
|
||||
// Someone else can steady them without a roll.
|
||||
await hp(playerAuth, 0);
|
||||
await hp(dmAuth, 0);
|
||||
expect((await save(playerAuth, { action: 'stabilize' })).body.character.deathSaves.state).toBe('STABLE');
|
||||
|
||||
// The tallies are the DM's to set by hand; a 1 on top of two failures kills.
|
||||
@@ -2020,7 +2171,7 @@ describe('death saves', () => {
|
||||
expect((await save(playerAuth, { action: 'roll', roll: 1 })).body.character.deathSaves.state).toBe('DEAD');
|
||||
|
||||
// The dead can't be healed or roll; only the DM brings them back.
|
||||
expect((await hp(playerAuth, max)).status).toBe(400);
|
||||
expect((await hp(dmAuth, max)).status).toBe(400);
|
||||
expect((await save(playerAuth, { action: 'roll', roll: 20 })).status).toBe(400);
|
||||
expect((await save(playerAuth, { action: 'revive' })).status).toBe(403);
|
||||
now = (await save(dmAuth, { action: 'revive' })).body.character;
|
||||
@@ -2038,7 +2189,7 @@ describe('death saves', () => {
|
||||
const combatant = async () => (await request(app).get(e).set(playerAuth)).body.encounter.participants[0];
|
||||
|
||||
// Temp HP that soaks the whole blow keeps them on their feet.
|
||||
await request(app).patch(ch).set(playerAuth).send({ tempHp: 2 });
|
||||
await request(app).patch(ch).set(dmAuth).send({ tempHp: 2 });
|
||||
await strike(max - 2);
|
||||
expect((await combatant()).characterCombatInfo.deathSaves.state).toBe('UP');
|
||||
|
||||
@@ -2055,9 +2206,82 @@ describe('death saves', () => {
|
||||
// Healed, then hit for max HP more than they have left.
|
||||
await strike(max);
|
||||
await strike(-max);
|
||||
expect((await combatant()).characterCombatInfo.deathSaves.state).toBe('DEAD');
|
||||
const dmView = (await request(app).get(e).set(dmAuth)).body.encounter.participants[0];
|
||||
expect([dmView.hidden, dmView.characterCombatInfo.deathSaves.state]).toEqual([true, 'DEAD']);
|
||||
expect((await strike(5)).status).toBe(400);
|
||||
});
|
||||
|
||||
itIfDb('hide the fallen from players, skip their turns, and let an ally with a reviving spell bring a character back', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('fallen');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const healerId = await buildCharacter(campaign.id, playerAuth, 'Healer');
|
||||
const doomedId = await buildCharacter(campaign.id, playerAuth, 'Doomed');
|
||||
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster;
|
||||
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Crypt' })).body.encounter.id}`;
|
||||
const add = async (body) => (await request(app).post(`${e}/participants`).set(dmAuth).send(body)).body.participant.id;
|
||||
const healer = await add({ characterId: healerId, initiativeRoll: 20 });
|
||||
const doomed = await add({ characterId: doomedId, initiativeRoll: 10 });
|
||||
const gob = await add({ monsterId: goblin.id, initiativeRoll: 1 });
|
||||
const setHp = (id, currentHp) => request(app).patch(`${e}/participants/${id}`).set(dmAuth).send({ currentHp });
|
||||
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
|
||||
const ids = (encounter) => encounter.participants.map((p) => p.id);
|
||||
const revive = (auth, id) => request(app).post(`${e}/participants/${id}/revive`).set(auth);
|
||||
const nextTurn = async () => (await request(app).post(`${e}/next-turn`).set(dmAuth)).body.encounter;
|
||||
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' }); // the healer is on turn
|
||||
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [gob] });
|
||||
|
||||
// A monster at 0 HP is gone from the player's list, target mark and all; the DM keeps it.
|
||||
await setHp(gob, 0);
|
||||
let seen = await view(playerAuth);
|
||||
expect([ids(seen), seen.targetParticipantIds, seen.fallen]).toEqual([[healer, doomed], [], []]);
|
||||
seen = await view(dmAuth);
|
||||
expect([ids(seen), seen.targetParticipantIds]).toEqual([[healer, doomed, gob], [gob]]);
|
||||
expect(seen.participants.map((p) => p.hidden)).toEqual([false, false, true]);
|
||||
expect((await request(app).put(`${e}/targets`).set(playerAuth).send({ participantIds: [gob] })).status).toBe(400);
|
||||
|
||||
// A dying character stays in view; a dead one is hidden, and named among the fallen.
|
||||
await setHp(doomed, 0);
|
||||
expect(ids(await view(playerAuth))).toEqual([healer, doomed]);
|
||||
await request(app).post(`/characters/${doomedId}/death-saves`).set(dmAuth).send({ action: 'set', successes: 0, failures: 3 });
|
||||
seen = await view(playerAuth);
|
||||
expect([ids(seen), seen.fallen]).toEqual([[healer], [{ participantId: doomed, characterId: doomedId, name: 'Doomed' }]]);
|
||||
|
||||
// The dead join no other scene until they are revived.
|
||||
const elsewhere = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Elsewhere' })).body.encounter.id}`;
|
||||
const join = () => request(app).post(`${elsewhere}/participants`).set(dmAuth).send({ characterId: doomedId });
|
||||
expect((await join()).status).toBe(400);
|
||||
|
||||
// The fallen have no turn: with only the healer standing, the round rolls straight over.
|
||||
seen = await nextTurn();
|
||||
expect([seen.activeParticipantId, seen.currentRound]).toEqual([healer, 2]);
|
||||
|
||||
// Reviving takes a spell that brings back the dead, at a level the caster can use.
|
||||
expect(seen.participants[0].characterCombatInfo.canRevive).toBe(false);
|
||||
expect((await revive(playerAuth, doomed)).status).toBe(400);
|
||||
const revivify = (await request(app).post(`${c}/spells/from-library`).set(dmAuth).send({ key: 'revivify' })).body.spell;
|
||||
expect(revivify.revives).toBe(true);
|
||||
await request(app).post(`/characters/${healerId}/spells`).set(dmAuth).send({ spellId: revivify.id });
|
||||
expect((await revive(playerAuth, doomed)).status).toBe(400); // level 1 can't cast a level 3 spell
|
||||
await request(app).patch(`/characters/${healerId}`).set(dmAuth).send({ level: 3 });
|
||||
expect((await view(playerAuth)).participants[0].characterCombatInfo.canRevive).toBe(true);
|
||||
expect((await revive(playerAuth, gob)).status).toBe(400); // monsters are healed, not revived
|
||||
|
||||
const revived = await revive(playerAuth, doomed);
|
||||
expect([revived.status, revived.body.revivedWith]).toEqual([200, 'Revivify']);
|
||||
expect([ids(revived.body.encounter), revived.body.encounter.fallen]).toEqual([[healer, doomed], []]);
|
||||
const back = (await request(app).get(`/characters/${doomedId}`).set(playerAuth)).body.character;
|
||||
expect([back.currentHp, back.deathSaves]).toEqual([1, { state: 'UP', successes: 0, failures: 0 }]);
|
||||
expect((await revive(playerAuth, doomed)).status).toBe(400); // not dead any more
|
||||
expect((await join()).status).toBe(201);
|
||||
|
||||
// Only on the reviver's own turn, and healing a monster brings it back into view.
|
||||
expect((await nextTurn()).activeParticipantId).toBe(doomed);
|
||||
await setHp(gob, 3);
|
||||
expect(ids(await view(playerAuth))).toEqual([healer, doomed, gob]);
|
||||
expect((await nextTurn()).activeParticipantId).toBe(gob);
|
||||
expect((await revive(playerAuth, doomed)).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('silver and copper', () => {
|
||||
@@ -2072,7 +2296,8 @@ describe('silver and copper', () => {
|
||||
const { gold, silver, copper } = (await request(app).get(`/characters/${id}`).set(auth)).body.character;
|
||||
return [gold, silver, copper];
|
||||
};
|
||||
const money = (body) => request(app).post(`${ch}/gold`).set(playerAuth).send(body);
|
||||
// Only the DM records a gain or a spend.
|
||||
const money = (body) => request(app).post(`${ch}/gold`).set(dmAuth).send(body);
|
||||
const exchange = (body) => request(app).post(`${ch}/gold/exchange`).set(playerAuth).send(body);
|
||||
|
||||
expect(await purse()).toEqual([5, 0, 0]);
|
||||
@@ -2151,7 +2376,8 @@ describe('paying with change', () => {
|
||||
const { gold, silver, copper } = (await request(app).get(`/characters/${id}`).set(auth)).body.character;
|
||||
return [gold, silver, copper];
|
||||
};
|
||||
const money = (body) => request(app).post(`${ch}/gold`).set(playerAuth).send(body);
|
||||
// Only the DM records a gain or a spend.
|
||||
const money = (body) => request(app).post(`${ch}/gold`).set(dmAuth).send(body);
|
||||
const newest = async (count) =>
|
||||
(await request(app).get(`${ch}/gold`).set(playerAuth)).body.transactions.slice(0, count).map((t) => [t.currency, t.amount, t.balanceAfter, t.reason]);
|
||||
|
||||
@@ -2202,3 +2428,388 @@ describe('paying with change', () => {
|
||||
expect(await purse()).toEqual([0, 6, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trade', () => {
|
||||
itIfDb('changes gold and cash with an NPC and sells wares, in a quest in progress only', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('trade');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const post = (path, body, auth = dmAuth) => request(app).post(path).set(auth).send(body);
|
||||
|
||||
// An NPC's rate has two sides, and only the DM sets it.
|
||||
expect((await post(`${c}/npcs`, { name: 'Half', exchangeGold: 2 })).status).toBe(400);
|
||||
const changer = (await post(`${c}/npcs`, { name: 'Vex', exchangeGold: 2, exchangeCash: 15 })).body.npc;
|
||||
expect(changer).toMatchObject({ exchangeGold: 2, exchangeCash: 15 });
|
||||
const baker = (await post(`${c}/npcs`, { name: 'Baker' })).body.npc;
|
||||
const elsewhere = (await post(`${c}/npcs`, { name: 'Hermit' })).body.npc;
|
||||
const patchNpc = (id, body) => request(app).patch(`${c}/npcs/${id}`).set(dmAuth).send(body);
|
||||
expect((await patchNpc(baker.id, { exchangeCash: 3 })).status).toBe(400);
|
||||
|
||||
// Wares come from the campaign pools and belong to one seller.
|
||||
const bread = (await post(`${c}/items`, { name: 'Bread' })).body.item;
|
||||
const sword = (await post(`${c}/weapons/from-library`, { key: 'longsword' })).body.weapon;
|
||||
const quest = (await post(`${c}/encounters`, { name: 'Market Day', mode: 'QUEST' })).body.encounter;
|
||||
const e = `/encounters/${quest.id}`;
|
||||
expect((await post(`${c}/wares`, { npcId: baker.id, itemId: bread.id, price: 3, currency: 'SILVER' }, playerAuth)).status).toBe(403);
|
||||
expect((await post(`${c}/wares`, { itemId: bread.id, price: 3 })).status).toBe(400); // no seller
|
||||
expect((await post(`${c}/wares`, { npcId: baker.id, encounterId: quest.id, itemId: bread.id, price: 3 })).status).toBe(400); // two sellers
|
||||
expect((await post(`${c}/wares`, { npcId: baker.id, itemId: bread.id, weaponId: sword.id, price: 3 })).status).toBe(400); // two things
|
||||
expect((await post(`${c}/wares`, { npcId: baker.id, itemId: 'nope', price: 3 })).status).toBe(400);
|
||||
const loaf = (await post(`${c}/wares`, { npcId: baker.id, itemId: bread.id, price: 3, currency: 'SILVER', stock: 2 })).body.ware;
|
||||
const blade = (await post(`${c}/wares`, { encounterId: quest.id, weaponId: sword.id, price: 40, currency: 'CASH' })).body.ware;
|
||||
const relic = (await post(`${c}/wares`, { npcId: elsewhere.id, itemId: bread.id, price: 1 })).body.ware;
|
||||
expect(loaf).toMatchObject({ stock: 2, level: 1, item: { name: 'Bread' } });
|
||||
expect(blade.stock).toBeNull();
|
||||
const npcList = (await request(app).get(`${c}/npcs`).set(dmAuth)).body.npcs;
|
||||
expect(npcList.find((n) => n.id === baker.id).wares).toHaveLength(1);
|
||||
|
||||
await post(`${e}/participants`, { characterId: heroId });
|
||||
await post(`${e}/participants`, { npcId: changer.id });
|
||||
const bakerRow = (await post(`${e}/participants`, { npcId: baker.id })).body.participant;
|
||||
await post(`/characters/${heroId}/gold`, { amount: 10 });
|
||||
|
||||
// Players see the rate and everything for sale in the scene.
|
||||
const seen = (await request(app).get(e).set(playerAuth)).body.encounter;
|
||||
const seenNpc = (name) => seen.participants.find((p) => p.npc?.name === name).npc;
|
||||
expect(seenNpc('Vex')).toMatchObject({ exchangeGold: 2, exchangeCash: 15 });
|
||||
expect(seenNpc('Baker').wares.map((w) => [w.item.name, w.price, w.currency, w.stock])).toEqual([['Bread', 3, 'SILVER', 2]]);
|
||||
expect(seen.wares.map((w) => w.weapon.name)).toEqual(['Longsword']);
|
||||
expect(seenNpc('Baker').dmNotes).toBeUndefined();
|
||||
|
||||
const exchange = (body, auth = playerAuth) => post(`${e}/exchange`, { characterId: heroId, npcId: changer.id, ...body }, auth);
|
||||
const buy = (wareId, body = {}, auth = playerAuth) => post(`${e}/wares/${wareId}/buy`, { characterId: heroId, ...body }, auth);
|
||||
const purse = async () => {
|
||||
const { gold, silver, copper, cash } = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character;
|
||||
return { gold, silver, copper, cash };
|
||||
};
|
||||
|
||||
// Nothing is traded before the quest begins, and never in a battle.
|
||||
expect((await exchange({ from: 'GOLD', amount: 2 })).status).toBe(400);
|
||||
expect((await buy(loaf.id)).status).toBe(400);
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE', mode: 'BATTLE' });
|
||||
expect((await exchange({ from: 'GOLD', amount: 2 })).status).toBe(400);
|
||||
expect((await buy(loaf.id)).status).toBe(400);
|
||||
await request(app).patch(e).set(dmAuth).send({ mode: 'QUEST' });
|
||||
|
||||
// Gold and cash only, in whole multiples of the rate, either way.
|
||||
expect((await exchange({ from: 'SILVER', amount: 10 })).status).toBe(400);
|
||||
expect((await exchange({ from: 'GOLD', amount: 3 })).status).toBe(400);
|
||||
expect((await exchange({ from: 'GOLD', amount: 12 })).status).toBe(400); // only has 10
|
||||
expect((await exchange({ from: 'GOLD', amount: 2, npcId: baker.id })).status).toBe(400); // not a money changer
|
||||
const changed = await exchange({ from: 'GOLD', amount: 6 });
|
||||
expect(changed.status).toBe(201);
|
||||
expect(changed.body).toMatchObject({ received: 45, currency: 'CASH' });
|
||||
expect(await purse()).toEqual({ gold: 4, silver: 0, copper: 0, cash: 45 });
|
||||
expect((await exchange({ from: 'CASH', amount: 20 })).status).toBe(400);
|
||||
expect((await exchange({ from: 'CASH', amount: 15 })).body.received).toBe(2);
|
||||
expect(await purse()).toEqual({ gold: 6, silver: 0, copper: 0, cash: 30 });
|
||||
const ledger = (await request(app).get(`/characters/${heroId}/gold`).set(playerAuth)).body.transactions;
|
||||
expect(ledger.slice(0, 2).map((t) => [t.currency, t.amount, t.reason]).sort()).toEqual([
|
||||
['CASH', -15, 'Changed with Vex for 2 gold'],
|
||||
['GOLD', 2, 'Changed with Vex from $15'],
|
||||
]);
|
||||
|
||||
// Buying pays the price (coins make change), hands the thing over and uses up stock.
|
||||
expect((await buy(relic.id)).status).toBe(400); // that NPC is not here
|
||||
expect((await buy(blade.id)).status).toBe(400); // $40, has $30
|
||||
expect((await buy(loaf.id, { quantity: 3 })).status).toBe(400); // only 2 left
|
||||
const bought = await buy(loaf.id, { quantity: 2 });
|
||||
expect(bought.status).toBe(201);
|
||||
expect(await purse()).toEqual({ gold: 5, silver: 4, copper: 0, cash: 30 });
|
||||
expect((await buy(loaf.id)).body.error).toContain('sold out');
|
||||
await exchange({ from: 'GOLD', amount: 2 });
|
||||
expect((await buy(blade.id)).status).toBe(201);
|
||||
expect(await purse()).toEqual({ gold: 3, silver: 4, copper: 0, cash: 5 });
|
||||
const sheet = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character;
|
||||
expect(sheet.inventoryItems.map((i) => [i.item.name, i.quantity])).toEqual([['Bread', 2]]);
|
||||
expect(sheet.weapons.map((w) => w.weapon.name)).toContain('Longsword');
|
||||
|
||||
// A player trades for their own character only; an NPC who is down sells nothing.
|
||||
const other = await request(app).post('/auth/register').send({ email: uniqueEmail('trade-other'), password: 'password123', displayName: 'Other' });
|
||||
cleanupUserIds.push(other.body.user.id);
|
||||
const otherAuth = { Authorization: `Bearer ${other.body.token}` };
|
||||
await request(app).post(`/campaigns/join/${campaign.inviteCode}`).set(otherAuth);
|
||||
const otherId = await buildCharacter(campaign.id, otherAuth, 'Bo');
|
||||
await post(`${e}/participants`, { characterId: otherId });
|
||||
expect((await buy(blade.id, {}, otherAuth)).status).toBe(403);
|
||||
expect((await exchange({ from: 'GOLD', amount: 2 }, dmAuth)).status).toBe(201); // the DM can, for anyone
|
||||
|
||||
// The DM can reprice and restock a ware; nobody else can.
|
||||
const patchWare = (auth, body) => request(app).patch(`${c}/wares/${loaf.id}`).set(auth).send(body);
|
||||
expect((await patchWare(playerAuth, { price: 1 })).status).toBe(403);
|
||||
expect((await patchWare(dmAuth, { price: 0 })).status).toBe(400);
|
||||
expect((await patchWare(dmAuth, { stock: 5, price: 1, currency: 'COPPER' })).body.ware).toMatchObject({ stock: 5, price: 1, currency: 'COPPER', item: { name: 'Bread' } });
|
||||
expect((await patchWare(dmAuth, { stock: null })).body.ware).toMatchObject({ stock: null, price: 1 });
|
||||
expect((await buy(loaf.id)).status).toBe(201);
|
||||
expect(await purse()).toEqual({ gold: 1, silver: 3, copper: 9, cash: 20 });
|
||||
await patchNpc(baker.id, { maxHp: 5 });
|
||||
await request(app).delete(`${e}/participants/${bakerRow.id}`).set(dmAuth);
|
||||
const downRow = (await post(`${e}/participants`, { npcId: baker.id })).body.participant;
|
||||
await request(app).patch(`${e}/participants/${downRow.id}`).set(dmAuth).send({ currentHp: 0 });
|
||||
expect((await buy(loaf.id)).status).toBe(400);
|
||||
|
||||
expect((await request(app).delete(`${c}/wares/${blade.id}`).set(playerAuth)).status).toBe(403);
|
||||
expect((await request(app).delete(`${c}/wares/${blade.id}`).set(dmAuth)).status).toBe(204);
|
||||
expect((await request(app).get(e).set(playerAuth)).body.encounter.wares).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gear effects', () => {
|
||||
itIfDb('equipped gear changes the sheet on its own, and the character panel lists everything held', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('gear');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const ch = `/characters/${heroId}`;
|
||||
const post = (path, body, auth = dmAuth) => request(app).post(path).set(auth).send(body);
|
||||
const sheet = async (auth = playerAuth) => (await request(app).get(ch).set(auth)).body.character;
|
||||
const quick = async (auth = playerAuth) => (await request(app).get(`${ch}/quick-stats`).set(auth)).body.quickStats;
|
||||
|
||||
const before = (await sheet()).sheet;
|
||||
expect(before.gear).toEqual({ effects: [], notes: [] });
|
||||
|
||||
// Only the DM says what gear does, and only with numbers the sheet has.
|
||||
const mail = {
|
||||
name: 'Chain Mail',
|
||||
effects: [{ stat: 'AC', amount: 6 }, { stat: 'SPEED', amount: -10 }, { stat: 'SKILL_STEALTH', amount: -2 }, { stat: 'CON', amount: 2 }, { stat: 'SAVES', amount: 1 }],
|
||||
effectNote: 'Disadvantage on Stealth',
|
||||
};
|
||||
expect((await post(`${c}/items`, mail, playerAuth)).status).toBe(403);
|
||||
expect((await post(`${c}/items`, { name: 'Bad', effects: [{ stat: 'LUCK', amount: 1 }] })).status).toBe(400);
|
||||
expect((await post(`${c}/items`, { name: 'Bad', effects: [{ stat: 'AC', amount: 0 }] })).status).toBe(400);
|
||||
const armor = (await post(`${c}/items`, mail)).body.item;
|
||||
expect(armor).toMatchObject({ effects: mail.effects, effectNote: 'Disadvantage on Stealth' });
|
||||
const heavy = (await post(`${c}/items`, { name: 'Dragon Plate', minLevel: 5, effects: [{ stat: 'AC', amount: 9 }] })).body.item;
|
||||
const ring = (await post(`${c}/weapons`, { name: 'Warded Blade', damageDice: '1d8', damageType: 'slashing', attackAbility: 'STR', effects: [{ stat: 'AC', amount: 1 }] })).body.weapon;
|
||||
|
||||
// Carried but not worn, it does nothing.
|
||||
await post(`${ch}/inventory`, { itemId: armor.id, quantity: 2 });
|
||||
await post(`${ch}/inventory`, { itemId: heavy.id });
|
||||
await post(`${ch}/weapons`, { weaponId: ring.id });
|
||||
expect((await sheet()).sheet).toMatchObject({ armorClass: before.armorClass, speed: before.speed });
|
||||
|
||||
// Worn, every number moves, however many are carried; the player may put it on.
|
||||
const wear = (path, id, equipped, auth = playerAuth) => request(app).patch(`${ch}/${path}/${id}`).set(auth).send({ equipped });
|
||||
expect((await wear('inventory', armor.id, true)).status).toBe(200);
|
||||
expect((await wear('weapons', ring.id, true)).status).toBe(200);
|
||||
const worn = (await sheet()).sheet;
|
||||
expect(worn.armorClass).toBe(before.armorClass + 6 + 1);
|
||||
expect(worn.speed).toBe(before.speed - 10);
|
||||
expect(worn.abilities.CON.final).toBe(before.abilities.CON.final + 2);
|
||||
expect(worn.hp.max).toBe(before.hp.max + 1); // +2 CON is +1 to the modifier, at level 1
|
||||
expect(worn.skills.STEALTH.bonus).toBe(before.skills.STEALTH.bonus - 2);
|
||||
expect(worn.saves.DEX.bonus).toBe(before.saves.DEX.bonus + 1);
|
||||
expect(worn.saves.CON.bonus).toBe(before.saves.CON.bonus + 2); // the save's own +1, and +1 from CON
|
||||
expect(worn.gear.notes).toEqual([{ source: 'Chain Mail', note: 'Disadvantage on Stealth' }]);
|
||||
expect(worn.gear.effects).toContainEqual({ stat: 'AC', amount: 1, source: 'Warded Blade' });
|
||||
|
||||
// The same numbers reach the party list and an encounter.
|
||||
const listed = (await request(app).get(`${c}/characters`).set(dmAuth)).body.characters.find((x) => x.id === heroId);
|
||||
expect(listed).toMatchObject({ armorClass: worn.armorClass, maxHp: worn.hp.max });
|
||||
const encounterId = (await post(`${c}/encounters`, { name: 'Gear check' })).body.encounter.id;
|
||||
const joined = (await post(`/encounters/${encounterId}/participants`, { characterId: heroId })).body.encounter.participants[0];
|
||||
expect(joined.maxHp).toBe(worn.hp.max);
|
||||
expect(joined.characterCombatInfo).toMatchObject({ armorClass: worn.armorClass, speed: worn.speed });
|
||||
expect(joined.characterCombatInfo.gear.effects).toHaveLength(6);
|
||||
|
||||
// Gear above the character's level cannot be put on, but is still listed.
|
||||
expect((await wear('inventory', heavy.id, true)).status).toBe(400);
|
||||
const panel = await quick();
|
||||
expect(panel).toMatchObject({ armorClass: worn.armorClass, speed: worn.speed, maxHp: worn.hp.max, restricted: false });
|
||||
expect(panel.inventory.map((i) => [i.name, i.quantity, i.equipped, i.usable]).sort()).toEqual([
|
||||
['Chain Mail', 2, true, true],
|
||||
['Dragon Plate', 1, false, false],
|
||||
]);
|
||||
expect(panel.inventory.find((i) => i.name === 'Chain Mail')).toMatchObject({ effects: mail.effects, effectNote: 'Disadvantage on Stealth' });
|
||||
expect(panel.weapons.find((w) => w.name === 'Warded Blade')).toMatchObject({ equipped: true, usable: true, effects: [{ stat: 'AC', amount: 1 }] });
|
||||
expect(panel.purse).toEqual({ gold: 0, silver: 0, copper: 0, cash: 0 });
|
||||
expect((await quick(dmAuth)).inventory).toHaveLength(2);
|
||||
|
||||
// Another player sees the numbers, not the pack or the purse.
|
||||
const other = await request(app).post('/auth/register').send({ email: uniqueEmail('gear-other'), password: 'password123', displayName: 'Other' });
|
||||
cleanupUserIds.push(other.body.user.id);
|
||||
const otherAuth = { Authorization: `Bearer ${other.body.token}` };
|
||||
await request(app).post(`/campaigns/join/${campaign.inviteCode}`).set(otherAuth);
|
||||
expect(await quick(otherAuth)).toMatchObject({ armorClass: worn.armorClass, restricted: true, inventory: [], spells: [], purse: null });
|
||||
|
||||
// The DM's own adjustment sits on top, and is the DM's alone.
|
||||
expect((await request(app).patch(ch).set(playerAuth).send({ armorBonus: 2 })).status).toBe(403);
|
||||
expect((await request(app).patch(ch).set(dmAuth).send({ armorBonus: -1 })).body.character.sheet.armorClass).toBe(worn.armorClass - 1);
|
||||
await request(app).patch(ch).set(dmAuth).send({ armorBonus: 0 });
|
||||
|
||||
// Changing what the item does reaches whoever wears it.
|
||||
const patched = await request(app).patch(`${c}/items/${armor.id}`).set(dmAuth).send({ effects: [{ stat: 'AC', amount: 4 }], effectNote: null });
|
||||
expect(patched.body.item).toMatchObject({ effects: [{ stat: 'AC', amount: 4 }], effectNote: null });
|
||||
const lighter = (await sheet()).sheet;
|
||||
expect(lighter.armorClass).toBe(before.armorClass + 4 + 1);
|
||||
expect(lighter.speed).toBe(before.speed);
|
||||
// The +1 max HP went with the CON bonus, and current HP came down with it.
|
||||
expect(lighter.hp.max).toBe(before.hp.max);
|
||||
expect((await sheet()).currentHp).toBe(before.hp.max);
|
||||
|
||||
// Taking it off, or losing it, takes its effects away.
|
||||
await wear('inventory', armor.id, false);
|
||||
expect((await sheet()).sheet.armorClass).toBe(before.armorClass + 1);
|
||||
expect((await request(app).delete(`${c}/weapons/${ring.id}`).set(dmAuth)).status).toBe(204);
|
||||
expect((await sheet()).sheet).toMatchObject({ armorClass: before.armorClass, gear: { effects: [], notes: [] } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('equipping', () => {
|
||||
itIfDb('only so much can be equipped at once, and only what is equipped is used in an encounter', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('equip');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
expect(campaign).toMatchObject({ maxEquipped: 2, maxEquippedWeapons: 1 });
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const ch = `/characters/${heroId}`;
|
||||
const post = (path, body, auth = dmAuth) => request(app).post(path).set(auth).send(body);
|
||||
|
||||
// The library has armor, with its effects, and weapons made as a pair.
|
||||
const library = (await request(app).get(`${c}/library`).set(dmAuth)).body;
|
||||
expect(library.items.length).toBeGreaterThan(20);
|
||||
expect(library.items.find((i) => i.key === 'chain-mail')).toMatchObject({ category: 'Heavy Armor', effects: [{ stat: 'AC', amount: 6 }, { stat: 'SPEED', amount: -10 }] });
|
||||
expect(library.weapons.find((w) => w.key === 'paired-daggers')).toMatchObject({ category: 'Paired', damageDice: '2d4' });
|
||||
expect((await post(`${c}/items/from-library`, { key: 'shield' }, playerAuth)).status).toBe(403);
|
||||
expect((await post(`${c}/items/from-library`, { key: 'nope' })).status).toBe(404);
|
||||
const shield = (await post(`${c}/items/from-library`, { key: 'shield' })).body.item;
|
||||
expect(shield).toMatchObject({ name: 'Shield', effects: [{ stat: 'AC', amount: 2 }], isStartingOption: false });
|
||||
const mail = (await post(`${c}/items/from-library`, { key: 'leather-armor' })).body.item;
|
||||
const ring = (await post(`${c}/items/from-library`, { key: 'ring-of-protection' })).body.item;
|
||||
const sword = (await post(`${c}/weapons/from-library`, { key: 'longsword' })).body.weapon;
|
||||
const daggers = (await post(`${c}/weapons/from-library`, { key: 'paired-daggers' })).body.weapon;
|
||||
expect(daggers.effectNote).toContain('both hands');
|
||||
|
||||
for (const item of [shield, mail, ring]) await post(`${ch}/inventory`, { itemId: item.id });
|
||||
for (const weapon of [sword, daggers]) await post(`${ch}/weapons`, { weaponId: weapon.id });
|
||||
|
||||
const encounterId = (await post(`${c}/encounters`, { name: 'Drill' })).body.encounter.id;
|
||||
await post(`/encounters/${encounterId}/participants`, { characterId: heroId });
|
||||
const combat = async () => (await request(app).get(`/encounters/${encounterId}`).set(playerAuth)).body.encounter.participants[0].characterCombatInfo;
|
||||
const wear = (path, id, equipped, auth = playerAuth) => request(app).patch(`${ch}/${path}/${id}`).set(auth).send({ equipped });
|
||||
|
||||
// Nothing in hand: nothing to fight with, though it is all in the pack.
|
||||
expect(await combat()).toMatchObject({ weapons: [], items: [] });
|
||||
const panel = async () => (await request(app).get(`${ch}/quick-stats`).set(playerAuth)).body.quickStats;
|
||||
expect((await panel()).weapons.map((w) => w.name).sort()).toEqual(['Longsword', 'Paired Daggers']);
|
||||
|
||||
// One weapon at a time; the player changes it themselves, mid-encounter.
|
||||
expect((await wear('weapons', sword.id, true)).status).toBe(200);
|
||||
expect((await combat()).weapons.map((w) => w.name)).toEqual(['Longsword']);
|
||||
const second = await wear('weapons', daggers.id, true);
|
||||
expect(second.status).toBe(400);
|
||||
expect(second.body.error).toContain('Longsword');
|
||||
expect((await wear('weapons', sword.id, true)).status).toBe(200); // already on: no complaint
|
||||
await wear('weapons', sword.id, false);
|
||||
expect((await wear('weapons', daggers.id, true)).status).toBe(200);
|
||||
expect((await combat()).weapons.map((w) => [w.name, w.damageDice])).toEqual([['Paired Daggers', '2d4']]);
|
||||
|
||||
// Two things in all: a weapon and an item, or two items.
|
||||
expect((await wear('inventory', shield.id, true)).status).toBe(200);
|
||||
expect((await combat()).items.map((i) => i.name)).toEqual(['Shield']);
|
||||
const third = await wear('inventory', mail.id, true);
|
||||
expect(third.status).toBe(400);
|
||||
expect(third.body.error).toContain('Shield');
|
||||
expect((await post(`${ch}/inventory`, { itemId: ring.id, equipped: true })).status).toBe(400); // nor by handing it over equipped
|
||||
await wear('weapons', daggers.id, false);
|
||||
expect((await wear('inventory', mail.id, true)).status).toBe(200);
|
||||
expect((await wear('weapons', sword.id, true)).status).toBe(400); // both places are taken by items
|
||||
expect((await combat()).items.map((i) => i.name).sort()).toEqual(['Leather Armor', 'Shield']);
|
||||
|
||||
// The DM sets the limits; raising them makes room, lowering them takes nothing off.
|
||||
expect((await request(app).patch(c).set(playerAuth).send({ maxEquipped: 5 })).status).toBe(403);
|
||||
expect((await request(app).patch(c).set(dmAuth).send({ maxEquipped: 0 })).status).toBe(400);
|
||||
expect((await request(app).patch(c).set(dmAuth).send({ maxEquipped: 4, maxEquippedWeapons: 2 })).body.campaign).toMatchObject({ maxEquipped: 4, maxEquippedWeapons: 2 });
|
||||
expect((await wear('weapons', sword.id, true)).status).toBe(200);
|
||||
expect((await wear('weapons', daggers.id, true)).status).toBe(200);
|
||||
expect((await wear('inventory', ring.id, true)).status).toBe(400); // four on
|
||||
await request(app).patch(c).set(dmAuth).send({ maxEquipped: 2, maxEquippedWeapons: 1 });
|
||||
expect((await combat()).weapons).toHaveLength(2);
|
||||
await wear('weapons', sword.id, false);
|
||||
expect((await wear('weapons', sword.id, true)).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a campaign from a world seed', () => {
|
||||
async function registerDm() {
|
||||
const res = await request(app).post('/auth/register').send({ email: uniqueEmail('seed-dm'), password: 'password123', displayName: 'Seed DM' });
|
||||
cleanupUserIds.push(res.body.user.id);
|
||||
return { Authorization: `Bearer ${res.body.token}` };
|
||||
}
|
||||
const fromSeed = (auth, code) => request(app).post('/campaigns/from-seed').set(auth).send({ code });
|
||||
|
||||
itIfDb('builds the whole world for whoever enters its code, as its DM', async () => {
|
||||
const auth = await registerDm();
|
||||
const res = await fromSeed(auth, ' aldenf ');
|
||||
expect(res.status).toBe(201);
|
||||
const campaign = res.body.campaign;
|
||||
cleanupCampaignIds.push(campaign.id);
|
||||
expect(campaign).toMatchObject({ name: 'Aldenfell', role: 'DM', startingGold: 15 });
|
||||
expect(campaign.inviteCode).toMatch(/^[A-Z0-9]{6}$/);
|
||||
|
||||
const get = async (path) => (await request(app).get(`/campaigns/${campaign.id}/${path}`).set(auth)).body;
|
||||
const { weapons } = await get('weapons');
|
||||
const { items } = await get('items');
|
||||
const { npcs } = await get('npcs');
|
||||
const { encounters } = await get('encounters');
|
||||
expect((await get('locations')).locations.length).toBeGreaterThan(10);
|
||||
expect((await get('monsters')).monsters.length).toBeGreaterThan(10);
|
||||
expect((await get('notes')).notes.length).toBeGreaterThan(10);
|
||||
|
||||
// Library copies and the seed's own rows, with what the seed set on them.
|
||||
expect(weapons.find((w) => w.name === 'Longsword')).toMatchObject({ isStartingOption: true });
|
||||
expect(weapons.find((w) => w.name === 'Hollowbane')).toMatchObject({ isStartingOption: false, minLevel: 7 });
|
||||
expect(items.find((i) => i.name === 'Shield').effects).toEqual([{ stat: 'AC', amount: 2 }]);
|
||||
|
||||
// An NPC's wares, and a stall's, point at the campaign's own pool entries.
|
||||
const mireille = npcs.find((n) => n.name === 'Mireille Costa');
|
||||
const potion = mireille.wares.find((w) => w.item && w.item.name === 'Potion of Healing');
|
||||
expect(potion).toMatchObject({ price: 50, currency: 'GOLD', itemId: items.find((i) => i.name === 'Potion of Healing').id });
|
||||
const greater = mireille.wares.find((w) => w.item && w.item.name === 'Potion of Greater Healing');
|
||||
expect(greater.level).toBe(3); // sold at the item's own starting level
|
||||
|
||||
// Encounters keep the seed's order, with their people, creatures and loot.
|
||||
expect(encounters[0].name).toBe('E1 — The Founding Fair');
|
||||
const fair = (await request(app).get(`/encounters/${encounters[0].id}`).set(auth)).body.encounter;
|
||||
expect(fair.wares.find((w) => w.item && w.item.name === 'Torches (bundle of 5)')).toMatchObject({ price: 5, currency: 'COPPER' });
|
||||
expect(fair.participants.map((p) => p.displayName)).toEqual(expect.arrayContaining(['Mireille Costa', 'Ser Kavan Thorne']));
|
||||
const ossuary = encounters.find((e) => e.name === 'E3 — Sunken Vaults: The Restless Ossuary');
|
||||
const fight = (await request(app).get(`/encounters/${ossuary.id}`).set(auth)).body.encounter;
|
||||
expect(fight.participants.map((p) => p.displayName).sort()).toEqual(['Skeleton #1', 'Skeleton #2']);
|
||||
expect(fight.rewards[0]).toMatchObject({ type: 'GOLD', gold: 25, currency: 'GOLD' });
|
||||
});
|
||||
|
||||
itIfDb('seeds cash, the other coins and money changers', async () => {
|
||||
const auth = await registerDm();
|
||||
const res = await fromSeed(auth, 'HOLLOW');
|
||||
expect(res.status).toBe(201);
|
||||
const campaign = res.body.campaign;
|
||||
cleanupCampaignIds.push(campaign.id);
|
||||
expect(campaign).toMatchObject({ startingItemCount: 4, maxEquipped: 3, maxEquippedWeapons: 1 });
|
||||
|
||||
const npcs = (await request(app).get(`/campaigns/${campaign.id}/npcs`).set(auth)).body.npcs;
|
||||
expect(npcs.find((n) => n.name === 'Ray')).toMatchObject({ exchangeGold: 1, exchangeCash: 50 });
|
||||
const encounters = (await request(app).get(`/campaigns/${campaign.id}/encounters`).set(auth)).body.encounters;
|
||||
const detail = async (name) => (await request(app).get(`/encounters/${encounters.find((e) => e.name === name).id}`).set(auth)).body.encounter;
|
||||
expect((await detail('Security Response')).rewards).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'GOLD', gold: 300, currency: 'CASH' })]));
|
||||
expect((await detail('The Goblin Den')).rewards.filter((r) => r.type === 'GOLD').map((r) => `${r.gold} ${r.currency}`)).toEqual(['12 GOLD', '50 SILVER', '100 COPPER']);
|
||||
const shops = await detail('Port Meridian: Ray’s Cash for Gold and the Shops');
|
||||
expect(shops.wares.find((w) => w.weapon && w.weapon.name === 'Chainsaw')).toMatchObject({ price: 300, currency: 'CASH', level: 5, stock: 1 });
|
||||
});
|
||||
|
||||
itIfDb('the same code can be used again, by anyone, for a copy of their own', async () => {
|
||||
const first = await fromSeed(await registerDm(), 'ALDENF');
|
||||
const second = await fromSeed(await registerDm(), 'ALDENF');
|
||||
cleanupCampaignIds.push(first.body.campaign.id, second.body.campaign.id);
|
||||
expect(second.status).toBe(201);
|
||||
expect(second.body.campaign.id).not.toBe(first.body.campaign.id);
|
||||
expect(second.body.campaign.inviteCode).not.toBe(first.body.campaign.inviteCode);
|
||||
});
|
||||
|
||||
itIfDb('refuses a code that is no world, and needs a login', async () => {
|
||||
const auth = await registerDm();
|
||||
expect((await fromSeed(auth, 'ZZZZZZ')).status).toBe(404);
|
||||
expect((await fromSeed(auth, 'abc')).status).toBe(400);
|
||||
expect((await request(app).post('/campaigns/from-seed').set(auth).send({})).status).toBe(400);
|
||||
expect((await request(app).post('/campaigns/from-seed').send({ code: 'ALDENF' })).status).toBe(401);
|
||||
expect((await request(app).get('/campaigns').set(auth)).body.campaigns).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,14 @@ 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 { findSeed, normalizeCode, CODE_LENGTH } = require('../seeds');
|
||||
const { createCampaignFromSeed } = require('../seeds/createCampaign');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -25,6 +29,9 @@ function campaignSummary(campaign, role) {
|
||||
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,
|
||||
@@ -54,6 +61,12 @@ const createCampaignSchema = z.object({
|
||||
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
|
||||
@@ -85,6 +98,28 @@ router.post('/', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /campaigns/from-seed — a ready-made world (a file in /backend/seeds)
|
||||
// as a new campaign, by its code. As with a blank campaign, whoever creates
|
||||
// it is its DM and owner; the same code can be used again for another copy.
|
||||
router.post('/from-seed', async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ code: z.string().max(40) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (normalizeCode(parsed.data.code).length !== CODE_LENGTH) {
|
||||
return res.status(400).json({ error: `A world code is ${CODE_LENGTH} letters or numbers` });
|
||||
}
|
||||
const seed = findSeed(parsed.data.code);
|
||||
if (!seed) return res.status(404).json({ error: 'There is no world with that code' });
|
||||
|
||||
// One transaction, so a failure doesn't leave half a world behind. A big
|
||||
// world is thousands of rows, which is more than the default 5 seconds.
|
||||
const campaign = await prisma.$transaction((tx) => createCampaignFromSeed(tx, req.user, seed), { timeout: 120000, maxWait: 20000 });
|
||||
res.status(201).json({ campaign: campaignSummary(campaign, 'DM') });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /campaigns/join/:inviteCode — must be before requireCampaignMember-gated routes below
|
||||
router.post('/join/:inviteCode', async (req, res, next) => {
|
||||
try {
|
||||
@@ -199,6 +234,7 @@ function characterSummary(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,
|
||||
@@ -210,7 +246,7 @@ 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' },
|
||||
});
|
||||
const summaries = characters.map(characterSummary);
|
||||
@@ -223,7 +259,7 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
|
||||
characters: summaries.map((c) => {
|
||||
if (c.userId === req.user.id) return c;
|
||||
const knowsPlace = c.currentLocationId && visited.has(c.currentLocationId);
|
||||
return { ...c, gold: null, silver: null, copper: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
|
||||
return { ...c, gold: null, silver: null, copper: null, cash: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -308,6 +344,25 @@ router.get('/:id/weapons', requireCampaignMember(), async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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),
|
||||
@@ -319,6 +374,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
|
||||
@@ -363,6 +419,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);
|
||||
@@ -371,9 +428,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);
|
||||
@@ -403,6 +462,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) => {
|
||||
@@ -459,6 +519,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) => {
|
||||
@@ -479,6 +540,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);
|
||||
@@ -487,9 +549,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);
|
||||
@@ -522,7 +586,7 @@ const monsterSchema = z.object({
|
||||
wisScore: z.number().int().min(1).max(30).optional(),
|
||||
chaScore: z.number().int().min(1).max(30).optional(),
|
||||
attackBonus: z.number().int().min(-20).max(50).optional(),
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
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(),
|
||||
@@ -641,7 +705,11 @@ router.put('/:id/encounters/order', requireCampaignMember(), requireDM(), async
|
||||
|
||||
router.get('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const npcs = await prisma.npc.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
const npcs = await prisma.npc.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
include: { wares: { include: WARE_INCLUDE, orderBy: { createdAt: 'asc' } } },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
res.json({ npcs });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -658,12 +726,31 @@ const npcSchema = z.object({
|
||||
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) {
|
||||
@@ -675,8 +762,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);
|
||||
@@ -776,15 +865,100 @@ 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.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A library entry goes into a campaign once: it is taken for as long as the
|
||||
// pool holds a row by its name, and free again when that row is renamed or
|
||||
// deleted.
|
||||
async function inPool(model, campaignId, name) {
|
||||
const row = await prisma[model].findFirst({ where: { campaignId, name: { equals: name, mode: 'insensitive' } }, select: { id: true } });
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -796,6 +970,9 @@ router.post('/:id/weapons/from-library', requireCampaignMember(), requireDM(), a
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const template = WEAPON_LIBRARY.find((w) => w.key === parsed.data.key);
|
||||
if (!template) return res.status(404).json({ error: 'Unknown library weapon.' });
|
||||
if (await inPool('weapon', req.campaign.id, template.name)) {
|
||||
return res.status(409).json({ error: `${template.name} is already in this campaign.` });
|
||||
}
|
||||
const { key, category, ...data } = template;
|
||||
const weapon = await prisma.weapon.create({ data: { ...data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ weapon });
|
||||
@@ -804,12 +981,32 @@ 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.' });
|
||||
if (await inPool('item', req.campaign.id, template.name)) {
|
||||
return res.status(409).json({ error: `${template.name} is already in this campaign.` });
|
||||
}
|
||||
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);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const template = SPELL_LIBRARY.find((s) => s.key === parsed.data.key);
|
||||
if (!template) return res.status(404).json({ error: 'Unknown library spell.' });
|
||||
if (await inPool('spell', req.campaign.id, template.name)) {
|
||||
return res.status(409).json({ error: `${template.name} is already in this campaign.` });
|
||||
}
|
||||
const { key, classes, ...data } = template;
|
||||
const spell = await prisma.spell.create({ data: { ...data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ spell });
|
||||
@@ -824,6 +1021,9 @@ router.post('/:id/monsters/from-library', requireCampaignMember(), requireDM(),
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const template = MONSTER_LIBRARY.find((m) => m.key === parsed.data.key);
|
||||
if (!template) return res.status(404).json({ error: 'Unknown library monster.' });
|
||||
if (await inPool('monster', req.campaign.id, template.name)) {
|
||||
return res.status(409).json({ error: `${template.name} is already in this campaign.` });
|
||||
}
|
||||
const { key, ...data } = template;
|
||||
const monster = await prisma.monster.create({ data: { ...data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ monster });
|
||||
|
||||
@@ -29,8 +29,9 @@ const {
|
||||
} = require('../game/buildFlow');
|
||||
const characterOptions = require('../game/data/characterOptions');
|
||||
const { SKILLS } = require('../game/data/skills');
|
||||
const { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, 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();
|
||||
@@ -97,6 +98,7 @@ function serializeCharacter(character, isOwner, isDM = true) {
|
||||
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,
|
||||
@@ -149,7 +151,7 @@ const patchCharacterSchema = z.object({
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
currentHp: z.number().int().optional(),
|
||||
tempHp: z.number().int().min(0).max(9999).optional(),
|
||||
armorBonus: z.number().int().min(0).max(30).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(),
|
||||
@@ -162,10 +164,12 @@ const patchCharacterSchema = z.object({
|
||||
|
||||
// Progression and placement are the DM's call, and so is rewriting what the
|
||||
// builder composed (special abilities, background, appearance): a player
|
||||
// picks those from lists and cannot type their own. The owning player can
|
||||
// picks those from lists and cannot type their own. So are HP and temp HP:
|
||||
// a player cannot heal or hurt their own character. 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 = ['currentHp', 'tempHp', 'level', 'xp', 'currentLocationId', 'speedOverride', 'armorBonus', 'specialAbilities', 'background', 'appearance'];
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
@@ -173,14 +177,14 @@ function clamp(value, min, max) {
|
||||
|
||||
router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
if (req.body && ['gold', 'silver', 'copper'].some((coin) => req.body[coin] !== undefined)) {
|
||||
return res.status(400).json({ error: 'Gold, silver and copper changes go through the money tracker.' });
|
||||
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 HP, temp HP, level, XP, speed, armor bonus, location, special abilities, background or appearance.' });
|
||||
}
|
||||
|
||||
// Damage is soaked by temp HP before it reaches real HP — unless this
|
||||
@@ -189,7 +193,8 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
|
||||
const settingTempHp = parsed.data.tempHp !== undefined;
|
||||
if (req.character.buildStage === 'COMPLETE' && (settingHp || parsed.data.level !== undefined)) {
|
||||
// Current HP stays within 0..max, including when a level change moves max.
|
||||
const maxHp = computeCharacterSheet({ ...req.character, ...parsed.data }).hp.max;
|
||||
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 {
|
||||
@@ -232,6 +237,8 @@ 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);
|
||||
|
||||
@@ -425,6 +432,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
|
||||
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
|
||||
@@ -595,8 +603,8 @@ const chooseSchemas = {
|
||||
.optional(),
|
||||
}),
|
||||
CHOOSING_ABILITIES: z.object({ talents: z.array(z.string()) }),
|
||||
CHOOSING_BACKGROUND: z.object({ picks: z.record(z.array(z.string())) }),
|
||||
CHOOSING_APPEARANCE: z.object({ picks: z.record(z.array(z.string())) }),
|
||||
CHOOSING_BACKGROUND: z.object({ picks: z.record(z.string(), z.array(z.string())) }),
|
||||
CHOOSING_APPEARANCE: z.object({ picks: z.record(z.string(), z.array(z.string())) }),
|
||||
CHOOSING_EQUIPMENT: z.object({
|
||||
weaponIds: z.array(z.string()).default([]),
|
||||
itemIds: z.array(z.string()).default([]),
|
||||
@@ -891,22 +899,53 @@ 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,
|
||||
@@ -916,8 +955,16 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
|
||||
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) {
|
||||
@@ -938,8 +985,11 @@ function coinName(currency) {
|
||||
return currency.toLowerCase();
|
||||
}
|
||||
|
||||
// For a payment the whole purse can't cover.
|
||||
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)}.`;
|
||||
}
|
||||
|
||||
@@ -951,16 +1001,18 @@ router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
|
||||
include: GOLD_TRANSACTION_INCLUDE,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
res.json({ gold: req.character.gold, silver: req.character.silver, copper: req.character.copper, transactions });
|
||||
res.json({ gold: req.character.gold, silver: req.character.silver, copper: req.character.copper, cash: req.character.cash, transactions });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
// amount is signed: positive = gained, negative = spent. Only the DM records
|
||||
// entries here — a player cannot add money to their own purse or write it
|
||||
// off; theirs moves by giving, buying and exchanging. Each entry 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
|
||||
@@ -972,6 +1024,7 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { amount, currency, reason } = parsed.data;
|
||||
if (!req.isDM) return res.status(403).json({ error: 'Only the DM can record a gain or a spend.' });
|
||||
|
||||
let transactions;
|
||||
try {
|
||||
@@ -986,7 +1039,7 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
|
||||
}
|
||||
|
||||
emitCharacterUpdated(req);
|
||||
const purse = await prisma.character.findUnique({ where: { id: req.character.id }, select: { gold: true, silver: true, copper: true } });
|
||||
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);
|
||||
@@ -996,11 +1049,12 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
|
||||
// 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.
|
||||
// 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(CURRENCIES), to: z.enum(CURRENCIES), amount: z.number().int().min(1).max(1000000) })
|
||||
.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() });
|
||||
@@ -1061,6 +1115,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 } });
|
||||
@@ -1098,6 +1175,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).
|
||||
@@ -1107,6 +1188,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) {
|
||||
@@ -1138,8 +1220,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) {
|
||||
@@ -1154,6 +1241,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) {
|
||||
@@ -1240,6 +1328,8 @@ router.post('/:id/give', requireCharacterAccess({ write: true }), async (req, re
|
||||
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 });
|
||||
|
||||
@@ -3,10 +3,11 @@ const { z } = require('zod');
|
||||
const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
const { computeCharacterSheet, computeWeaponStats, canUseGear, abilityModifier, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
const { CURRENCIES, 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 { lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
|
||||
const { SHEET_INCLUDE } = require('../utils/sheet');
|
||||
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -66,18 +67,66 @@ function initiativeBonus({ character, monster }) {
|
||||
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,
|
||||
@@ -104,6 +153,7 @@ function characterCombatInfo(character) {
|
||||
maxHp: sheet.hp.max,
|
||||
tempHp: character.tempHp,
|
||||
deathSaves: deathSaveInfo(character),
|
||||
canRevive: Boolean(revivalSpell(character)),
|
||||
speed: sheet.speed,
|
||||
initiativeBonus: sheet.initiative,
|
||||
passivePerception: sheet.passivePerception,
|
||||
@@ -111,15 +161,25 @@ 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.
|
||||
// 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
|
||||
@@ -135,28 +195,32 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
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 hideHp = !isDM && !encounter.campaign.showMonsterHp;
|
||||
const participants = inTurnOrder(encounter.participants).map((p) => {
|
||||
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;
|
||||
// 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' };
|
||||
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.
|
||||
@@ -187,7 +251,10 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -255,13 +322,15 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
|
||||
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
|
||||
}
|
||||
|
||||
// Starting a battle puts the first combatant in the initiative order on turn.
|
||||
// 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) {
|
||||
@@ -309,11 +378,15 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
||||
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;
|
||||
@@ -376,7 +449,7 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, async (
|
||||
|
||||
const existing = await prisma.encounterParticipant.findUnique({
|
||||
where: { id: req.params.participantId },
|
||||
include: { character: { include: { skillProficiencies: true, saveProficiencies: true } }, monster: 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' });
|
||||
@@ -425,15 +498,20 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, async (
|
||||
|
||||
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);
|
||||
@@ -442,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 } } : {}),
|
||||
},
|
||||
});
|
||||
@@ -472,14 +643,15 @@ 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);
|
||||
@@ -624,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;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Creates one campaign, fully built, from a checked seed (seeds/index.js,
|
||||
// buildSeed), with `owner` as its DM. `db` is a transaction client, so a
|
||||
// failure leaves nothing behind.
|
||||
const { generateInviteCode } = require('../utils/inviteCode');
|
||||
|
||||
// Notes list newest-first on screen, so they are stamped a second apart,
|
||||
// backwards: the first entry in the seed 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]));
|
||||
}
|
||||
|
||||
const MONEY = { gold: 'GOLD', silver: 'SILVER', copper: 'COPPER', cash: 'CASH' };
|
||||
|
||||
async function createCampaignFromSeed(db, owner, seed) {
|
||||
let inviteCode = generateInviteCode();
|
||||
while (await db.campaign.findUnique({ where: { inviteCode } })) inviteCode = generateInviteCode();
|
||||
|
||||
const campaign = await db.campaign.create({
|
||||
data: { ...seed.campaign, inviteCode, ownerId: owner.id, members: { create: { userId: owner.id, role: 'DM' } } },
|
||||
});
|
||||
const campaignId = campaign.id;
|
||||
const inCampaign = (rows) => rows.map((row) => ({ ...row, campaignId }));
|
||||
|
||||
await db.location.createMany({ data: inCampaign(seed.locations) });
|
||||
await db.weapon.createMany({ data: inCampaign(seed.weapons) });
|
||||
await db.item.createMany({ data: inCampaign(seed.items) });
|
||||
await db.spell.createMany({ data: inCampaign(seed.spells) });
|
||||
await db.monster.createMany({ data: inCampaign(seed.monsters) });
|
||||
await db.npc.createMany({ data: inCampaign(seed.npcs.map(({ wares, ...npc }) => npc)) });
|
||||
|
||||
const where = { where: { campaignId } };
|
||||
const monsters = byName(await db.monster.findMany(where));
|
||||
const npcs = byName(await db.npc.findMany(where));
|
||||
const weapons = byName(await db.weapon.findMany(where));
|
||||
const items = byName(await db.item.findMany(where));
|
||||
const locations = byName(await db.location.findMany(where));
|
||||
|
||||
// A weapon or item named in a reward or a ware, with the level it is handed
|
||||
// over at: its own starting level unless the seed asks for higher.
|
||||
const gear = ({ weapon, item, level }) => {
|
||||
const entry = weapon ? weapons.get(weapon) : items.get(item);
|
||||
return { [weapon ? 'weaponId' : 'itemId']: entry.id, level: level ?? entry.minLevel };
|
||||
};
|
||||
const wares = (list, seller) =>
|
||||
(list || []).map(({ price, currency, stock, ...ware }, i) => ({
|
||||
...seller,
|
||||
...gear(ware),
|
||||
campaignId,
|
||||
price,
|
||||
currency: currency || 'GOLD',
|
||||
stock: stock ?? null,
|
||||
createdAt: new Date(Date.now() + i), // wares list in the order written
|
||||
}));
|
||||
|
||||
for (const npc of seed.npcs) {
|
||||
if (npc.wares) await db.saleOffer.createMany({ data: wares(npc.wares, { npcId: npcs.get(npc.name).id }) });
|
||||
}
|
||||
|
||||
for (const [index, enc] of seed.encounters.entries()) {
|
||||
const participants = [];
|
||||
for (const [name, count, displayName, hp] of enc.monsters || []) {
|
||||
const monster = monsters.get(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 = npcs.get(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(({ quantity, note, ...reward }, i) => {
|
||||
const createdAt = new Date(Date.now() + i);
|
||||
const coin = Object.keys(MONEY).find((key) => reward[key]);
|
||||
if (coin) return { type: 'GOLD', gold: reward[coin], currency: MONEY[coin], note, createdAt };
|
||||
return { type: reward.weapon ? 'WEAPON' : 'ITEM', ...gear(reward), quantity: quantity ?? 1, note, createdAt };
|
||||
});
|
||||
|
||||
const encounter = await db.encounter.create({
|
||||
data: {
|
||||
campaignId,
|
||||
name: enc.name,
|
||||
mode: enc.mode,
|
||||
description: enc.description,
|
||||
dmNotes: enc.dmNotes,
|
||||
locationId: enc.location ? locations.get(enc.location).id : null,
|
||||
sortOrder: index,
|
||||
participants: { create: participants },
|
||||
rewards: { create: rewards },
|
||||
},
|
||||
});
|
||||
if (enc.wares) await db.saleOffer.createMany({ data: wares(enc.wares, { encounterId: encounter.id }) });
|
||||
}
|
||||
|
||||
await db.note.createMany({
|
||||
data: [
|
||||
...seed.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
|
||||
...seed.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
|
||||
].map((note, i) => ({ ...note, campaignId, authorId: owner.id, createdAt: bookOrder(i) })),
|
||||
});
|
||||
|
||||
return campaign;
|
||||
}
|
||||
|
||||
module.exports = { createCampaignFromSeed };
|
||||
@@ -0,0 +1,317 @@
|
||||
// World seeds: ready-made campaigns a DM starts from by typing a code.
|
||||
//
|
||||
// Every .js file in /backend/seeds is one world (docs/SEEDS.md has the full
|
||||
// format). It exports its `code` and the campaign's content; this module
|
||||
// finds the files, checks them, and turns one into rows for
|
||||
// createCampaign.js. The folder is read again on every lookup, so a file
|
||||
// dropped in is live without a restart.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { z } = require('zod');
|
||||
const { GEAR_STATS, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
const { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
|
||||
const { CURRENCIES } = require('../utils/grants');
|
||||
|
||||
const SEEDS_DIR = path.join(__dirname, '..', '..', 'seeds');
|
||||
const CODE_LENGTH = 6;
|
||||
const CODE_PATTERN = /^[A-Z0-9]{6}$/;
|
||||
|
||||
// What someone types, as a seed file would have it.
|
||||
function normalizeCode(code) {
|
||||
return String(code || '').replace(/[\s-]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The shape of a seed file. Objects are strict, so a misspelt field is an
|
||||
// error rather than something silently left out of the campaign. The limits
|
||||
// are the ones the app itself enforces (routes/*.js), so whatever a seed
|
||||
// creates can still be edited on screen.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const text = (max) => z.string().trim().min(1).max(max);
|
||||
const optionalText = (max) => z.string().max(max).nullish();
|
||||
const dice = z.string().min(1).max(20).nullish();
|
||||
// A creature's damage may name its type: "1d6+2 necrotic".
|
||||
const damage = z.string().min(1).max(60).nullish();
|
||||
const score = z.number().int().min(1).max(30).optional();
|
||||
const gearLevel = z.number().int().min(1).max(MAX_GEAR_LEVEL);
|
||||
|
||||
const gear = {
|
||||
damageScaling: z.number().int().min(1).max(MAX_DAMAGE_SCALING).optional(),
|
||||
minLevel: gearLevel.optional(),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
effects: z.array(z.strictObject({ stat: z.enum(GEAR_STATS), amount: z.number().int().min(-100).max(100) })).max(12).optional(),
|
||||
effectNote: optionalText(300),
|
||||
};
|
||||
|
||||
const POOLS = {
|
||||
weapons: {
|
||||
library: WEAPON_LIBRARY,
|
||||
row: z.strictObject({
|
||||
name: text(80),
|
||||
damageDice: z.string().min(1).max(20),
|
||||
damageType: text(40),
|
||||
attackAbility: z.enum(['STR', 'DEX', 'FINESSE']),
|
||||
properties: z.array(z.string()).optional(),
|
||||
rangeNormal: z.number().int().min(0).nullish(),
|
||||
rangeLong: z.number().int().min(0).nullish(),
|
||||
...gear,
|
||||
}),
|
||||
},
|
||||
items: {
|
||||
library: ITEM_LIBRARY,
|
||||
row: z.strictObject({ name: text(120), description: optionalText(2000), damageDice: dice, ...gear }),
|
||||
},
|
||||
spells: {
|
||||
library: SPELL_LIBRARY,
|
||||
row: z.strictObject({
|
||||
name: text(80),
|
||||
level: z.number().int().min(0).max(9),
|
||||
damageDice: dice,
|
||||
school: optionalText(40),
|
||||
castingTime: optionalText(80),
|
||||
range: optionalText(80),
|
||||
duration: optionalText(80),
|
||||
description: optionalText(4000),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
revives: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
monsters: {
|
||||
library: MONSTER_LIBRARY,
|
||||
row: z.strictObject({
|
||||
name: text(80),
|
||||
ac: z.number().int().min(0).max(40),
|
||||
maxHp: z.number().int().min(1).max(10000),
|
||||
speed: z.number().int().min(0).max(500).optional(),
|
||||
strScore: score,
|
||||
dexScore: score,
|
||||
conScore: score,
|
||||
intScore: score,
|
||||
wisScore: score,
|
||||
chaScore: score,
|
||||
attackBonus: z.number().int().min(-10).max(30).optional(),
|
||||
damageDice: damage,
|
||||
challengeRating: optionalText(10),
|
||||
specialTrait: optionalText(500),
|
||||
notes: optionalText(2000),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const moneyAmount = z.number().int().min(1).max(1000000);
|
||||
|
||||
// Something for sale: one weapon or one item from this seed's pools, by name.
|
||||
const wareSchema = z
|
||||
.strictObject({
|
||||
weapon: z.string().optional(),
|
||||
item: z.string().optional(),
|
||||
price: moneyAmount,
|
||||
currency: z.enum(CURRENCIES).optional(),
|
||||
level: gearLevel.nullish(),
|
||||
stock: z.number().int().min(0).max(9999).nullish(),
|
||||
})
|
||||
.refine((ware) => Boolean(ware.weapon) !== Boolean(ware.item), { message: 'A ware sells one weapon or one item' });
|
||||
|
||||
// Loot: money ({ gold: 25 }, or silver / copper / cash), a weapon or an item.
|
||||
const rewardSchema = z
|
||||
.strictObject({
|
||||
gold: moneyAmount.nullish(),
|
||||
silver: moneyAmount.nullish(),
|
||||
copper: moneyAmount.nullish(),
|
||||
cash: moneyAmount.nullish(),
|
||||
weapon: z.string().nullish(),
|
||||
item: z.string().nullish(),
|
||||
quantity: z.number().int().min(1).max(999).nullish(),
|
||||
level: gearLevel.nullish(),
|
||||
note: optionalText(200),
|
||||
})
|
||||
.refine((reward) => ['gold', 'silver', 'copper', 'cash', 'weapon', 'item'].filter((key) => reward[key] != null).length === 1, {
|
||||
message: 'A reward is one thing: gold, silver, copper, cash, a weapon or an item',
|
||||
});
|
||||
|
||||
const npcSchema = z.strictObject({
|
||||
name: text(80),
|
||||
role: optionalText(80),
|
||||
disposition: z.enum(['FRIENDLY', 'NEUTRAL', 'HOSTILE']).optional(),
|
||||
description: optionalText(2000),
|
||||
dmNotes: optionalText(4000),
|
||||
ac: z.number().int().min(0).max(40).nullish(),
|
||||
maxHp: z.number().int().min(1).max(10000).nullish(),
|
||||
attackBonus: z.number().int().min(-10).max(30).nullish(),
|
||||
damageDice: damage,
|
||||
// A money changer: exchangeGold gold for exchangeCash cash, either way.
|
||||
exchangeGold: moneyAmount.nullish(),
|
||||
exchangeCash: moneyAmount.nullish(),
|
||||
wares: z.array(wareSchema).optional(),
|
||||
});
|
||||
|
||||
const encounterSchema = z.strictObject({
|
||||
name: text(120),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
description: optionalText(4000),
|
||||
dmNotes: optionalText(4000),
|
||||
location: z.string().nullish(),
|
||||
// [monster name, how many, display name?, max HP?]
|
||||
monsters: z
|
||||
.array(z.tuple([z.string(), z.number().int().min(1).max(50)]).rest(z.union([z.string(), z.number().int().min(1), z.null(), z.undefined()])))
|
||||
.optional(),
|
||||
npcs: z.array(z.union([z.string(), z.strictObject({ name: z.string(), hp: z.number().int().min(0).optional() })])).optional(),
|
||||
rewards: z.array(rewardSchema).optional(),
|
||||
wares: z.array(wareSchema).optional(),
|
||||
});
|
||||
|
||||
// A pool entry is a full row, or one copied from the site library: its key
|
||||
// alone, or { library: key } with fields to change on the copy. The rows
|
||||
// themselves are checked once the copies are made (resolvePool).
|
||||
const poolSchema = z.array(z.union([z.string(), z.record(z.string(), z.any())])).default([]);
|
||||
|
||||
const seedSchema = z.strictObject({
|
||||
code: z.string().regex(CODE_PATTERN, `The code is ${CODE_LENGTH} capital letters or digits`),
|
||||
campaign: z.strictObject({
|
||||
name: text(120),
|
||||
description: optionalText(2000),
|
||||
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(),
|
||||
maxEquipped: z.number().int().min(1).max(20).optional(),
|
||||
maxEquippedWeapons: z.number().int().min(0).max(20).optional(),
|
||||
}),
|
||||
locations: z.array(z.strictObject({ name: text(80), description: optionalText(2000) })).default([]),
|
||||
weapons: poolSchema,
|
||||
items: poolSchema,
|
||||
spells: poolSchema,
|
||||
monsters: poolSchema,
|
||||
npcs: z.array(npcSchema).default([]),
|
||||
encounters: z.array(encounterSchema).default([]),
|
||||
// Note bodies, in reading order.
|
||||
sharedNotes: z.array(text(10000)).default([]),
|
||||
dmNotes: z.array(text(10000)).default([]),
|
||||
});
|
||||
|
||||
class SeedError extends Error {}
|
||||
|
||||
function issueList(error) {
|
||||
return error.issues.map((issue) => `${issue.path.join('.') || '(file)'}: ${issue.message}`);
|
||||
}
|
||||
|
||||
// Swaps library references for copies of the library's rows (less the key and
|
||||
// the picker-only tags) and checks every row. Problems are pushed to `problems`.
|
||||
function resolvePool(pool, entries, problems) {
|
||||
const { library, row } = POOLS[pool];
|
||||
const rows = [];
|
||||
entries.forEach((entry, index) => {
|
||||
let candidate = entry;
|
||||
if (typeof entry === 'string' || (entry && entry.library)) {
|
||||
const { library: key, ...changes } = typeof entry === 'string' ? { library: entry } : entry;
|
||||
const template = library.find((item) => item.key === key);
|
||||
if (!template) return problems.push(`${pool}.${index}: there is no "${key}" in the ${pool} library`);
|
||||
const { key: _key, category, classes, ...copy } = template;
|
||||
candidate = { ...copy, ...changes };
|
||||
}
|
||||
const parsed = row.safeParse(candidate);
|
||||
if (!parsed.success) return problems.push(...issueList(parsed.error).map((issue) => `${pool}.${index} (${candidate.name || '?'}): ${issue}`));
|
||||
rows.push(parsed.data);
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Checks a seed file's export and returns it ready to create: library
|
||||
// references filled in, defaults applied. Throws a SeedError listing
|
||||
// everything wrong with it.
|
||||
function buildSeed(raw) {
|
||||
const parsed = seedSchema.safeParse(raw);
|
||||
if (!parsed.success) throw new SeedError(issueList(parsed.error).join('\n'));
|
||||
const seed = parsed.data;
|
||||
const problems = [];
|
||||
for (const pool of Object.keys(POOLS)) seed[pool] = resolvePool(pool, seed[pool], problems);
|
||||
|
||||
// Encounters, rewards and wares point at things by name, so within a pool a
|
||||
// name can only be used once.
|
||||
const names = {};
|
||||
for (const pool of ['locations', 'weapons', 'items', 'spells', 'monsters', 'npcs']) {
|
||||
names[pool] = new Set();
|
||||
for (const { name } of seed[pool]) {
|
||||
if (names[pool].has(name)) problems.push(`${pool}: "${name}" is there twice`);
|
||||
names[pool].add(name);
|
||||
}
|
||||
}
|
||||
const gearByName = { weapon: new Map(seed.weapons.map((w) => [w.name, w])), item: new Map(seed.items.map((i) => [i.name, i])) };
|
||||
const checkGear = (where, { weapon, item, level }) => {
|
||||
const kind = weapon ? 'weapon' : 'item';
|
||||
const entry = gearByName[kind].get(weapon || item);
|
||||
if (!entry) return problems.push(`${where}: there is no ${kind} called "${weapon || item}"`);
|
||||
if (level && level < (entry.minLevel || 1)) problems.push(`${where}: "${entry.name}" starts at level ${entry.minLevel}, so it can't be level ${level}`);
|
||||
};
|
||||
|
||||
for (const npc of seed.npcs) {
|
||||
if (Boolean(npc.exchangeGold) !== Boolean(npc.exchangeCash)) problems.push(`NPC "${npc.name}": an exchange rate needs both exchangeGold and exchangeCash`);
|
||||
(npc.wares || []).forEach((ware) => checkGear(`NPC "${npc.name}" wares`, ware));
|
||||
}
|
||||
for (const encounter of seed.encounters) {
|
||||
const where = `Encounter "${encounter.name}"`;
|
||||
if (encounter.location && !names.locations.has(encounter.location)) problems.push(`${where}: there is no location called "${encounter.location}"`);
|
||||
for (const [monster] of encounter.monsters || []) {
|
||||
if (!names.monsters.has(monster)) problems.push(`${where}: there is no monster called "${monster}"`);
|
||||
}
|
||||
for (const entry of encounter.npcs || []) {
|
||||
const name = typeof entry === 'string' ? entry : entry.name;
|
||||
if (!names.npcs.has(name)) problems.push(`${where}: there is no NPC called "${name}"`);
|
||||
}
|
||||
for (const reward of encounter.rewards || []) if (reward.weapon || reward.item) checkGear(`${where} rewards`, reward);
|
||||
(encounter.wares || []).forEach((ware) => checkGear(`${where} wares`, ware));
|
||||
}
|
||||
|
||||
if (problems.length) throw new SeedError(problems.join('\n'));
|
||||
return seed;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The folder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Every seed file, good or bad: [{ file, code, seed } | { file, error }].
|
||||
// A file is only built again when it has changed on disk.
|
||||
const built = new Map();
|
||||
|
||||
function loadSeeds(dir = SEEDS_DIR) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const results = [];
|
||||
const codes = new Map();
|
||||
for (const file of fs.readdirSync(dir).filter((name) => name.endsWith('.js')).sort()) {
|
||||
const fullPath = path.join(dir, file);
|
||||
const { mtimeMs } = fs.statSync(fullPath);
|
||||
let entry = built.get(fullPath);
|
||||
if (!entry || entry.mtimeMs !== mtimeMs) {
|
||||
try {
|
||||
delete require.cache[require.resolve(fullPath)];
|
||||
const seed = buildSeed(require(fullPath));
|
||||
entry = { mtimeMs, result: { file, code: seed.code, seed } };
|
||||
} catch (err) {
|
||||
entry = { mtimeMs, result: { file, error: err.message } };
|
||||
}
|
||||
built.set(fullPath, entry);
|
||||
}
|
||||
let { result } = entry;
|
||||
if (result.code && codes.has(result.code)) {
|
||||
result = { file, error: `Its code ${result.code} is already used by ${codes.get(result.code)}` };
|
||||
} else if (result.code) {
|
||||
codes.set(result.code, file);
|
||||
}
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// The world behind a code, or null. A file with something wrong in it is
|
||||
// never offered; `npm run seeds:check` says what is wrong.
|
||||
function findSeed(code, dir = SEEDS_DIR) {
|
||||
const wanted = normalizeCode(code);
|
||||
const match = loadSeeds(dir).find((result) => result.code === wanted && !result.error);
|
||||
return match ? match.seed : null;
|
||||
}
|
||||
|
||||
module.exports = { SEEDS_DIR, CODE_LENGTH, SeedError, normalizeCode, buildSeed, loadSeeds, findSeed };
|
||||
@@ -0,0 +1,138 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { buildSeed, loadSeeds, findSeed, normalizeCode, SeedError } = require('./index');
|
||||
|
||||
// The smallest seed there is, with whatever a test adds or changes.
|
||||
function seedWith(extra = {}) {
|
||||
return { code: 'TEST01', campaign: { name: 'Test World' }, ...extra };
|
||||
}
|
||||
|
||||
describe('the seeds in /backend/seeds', () => {
|
||||
const results = loadSeeds();
|
||||
|
||||
test('every file is a valid world', () => {
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
for (const { file, error } of results) expect({ file, error }).toEqual({ file, error: undefined });
|
||||
});
|
||||
|
||||
test('each has its own code', () => {
|
||||
const codes = results.map((result) => result.code);
|
||||
expect(new Set(codes).size).toBe(codes.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSeed', () => {
|
||||
test('fills in what is left out', () => {
|
||||
const seed = buildSeed(seedWith());
|
||||
expect(seed.weapons).toEqual([]);
|
||||
expect(seed.encounters).toEqual([]);
|
||||
expect(seed.sharedNotes).toEqual([]);
|
||||
});
|
||||
|
||||
test('copies library entries, with changes, and drops the library-only fields', () => {
|
||||
const seed = buildSeed(seedWith({ weapons: ['dagger', { library: 'longsword', isStartingOption: true, minLevel: 2 }], monsters: ['goblin'] }));
|
||||
expect(seed.weapons[0]).toMatchObject({ name: 'Dagger', damageDice: '1d4' });
|
||||
expect(seed.weapons[1]).toMatchObject({ name: 'Longsword', isStartingOption: true, minLevel: 2 });
|
||||
expect(seed.weapons[0]).not.toHaveProperty('key');
|
||||
expect(seed.weapons[0]).not.toHaveProperty('category');
|
||||
expect(seed.monsters[0].name).toBe('Goblin');
|
||||
});
|
||||
|
||||
test('refuses a code that is not six capital letters or digits', () => {
|
||||
for (const code of ['abc123', 'ABC12', 'ABC1234', 'ABC-12', undefined]) {
|
||||
expect(() => buildSeed(seedWith({ code }))).toThrow(SeedError);
|
||||
}
|
||||
});
|
||||
|
||||
test('refuses a misspelt field rather than leaving it out', () => {
|
||||
expect(() => buildSeed(seedWith({ npcs: [{ name: 'Mira', desciption: 'A baker' }] }))).toThrow(/desciption/);
|
||||
expect(() => buildSeed(seedWith({ items: [{ name: 'Rope', starting: true }] }))).toThrow(/starting/);
|
||||
});
|
||||
|
||||
test('refuses an unknown library key', () => {
|
||||
expect(() => buildSeed(seedWith({ spells: ['fire-bolt', 'not-a-spell'] }))).toThrow(/no "not-a-spell" in the spells library/);
|
||||
});
|
||||
|
||||
test('refuses the same name twice in a pool', () => {
|
||||
expect(() => buildSeed(seedWith({ weapons: ['dagger', 'dagger'] }))).toThrow(/"Dagger" is there twice/);
|
||||
});
|
||||
|
||||
test('refuses an encounter that points at something the seed does not have', () => {
|
||||
const encounter = (extra) => seedWith({ encounters: [{ name: 'The Bridge', ...extra }] });
|
||||
expect(() => buildSeed(encounter({ location: 'Nowhere' }))).toThrow(/no location called "Nowhere"/);
|
||||
expect(() => buildSeed(encounter({ monsters: [['Troll', 1]] }))).toThrow(/no monster called "Troll"/);
|
||||
expect(() => buildSeed(encounter({ npcs: ['Mira'] }))).toThrow(/no NPC called "Mira"/);
|
||||
expect(() => buildSeed(encounter({ rewards: [{ item: 'Rope' }] }))).toThrow(/no item called "Rope"/);
|
||||
expect(() => buildSeed(encounter({ wares: [{ weapon: 'Dagger', price: 2 }] }))).toThrow(/no weapon called "Dagger"/);
|
||||
});
|
||||
|
||||
test('a reward is exactly one thing', () => {
|
||||
const rewards = (list) => seedWith({ items: [{ name: 'Rope' }], encounters: [{ name: 'The Bridge', rewards: list }] });
|
||||
expect(buildSeed(rewards([{ gold: 5 }, { cash: 20 }, { item: 'Rope', quantity: 2 }])).encounters[0].rewards).toHaveLength(3);
|
||||
expect(() => buildSeed(rewards([{ gold: 5, item: 'Rope' }]))).toThrow(SeedError);
|
||||
expect(() => buildSeed(rewards([{ note: 'nothing' }]))).toThrow(SeedError);
|
||||
});
|
||||
|
||||
test('gear cannot be sold or handed out below its starting level', () => {
|
||||
const world = (ware) => seedWith({ items: [{ name: 'Old Crown', minLevel: 4 }], npcs: [{ name: 'Mira', wares: [ware] }] });
|
||||
expect(buildSeed(world({ item: 'Old Crown', price: 100, level: 5 })).npcs[0].wares).toHaveLength(1);
|
||||
expect(() => buildSeed(world({ item: 'Old Crown', price: 100, level: 2 }))).toThrow(/starts at level 4/);
|
||||
});
|
||||
|
||||
test('an exchange rate needs both halves', () => {
|
||||
expect(() => buildSeed(seedWith({ npcs: [{ name: 'Mira', exchangeGold: 1 }] }))).toThrow(/exchange rate/);
|
||||
expect(buildSeed(seedWith({ npcs: [{ name: 'Mira', exchangeGold: 1, exchangeCash: 20 }] })).npcs[0].exchangeCash).toBe(20);
|
||||
});
|
||||
|
||||
test('an effect has to be on a number the sheet has', () => {
|
||||
const item = (effects) => seedWith({ items: [{ name: 'Shield', effects }] });
|
||||
expect(buildSeed(item([{ stat: 'AC', amount: 2 }])).items[0].effects).toEqual([{ stat: 'AC', amount: 2 }]);
|
||||
expect(() => buildSeed(item([{ stat: 'LUCK', amount: 2 }]))).toThrow(SeedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the seeds folder', () => {
|
||||
let dir;
|
||||
const drop = (file, seed) => fs.writeFileSync(path.join(dir, file), `module.exports = ${JSON.stringify(seed)};`);
|
||||
|
||||
beforeEach(() => {
|
||||
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'seeds-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('a code finds its world however it is typed', () => {
|
||||
drop('one.js', seedWith({ code: 'AB12CD' }));
|
||||
expect(normalizeCode(' ab1-2cd ')).toBe('AB12CD');
|
||||
expect(findSeed(' ab1-2cd ', dir).campaign.name).toBe('Test World');
|
||||
expect(findSeed('ZZZZZZ', dir)).toBeNull();
|
||||
expect(findSeed('', dir)).toBeNull();
|
||||
});
|
||||
|
||||
test('a file dropped in later is picked up', () => {
|
||||
expect(findSeed('LATER1', dir)).toBeNull();
|
||||
drop('later.js', seedWith({ code: 'LATER1' }));
|
||||
expect(findSeed('LATER1', dir)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a broken file is reported and never offered, without hiding the good ones', () => {
|
||||
drop('bad.js', seedWith({ code: 'BAD001', encounters: [{ name: 'X', monsters: [['Nobody', 1]] }] }));
|
||||
drop('good.js', seedWith({ code: 'GOOD01' }));
|
||||
fs.writeFileSync(path.join(dir, 'syntax.js'), 'module.exports = {');
|
||||
const byFile = Object.fromEntries(loadSeeds(dir).map((result) => [result.file, result]));
|
||||
expect(byFile['bad.js'].error).toMatch(/no monster called "Nobody"/);
|
||||
expect(byFile['syntax.js'].error).toBeTruthy();
|
||||
expect(byFile['good.js'].error).toBeUndefined();
|
||||
expect(findSeed('BAD001', dir)).toBeNull();
|
||||
expect(findSeed('GOOD01', dir)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('two files cannot share a code: the first keeps it', () => {
|
||||
drop('a.js', seedWith({ code: 'SAME01', campaign: { name: 'First' } }));
|
||||
drop('b.js', seedWith({ code: 'SAME01', campaign: { name: 'Second' } }));
|
||||
expect(loadSeeds(dir)[1].error).toMatch(/already used by a\.js/);
|
||||
expect(findSeed('SAME01', dir).campaign.name).toBe('First');
|
||||
});
|
||||
});
|
||||
+18
-12
@@ -6,10 +6,14 @@
|
||||
// The coins, and what each is worth counted in copper: silver is 0.1 gold and
|
||||
// copper is 0.01 gold, so 1 gold = 10 silver = 100 copper and 1 silver = 10
|
||||
// copper. Whole numbers keep exchanges exact.
|
||||
const CURRENCIES = ['GOLD', 'SILVER', 'COPPER'];
|
||||
const COINS = ['GOLD', 'SILVER', 'COPPER'];
|
||||
const COIN_VALUE = { GOLD: 100, SILVER: 10, COPPER: 1 };
|
||||
// Each coin is its own column on the character.
|
||||
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
|
||||
// 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 {}
|
||||
|
||||
@@ -43,7 +47,7 @@ function planPayment(purse, currency, amount) {
|
||||
const delta = { GOLD: 0, SILVER: 0, COPPER: 0 };
|
||||
const left = (coin) => purse[coin] + delta[coin];
|
||||
let owed = amount * worth(currency);
|
||||
if (CURRENCIES.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
|
||||
if (COINS.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
|
||||
|
||||
const pay = (coin, count) => {
|
||||
delta[coin] -= count;
|
||||
@@ -51,13 +55,13 @@ function planPayment(purse, currency, amount) {
|
||||
};
|
||||
pay(currency, Math.min(purse[currency], amount));
|
||||
|
||||
const bigger = CURRENCIES.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
|
||||
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 CURRENCIES.filter((c) => worth(c) < worth(coin))) {
|
||||
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;
|
||||
@@ -66,7 +70,7 @@ function planPayment(purse, currency, amount) {
|
||||
if (owed < 0) owed = 0;
|
||||
}
|
||||
|
||||
for (const coin of CURRENCIES.filter((c) => worth(c) < worth(currency))) {
|
||||
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))));
|
||||
}
|
||||
@@ -76,14 +80,16 @@ function planPayment(purse, currency, amount) {
|
||||
// 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.
|
||||
// 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 = CURRENCIES.filter((coin) => delta[coin] !== 0).sort((a, b) => (b === currency) - (a === currency));
|
||||
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 = [];
|
||||
@@ -103,9 +109,9 @@ async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason,
|
||||
return rows;
|
||||
}
|
||||
|
||||
// A purse's whole worth as one figure in gold, for messages: 265 copper-worth -> 2.65.
|
||||
// A purse's coins as one figure in gold, for messages: 265 copper-worth -> 2.65.
|
||||
function worthInGold(character) {
|
||||
const copper = CURRENCIES.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
|
||||
const copper = COINS.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
|
||||
return copper / COIN_VALUE.GOLD;
|
||||
}
|
||||
|
||||
@@ -127,4 +133,4 @@ async function giveGear(db, kind, { characterId, entry, quantity = 1, level, equ
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
|
||||
module.exports = { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
|
||||
|
||||
@@ -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 };
|
||||
+79
-31
@@ -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:
|
||||
|
||||
@@ -315,14 +315,15 @@ Example: a Fighter (d10) with CON 14 (+2) has 12 at level 1 and 28 at level 3.
|
||||
**Current HP** is a stored number, separate from max:
|
||||
|
||||
- Set to max when the build is finalized, and when a character is copied.
|
||||
- Changed with the Damage and Heal buttons on the sheet, or by the DM in an
|
||||
encounter (section 15). Both change the same number.
|
||||
- Changed by the **DM only**: with the Damage and Heal buttons on the sheet,
|
||||
or in an encounter (section 15). Both change the same number. A player sees
|
||||
their HP but has no buttons to change it, and the server refuses the change.
|
||||
- Always kept between **0 and max**: healing stops at max and damage stops at 0.
|
||||
- A level-up raises max but leaves current HP where it was; the DM clicks
|
||||
"Recompute Max HP" to set current HP to the new max (a full heal). If a
|
||||
level change lowers max below current HP, current HP drops to the new max.
|
||||
|
||||
**Temp HP** is a tracked number shown beside HP. Damage is not taken from it
|
||||
**Temp HP** is a tracked number shown beside HP, set by the DM. Damage is not taken from it
|
||||
automatically.
|
||||
|
||||
### At 0 HP: death saves
|
||||
@@ -334,7 +335,7 @@ HP bar, on their encounter card, in the quick stats and in the party lists.
|
||||
|---|---|---|
|
||||
| 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 | Only the DM's **Revive at 1 HP** |
|
||||
| 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.
|
||||
@@ -359,7 +360,10 @@ The app does not roll it.
|
||||
- **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.
|
||||
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
|
||||
@@ -370,17 +374,27 @@ encounter.
|
||||
|
||||
| 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.
|
||||
@@ -407,7 +421,9 @@ calculations but is only ever set if the DM supplies it during step 5.
|
||||
|
||||
## 10. Gear pools, starting options and gear levels
|
||||
|
||||
Each campaign has three **pools**, managed by the DM on the campaign page:
|
||||
Each campaign has three **pools**, managed by the DM on the campaign page,
|
||||
each in a part of its own ("Weapons", "Items", "Spells") where every entry
|
||||
opens in a pop-out:
|
||||
|
||||
| Pool | What an entry holds | How entries are added |
|
||||
|---|---|---|
|
||||
@@ -424,7 +440,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.
|
||||
|
||||
@@ -459,8 +478,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
|
||||
@@ -516,6 +536,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 |
|
||||
|---|---|
|
||||
@@ -546,7 +570,9 @@ 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).
|
||||
|
||||
@@ -562,8 +588,11 @@ behind it.
|
||||
- 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.
|
||||
- Only the **DM** records a gain or a spend, choosing the coin: a player
|
||||
cannot add money to their own purse or write it off. A player's money
|
||||
moves by exchanging coins, giving to another character, and trading with
|
||||
an NPC; it arrives from the DM, an encounter reward, a gift or an NPC.
|
||||
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
|
||||
@@ -576,9 +605,24 @@ behind it.
|
||||
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.
|
||||
deleted; to correct a mistake, the DM records the opposite entry.
|
||||
- Starting gold (a campaign setting) is gold only. Encounter rewards and gifts
|
||||
between characters can be in any coin.
|
||||
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
|
||||
|
||||
@@ -624,11 +668,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 | On its "Overview". 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
|
||||
@@ -731,15 +775,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 | No | 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 |
|
||||
| Record a money gain or spend | No | Yes | No |
|
||||
| Exchange coins (gold, silver, copper) | Yes | Yes | No |
|
||||
| Give gold, weapons, items or spells to another character | Yes | Yes | No |
|
||||
| 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 |
|
||||
@@ -767,16 +815,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)
|
||||
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
|
||||
|
||||
|
||||
+212
-24
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
@@ -46,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 | 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.
|
||||
@@ -75,17 +77,23 @@ 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
|
||||
under "Places", 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
|
||||
**Order.** The campaign page lists encounters on its "Overview", under the
|
||||
characters, 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,
|
||||
with three pages or more, a menu to jump to any page), 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
|
||||
@@ -111,16 +119,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.
|
||||
@@ -131,6 +156,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.
|
||||
|
||||
@@ -149,8 +178,7 @@ The DM types an amount and clicks Damage or Heal on a card.
|
||||
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 marked **Down** and dimmed. It keeps its place
|
||||
in the order; nothing is removed automatically.
|
||||
- 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
|
||||
@@ -173,12 +201,54 @@ the card is the same one as on the sheet, and both change the same character.
|
||||
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**.
|
||||
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 or advances by itself: the app does not prompt for a save when
|
||||
the turn comes round, and a dying, stable or dead character keeps its place
|
||||
in the order.
|
||||
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
|
||||
|
||||
@@ -201,6 +271,71 @@ battles only.
|
||||
- **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
|
||||
|
||||
@@ -220,7 +355,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 |
|
||||
|
||||
@@ -231,9 +366,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
|
||||
@@ -244,22 +379,67 @@ 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**, in the NPC's
|
||||
pop-out, 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 |
|
||||
|---|---|---|
|
||||
@@ -271,23 +451,31 @@ information cannot be dug out of the page.
|
||||
| Add or remove anyone | 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, revive the dead | Yes | No |
|
||||
| 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 for monsters and NPCs, and prompting for a save on a dying
|
||||
character's turn
|
||||
- A roll, a spell slot or a time limit for reviving the dead
|
||||
- Conditions with durations or mechanical effects
|
||||
- Maps, positions, distances and areas of effect
|
||||
- Experience points and encounter difficulty
|
||||
- Spells as rewards
|
||||
- Spells as rewards, and spells for sale
|
||||
- Selling to an NPC, haggling, and buying more than one of a thing in a
|
||||
single step
|
||||
- Renaming an encounter from the screen
|
||||
- A log of what happened during the encounter
|
||||
|
||||
+199
-14
@@ -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,13 +29,18 @@ 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.
|
||||
|
||||
## 2. The item pool
|
||||
|
||||
The pool is the "Items Pool" card on the campaign page (DM only).
|
||||
The pool is the "Items" part of the campaign page (DM only). It lists each
|
||||
item as a short row: its name, then what it does, its dice and its
|
||||
description, as far as they fit on a line. The list has a search box, shows
|
||||
ten to a page, and clicking a row opens the item's **pop-out**, which shows
|
||||
everything below and is where it is changed.
|
||||
|
||||
| Field | What it is |
|
||||
|---|---|
|
||||
@@ -43,13 +50,25 @@ 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 "+ Add Item", or with "📚 From Library". Both open
|
||||
in a pop-out; an item added by hand opens its own pop-out straight away, to
|
||||
set what it does. The library 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. Each
|
||||
can be added once: an item the pool already has by that name is left out of
|
||||
the library list, and comes back if the pool's item is renamed or deleted.
|
||||
- **Effects:** under "While equipped" in the item's pop-out, saved with "Save
|
||||
Effects". The item's row shows what it 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
|
||||
and re-add instead.
|
||||
- **Removing:** the ✕ button. This takes the item out of **every inventory**
|
||||
starting-option tick box are changed in the pop-out, and saved as they are
|
||||
changed. Name, description and dice cannot be changed on screen once
|
||||
created (the API accepts it); remove and re-add instead.
|
||||
- **Removing:** "Remove from campaign" at the foot of the pop-out, after a
|
||||
confirmation. This takes the item out of **every inventory**
|
||||
and off every encounter's reward list. There is no undo.
|
||||
|
||||
## 3. A character's inventory
|
||||
@@ -67,8 +86,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 +161,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**, under "While equipped" in the item's pop-out
|
||||
on the campaign page. 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,13 +213,20 @@ 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 |
|
||||
@@ -136,16 +235,102 @@ character's inventory, stacking with any they already hold. See
|
||||
| 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 -->
|
||||
|
||||
+31
-15
@@ -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 |
|
||||
@@ -50,23 +50,37 @@ from its scores for display only.
|
||||
|
||||
## 3. Managing monsters
|
||||
|
||||
The list is the "Monsters" card on the campaign page (DM only).
|
||||
The list is the "Monsters" part of the campaign page (DM only). Each monster
|
||||
is a short row: its name, challenge rating, AC, HP and attack. The list has a
|
||||
search box, shows ten to a page, and clicking a row opens the monster's
|
||||
**pop-out** with its whole stat block.
|
||||
|
||||
- **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 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
|
||||
- **Adding by hand:** "+ Add Monster" opens a pop-out with 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" opens the bestiary in a
|
||||
pop-out, and "+ Add" copies a complete stat block.
|
||||
Each monster can be added once: one the campaign already has by that name is
|
||||
left out of the list, and comes back if the campaign's monster is renamed or
|
||||
deleted.
|
||||
- **Changing:** "Edit stat block" in a monster's pop-out 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 pop-out shows the full stat block: the numbers, the six ability scores,
|
||||
the trait and the notes.
|
||||
- **Removing:** "Remove from campaign" at the foot of the pop-out, 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 +160,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 +178,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
|
||||
|
||||
+108
-19
@@ -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,22 +42,33 @@ 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.
|
||||
|
||||
## 3. Managing NPCs
|
||||
|
||||
The NPC list is the "NPCs" card on the campaign page (DM only).
|
||||
The NPC list is the "NPCs" part of the campaign page (DM only). Each NPC is a
|
||||
short row: name, role, the start of their description, and badges for their
|
||||
attitude, a 💱 with their exchange rate and a 🛒 with how many things they
|
||||
have for sale. The list has a search box and shows ten to a page.
|
||||
|
||||
- **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).
|
||||
- **Removing:** the ✕ button, after a confirmation. This also **removes the
|
||||
NPC from every encounter** it was in.
|
||||
- **The pop-out:** clicking a row opens the NPC's pop-out, with everything
|
||||
above: description, DM notes, combat stats, and their trade.
|
||||
- **Adding:** "+ Add NPC" opens a pop-out with every field above except the
|
||||
exchange rate and wares. Only the name is required. Once added, the NPC's
|
||||
own pop-out opens, to set their trade.
|
||||
- **Trade:** the exchange rate and the wares are set in the NPC's pop-out,
|
||||
under "Changes money" and "For sale" (section 5).
|
||||
- **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:** "Remove from campaign" at the foot of the pop-out, after a
|
||||
confirmation. This also **removes the NPC from every encounter** it was in.
|
||||
|
||||
## 4. NPCs in encounters
|
||||
|
||||
@@ -73,7 +88,73 @@ 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 "Changes money" in the NPC's pop-out
|
||||
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 +162,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 +170,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 +181,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 +194,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
|
||||
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
# World Seeds
|
||||
|
||||
A world seed is a ready-made campaign: locations, weapons, items, spells,
|
||||
monsters, NPCs with their wares, encounters with their rewards, and notes. A
|
||||
DM starts a campaign from one by typing its code.
|
||||
|
||||
This describes the code as it stands. Related: [ITEMS.md](ITEMS.md),
|
||||
[WEAPONS.md](WEAPONS.md), [SPELLS.md](SPELLS.md), [MONSTERS.md](MONSTERS.md),
|
||||
[NPCS.md](NPCS.md), [ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
|
||||
## Contents
|
||||
|
||||
1. [The basics](#1-the-basics)
|
||||
2. [Starting a campaign from a code](#2-starting-a-campaign-from-a-code)
|
||||
3. [Adding a world](#3-adding-a-world)
|
||||
4. [What a seed file holds](#4-what-a-seed-file-holds)
|
||||
5. [Checking a seed](#5-checking-a-seed)
|
||||
6. [The database seed](#6-the-database-seed)
|
||||
7. [The worlds that come with the app](#7-the-worlds-that-come-with-the-app)
|
||||
|
||||
---
|
||||
|
||||
## 1. The basics
|
||||
|
||||
- Every `.js` file in `/backend/seeds` is one world. The file says what its
|
||||
**code** is: six capital letters or digits.
|
||||
- Anyone who is logged in and knows a code can create that world as a new
|
||||
campaign. They become its DM and owner, exactly as with a blank campaign.
|
||||
- The same code can be used any number of times. Each use makes a separate
|
||||
campaign with its own invite code; nothing is shared between copies.
|
||||
- A seed only says what a campaign **starts** as. Once created, the campaign
|
||||
is the DM's to change, and changing the seed file later does not touch
|
||||
campaigns already made from it.
|
||||
- The folder is read each time a code is entered, so a new file is live as
|
||||
soon as it is on the server. No restart, no migration.
|
||||
|
||||
## 2. Starting a campaign from a code
|
||||
|
||||
On the **Campaigns** page, click **+ New Campaign**. Under the form for a
|
||||
blank campaign is **Or start from a ready-made world**: type the code and
|
||||
click **Create From Code**. Capitals and spaces do not matter (`aldenf`,
|
||||
`ALDENF` and `ald-enf` are the same code).
|
||||
|
||||
The whole world is created in one go, and you land on its campaign page. If
|
||||
anything in it fails, nothing is created.
|
||||
|
||||
| Reply | Meaning |
|
||||
|---|---|
|
||||
| "A world code is 6 letters or numbers" | What was typed is not six characters long |
|
||||
| "There is no world with that code" | No valid seed file has that code |
|
||||
|
||||
The API is `POST /campaigns/from-seed` with `{ "code": "ALDENF" }`. It
|
||||
answers with the new campaign, as `POST /campaigns` does.
|
||||
|
||||
## 3. Adding a world
|
||||
|
||||
1. Write the world as a file in `/backend/seeds`, for example
|
||||
`/backend/seeds/my-world.js` (section 4).
|
||||
2. Run `npm run seeds:check` (section 5) and fix whatever it reports.
|
||||
3. Put the file on the server. The code works straight away.
|
||||
|
||||
A file with a mistake in it is never offered: its code simply answers "There
|
||||
is no world with that code" until the file is fixed. Two files cannot share a
|
||||
code; the one whose file name comes first keeps it.
|
||||
|
||||
To retire a world, delete its file. Campaigns already made from it stay.
|
||||
|
||||
## 4. What a seed file holds
|
||||
|
||||
A seed file is a JavaScript module that exports one object. Only `code` and
|
||||
`campaign.name` are required; every list can be left out.
|
||||
|
||||
```js
|
||||
module.exports = {
|
||||
code: 'MYWRLD',
|
||||
campaign: { name: 'My World', description: '...', startingGold: 20, startingItemCount: 2, startingSpellCount: 2 },
|
||||
locations: [{ name: 'The Old Mill', description: '...' }],
|
||||
weapons: [
|
||||
'dagger', // a copy from the site library
|
||||
{ library: 'longsword', isStartingOption: true }, // a copy, with changes
|
||||
{ name: 'Millstone Hammer', damageDice: '2d6', damageType: 'bludgeoning', attackAbility: 'STR', minLevel: 3 },
|
||||
],
|
||||
items: [
|
||||
{ library: 'shield', isStartingOption: true },
|
||||
{ name: 'Miller’s Charm', description: '...', effects: [{ stat: 'SKILL_PERCEPTION', amount: 1 }] },
|
||||
],
|
||||
spells: [{ library: 'fire-bolt', isStartingOption: true }, 'fireball'],
|
||||
monsters: ['goblin', { name: 'Mill Wraith', ac: 13, maxHp: 30, attackBonus: 4, damageDice: '1d8+2 necrotic' }],
|
||||
npcs: [
|
||||
{
|
||||
name: 'Old Hob', role: 'Miller', disposition: 'FRIENDLY', description: '...', dmNotes: '...',
|
||||
wares: [{ item: 'Shield', price: 10 }, { weapon: 'Dagger', price: 2, stock: 3 }],
|
||||
},
|
||||
],
|
||||
encounters: [
|
||||
{
|
||||
name: 'The Mill at Night', mode: 'BATTLE', location: 'The Old Mill', description: '...', dmNotes: '...',
|
||||
monsters: [['Goblin', 3], ['Mill Wraith', 1, 'The Miller’s Wife', 40]],
|
||||
npcs: ['Old Hob'],
|
||||
rewards: [{ gold: 25 }, { silver: 40 }, { weapon: 'Millstone Hammer', note: 'Behind the wheel' }],
|
||||
},
|
||||
],
|
||||
sharedNotes: ['Welcome to My World...'],
|
||||
dmNotes: ['The secret of the mill...'],
|
||||
};
|
||||
```
|
||||
|
||||
The file is ordinary JavaScript, so it can build its lists however it likes
|
||||
(the Hollowcrown seed adds the whole spell library with a `map`).
|
||||
|
||||
### Top level
|
||||
|
||||
| Field | What it is |
|
||||
|---|---|
|
||||
| `code` | Six capital letters or digits. What a DM types |
|
||||
| `campaign` | The campaign's settings: `name`, and optionally `description`, `startingGold`, `startingItemCount`, `startingSpellCount`, `skipMotivationRoll`, `showMonsterHp`, `areaAttackAcPenalty`, `maxEquipped`, `maxEquippedWeapons`. Anything left out takes the app's default |
|
||||
| `locations` | `{ name, description }` |
|
||||
| `weapons`, `items`, `spells`, `monsters` | The pools. Each entry is a row or a library copy (below) |
|
||||
| `npcs` | NPC rows, each optionally with `wares` |
|
||||
| `encounters` | In the order they should list |
|
||||
| `sharedNotes`, `dmNotes` | Note bodies, in reading order: the first is shown at the top. Shared notes are for everyone, DM notes for the DM |
|
||||
|
||||
### Pool entries
|
||||
|
||||
An entry in `weapons`, `items`, `spells` or `monsters` is one of:
|
||||
|
||||
- **A library key** such as `'longsword'`: a copy of that entry from the
|
||||
site's built-in library (`src/game/data/*Library.js`).
|
||||
- **A library copy with changes**: `{ library: 'longsword', isStartingOption: true, minLevel: 2 }`.
|
||||
Any field of the row can be changed, including its name.
|
||||
- **A full row**, with the same fields the app's own forms have:
|
||||
|
||||
| Pool | Fields (required in bold) |
|
||||
|---|---|
|
||||
| Weapon | **`name`**, **`damageDice`**, **`damageType`**, **`attackAbility`** (`STR`, `DEX` or `FINESSE`), `properties`, `rangeNormal`, `rangeLong`, `damageScaling` (1 to 3), `minLevel` (1 to 9), `isStartingOption`, `effects`, `effectNote` |
|
||||
| Item | **`name`**, `description`, `damageDice`, `damageScaling`, `minLevel`, `isStartingOption`, `effects`, `effectNote` |
|
||||
| Spell | **`name`**, **`level`** (0 to 9), `damageDice`, `school`, `castingTime`, `range`, `duration`, `description`, `isStartingOption`, `revives` |
|
||||
| Monster | **`name`**, **`ac`**, **`maxHp`**, `speed`, `strScore` ... `chaScore`, `attackBonus`, `damageDice` (may name its type: `"1d6+2 necrotic"`), `challengeRating`, `specialTrait`, `notes` |
|
||||
|
||||
`effects` is a list of `{ stat, amount }`, as described in
|
||||
[ITEMS.md](ITEMS.md): armor is an item with an `AC` effect.
|
||||
|
||||
Within a pool a name can only be used once, because everything else in the
|
||||
file points at pool entries **by name**.
|
||||
|
||||
### NPCs
|
||||
|
||||
**`name`**, `role`, `disposition` (`FRIENDLY`, `NEUTRAL` or `HOSTILE`),
|
||||
`description` (what players see), `dmNotes`, and for those who might fight
|
||||
`ac`, `maxHp`, `attackBonus`, `damageDice`.
|
||||
|
||||
- **A money changer** has `exchangeGold` and `exchangeCash` together:
|
||||
`exchangeGold: 1, exchangeCash: 50` is 1 gold for $50, either way.
|
||||
- **A shopkeeper** has `wares`, a list of things for sale (below).
|
||||
|
||||
### Encounters
|
||||
|
||||
| Field | What it is |
|
||||
|---|---|
|
||||
| **`name`** | |
|
||||
| `mode` | `BATTLE` (the default) or `QUEST`. Buying and money changing only happen in a quest |
|
||||
| `description` | The scene, shown to the players in it |
|
||||
| `dmNotes` | DM only |
|
||||
| `location` | A location's name |
|
||||
| `monsters` | `[monster name, how many, display name, max HP]`. The last two are optional; more than one gets "#1", "#2"... |
|
||||
| `npcs` | NPC names, or `{ name, hp }` to set the hit points for this scene |
|
||||
| `rewards` | Lined up unawarded (below) |
|
||||
| `wares` | For sale at the scene itself, with nobody behind it: a market, a vending machine |
|
||||
|
||||
A **reward** is exactly one of `gold`, `silver`, `copper`, `cash` (an amount),
|
||||
`weapon` or `item` (a name), with an optional `note`. A weapon or item can also
|
||||
have `quantity` and `level`.
|
||||
|
||||
A **ware** is `{ weapon | item, price }` with optional `currency` (`GOLD`, the
|
||||
default, `SILVER`, `COPPER` or `CASH`), `level` and `stock` (left out: no
|
||||
limit).
|
||||
|
||||
A reward's or ware's `level` defaults to the entry's own starting level, and
|
||||
cannot be below it.
|
||||
|
||||
### Limits
|
||||
|
||||
Text fields have the same length limits as the app's forms (for example 4,000
|
||||
characters for an encounter's DM notes and 10,000 for a note), so that
|
||||
everything a seed creates can still be edited on screen. The checker says
|
||||
which field is over.
|
||||
|
||||
## 5. Checking a seed
|
||||
|
||||
```bash
|
||||
npm run seeds:check
|
||||
```
|
||||
|
||||
lists every file in `/backend/seeds` with its code and what it holds, or
|
||||
everything wrong with it:
|
||||
|
||||
```
|
||||
✓ aldenfell.js — code ALDENF — Aldenfell
|
||||
24 weapons (16 starting), 31 items (10 starting), 45 spells (30 starting), 21 locations
|
||||
15 monsters, 13 NPCs (8 wares), 38 encounters (40 rewards, 9 wares), 23 notes
|
||||
✗ my-world.js
|
||||
npcs.0.desciption: Unrecognized key: "desciption"
|
||||
Encounter "The Mill at Night": there is no monster called "Gobblin"
|
||||
```
|
||||
|
||||
It checks the things that would otherwise only show up as a half-working
|
||||
campaign:
|
||||
|
||||
- A field name that is not one of the fields above (a typo).
|
||||
- A library key that is not in the library.
|
||||
- The same name twice in one pool.
|
||||
- An encounter, reward or ware that names a location, monster, NPC, weapon or
|
||||
item the file does not have.
|
||||
- A reward that is not exactly one thing, or gear handed out below its
|
||||
starting level.
|
||||
- An exchange rate with only one half.
|
||||
|
||||
`npm test` runs the same check on every seed in the folder.
|
||||
|
||||
## 6. The database seed
|
||||
|
||||
`npx prisma db seed` creates the admin account if it is missing, and nothing
|
||||
else. It never adds a campaign: worlds come from codes, in the app. See the
|
||||
README for the account it creates and `--reset`.
|
||||
|
||||
## 7. The worlds that come with the app
|
||||
|
||||
| Code | World | From |
|
||||
|---|---|---|
|
||||
| `ALDENF` | Aldenfell | The Aldenfell Campaign Book |
|
||||
| `HOLLOW` | Hollowcrown: The Rift Between Worlds | The Hollowcrown Dungeon Master's Book (V3) |
|
||||
|
||||
Change a world's code by editing `code` in its file.
|
||||
+15
-6
@@ -36,7 +36,10 @@ This describes the code as it stands. The built-in list is
|
||||
|
||||
## 2. The spell list
|
||||
|
||||
The list is the "Spell List" card on the campaign page (DM only).
|
||||
The list is the "Spells" part of the campaign page (DM only). Each spell is a
|
||||
short row: its name, level, damage and the start of what it does. The list
|
||||
has a search box, shows ten to a page, and clicking a row opens the spell's
|
||||
**pop-out**, which shows everything below and is where it is changed.
|
||||
|
||||
| Field | What it is |
|
||||
|---|---|
|
||||
@@ -46,15 +49,17 @@ 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) |
|
||||
| Casting time, range, duration | Stored for library spells but not shown on any screen |
|
||||
| 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. Shown to the DM in the spell's pop-out on the campaign page, and nowhere else |
|
||||
|
||||
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
|
||||
- **Adding:** by hand with "+ Add Spell" (name, level, school, damage, what
|
||||
it does and the two tick boxes), or from the library. Both open in a
|
||||
pop-out.
|
||||
- **Changing:** in the spell's pop-out: damage (the drop-down) and the Revives
|
||||
and starting-option tick boxes, saved as they are changed. 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
|
||||
@@ -106,6 +111,10 @@ one shown.
|
||||
A spell added from the library is **not** a starting option until ticked. The
|
||||
library has nothing above level 5; add higher spells by hand.
|
||||
|
||||
Each library spell can be added once. A spell the list already has by that
|
||||
name is left out of the library list, and comes back if the campaign's spell
|
||||
is renamed or deleted.
|
||||
|
||||
## 4. Granting spells to characters
|
||||
|
||||
On a character's sheet, the DM picks a spell from the list and clicks Grant.
|
||||
|
||||
+132
-61
@@ -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)
|
||||
@@ -37,7 +37,11 @@ This describes the code as it stands. The formulas live in
|
||||
|
||||
## 2. The weapon pool
|
||||
|
||||
The pool is the "Weapons Pool" card on the campaign page (DM only).
|
||||
The pool is the "Weapons" part of the campaign page (DM only). It lists each
|
||||
weapon as a short row: its name, base damage, starting level and what it does
|
||||
while equipped. The list has a search box, shows ten to a page, and clicking a
|
||||
row opens the weapon's **pop-out**, which shows everything below and is where
|
||||
it is changed.
|
||||
|
||||
| Field | What it is |
|
||||
|---|---|
|
||||
@@ -48,20 +52,29 @@ The pool is the "Weapons Pool" card on the campaign page (DM only).
|
||||
| Starts at level | 1 to 9. The lowest level this weapon can be (section 5) |
|
||||
| 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 |
|
||||
| Properties, range | Stored for library weapons (light, thrown, 20/60 ft…). Shown in the weapon's pop-out, and not 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.
|
||||
- **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.
|
||||
- **Removing:** the ✕ button. This takes the weapon away from **every
|
||||
- **Adding:** by hand with "+ Add Weapon", or from the library. Both open in
|
||||
a pop-out. A weapon added by hand opens its own pop-out straight away, to
|
||||
set what it does.
|
||||
- **Effects:** under "While equipped" in the weapon's pop-out, saved with
|
||||
"Save Effects". 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 are
|
||||
changed in the pop-out, and saved as they are changed. Name, damage, type
|
||||
and ability cannot be changed on screen once created (the API accepts it);
|
||||
remove and re-add instead.
|
||||
- **Removing:** "Remove from campaign" at the foot of the pop-out, after a
|
||||
confirmation. This takes the weapon away from **every
|
||||
character holding it** and off every encounter's reward list. There is no
|
||||
undo.
|
||||
- The same weapon can be added twice; nothing stops duplicate names.
|
||||
|
||||
## 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,12 +82,23 @@ 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
|
||||
afterwards. Every library weapon is listed in
|
||||
[section 12](#12-the-complete-weapon-library).
|
||||
|
||||
Each library weapon can be added once. A weapon the pool already has by that
|
||||
name is left out of the library list, and comes back if the pool's weapon is
|
||||
renamed or deleted.
|
||||
|
||||
## 4. Giving weapons to characters
|
||||
|
||||
On a character's sheet, the DM picks a weapon from the pool and clicks Give.
|
||||
@@ -84,8 +108,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 +164,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,13 +235,20 @@ 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 |
|
||||
@@ -201,6 +258,8 @@ on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
| 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 |
|
||||
@@ -208,74 +267,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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.5.0",
|
||||
"version": "1.13.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -18,12 +18,14 @@
|
||||
"socket.io-client": "^4.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"compression": "^1.8.2",
|
||||
"oxlint": "^1.81.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript": "~7.0.2",
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
+30
-24
@@ -1,35 +1,41 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { Layout } from './components/Layout';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import { RegisterPage } from './pages/RegisterPage';
|
||||
import { CampaignListPage } from './pages/CampaignListPage';
|
||||
import { CampaignHomePage } from './pages/CampaignHomePage';
|
||||
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';
|
||||
|
||||
// Each page is its own file to download, fetched the first time it is opened,
|
||||
// so signing in does not wait on the encounter tracker or the character builder.
|
||||
const RegisterPage = lazy(() => import('./pages/RegisterPage').then((m) => ({ default: m.RegisterPage })));
|
||||
const CampaignListPage = lazy(() => import('./pages/CampaignListPage').then((m) => ({ default: m.CampaignListPage })));
|
||||
const CampaignHomePage = lazy(() => import('./pages/CampaignHomePage').then((m) => ({ default: m.CampaignHomePage })));
|
||||
const PartyOverviewPage = lazy(() => import('./pages/PartyOverviewPage').then((m) => ({ default: m.PartyOverviewPage })));
|
||||
const CharacterSheetPage = lazy(() => import('./pages/CharacterSheetPage').then((m) => ({ default: m.CharacterSheetPage })));
|
||||
const CharacterBuilderPage = lazy(() => import('./pages/CharacterBuilderPage').then((m) => ({ default: m.CharacterBuilderPage })));
|
||||
const EncounterPage = lazy(() => import('./pages/EncounterPage').then((m) => ({ default: m.EncounterPage })));
|
||||
const AccountPage = lazy(() => import('./pages/AccountPage').then((m) => ({ default: m.AccountPage })));
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Navigate to="/campaigns" replace />} />
|
||||
<Route path="/campaigns" element={<CampaignListPage />} />
|
||||
<Route path="/campaigns/:campaignId" element={<CampaignHomePage />} />
|
||||
<Route path="/campaigns/:campaignId/party" element={<PartyOverviewPage />} />
|
||||
<Route path="/characters/:characterId" element={<CharacterSheetPage />} />
|
||||
<Route path="/characters/:characterId/build" element={<CharacterBuilderPage />} />
|
||||
<Route path="/encounters/:encounterId" element={<EncounterPage />} />
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
<Suspense fallback={null}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/register" element={<RegisterPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Navigate to="/campaigns" replace />} />
|
||||
<Route path="/campaigns" element={<CampaignListPage />} />
|
||||
<Route path="/campaigns/:campaignId" element={<CampaignHomePage />} />
|
||||
<Route path="/campaigns/:campaignId/party" element={<PartyOverviewPage />} />
|
||||
<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>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+157
-15
@@ -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,
|
||||
@@ -12,12 +12,14 @@ import type {
|
||||
Encounter,
|
||||
EncounterMode,
|
||||
EncounterParticipant,
|
||||
GearEffect,
|
||||
GoldTransaction,
|
||||
Item,
|
||||
Location,
|
||||
Monster,
|
||||
Note,
|
||||
Npc,
|
||||
SaleOffer,
|
||||
QuickStats,
|
||||
Spell,
|
||||
Weapon,
|
||||
@@ -60,6 +62,16 @@ export function useCreateCampaign() {
|
||||
});
|
||||
}
|
||||
|
||||
// A ready-made world (a seed file on the server) as a new campaign, by its code.
|
||||
export function useCreateCampaignFromSeed() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (code: string) =>
|
||||
(await apiClient.post<{ campaign: Campaign }>('/campaigns/from-seed', { code })).data.campaign,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaigns'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCampaign(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -463,12 +475,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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -536,6 +558,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({
|
||||
@@ -567,6 +603,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({
|
||||
@@ -691,12 +735,21 @@ export function useEncounter(encounterId: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
// Every change to an encounter is answered with the encounter as this viewer
|
||||
// sees it, so the cache takes that as it is: no second request for what just
|
||||
// came back.
|
||||
function keepEncounter(qc: QueryClient, encounterId: string, data: unknown) {
|
||||
const encounter = (data as { encounter?: Encounter } | undefined)?.encounter;
|
||||
if (encounter) qc.setQueryData(['encounter', encounterId], encounter);
|
||||
else qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
}
|
||||
|
||||
export function useAddParticipant(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
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] }),
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -705,7 +758,7 @@ export function usePatchParticipant(encounterId: string) {
|
||||
return useMutation({
|
||||
mutationFn: async ({ participantId, ...data }: Partial<EncounterParticipant> & { participantId: string }) =>
|
||||
(await apiClient.patch(`/encounters/${encounterId}/participants/${participantId}`, data)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -713,8 +766,8 @@ export function useRemoveParticipant(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (participantId: string) =>
|
||||
apiClient.delete(`/encounters/${encounterId}/participants/${participantId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
(await apiClient.delete(`/encounters/${encounterId}/participants/${participantId}`)).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -722,7 +775,7 @@ export function useAdvanceRound(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => (await apiClient.post(`/encounters/${encounterId}/advance-round`)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -737,8 +790,8 @@ export function usePatchEncounter(encounterId: string) {
|
||||
dmNotes?: string | null;
|
||||
locationId?: string | null;
|
||||
}) => (await apiClient.patch(`/encounters/${encounterId}`, data)).data,
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
onSuccess: (data, variables) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
// 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) {
|
||||
@@ -750,11 +803,34 @@ export function usePatchEncounter(encounterId: string) {
|
||||
}
|
||||
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: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
// 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: (data) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
qc.invalidateQueries({ queryKey: ['character'] }); // the revived character's sheet
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useNextTurn(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => (await apiClient.post(`/encounters/${encounterId}/next-turn`)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -771,15 +847,15 @@ export function useAddReward(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: NewReward) => (await apiClient.post(`/encounters/${encounterId}/rewards`, data)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteReward(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (rewardId: string) => apiClient.delete(`/encounters/${encounterId}/rewards/${rewardId}`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
mutationFn: async (rewardId: string) => (await apiClient.delete(`/encounters/${encounterId}/rewards/${rewardId}`)).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -790,8 +866,8 @@ export function useAwardReward(encounterId: string) {
|
||||
return useMutation({
|
||||
mutationFn: async ({ rewardId, ...target }: { rewardId: string } & ({ characterId: string } | { split: true })) =>
|
||||
(await apiClient.post(`/encounters/${encounterId}/rewards/${rewardId}/award`, target)).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
onSuccess: (data) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] });
|
||||
},
|
||||
@@ -819,6 +895,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, data: unknown) {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
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: (data) => invalidateAfterTrade(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
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: (data) => invalidateAfterTrade(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Notes
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
+135
-5
@@ -93,6 +93,12 @@ export interface Campaign {
|
||||
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';
|
||||
@@ -106,7 +112,9 @@ export interface CampaignMemberSummary {
|
||||
}
|
||||
|
||||
// The coins a character carries. 1 gold = 10 silver = 100 copper.
|
||||
export type Currency = 'GOLD' | 'SILVER' | '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
|
||||
@@ -133,10 +141,11 @@ export interface CharacterSummary {
|
||||
currentHp: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
// The purse; all three are null for another player's character.
|
||||
// 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;
|
||||
@@ -157,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;
|
||||
@@ -169,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 {
|
||||
@@ -187,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 {
|
||||
@@ -213,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 {
|
||||
@@ -234,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 {
|
||||
@@ -300,6 +330,7 @@ export interface CharacterDetail {
|
||||
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;
|
||||
@@ -337,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;
|
||||
@@ -344,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.
|
||||
@@ -384,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;
|
||||
@@ -400,6 +462,7 @@ export interface CharacterCombatInfo {
|
||||
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;
|
||||
@@ -407,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;
|
||||
}
|
||||
@@ -431,6 +496,9 @@ export interface EncounterParticipant {
|
||||
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; userId: string | null } | null;
|
||||
// The DM gets the whole stat block; players only the id and name.
|
||||
@@ -447,6 +515,9 @@ 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.
|
||||
@@ -455,8 +526,12 @@ export interface Encounter {
|
||||
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 {
|
||||
@@ -480,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 {
|
||||
@@ -492,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[];
|
||||
}
|
||||
|
||||
@@ -516,6 +604,7 @@ export interface LibraryMonster {
|
||||
|
||||
export interface CampaignLibrary {
|
||||
weapons: LibraryWeapon[];
|
||||
items: LibraryItem[];
|
||||
spells: LibrarySpell[];
|
||||
monsters: LibraryMonster[];
|
||||
}
|
||||
@@ -527,14 +616,48 @@ 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;
|
||||
@@ -544,6 +667,13 @@ export interface QuickStats {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { apiClient, getStoredToken, setAuthToken } from '../api/client';
|
||||
import type { User } from '../api/types';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
import { AuthContext } from './useAuth';
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(getStoredToken());
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// With no stored token there is nothing to check, so nothing to wait for.
|
||||
const [loading, setLoading] = useState(() => getStoredToken() !== null);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = getStoredToken();
|
||||
if (!stored) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!stored) return;
|
||||
setAuthToken(stored);
|
||||
apiClient
|
||||
.get<{ user: User }>('/auth/me')
|
||||
@@ -53,9 +42,3 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { User } from '../api/types';
|
||||
|
||||
export interface AuthContextValue {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (token: string, user: User) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { Link, useMatch } from 'react-router-dom';
|
||||
import { useCampaign, useCampaignCharacters, usePatchCharacterWeapon, usePatchInventoryItem, useQuickStats } from '../api/queries';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ function Pips({ kind, count, onSet }: { kind: 'success' | 'failure'; count: numb
|
||||
|
||||
// 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 are the DM's alone.
|
||||
// tallies by hand and raising the dead from here are the DM's alone.
|
||||
export function DeathSaves({
|
||||
characterId,
|
||||
name,
|
||||
@@ -78,7 +78,7 @@ export function DeathSaves({
|
||||
</>
|
||||
)}
|
||||
{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.' : 'Only the DM can bring them back.'}</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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ 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
|
||||
// out" for someone who doesn't already know D&D 5e by heart.
|
||||
export const STAT_HELP: Record<string, string> = {
|
||||
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).',
|
||||
@@ -16,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.',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,41 @@
|
||||
import { Link, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { Suspense } from 'react';
|
||||
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
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,23 +45,31 @@ 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">
|
||||
<Link to="/account" title="Account settings">
|
||||
{user.displayName}
|
||||
</Link>
|
||||
<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 />
|
||||
{/* The bars stay put while a page that has not been opened before downloads. */}
|
||||
<Suspense fallback={null}>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
{user && (
|
||||
<nav className="bottom-nav" aria-label="Main">
|
||||
{links('bottom-nav-link')}
|
||||
</nav>
|
||||
)}
|
||||
<DiceRoller />
|
||||
{user && <CharacterPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
// A pop-out over the page: a centred window on a wide screen, the whole
|
||||
// screen on a phone. Escape, the ✕ and a click outside all close it, and the
|
||||
// page behind stays where it was.
|
||||
export function Modal({
|
||||
title,
|
||||
subtitle,
|
||||
wide,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
wide?: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const titleId = useId();
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
// A click only closes if it both started and ended outside the window, so
|
||||
// dragging to select text in a field never does.
|
||||
const pressedOutside = useRef(false);
|
||||
const close = useRef(onClose);
|
||||
useEffect(() => {
|
||||
close.current = onClose;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const opener = document.activeElement as HTMLElement | null;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close.current();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
const scroll = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
windowRef.current?.focus();
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = scroll;
|
||||
opener?.focus?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
onMouseDown={(e) => {
|
||||
pressedOutside.current = e.target === e.currentTarget;
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (pressedOutside.current && e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div ref={windowRef} className={`modal${wide ? ' is-wide' : ''}`} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
|
||||
<div className="modal-head">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h2 id={titleId} className="modal-title">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && <div className="combatant-sub">{subtitle}</div>}
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" aria-label="Close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,17 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
// Small generic pager shared by every list-turned-table in the app, so page
|
||||
// state isn't hand-rolled per screen. Resets to page 1 whenever the
|
||||
// underlying item count shrinks below the current page (e.g. after a delete
|
||||
// or a filter change).
|
||||
export function usePagination<T>(items: T[] | undefined, pageSize = 10) {
|
||||
const [page, setPage] = useState(1);
|
||||
const all = items || [];
|
||||
const pageCount = Math.max(1, Math.ceil(all.length / pageSize));
|
||||
const safePage = Math.min(page, pageCount);
|
||||
const pageItems = useMemo(() => all.slice((safePage - 1) * pageSize, safePage * pageSize), [all, safePage, pageSize]);
|
||||
|
||||
return {
|
||||
page: safePage,
|
||||
pageCount,
|
||||
pageItems,
|
||||
total: all.length,
|
||||
setPage,
|
||||
nextPage: () => setPage((p) => Math.min(pageCount, p + 1)),
|
||||
prevPage: () => setPage((p) => Math.max(1, p - 1)),
|
||||
};
|
||||
}
|
||||
|
||||
// The controls for a paged list; the page state itself is hooks/usePagination.
|
||||
export function Pagination({
|
||||
page,
|
||||
pageCount,
|
||||
onPrev,
|
||||
onNext,
|
||||
onJump,
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
// Given this, the page number is a list to pick any page from.
|
||||
onJump?: (page: number) => void;
|
||||
}) {
|
||||
if (pageCount <= 1) return null;
|
||||
return (
|
||||
@@ -39,9 +19,23 @@ export function Pagination({
|
||||
<button type="button" className="btn btn-ghost" onClick={onPrev} disabled={page <= 1}>
|
||||
← Prev
|
||||
</button>
|
||||
<span className="pagination-status">
|
||||
Page {page} of {pageCount}
|
||||
</span>
|
||||
{onJump && pageCount > 2 ? (
|
||||
<label className="pagination-status flex-row gap-sm">
|
||||
Page
|
||||
<select aria-label="Go to page" value={page} onChange={(e) => onJump(Number(e.target.value))}>
|
||||
{Array.from({ length: pageCount }, (_, i) => i + 1).map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
of {pageCount}
|
||||
</label>
|
||||
) : (
|
||||
<span className="pagination-status">
|
||||
Page {page} of {pageCount}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost" onClick={onNext} disabled={page >= pageCount}>
|
||||
Next →
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { Spinner } from './Spinner';
|
||||
|
||||
export function ProtectedRoute() {
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuickStats } from '../api/queries';
|
||||
import { Spinner, ErrorBanner } from './Spinner';
|
||||
import { fmtMod } from '../lib/format';
|
||||
import { HpBar } from './HpBar';
|
||||
import { DeathSaves } from './DeathSaves';
|
||||
|
||||
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>
|
||||
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
|
||||
{/* Read-only here; the saves are rolled on the sheet or in the encounter. */}
|
||||
<DeathSaves characterId={characterId} name={data.name} deathSaves={data.deathSaves} canAct={false} isDM={false} />
|
||||
|
||||
{data.spellcasting && (
|
||||
<div className="card mt-md">
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export interface Section {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
// How many things are in it, shown beside its name.
|
||||
count?: number;
|
||||
}
|
||||
|
||||
// The parts of a page that shows one part at a time. A row of buttons on a
|
||||
// wide screen; on a phone a single line that scrolls sideways, with the
|
||||
// current part kept in view.
|
||||
export function SectionNav({ sections, current, onSelect }: { sections: Section[]; current: string; onSelect: (id: string) => void }) {
|
||||
const currentRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
currentRef.current?.scrollIntoView({ block: 'nearest', inline: 'center' });
|
||||
}, [current]);
|
||||
|
||||
return (
|
||||
<nav className="section-nav" aria-label="Parts of this campaign">
|
||||
{sections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
ref={section.id === current ? currentRef : undefined}
|
||||
type="button"
|
||||
className={`jump-link${section.id === current ? ' is-active' : ''}`}
|
||||
aria-current={section.id === current ? 'page' : undefined}
|
||||
onClick={() => onSelect(section.id)}
|
||||
>
|
||||
<span aria-hidden>{section.icon}</span> {section.label}
|
||||
{section.count !== undefined && <span className="section-count">{section.count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBuyWare, useExchangeWithNpc } from '../api/queries';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import { WaresEditor } from './Wares';
|
||||
import { stockLabel, wareName } from '../lib/wares';
|
||||
import { formatMoney, formatPurse } from '../lib/format';
|
||||
import type { CharacterSummary, Encounter, Item, SaleOffer, Weapon } from '../api/types';
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// Trade in a quest: changing gold and cash with an NPC who does that, at
|
||||
// their own rate, and buying what the NPCs in the scene — or the scene itself
|
||||
// — have for sale. Everyone sees what is on offer; a player trades for their
|
||||
// own character, the DM for anyone. Trading is open only while the quest is in
|
||||
// progress, and there is none in a battle. The DM sets it all up: an NPC's
|
||||
// rate and wares on the campaign page, the scene's own wares here.
|
||||
export function TradePanel({
|
||||
encounter,
|
||||
isDM,
|
||||
userId,
|
||||
characters,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
isDM: boolean;
|
||||
userId: string | undefined;
|
||||
characters: CharacterSummary[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const exchange = useExchangeWithNpc(encounter.id);
|
||||
const buy = useBuyWare(encounter.id);
|
||||
|
||||
const [traderId, setTraderId] = useState('');
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({});
|
||||
const [sides, setSides] = useState<Record<string, ExchangeSide>>({});
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
if (encounter.mode !== 'QUEST') return null;
|
||||
|
||||
const here = (encounter.participants ?? []).filter((p) => !p.hidden);
|
||||
const npcs = here.flatMap((p) => (p.npc ? [p.npc] : []));
|
||||
const changers = npcs.filter((npc) => npc.exchangeGold && npc.exchangeCash);
|
||||
const stalls: [string, string, SaleOffer[]][] = [
|
||||
...npcs.map((npc): [string, string, SaleOffer[]] => [npc.id, `${npc.name} sells`, npc.wares ?? []]),
|
||||
['here', 'For sale here', encounter.wares ?? []],
|
||||
];
|
||||
const openStalls = stalls.filter(([, , wares]) => wares.length > 0);
|
||||
if (!isDM && changers.length === 0 && openStalls.length === 0) return null;
|
||||
|
||||
// Who can do the trading: a player's own characters in the scene, or for the DM anyone's.
|
||||
const traders = here.flatMap((p) => (p.character && (isDM || p.character.userId === userId) ? [p.character] : []));
|
||||
const trader = traders.find((c) => c.id === traderId) ?? traders[0];
|
||||
const purse = characters.find((c) => c.id === trader?.id);
|
||||
const open = encounter.status === 'ACTIVE';
|
||||
const busy = exchange.isPending || buy.isPending;
|
||||
|
||||
function handleExchange(npc: (typeof changers)[number]) {
|
||||
const from = sides[npc.id] ?? 'GOLD';
|
||||
const amount = Math.floor(Number(amounts[npc.id]));
|
||||
if (!trader || !(amount > 0)) return;
|
||||
setDone(null);
|
||||
exchange.mutate(
|
||||
{ characterId: trader.id, npcId: npc.id, from, amount },
|
||||
{
|
||||
onError,
|
||||
onSuccess: ({ received, currency }) => {
|
||||
setDone(`${trader.name} changed ${formatMoney(amount, from, true)} for ${formatMoney(received, currency, true)} with ${npc.name}.`);
|
||||
setAmounts((a) => ({ ...a, [npc.id]: '' }));
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function handleBuy(ware: SaleOffer) {
|
||||
if (!trader) return;
|
||||
setDone(null);
|
||||
buy.mutate(
|
||||
{ wareId: ware.id, characterId: trader.id },
|
||||
{ onError, onSuccess: () => setDone(`${trader.name} bought ${(ware.weapon ?? ware.item)?.name} for ${formatMoney(ware.price, ware.currency, true)}.`) }
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">Trade</div>
|
||||
{!open && (
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
{encounter.status === 'PLANNING' ? 'Trading opens once the quest is in progress.' : 'The quest is over; trading is closed.'}
|
||||
</p>
|
||||
)}
|
||||
{traders.length > 0 && (
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<select aria-label="Who is trading" value={trader?.id ?? ''} onChange={(e) => setTraderId(e.target.value)} style={{ flex: 1, minWidth: 110 }}>
|
||||
{traders.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{purse && purse.gold !== null && <span className="mono">{formatPurse(purse)}</span>}
|
||||
</div>
|
||||
)}
|
||||
{traders.length === 0 && (changers.length > 0 || openStalls.length > 0) && (
|
||||
<p className="combatant-sub">{isDM ? 'Add a character to the scene to trade for them.' : 'Your character has to be in the scene to trade.'}</p>
|
||||
)}
|
||||
{done && <p className="combatant-sub">✓ {done}</p>}
|
||||
|
||||
{changers.map((npc) => {
|
||||
const from = sides[npc.id] ?? 'GOLD';
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
const rate = { GOLD: npc.exchangeGold ?? 1, CASH: npc.exchangeCash ?? 1 };
|
||||
const amount = Math.floor(Number(amounts[npc.id]));
|
||||
const whole = amount > 0 && amount % rate[from] === 0;
|
||||
return (
|
||||
<div key={npc.id} className="reward-row">
|
||||
<strong>💱 {npc.name}</strong>
|
||||
<span className="combatant-sub">
|
||||
{' '}
|
||||
changes {formatMoney(rate.GOLD, 'GOLD', true)} for {formatMoney(rate.CASH, 'CASH')}, either way. No silver or copper.
|
||||
</span>
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label={`How much to change with ${npc.name}`}
|
||||
value={amounts[npc.id] ?? ''}
|
||||
onChange={(e) => setAmounts((a) => ({ ...a, [npc.id]: e.target.value }))}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<select
|
||||
aria-label={`What to hand ${npc.name}`}
|
||||
value={from}
|
||||
onChange={(e) => setSides((s) => ({ ...s, [npc.id]: e.target.value as ExchangeSide }))}
|
||||
>
|
||||
<option value="GOLD">gold into cash</option>
|
||||
<option value="CASH">cash into gold</option>
|
||||
</select>
|
||||
<button className="btn" disabled={!open || !trader || !whole || busy} onClick={() => handleExchange(npc)}>
|
||||
{whole ? `Get ${formatMoney((amount / rate[from]) * rate[to], to, true)}` : 'Exchange'}
|
||||
</button>
|
||||
</div>
|
||||
{amount > 0 && !whole && (
|
||||
<div className="combatant-sub">
|
||||
Hand over {from.toLowerCase()} in multiples of {rate[from]}.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{openStalls.map(([key, label, wares]) => (
|
||||
<div key={key} className="mt-sm">
|
||||
<div className="section-label">🛒 {label}</div>
|
||||
{wares.map((ware) => (
|
||||
<div key={ware.id} className="reward-row">
|
||||
<div className="flex-between">
|
||||
<span>
|
||||
<strong>{wareName(ware)}</strong>
|
||||
<span className="combatant-sub"> · {stockLabel(ware)}</span>
|
||||
</span>
|
||||
<button className="btn" disabled={!open || !trader || ware.stock === 0 || busy} onClick={() => handleBuy(ware)}>
|
||||
Buy · {formatMoney(ware.price, ware.currency)}
|
||||
</button>
|
||||
</div>
|
||||
{ware.item?.description && <div className="combatant-sub">{ware.item.description}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isDM && (
|
||||
<div className="mt-md">
|
||||
<div className="section-label">Set up: for sale at this encounter</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
For a stall or a machine with nobody behind it. An NPC's wares and exchange rate are set under "Trade" on the{' '}
|
||||
<Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link>, and follow the NPC into every quest.
|
||||
</p>
|
||||
<WaresEditor
|
||||
campaignId={encounter.campaignId}
|
||||
seller={{ encounterId: encounter.id }}
|
||||
wares={encounter.wares ?? []}
|
||||
weapons={weapons}
|
||||
items={items}
|
||||
onError={onError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react';
|
||||
import { useCreateWare, useDeleteWare, usePatchWare } from '../api/queries';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import { COIN_LABELS, CURRENCIES, formatMoney } from '../lib/format';
|
||||
import { scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import { stockLabel, wareName } from '../lib/wares';
|
||||
import type { Currency, Item, SaleOffer, Weapon } from '../api/types';
|
||||
|
||||
// One ware in the DM's list. "Edit" swaps the line for its price and stock.
|
||||
function WareRow({ campaignId, ware, onError }: { campaignId: string; ware: SaleOffer; onError: (err: unknown) => void }) {
|
||||
const patchWare = usePatchWare(campaignId);
|
||||
const deleteWare = useDeleteWare(campaignId);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [price, setPrice] = useState('');
|
||||
const [currency, setCurrency] = useState<Currency>(ware.currency);
|
||||
const [stock, setStock] = useState('');
|
||||
|
||||
function startEditing() {
|
||||
setPrice(String(ware.price));
|
||||
setCurrency(ware.currency);
|
||||
setStock(ware.stock === null ? '' : String(ware.stock));
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const amount = Math.floor(Number(price));
|
||||
if (!(amount > 0)) return;
|
||||
patchWare.mutate({ id: ware.id, price: amount, currency, stock: stock === '' ? null : Number(stock) }, { onError, onSuccess: () => setEditing(false) });
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '0.3rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div className="flex-between">
|
||||
<span>
|
||||
{wareName(ware)} — <strong className="mono">{formatMoney(ware.price, ware.currency)}</strong>
|
||||
<span className="combatant-sub"> · {stockLabel(ware)}</span>
|
||||
</span>
|
||||
<span className="flex-row">
|
||||
<button className="btn btn-ghost" aria-label={`Edit the price and stock of ${wareName(ware)}`} onClick={() => (editing ? setEditing(false) : startEditing())}>
|
||||
{editing ? 'Cancel' : 'Edit'}
|
||||
</button>
|
||||
<button className="btn btn-ghost" aria-label={`Stop selling ${wareName(ware)}`} onClick={() => deleteWare.mutate(ware.id, { onError })}>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput min={1} max={1000000} placeholder="Price" aria-label="Price for one" value={price} onChange={(e) => setPrice(e.target.value)} style={{ width: 90 }} />
|
||||
<select aria-label="Price is in" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={9999}
|
||||
placeholder="Stock (empty = no limit)"
|
||||
aria-label="How many are in stock"
|
||||
title="How many can be bought. Leave empty for no limit."
|
||||
value={stock}
|
||||
onChange={(e) => setStock(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 120 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={!price || patchWare.isPending}>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The DM's list of what one seller has for sale — an NPC, or an encounter
|
||||
// itself — with a form to put something from the campaign pools up for sale.
|
||||
export function WaresEditor({
|
||||
campaignId,
|
||||
seller,
|
||||
wares,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
campaignId: string;
|
||||
seller: { npcId: string } | { encounterId: string };
|
||||
wares: SaleOffer[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const createWare = useCreateWare(campaignId);
|
||||
|
||||
const [kind, setKind] = useState<'item' | 'weapon'>('item');
|
||||
const [poolId, setPoolId] = useState('');
|
||||
const [level, setLevel] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [currency, setCurrency] = useState<Currency>('GOLD');
|
||||
const [stock, setStock] = useState('');
|
||||
|
||||
const pool: (Weapon | Item)[] = kind === 'weapon' ? weapons : items;
|
||||
const picked = pool.find((entry) => entry.id === poolId);
|
||||
|
||||
function handleAdd() {
|
||||
const amount = Math.floor(Number(price));
|
||||
if (!picked || !(amount > 0)) return;
|
||||
createWare.mutate(
|
||||
{
|
||||
...seller,
|
||||
...(kind === 'weapon' ? { weaponId: picked.id } : { itemId: picked.id }),
|
||||
price: amount,
|
||||
currency,
|
||||
level: level ? Number(level) : undefined,
|
||||
stock: stock === '' ? null : Number(stock),
|
||||
},
|
||||
{
|
||||
onError,
|
||||
onSuccess: () => {
|
||||
setPoolId('');
|
||||
setLevel('');
|
||||
setPrice('');
|
||||
setStock('');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{wares.length === 0 && (
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Nothing for sale yet.
|
||||
</p>
|
||||
)}
|
||||
{wares.map((ware) => (
|
||||
<WareRow key={ware.id} campaignId={campaignId} ware={ware} onError={onError} />
|
||||
))}
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<select
|
||||
aria-label="What kind of thing to sell"
|
||||
value={kind}
|
||||
onChange={(e) => {
|
||||
setKind(e.target.value as typeof kind);
|
||||
setPoolId('');
|
||||
setLevel('');
|
||||
}}
|
||||
>
|
||||
<option value="item">Item</option>
|
||||
<option value="weapon">Weapon</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label={kind === 'weapon' ? 'Weapon to sell' : 'Item to sell'}
|
||||
value={poolId}
|
||||
onChange={(e) => {
|
||||
setPoolId(e.target.value);
|
||||
setLevel('');
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 110 }}
|
||||
>
|
||||
<option value="">{pool.length ? 'Choose…' : 'The pool is empty'}</option>
|
||||
{pool.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{picked && (
|
||||
<select aria-label="Level" value={level || picked.minLevel} onChange={(e) => setLevel(e.target.value)}>
|
||||
{Array.from({ length: MAX_GEAR_LEVEL - picked.minLevel + 1 }, (_, i) => picked.minLevel + i).map((l) => (
|
||||
<option key={l} value={l}>
|
||||
Lv {l}
|
||||
{picked.damageDice ? ` · ${scaledDamageDice(picked.damageDice, l, picked.damageScaling, picked.minLevel)}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput min={1} max={1000000} placeholder="Price" aria-label="Price for one" value={price} onChange={(e) => setPrice(e.target.value)} style={{ width: 90 }} />
|
||||
<select aria-label="Price is in" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{COIN_LABELS[c]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={9999}
|
||||
placeholder="Stock (empty = no limit)"
|
||||
aria-label="How many are in stock"
|
||||
title="How many can be bought. Leave empty for no limit."
|
||||
value={stock}
|
||||
onChange={(e) => setStock(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 120 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleAdd} disabled={!picked || !price || createWare.isPending}>
|
||||
Put up for sale
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { EntryRow } from './parts';
|
||||
|
||||
export interface SearchHit {
|
||||
key: string;
|
||||
title: string;
|
||||
detail?: ReactNode;
|
||||
badges?: ReactNode;
|
||||
// Where it goes: a page of its own, or whatever `onPick` opens.
|
||||
to?: string;
|
||||
onPick?: () => void;
|
||||
}
|
||||
|
||||
export interface SearchGroup {
|
||||
label: string;
|
||||
icon: string;
|
||||
hits: SearchHit[];
|
||||
}
|
||||
|
||||
// More than this of one kind and the rest are counted, not listed.
|
||||
const SHOWN_PER_GROUP = 12;
|
||||
|
||||
// Everything in the campaign that a search found, a kind at a time.
|
||||
export function CampaignSearch({ query, groups }: { query: string; groups: SearchGroup[] }) {
|
||||
const found = groups.filter((g) => g.hits.length > 0);
|
||||
const total = found.reduce((sum, g) => sum + g.hits.length, 0);
|
||||
|
||||
return (
|
||||
<div className="card" aria-live="polite">
|
||||
<div className="card-title">
|
||||
<span>
|
||||
{total} {total === 1 ? 'result' : 'results'} for "{query}"
|
||||
</span>
|
||||
</div>
|
||||
{total === 0 && <p style={{ color: 'var(--text-muted)', margin: 0 }}>Nothing in this campaign matches. Try fewer words, or part of a name.</p>}
|
||||
{found.map((group) => (
|
||||
<div key={group.label} className="search-group">
|
||||
<div className="section-label">
|
||||
<span aria-hidden>{group.icon}</span> {group.label} ({group.hits.length})
|
||||
</div>
|
||||
<div className="entry-list">
|
||||
{group.hits.slice(0, SHOWN_PER_GROUP).map((hit) => (
|
||||
<EntryRow key={hit.key} name={hit.title} meta={hit.detail} badges={hit.badges} to={hit.to} onOpen={hit.onPick} />
|
||||
))}
|
||||
</div>
|
||||
{group.hits.length > SHOWN_PER_GROUP && (
|
||||
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>
|
||||
…and {group.hits.length - SHOWN_PER_GROUP} more. Add a word to narrow it down.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useDeleteMonster, useDeletePoolEntry, usePatchPoolEntry, useUpdateMonster } from '../../api/queries';
|
||||
import { Modal } from '../Modal';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { ErrorBanner } from '../Spinner';
|
||||
import { EffectsEditor } from '../EffectsEditor';
|
||||
import { WaresEditor } from '../Wares';
|
||||
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
|
||||
import { abilityLine, rateLabel, type EntryRef } from '../../lib/campaignEntries';
|
||||
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, StartLevelSelect } from './parts';
|
||||
import type { GearEffect, Item, Monster, Npc, Spell, Weapon } from '../../api/types';
|
||||
|
||||
const ATTACK_ABILITY_LABELS: Record<Weapon['attackAbility'], string> = { STR: 'STR', DEX: 'DEX', FINESSE: 'Finesse (the better of STR and DEX)' };
|
||||
|
||||
function Chip({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="stat-chip">
|
||||
<span>{label}</span>
|
||||
<b>{children}</b>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The foot of every pop-out: taking the thing out of the campaign.
|
||||
function RemoveButton({ name, warning, pending, onRemove }: { name: string; warning: string; pending: boolean; onRemove: () => void }) {
|
||||
return (
|
||||
<div className="modal-foot">
|
||||
<button className="btn btn-danger" disabled={pending} onClick={() => window.confirm(`Remove ${name}? ${warning}`) && onRemove()}>
|
||||
Remove from campaign
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A weapon or an item: the two share levels, growth and effects.
|
||||
function GearDetail({ campaignId, pool, entry, onClose }: { campaignId: string; pool: 'weapons' | 'items'; entry: Weapon | Item; onClose: () => void }) {
|
||||
const patch = usePatchPoolEntry(campaignId, pool);
|
||||
const remove = useDeletePoolEntry(campaignId, pool);
|
||||
const [effects, setEffects] = useState<GearEffect[]>(entry.effects ?? []);
|
||||
const [note, setNote] = useState(entry.effectNote ?? '');
|
||||
const [effectsSaved, setEffectsSaved] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const onError = (err: unknown) => setError(extractErrorMessage(err));
|
||||
const weapon = 'damageType' in entry ? entry : null;
|
||||
|
||||
return (
|
||||
<Modal title={entry.name} subtitle={weapon ? 'Weapon' : 'Item'} onClose={onClose}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="stat-chips">
|
||||
{weapon ? (
|
||||
<>
|
||||
<Chip label="Base damage">
|
||||
{weapon.damageDice} {weapon.damageType}
|
||||
</Chip>
|
||||
<Chip label="Attacks with">{ATTACK_ABILITY_LABELS[weapon.attackAbility]}</Chip>
|
||||
{weapon.rangeNormal && (
|
||||
<Chip label="Range">
|
||||
{weapon.rangeNormal}
|
||||
{weapon.rangeLong ? `/${weapon.rangeLong}` : ''} ft
|
||||
</Chip>
|
||||
)}
|
||||
{weapon.properties.length > 0 && <Chip label="Properties">{weapon.properties.join(', ')}</Chip>}
|
||||
</>
|
||||
) : (
|
||||
entry.damageDice && <Chip label="Base dice">{entry.damageDice}</Chip>
|
||||
)}
|
||||
</div>
|
||||
{!weapon && 'description' in entry && entry.description && <p className="mt-sm" style={{ whiteSpace: 'pre-wrap' }}>{entry.description}</p>}
|
||||
|
||||
<div className="grid grid-cols-2 mt-md">
|
||||
<Field label="Level it starts at">
|
||||
{(id) => <StartLevelSelect id={id} value={entry.minLevel} onChange={(minLevel) => patch.mutate({ id: entry.id, minLevel }, { onError })} />}
|
||||
</Field>
|
||||
{entry.damageDice && (
|
||||
<Field label="Growth per level">
|
||||
{(id) => <ScalingSelect id={id} value={entry.damageScaling} onChange={(damageScaling) => patch.mutate({ id: entry.id, damageScaling }, { onError })} />}
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
<p className="combatant-sub mt-sm">
|
||||
A character must be at least its level to use it.
|
||||
{entry.damageDice &&
|
||||
' The dice above are what it does at the level it starts at. When you level up a character\'s copy they grow one small step per level (1d4 → 1d6 → 1d8 → 1d10 → 1d12 → 2d6 → …): two steps for Powerful gear, three for Legendary.'}
|
||||
</p>
|
||||
<label className="checkbox-row mt-md">
|
||||
<input type="checkbox" checked={entry.isStartingOption} onChange={(e) => patch.mutate({ id: entry.id, isStartingOption: e.target.checked }, { onError })} />
|
||||
Starting option: a player may pick it while building a character
|
||||
</label>
|
||||
|
||||
<div className="section-label mt-md">While equipped</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
These count for a character only while it is equipped and they are high enough level to use it. Armor is an item with
|
||||
+AC; a penalty is a negative number.
|
||||
</p>
|
||||
<EffectsEditor
|
||||
effects={effects}
|
||||
note={note}
|
||||
onChange={(nextEffects, nextNote) => {
|
||||
setEffects(nextEffects);
|
||||
setNote(nextNote);
|
||||
setEffectsSaved(false);
|
||||
}}
|
||||
/>
|
||||
<div className="flex-row mt-sm">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={patch.isPending}
|
||||
onClick={() => patch.mutate({ id: entry.id, effects, effectNote: note.trim() || null }, { onError, onSuccess: () => setEffectsSaved(true) })}
|
||||
>
|
||||
Save Effects
|
||||
</button>
|
||||
{effectsSaved && <span className="combatant-sub">Saved.</span>}
|
||||
</div>
|
||||
|
||||
<RemoveButton name={entry.name} warning="Any character holding it loses it." pending={remove.isPending} onRemove={() => remove.mutate(entry.id, { onError, onSuccess: onClose })} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function SpellDetail({ campaignId, spell, onClose }: { campaignId: string; spell: Spell; onClose: () => void }) {
|
||||
const patch = usePatchPoolEntry(campaignId, 'spells');
|
||||
const remove = useDeletePoolEntry(campaignId, 'spells');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const onError = (err: unknown) => setError(extractErrorMessage(err));
|
||||
|
||||
return (
|
||||
<Modal title={spell.name} subtitle={[spellLevelLabel(spell.level), spell.school, spellRequirementLabel(spell.level)].filter(Boolean).join(' · ')} onClose={onClose}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="stat-chips">
|
||||
{spell.castingTime && <Chip label="Casting time">{spell.castingTime}</Chip>}
|
||||
{spell.range && <Chip label="Range">{spell.range}</Chip>}
|
||||
{spell.duration && <Chip label="Duration">{spell.duration}</Chip>}
|
||||
</div>
|
||||
<p className="mt-sm" style={{ whiteSpace: 'pre-wrap' }}>{spell.description || 'No description.'}</p>
|
||||
|
||||
<div className="grid grid-cols-2 mt-md">
|
||||
<Field label="Damage">
|
||||
{(id) => <SpellDamageSelect id={id} spellName={spell.name} value={spell.damageDice} onChange={(damageDice) => patch.mutate({ id: spell.id, damageDice }, { onError })} />}
|
||||
</Field>
|
||||
</div>
|
||||
<label className="checkbox-row mt-md">
|
||||
<input type="checkbox" checked={spell.isStartingOption} onChange={(e) => patch.mutate({ id: spell.id, isStartingOption: e.target.checked }, { onError })} />
|
||||
Starting option: a player may pick it while building a character
|
||||
</label>
|
||||
<label className="checkbox-row mt-sm">
|
||||
<input type="checkbox" checked={spell.revives} onChange={(e) => patch.mutate({ id: spell.id, revives: e.target.checked }, { onError })} />
|
||||
Revives the dead: a character who can cast it may revive a dead ally on their turn in a battle
|
||||
</label>
|
||||
|
||||
<RemoveButton name={spell.name} warning="Any character holding it loses it." pending={remove.isPending} onRemove={() => remove.mutate(spell.id, { onError, onSuccess: onClose })} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function MonsterDetail({ campaignId, monster, onClose }: { campaignId: string; monster: Monster; onClose: () => void }) {
|
||||
const update = useUpdateMonster(campaignId);
|
||||
const remove = useDeleteMonster(campaignId);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const onError = (err: unknown) => setError(extractErrorMessage(err));
|
||||
|
||||
return (
|
||||
<Modal title={editing ? `Editing ${monster.name}` : monster.name} subtitle="Monster" wide onClose={onClose}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
{editing ? (
|
||||
<MonsterForm
|
||||
monster={monster}
|
||||
saving={update.isPending}
|
||||
onCancel={() => setEditing(false)}
|
||||
onSave={async (stats) => {
|
||||
setError(null);
|
||||
try {
|
||||
await update.mutateAsync({ id: monster.id, ...stats });
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="stat-chips">
|
||||
<Chip label="CR">{monster.challengeRating || '—'}</Chip>
|
||||
<Chip label="AC">{monster.ac}</Chip>
|
||||
<Chip label="HP">{monster.maxHp}</Chip>
|
||||
<Chip label="Speed">{monster.speed}</Chip>
|
||||
<Chip label="Attack">{fmtMod(monster.attackBonus)}</Chip>
|
||||
<Chip label="Damage">{monster.damageDice || '—'}</Chip>
|
||||
</div>
|
||||
<p className="mono mt-sm" style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{abilityLine(monster)}</p>
|
||||
{monster.specialTrait && (
|
||||
<p>
|
||||
<strong>Trait:</strong> {monster.specialTrait}
|
||||
</p>
|
||||
)}
|
||||
{monster.notes && <p style={{ color: 'var(--text-muted)', whiteSpace: 'pre-wrap' }}>{monster.notes}</p>}
|
||||
<button className="btn" onClick={() => setEditing(true)}>
|
||||
Edit stat block
|
||||
</button>
|
||||
<RemoveButton
|
||||
name={monster.name}
|
||||
warning="Copies already in encounters stay, but lose their stat block."
|
||||
pending={remove.isPending}
|
||||
onRemove={() => remove.mutate(monster.id, { onError, onSuccess: onClose })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// An NPC: who they are, and what they trade — the rate they change gold and
|
||||
// cash at, and their wares. Players meet both on the encounter page, in a
|
||||
// quest the NPC is part of.
|
||||
function NpcDetail({ campaignId, npc, weapons, items, onClose }: { campaignId: string; npc: Npc; weapons: Weapon[]; items: Item[]; onClose: () => void }) {
|
||||
const patch = usePatchPoolEntry(campaignId, 'npcs');
|
||||
const remove = useDeletePoolEntry(campaignId, 'npcs');
|
||||
const [gold, setGold] = useState(npc.exchangeGold ? String(npc.exchangeGold) : '');
|
||||
const [cash, setCash] = useState(npc.exchangeCash ? String(npc.exchangeCash) : '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const onError = (err: unknown) => setError(extractErrorMessage(err));
|
||||
const rate = rateLabel(npc);
|
||||
const hasStats = npc.ac !== null || npc.maxHp !== null || npc.attackBonus !== null || Boolean(npc.damageDice);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={npc.name}
|
||||
subtitle={
|
||||
<span className="flex-row flex-wrap gap-sm">
|
||||
{npc.role || 'NPC'}
|
||||
<span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</span>
|
||||
{rate && <span className="badge">💱 {rate}</span>}
|
||||
</span>
|
||||
}
|
||||
wide
|
||||
onClose={onClose}
|
||||
>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
{npc.description ? <p style={{ whiteSpace: 'pre-wrap' }}>{npc.description}</p> : <p style={{ color: 'var(--text-muted)' }}>No description for the players.</p>}
|
||||
{npc.dmNotes && <p style={{ color: 'var(--text-muted)', whiteSpace: 'pre-wrap' }}>🔒 {npc.dmNotes}</p>}
|
||||
{hasStats && (
|
||||
<div className="stat-chips">
|
||||
{npc.ac !== null && <Chip label="AC">{npc.ac}</Chip>}
|
||||
{npc.maxHp !== null && <Chip label="HP">{npc.maxHp}</Chip>}
|
||||
{npc.attackBonus !== null && <Chip label="Attack">{fmtMod(npc.attackBonus)}</Chip>}
|
||||
{npc.damageDice && <Chip label="Damage">{npc.damageDice}</Chip>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section-label mt-md">Changes money</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Set a rate and the party can change gold into cash, or cash into gold, with {npc.name}. Silver and copper are not
|
||||
taken. Leave it empty for someone who does not change money.
|
||||
</p>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<NumberInput min={1} max={1000000} aria-label="Gold side of the rate" placeholder="Gold" value={gold} onChange={(e) => setGold(e.target.value)} style={{ width: 90 }} />
|
||||
<span>gold =</span>
|
||||
<span>$</span>
|
||||
<NumberInput min={1} max={1000000} aria-label="Cash side of the rate" placeholder="Cash" value={cash} onChange={(e) => setCash(e.target.value)} style={{ width: 90 }} />
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!gold || !cash || patch.isPending}
|
||||
onClick={() => patch.mutate({ id: npc.id, exchangeGold: Number(gold), exchangeCash: Number(cash) }, { onError })}
|
||||
>
|
||||
Save rate
|
||||
</button>
|
||||
{rate && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
disabled={patch.isPending}
|
||||
onClick={() =>
|
||||
patch.mutate(
|
||||
{ id: npc.id, exchangeGold: null, exchangeCash: null },
|
||||
{
|
||||
onError,
|
||||
onSuccess: () => {
|
||||
setGold('');
|
||||
setCash('');
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
>
|
||||
Stop changing money
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="section-label mt-md">For sale</div>
|
||||
<WaresEditor campaignId={campaignId} seller={{ npcId: npc.id }} wares={npc.wares ?? []} weapons={weapons} items={items} onError={onError} />
|
||||
|
||||
<RemoveButton
|
||||
name={npc.name}
|
||||
warning="They leave every encounter they are in, and their wares go with them."
|
||||
pending={remove.isPending}
|
||||
onRemove={() => remove.mutate(npc.id, { onError, onSuccess: onClose })}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// The pop-out for whichever weapon, item, spell, monster or NPC is open.
|
||||
// Nothing shows if it is no longer in the campaign.
|
||||
export function EntryDetail({
|
||||
campaignId,
|
||||
entry,
|
||||
weapons,
|
||||
items,
|
||||
spells,
|
||||
monsters,
|
||||
npcs,
|
||||
onClose,
|
||||
}: {
|
||||
campaignId: string;
|
||||
entry: EntryRef;
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
spells: Spell[];
|
||||
monsters: Monster[];
|
||||
npcs: Npc[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const find = <T extends { id: string }>(list: T[]) => list.find((e) => e.id === entry.id);
|
||||
switch (entry.kind) {
|
||||
case 'weapon': {
|
||||
const weapon = find(weapons);
|
||||
return weapon ? <GearDetail key={weapon.id} campaignId={campaignId} pool="weapons" entry={weapon} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'item': {
|
||||
const item = find(items);
|
||||
return item ? <GearDetail key={item.id} campaignId={campaignId} pool="items" entry={item} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'spell': {
|
||||
const spell = find(spells);
|
||||
return spell ? <SpellDetail key={spell.id} campaignId={campaignId} spell={spell} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'monster': {
|
||||
const monster = find(monsters);
|
||||
return monster ? <MonsterDetail key={monster.id} campaignId={campaignId} monster={monster} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'npc': {
|
||||
const npc = find(npcs);
|
||||
return npc ? <NpcDetail key={npc.id} campaignId={campaignId} npc={npc} weapons={weapons} items={items} onClose={onClose} /> : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
import { useState, type FormEvent, type ReactNode } from 'react';
|
||||
import {
|
||||
useAddItemFromLibrary,
|
||||
useAddMonsterFromLibrary,
|
||||
useAddSpellFromLibrary,
|
||||
useAddWeaponFromLibrary,
|
||||
useCampaignLibrary,
|
||||
useCreateItem,
|
||||
useCreateMonster,
|
||||
useCreateNpc,
|
||||
useCreateSpell,
|
||||
useCreateWeapon,
|
||||
} from '../../api/queries';
|
||||
import { Modal } from '../Modal';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { ErrorBanner, Spinner } from '../Spinner';
|
||||
import { effectsSummary } from '../../lib/gear';
|
||||
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
|
||||
import { MAX_GEAR_LEVEL } from '../../lib/previewCalc';
|
||||
import { abilityLine, rateLabel, type EntryRef } from '../../lib/campaignEntries';
|
||||
import { EntryList, Field, LibraryPicker, MonsterForm, ScalingSelect, SpellDamageSelect, StartLevelSelect } from './parts';
|
||||
import type { CampaignLibrary, Item, LibraryItem, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Spell, Weapon } from '../../api/types';
|
||||
|
||||
interface SectionProps<T> {
|
||||
campaignId: string;
|
||||
entries: T[] | undefined;
|
||||
onOpen: (entry: EntryRef) => void;
|
||||
}
|
||||
|
||||
// Which of a list's two "add" pop-outs is open: the form, or the library.
|
||||
type Adding = 'form' | 'library' | null;
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
addLabel,
|
||||
hasLibrary = true,
|
||||
onAdd,
|
||||
intro,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
addLabel: string;
|
||||
hasLibrary?: boolean;
|
||||
onAdd: (adding: Adding) => void;
|
||||
intro: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">
|
||||
<span>{title}</span>
|
||||
<div className="flex-row gap-sm">
|
||||
{hasLibrary && (
|
||||
<button className="btn" onClick={() => onAdd('library')}>
|
||||
📚 From Library
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={() => onAdd('form')}>
|
||||
{addLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="subtitle" style={{ marginTop: 0 }}>
|
||||
{intro}
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The built-in library in a pop-out. It is a large download, so it is only
|
||||
// asked for once one of these is opened.
|
||||
function LibraryModal<T extends { key: string; name: string }>({
|
||||
campaignId,
|
||||
title,
|
||||
pick,
|
||||
taken,
|
||||
adding,
|
||||
onAdd,
|
||||
placeholder,
|
||||
renderMeta,
|
||||
renderDetail,
|
||||
error,
|
||||
onClose,
|
||||
}: {
|
||||
campaignId: string;
|
||||
title: string;
|
||||
pick: (library: CampaignLibrary) => T[];
|
||||
taken: { name: string }[] | undefined;
|
||||
adding: boolean;
|
||||
onAdd: (key: string) => void;
|
||||
placeholder: string;
|
||||
renderMeta: (item: T) => string;
|
||||
renderDetail?: (item: T) => string | null;
|
||||
error: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data: library, isError } = useCampaignLibrary(campaignId);
|
||||
return (
|
||||
<Modal title={title} subtitle="Each entry can be added once. This stays open so you can add several." wide onClose={onClose}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
{isError && <ErrorBanner message="Could not load the library." />}
|
||||
{!library && !isError && <Spinner label="Fetching the library…" />}
|
||||
{library && (
|
||||
<LibraryPicker<T> items={pick(library)} taken={taken} adding={adding} onAdd={onAdd} placeholder={placeholder} renderMeta={renderMeta} renderDetail={renderDetail} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const StartingOption = () => (
|
||||
<label className="checkbox-row" style={{ alignSelf: 'end', paddingBottom: '0.6rem' }}>
|
||||
<input type="checkbox" name="isStartingOption" />
|
||||
Starting option (a player may pick it while building a character)
|
||||
</label>
|
||||
);
|
||||
|
||||
const startingBadge = (entry: { isStartingOption: boolean }) => entry.isStartingOption && <span className="badge">Starting</span>;
|
||||
|
||||
export function WeaponsSection({ campaignId, entries, onOpen }: SectionProps<Weapon>) {
|
||||
const createWeapon = useCreateWeapon(campaignId);
|
||||
const addFromLibrary = useAddWeaponFromLibrary(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
setError(null);
|
||||
try {
|
||||
const weapon = await createWeapon.mutateAsync({
|
||||
name: String(fd.get('name')).trim(),
|
||||
damageDice: String(fd.get('damageDice')).trim(),
|
||||
damageType: String(fd.get('damageType')).trim(),
|
||||
attackAbility: fd.get('attackAbility') as Weapon['attackAbility'],
|
||||
properties: [],
|
||||
rangeNormal: null,
|
||||
rangeLong: null,
|
||||
damageScaling: Number(fd.get('damageScaling')),
|
||||
minLevel: Number(fd.get('minLevel')),
|
||||
isStartingOption: fd.get('isStartingOption') === 'on',
|
||||
effects: [],
|
||||
effectNote: null,
|
||||
});
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'weapon', id: weapon.id });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Weapons"
|
||||
addLabel="+ Add Weapon"
|
||||
onAdd={open}
|
||||
intro={
|
||||
<>
|
||||
The weapons you hand out on each character's sheet. Open one to set the level it starts at, how fast its damage grows
|
||||
(levels 1–{MAX_GEAR_LEVEL}), whether a new character may start with it, and what it does while equipped.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<EntryList
|
||||
entries={entries}
|
||||
noun="weapons"
|
||||
emptyText="No weapons yet. Add one by hand, or copy some from the library."
|
||||
searchText={(w) => [w.name, w.damageDice, w.damageType, effectsSummary(w.effects, w.effectNote)]}
|
||||
renderMeta={(w) => [`${w.damageDice} ${w.damageType}`, `Lv ${w.minLevel}`, effectsSummary(w.effects, w.effectNote) && `✦ ${effectsSummary(w.effects, w.effectNote)}`].filter(Boolean).join(' · ')}
|
||||
renderBadges={startingBadge}
|
||||
onOpen={(id) => onOpen({ kind: 'weapon', id })}
|
||||
/>
|
||||
{adding === 'library' && (
|
||||
<LibraryModal<LibraryWeapon>
|
||||
campaignId={campaignId}
|
||||
title="Weapon library"
|
||||
pick={(library) => library.weapons}
|
||||
taken={entries}
|
||||
adding={addFromLibrary.isPending}
|
||||
onAdd={(key) => addFromLibrary.mutate(key, { onError: (err) => setError(extractErrorMessage(err)) })}
|
||||
placeholder="Search the weapon library…"
|
||||
renderMeta={(w) => `${w.category} · ${w.damageDice} ${w.damageType} · starts at level 1`}
|
||||
renderDetail={(w) => effectsSummary(w.effects, w.effectNote)}
|
||||
error={error}
|
||||
onClose={() => setAdding(null)}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add a weapon" subtitle="What it does while equipped is set once it is added." onClose={() => setAdding(null)}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2">
|
||||
<Field label="Name">{(id) => <input id={id} name="name" required maxLength={80} />}</Field>
|
||||
<Field label="Damage at its starting level">{(id) => <input id={id} name="damageDice" placeholder="e.g. 1d8" required />}</Field>
|
||||
<Field label="Damage type">{(id) => <input id={id} name="damageType" placeholder="e.g. slashing" required />}</Field>
|
||||
<Field label="Attacks with">
|
||||
{(id) => (
|
||||
<select id={id} name="attackAbility" defaultValue="STR">
|
||||
<option value="STR">STR</option>
|
||||
<option value="DEX">DEX</option>
|
||||
<option value="FINESSE">Finesse (the better of the two)</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Level it starts at">{(id) => <StartLevelSelect id={id} name="minLevel" />}</Field>
|
||||
<Field label="Growth per level">{(id) => <ScalingSelect id={id} name="damageScaling" />}</Field>
|
||||
<StartingOption />
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary mt-md" disabled={createWeapon.isPending}>
|
||||
Add Weapon
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemsSection({ campaignId, entries, onOpen }: SectionProps<Item>) {
|
||||
const createItem = useCreateItem(campaignId);
|
||||
const addFromLibrary = useAddItemFromLibrary(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
setError(null);
|
||||
try {
|
||||
const item = await createItem.mutateAsync({
|
||||
name: String(fd.get('name')).trim(),
|
||||
description: String(fd.get('description') || '').trim() || null,
|
||||
damageDice: String(fd.get('damageDice') || '').trim() || null,
|
||||
damageScaling: Number(fd.get('damageScaling')),
|
||||
minLevel: Number(fd.get('minLevel')),
|
||||
isStartingOption: fd.get('isStartingOption') === 'on',
|
||||
effects: [],
|
||||
effectNote: null,
|
||||
});
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'item', id: item.id });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Items"
|
||||
addLabel="+ Add Item"
|
||||
onAdd={open}
|
||||
intro={
|
||||
<>
|
||||
Rope, potions, armor, quest items. Armor, shields, rings and the like are items with effects: open the item and set
|
||||
what it does while equipped (chain mail: +6 AC, −10 Speed).
|
||||
</>
|
||||
}
|
||||
>
|
||||
<EntryList
|
||||
entries={entries}
|
||||
noun="items"
|
||||
emptyText="No items yet. Add rope, potions, armor, quest items…"
|
||||
searchText={(i) => [i.name, i.description, i.damageDice, effectsSummary(i.effects, i.effectNote)]}
|
||||
renderMeta={(i) => [effectsSummary(i.effects, i.effectNote) && `✦ ${effectsSummary(i.effects, i.effectNote)}`, i.damageDice, i.description].filter(Boolean).join(' · ') || `Lv ${i.minLevel}`}
|
||||
renderBadges={startingBadge}
|
||||
onOpen={(id) => onOpen({ kind: 'item', id })}
|
||||
/>
|
||||
{adding === 'library' && (
|
||||
<LibraryModal<LibraryItem>
|
||||
campaignId={campaignId}
|
||||
title="Item library"
|
||||
pick={(library) => library.items}
|
||||
taken={entries}
|
||||
adding={addFromLibrary.isPending}
|
||||
onAdd={(key) => addFromLibrary.mutate(key, { onError: (err) => setError(extractErrorMessage(err)) })}
|
||||
placeholder="Search the item library: armor, shields, worn gear, supplies…"
|
||||
renderMeta={(i) => [i.category, effectsSummary(i.effects, i.effectNote), i.damageDice].filter(Boolean).join(' · ')}
|
||||
renderDetail={(i) => i.description}
|
||||
error={error}
|
||||
onClose={() => setAdding(null)}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add an item" subtitle="What it does while equipped is set once it is added." onClose={() => setAdding(null)}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2">
|
||||
<Field label="Name">{(id) => <input id={id} name="name" required maxLength={80} />}</Field>
|
||||
<Field label="Dice (optional)">{(id) => <input id={id} name="damageDice" placeholder="e.g. 2d4+2" maxLength={20} />}</Field>
|
||||
<Field label="Description (optional)" style={{ gridColumn: '1 / -1' }}>
|
||||
{(id) => <input id={id} name="description" />}
|
||||
</Field>
|
||||
<Field label="Level it starts at">{(id) => <StartLevelSelect id={id} name="minLevel" />}</Field>
|
||||
<Field label="Growth of its dice per level">{(id) => <ScalingSelect id={id} name="damageScaling" />}</Field>
|
||||
<StartingOption />
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary mt-md" disabled={createItem.isPending}>
|
||||
Add Item
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpellsSection({ campaignId, entries, onOpen }: SectionProps<Spell>) {
|
||||
const createSpell = useCreateSpell(campaignId);
|
||||
const addFromLibrary = useAddSpellFromLibrary(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
setError(null);
|
||||
try {
|
||||
const spell = await createSpell.mutateAsync({
|
||||
name: String(fd.get('name')).trim(),
|
||||
level: Number(fd.get('level')),
|
||||
school: String(fd.get('school') || '').trim() || null,
|
||||
castingTime: null,
|
||||
range: null,
|
||||
duration: null,
|
||||
description: String(fd.get('description') || '').trim() || null,
|
||||
damageDice: String(fd.get('damageDice') || '') || null,
|
||||
isStartingOption: fd.get('isStartingOption') === 'on',
|
||||
revives: fd.get('revives') === 'on',
|
||||
});
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'spell', id: spell.id });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Spells"
|
||||
addLabel="+ Add Spell"
|
||||
onAdd={open}
|
||||
intro="The spells you hand out on each character's sheet. A character must be at least a spell's level to cast it. Open one to change its damage, or whether a new character may start with it."
|
||||
>
|
||||
<EntryList
|
||||
entries={entries}
|
||||
noun="spells"
|
||||
emptyText="No spells yet. Add one by hand, or copy some from the library."
|
||||
searchText={(s) => [s.name, s.school, s.description, spellLevelLabel(s.level)]}
|
||||
renderMeta={(s) => [spellLevelLabel(s.level), s.damageDice && `${s.damageDice} damage`, s.description].filter(Boolean).join(' · ')}
|
||||
renderBadges={(s) => (
|
||||
<>
|
||||
{s.revives && <span className="badge">Revives</span>}
|
||||
{startingBadge(s)}
|
||||
</>
|
||||
)}
|
||||
onOpen={(id) => onOpen({ kind: 'spell', id })}
|
||||
/>
|
||||
{adding === 'library' && (
|
||||
<LibraryModal<LibrarySpell>
|
||||
campaignId={campaignId}
|
||||
title="Spell library"
|
||||
pick={(library) => library.spells}
|
||||
taken={entries}
|
||||
adding={addFromLibrary.isPending}
|
||||
onAdd={(key) => addFromLibrary.mutate(key, { onError: (err) => setError(extractErrorMessage(err)) })}
|
||||
placeholder="Search the spell library…"
|
||||
renderMeta={(s) =>
|
||||
[spellLevelLabel(s.level), spellRequirementLabel(s.level), s.damageDice ? `${s.damageDice} damage` : 'no damage', s.school].filter(Boolean).join(' · ')
|
||||
}
|
||||
renderDetail={(s) => s.description}
|
||||
error={error}
|
||||
onClose={() => setAdding(null)}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add a spell" onClose={() => setAdding(null)}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2">
|
||||
<Field label="Name">{(id) => <input id={id} name="name" required maxLength={80} />}</Field>
|
||||
<Field label="Level">
|
||||
{(id) => (
|
||||
<select id={id} name="level" defaultValue={0}>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{spellLevelLabel(level)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="School (optional)">{(id) => <input id={id} name="school" />}</Field>
|
||||
<Field label="Damage">{(id) => <SpellDamageSelect id={id} name="damageDice" />}</Field>
|
||||
<Field label="What it does (one sentence)" style={{ gridColumn: '1 / -1' }}>
|
||||
{(id) => <input id={id} name="description" maxLength={4000} required />}
|
||||
</Field>
|
||||
<StartingOption />
|
||||
<label className="checkbox-row" style={{ alignSelf: 'end', paddingBottom: '0.6rem' }}>
|
||||
<input type="checkbox" name="revives" />
|
||||
Revives the dead (its caster may revive a dead ally on their turn in a battle)
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary mt-md" disabled={createSpell.isPending}>
|
||||
Add Spell
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonstersSection({ campaignId, entries, onOpen }: SectionProps<Monster>) {
|
||||
const createMonster = useCreateMonster(campaignId);
|
||||
const addFromLibrary = useAddMonsterFromLibrary(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="Monsters"
|
||||
addLabel="+ Add Monster"
|
||||
onAdd={open}
|
||||
intro="The stat blocks you bring into battles. Players never see them. Open one for the whole block, or to change it."
|
||||
>
|
||||
<EntryList
|
||||
entries={entries}
|
||||
noun="monsters"
|
||||
emptyText="No monsters yet. Add one by hand, or copy some from the library."
|
||||
searchText={(m) => [m.name, m.challengeRating && `CR ${m.challengeRating}`, m.specialTrait, m.notes]}
|
||||
renderMeta={(m) => `CR ${m.challengeRating || '—'} · AC ${m.ac} · HP ${m.maxHp} · attack ${fmtMod(m.attackBonus)}${m.damageDice ? ` for ${m.damageDice}` : ''}`}
|
||||
onOpen={(id) => onOpen({ kind: 'monster', id })}
|
||||
/>
|
||||
{adding === 'library' && (
|
||||
<LibraryModal<LibraryMonster>
|
||||
campaignId={campaignId}
|
||||
title="Monster library"
|
||||
pick={(library) => library.monsters}
|
||||
taken={entries}
|
||||
adding={addFromLibrary.isPending}
|
||||
onAdd={(key) => addFromLibrary.mutate(key, { onError: (err) => setError(extractErrorMessage(err)) })}
|
||||
placeholder="Search the monster library…"
|
||||
renderMeta={(m) =>
|
||||
`CR ${m.challengeRating ?? '—'} · AC ${m.ac} · HP ${m.maxHp} · speed ${m.speed} · attack ${fmtMod(m.attackBonus)}${m.damageDice ? ` for ${m.damageDice}` : ''} · ${abilityLine(m)}`
|
||||
}
|
||||
renderDetail={(m) => [m.specialTrait, m.notes].filter(Boolean).join(' — ') || null}
|
||||
error={error}
|
||||
onClose={() => setAdding(null)}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add a monster" wide onClose={() => setAdding(null)}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<MonsterForm
|
||||
saving={createMonster.isPending}
|
||||
onSave={async (stats) => {
|
||||
setError(null);
|
||||
try {
|
||||
const monster = await createMonster.mutateAsync(stats);
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'monster', id: monster.id });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcsSection({ campaignId, entries, onOpen }: SectionProps<Npc>) {
|
||||
const createNpc = useCreateNpc(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
const text = (key: string) => String(fd.get(key) || '').trim() || null;
|
||||
const num = (key: string) => (fd.get(key) ? Number(fd.get(key)) : null);
|
||||
setError(null);
|
||||
try {
|
||||
const npc = await createNpc.mutateAsync({
|
||||
name: String(fd.get('name')).trim(),
|
||||
role: text('role'),
|
||||
disposition: fd.get('disposition') as NpcDisposition,
|
||||
description: text('description'),
|
||||
dmNotes: text('dmNotes'),
|
||||
ac: num('ac'),
|
||||
maxHp: num('maxHp'),
|
||||
attackBonus: num('attackBonus'),
|
||||
damageDice: text('damageDice'),
|
||||
});
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'npc', id: npc.id });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title="NPCs"
|
||||
addLabel="+ Add NPC"
|
||||
hasLibrary={false}
|
||||
onAdd={open}
|
||||
intro="The people your party meets. Add them to an encounter to bring them into a scene. Players never see your notes or an NPC's stats. Open one to set what they sell and the rate they change gold and cash at; players see both when they meet the NPC in a quest."
|
||||
>
|
||||
<EntryList
|
||||
entries={entries}
|
||||
noun="NPCs"
|
||||
emptyText="No NPCs yet."
|
||||
searchText={(n) => [n.name, n.role, n.disposition, n.description, n.dmNotes]}
|
||||
renderMeta={(n) => [n.role, n.description].filter(Boolean).join(' · ')}
|
||||
renderBadges={(n) => (
|
||||
<>
|
||||
{rateLabel(n) && <span className="badge">💱 {rateLabel(n)}</span>}
|
||||
{(n.wares?.length ?? 0) > 0 && <span className="badge">🛒 {n.wares?.length}</span>}
|
||||
<span className={`badge badge-${n.disposition.toLowerCase()}`}>{n.disposition}</span>
|
||||
</>
|
||||
)}
|
||||
onOpen={(id) => onOpen({ kind: 'npc', id })}
|
||||
/>
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add an NPC" subtitle="Only the name is needed. What they sell is set once they are added." onClose={() => setAdding(null)}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2">
|
||||
<Field label="Name">{(id) => <input id={id} name="name" required maxLength={80} />}</Field>
|
||||
<Field label="Role">{(id) => <input id={id} name="role" placeholder="innkeeper, captain…" maxLength={80} />}</Field>
|
||||
<Field label="Attitude toward the party">
|
||||
{(id) => (
|
||||
<select id={id} name="disposition" defaultValue="NEUTRAL">
|
||||
<option value="FRIENDLY">Friendly</option>
|
||||
<option value="NEUTRAL">Neutral</option>
|
||||
<option value="HOSTILE">Hostile</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="What the party sees and knows (shown to players)" style={{ marginTop: '0.75rem' }}>
|
||||
{(id) => <textarea id={id} name="description" rows={2} className="textarea-block" />}
|
||||
</Field>
|
||||
<Field label="Secrets and motives (only you see this)" style={{ marginTop: '0.75rem' }}>
|
||||
{(id) => <textarea id={id} name="dmNotes" rows={2} className="textarea-block" />}
|
||||
</Field>
|
||||
<div className="section-label mt-md">Combat stats (optional)</div>
|
||||
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))' }}>
|
||||
<Field label="AC">{(id) => <NumberInput id={id} name="ac" min={0} max={50} />}</Field>
|
||||
<Field label="HP">{(id) => <NumberInput id={id} name="maxHp" min={1} max={100000} />}</Field>
|
||||
<Field label="Attack bonus">{(id) => <NumberInput id={id} name="attackBonus" min={-20} max={50} />}</Field>
|
||||
<Field label="Damage">{(id) => <input id={id} name="damageDice" placeholder="e.g. 1d6" maxLength={20} />}</Field>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary mt-md" disabled={createNpc.isPending}>
|
||||
Add NPC
|
||||
</button>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useId, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { Pagination } from '../Paginated';
|
||||
import { usePagination } from '../../hooks/usePagination';
|
||||
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS } from '../../lib/format';
|
||||
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../../lib/previewCalc';
|
||||
import { matchesSearch, scoreField, type MonsterInput } from '../../lib/campaignEntries';
|
||||
import type { Monster } from '../../api/types';
|
||||
|
||||
// A labelled field in a pop-out form. The child is handed the id its label points at.
|
||||
export function Field({ label, style, children }: { label: string; style?: CSSProperties; children: (id: string) => ReactNode }) {
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="field" style={{ marginBottom: 0, ...style }}>
|
||||
<label htmlFor={id}>{label}</label>
|
||||
{children(id)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// How fast a weapon's or item's dice grow as the DM levels a held copy up.
|
||||
export function ScalingSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: number; onChange?: (scaling: number) => void }) {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={name}
|
||||
aria-label="How fast the dice grow per level"
|
||||
title="How fast the dice grow each time a held copy is levelled up"
|
||||
{...(onChange ? { value, onChange: (e) => onChange(Number(e.target.value)) } : { defaultValue: 1 })}
|
||||
>
|
||||
{Object.entries(DAMAGE_SCALING_LABELS).map(([scaling, label]) => (
|
||||
<option key={scaling} value={scaling}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
const GEAR_LEVELS = Array.from({ length: MAX_GEAR_LEVEL }, (_, i) => i + 1);
|
||||
|
||||
// The level a weapon or item starts at. A held copy is never below it, and a
|
||||
// character must be at least this level to use it.
|
||||
export function StartLevelSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: number; onChange?: (minLevel: number) => void }) {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={name}
|
||||
aria-label="Level it starts at"
|
||||
title="The level this starts at — a character must be at least this level to use it"
|
||||
{...(onChange ? { value, onChange: (e) => onChange(Number(e.target.value)) } : { defaultValue: 1 })}
|
||||
>
|
||||
{GEAR_LEVELS.map((level) => (
|
||||
<option key={level} value={level}>
|
||||
Starts at Lv {level}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// A spell's damage is picked from a list rather than typed. A value that is
|
||||
// not in the list (set through the API, say) is still shown and kept.
|
||||
export function SpellDamageSelect({
|
||||
id,
|
||||
name,
|
||||
spellName,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
spellName?: string;
|
||||
value?: string | null;
|
||||
onChange?: (damageDice: string | null) => void;
|
||||
}) {
|
||||
const known = [...SPELL_DAMAGE_GROUPS.flatMap((g) => g.options), ...SPELL_DAMAGE_SPECIALS];
|
||||
const others = value && !known.includes(value) ? [...SPELL_DAMAGE_SPECIALS, value] : SPELL_DAMAGE_SPECIALS;
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={name}
|
||||
aria-label={spellName ? `${spellName} damage` : 'Damage'}
|
||||
{...(onChange ? { value: value ?? '', onChange: (e) => onChange(e.target.value || null) } : { defaultValue: '' })}
|
||||
>
|
||||
<option value="">No damage</option>
|
||||
{SPELL_DAMAGE_GROUPS.map((group) => (
|
||||
<optgroup key={group.label} label={group.label}>
|
||||
{group.options.map((dice) => (
|
||||
<option key={dice} value={dice}>
|
||||
{dice}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
<optgroup label="Other">
|
||||
{others.map((dice) => (
|
||||
<option key={dice} value={dice}>
|
||||
{dice}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// The whole stat block, field for field as a printed one gives it. Adds a
|
||||
// monster, or edits one when `monster` is passed (its values fill the form).
|
||||
export function MonsterForm({
|
||||
monster,
|
||||
saving,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
monster?: Monster;
|
||||
saving: boolean;
|
||||
// Rejects if the save failed, so the form keeps what was typed.
|
||||
onSave: (data: MonsterInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => (
|
||||
<div className="field" style={{ marginBottom: 0, ...style }}>
|
||||
<label htmlFor={`monster-${name}`}>{label}</label>
|
||||
{input}
|
||||
</div>
|
||||
);
|
||||
const number = (name: keyof MonsterInput, label: string, min: number, max: number, fallback?: number) =>
|
||||
field(
|
||||
name,
|
||||
label,
|
||||
<NumberInput
|
||||
id={`monster-${name}`}
|
||||
name={name}
|
||||
min={min}
|
||||
max={max}
|
||||
defaultValue={(monster?.[name] as number | undefined) ?? fallback}
|
||||
keepValue={fallback !== undefined}
|
||||
required
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
const text = (key: string) => String(fd.get(key) || '').trim() || null;
|
||||
const num = (key: string) => Number(fd.get(key));
|
||||
try {
|
||||
await onSave({
|
||||
name: String(fd.get('name')).trim(),
|
||||
challengeRating: text('challengeRating'),
|
||||
ac: num('ac'),
|
||||
maxHp: num('maxHp'),
|
||||
speed: num('speed'),
|
||||
attackBonus: num('attackBonus'),
|
||||
damageDice: text('damageDice'),
|
||||
strScore: num('strScore'),
|
||||
dexScore: num('dexScore'),
|
||||
conScore: num('conScore'),
|
||||
intScore: num('intScore'),
|
||||
wisScore: num('wisScore'),
|
||||
chaScore: num('chaScore'),
|
||||
specialTrait: text('specialTrait'),
|
||||
notes: text('notes'),
|
||||
});
|
||||
} catch {
|
||||
// Whoever opened the form shows the error; what was typed stays.
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-3">
|
||||
{field('name', 'Name', <input id="monster-name" name="name" defaultValue={monster?.name} required maxLength={80} />)}
|
||||
{field(
|
||||
'challengeRating',
|
||||
'CR (Challenge Rating)',
|
||||
<input id="monster-challengeRating" name="challengeRating" defaultValue={monster?.challengeRating ?? ''} placeholder="e.g. 1/4 or 3" maxLength={10} />
|
||||
)}
|
||||
{number('ac', 'AC (Armor Class)', 0, 50)}
|
||||
{number('maxHp', 'HP (Hit Points)', 1, 100000)}
|
||||
{number('speed', 'Speed (feet)', 0, 1000, 30)}
|
||||
{number('attackBonus', 'Attack bonus', -20, 50, 0)}
|
||||
{field(
|
||||
'damageDice',
|
||||
'Damage',
|
||||
<input id="monster-damageDice" name="damageDice" defaultValue={monster?.damageDice ?? ''} placeholder="e.g. 1d6+2 necrotic" maxLength={60} />
|
||||
)}
|
||||
</div>
|
||||
<div className="grid mt-sm" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))' }}>
|
||||
{ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))}
|
||||
</div>
|
||||
{field(
|
||||
'specialTrait',
|
||||
'Special trait',
|
||||
<input
|
||||
id="monster-specialTrait"
|
||||
name="specialTrait"
|
||||
defaultValue={monster?.specialTrait ?? ''}
|
||||
placeholder="The one thing that makes it different"
|
||||
maxLength={500}
|
||||
/>,
|
||||
{ marginTop: '0.5rem' }
|
||||
)}
|
||||
{field(
|
||||
'notes',
|
||||
'Notes',
|
||||
<textarea
|
||||
id="monster-notes"
|
||||
name="notes"
|
||||
rows={3}
|
||||
className="textarea-block"
|
||||
defaultValue={monster?.notes ?? ''}
|
||||
placeholder="Everything else: other speeds (fly 30, swim 40), extra attacks, spells, tactics, resistances, senses, what it drops"
|
||||
maxLength={2000}
|
||||
/>,
|
||||
{ marginTop: '0.5rem' }
|
||||
)}
|
||||
<div className="flex-row mt-md">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{monster ? 'Save Changes' : 'Add Monster'}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" className="btn btn-ghost" onClick={onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
// The built-in library, searched by name, with what the campaign already
|
||||
// holds left out. It stays open after an add, for adding several in a row.
|
||||
export function LibraryPicker<T extends { key: string; name: string }>({
|
||||
items,
|
||||
taken,
|
||||
onAdd,
|
||||
adding,
|
||||
renderMeta,
|
||||
renderDetail,
|
||||
placeholder,
|
||||
}: {
|
||||
items: T[];
|
||||
// What the campaign already holds: a library entry by one of these names is
|
||||
// left out, since each can be added once.
|
||||
taken: { name: string }[] | undefined;
|
||||
onAdd: (key: string) => void;
|
||||
adding: boolean;
|
||||
renderMeta: (item: T) => string;
|
||||
// A sentence on what the entry is or does, shown under its name.
|
||||
renderDetail?: (item: T) => string | null;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const takenNames = new Set((taken ?? []).map((t) => t.name.toLowerCase()));
|
||||
const available = items.filter((i) => !takenNames.has(i.name.toLowerCase()));
|
||||
const filtered = available.filter((i) => matchesSearch(query, i.name));
|
||||
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(filtered, 8);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input type="search" placeholder={placeholder} aria-label={placeholder} value={query} onChange={(e) => setQuery(e.target.value)} style={{ width: '100%' }} />
|
||||
<div className="mt-sm">
|
||||
{pageItems.map((item) => (
|
||||
<div key={item.key} className="flex-between" style={{ padding: '0.45rem 0', borderBottom: '1px solid var(--border-soft)', flexWrap: 'nowrap' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<strong>{item.name}</strong>
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 6, fontSize: '0.85rem' }}>{renderMeta(item)}</span>
|
||||
{renderDetail?.(item) && <div style={{ fontSize: '0.9rem' }}>{renderDetail(item)}</div>}
|
||||
</div>
|
||||
<button className="btn" style={{ flex: 'none' }} disabled={adding} aria-label={`Add ${item.name}`} onClick={() => onAdd(item.key)}>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
{available.length === 0 ? 'Everything in the library is already in this campaign.' : 'No matches.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// One line of a list: what it is and a few words about it. The rest is in
|
||||
// the pop-out it opens, or on the page it links to when given `to`.
|
||||
export function EntryRow({ name, meta, badges, onOpen, to }: { name: string; meta?: ReactNode; badges?: ReactNode; onOpen?: () => void; to?: string }) {
|
||||
const inner = (
|
||||
<>
|
||||
<span className="entry-row-main">
|
||||
<span className="entry-row-name">{name}</span>
|
||||
{meta && <span className="entry-row-meta">{meta}</span>}
|
||||
</span>
|
||||
{badges && <span className="entry-row-badges">{badges}</span>}
|
||||
<span className="entry-row-chevron" aria-hidden>
|
||||
›
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return to ? (
|
||||
<Link to={to} className="entry-row">
|
||||
{inner}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" className="entry-row" onClick={onOpen}>
|
||||
{inner}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// A campaign list as short rows: searched by a box of its own, paged, and
|
||||
// each row opening its pop-out.
|
||||
export function EntryList<T extends { id: string; name: string }>({
|
||||
entries,
|
||||
noun,
|
||||
emptyText,
|
||||
searchText,
|
||||
renderMeta,
|
||||
renderBadges,
|
||||
onOpen,
|
||||
}: {
|
||||
entries: T[] | undefined;
|
||||
// What the list holds, in the plural: "weapons".
|
||||
noun: string;
|
||||
emptyText: string;
|
||||
// Everything about an entry that a search should find it by.
|
||||
searchText: (entry: T) => (string | number | null | undefined)[];
|
||||
renderMeta: (entry: T) => ReactNode;
|
||||
renderBadges?: (entry: T) => ReactNode;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState('');
|
||||
const filtered = (entries ?? []).filter((entry) => matchesSearch(query, ...searchText(entry)));
|
||||
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(filtered, 10);
|
||||
|
||||
if (entries && entries.length === 0) return <p className="mt-sm" style={{ color: 'var(--text-muted)' }}>{emptyText}</p>;
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
type="search"
|
||||
className="mt-sm"
|
||||
style={{ width: '100%' }}
|
||||
placeholder={`Search the ${noun} in this campaign…`}
|
||||
aria-label={`Search the ${noun} in this campaign`}
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<div className="entry-list mt-sm">
|
||||
{pageItems.map((entry) => (
|
||||
<EntryRow key={entry.id} name={entry.name} meta={renderMeta(entry)} badges={renderBadges?.(entry)} onOpen={() => onOpen(entry.id)} />
|
||||
))}
|
||||
</div>
|
||||
{entries && filtered.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No {noun} match "{query}".</p>}
|
||||
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { API_URL } from '../api/client';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
|
||||
let sharedSocket: Socket | null = null;
|
||||
|
||||
function getSocket(token: string): Socket {
|
||||
if (!sharedSocket) {
|
||||
sharedSocket = io(API_URL, { auth: { token }, transports: ['websocket', 'polling'] });
|
||||
// Starts on plain requests and moves up to a websocket when one gets
|
||||
// through. Asking for the websocket first leaves no way back: behind a
|
||||
// proxy that does not pass the upgrade, nothing live arrives at all.
|
||||
sharedSocket = io(API_URL, { auth: { token } });
|
||||
}
|
||||
return sharedSocket;
|
||||
}
|
||||
|
||||
// One change on the server often sends two or three events together. They are
|
||||
// gathered for this long and answered with one refetch per query.
|
||||
const FLUSH_MS = 50;
|
||||
|
||||
// Joins the Socket.io room for a campaign and keeps React Query's cache in
|
||||
// sync with server-pushed events (HP/condition changes, encounter rounds,
|
||||
// new characters) so every open tab in the campaign updates live.
|
||||
@@ -25,27 +32,44 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
const socket = getSocket(token);
|
||||
socket.emit('campaign:join', campaignId);
|
||||
|
||||
const onCharacterUpdated = () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet HP
|
||||
const pending = new Map<string, QueryKey>();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const flush = () => {
|
||||
timer = undefined;
|
||||
const keys = [...pending.values()];
|
||||
pending.clear();
|
||||
// A wider key covers a narrower one waiting beside it: ['encounter'] takes in ['encounter', id].
|
||||
const covered = (key: QueryKey) => keys.some((other) => other.length < key.length && other.every((part, i) => part === key[i]));
|
||||
for (const queryKey of keys) if (!covered(queryKey)) qc.invalidateQueries({ queryKey });
|
||||
};
|
||||
const invalidate = (queryKey: QueryKey) => {
|
||||
pending.set(JSON.stringify(queryKey), queryKey);
|
||||
timer ??= setTimeout(flush, FLUSH_MS);
|
||||
};
|
||||
// An event names what changed when it is one thing, and nothing when it may be many.
|
||||
const one = (root: string, id?: string) => invalidate(id ? [root, id] : [root]);
|
||||
|
||||
const onCharacterUpdated = ({ characterId }: { characterId?: string } = {}) => {
|
||||
invalidate(['campaign', campaignId, 'characters']);
|
||||
one('character', characterId);
|
||||
invalidate(['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'] });
|
||||
invalidate(['campaign', campaignId, 'locations']);
|
||||
};
|
||||
const onCharacterCreated = () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||
invalidate(['campaign', campaignId, 'characters']);
|
||||
};
|
||||
const onParticipantUpdated = () => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||
const onParticipantUpdated = ({ encounterId }: { encounterId?: string } = {}) => {
|
||||
one('encounter', encounterId);
|
||||
// A player's list only holds the encounters their characters are in.
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
|
||||
invalidate(['campaign', campaignId, 'encounters']);
|
||||
};
|
||||
const onRoundAdvanced = () => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||
const onRoundAdvanced = ({ encounterId }: { encounterId?: string } = {}) => {
|
||||
one('encounter', encounterId);
|
||||
};
|
||||
const onCampaignDeleted = () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
|
||||
invalidate(['campaigns']);
|
||||
invalidate(['campaign', campaignId]);
|
||||
};
|
||||
|
||||
socket.on('character:updated', onCharacterUpdated);
|
||||
@@ -55,6 +79,10 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
socket.on('campaign:deleted', onCampaignDeleted);
|
||||
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
flush();
|
||||
}
|
||||
socket.emit('campaign:leave', campaignId);
|
||||
socket.off('character:updated', onCharacterUpdated);
|
||||
socket.off('character:created', onCharacterCreated);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
// Small generic pager shared by every list-turned-table in the app, so page
|
||||
// state isn't hand-rolled per screen. Resets to page 1 whenever the
|
||||
// underlying item count shrinks below the current page (e.g. after a delete
|
||||
// or a filter change).
|
||||
export function usePagination<T>(items: T[] | undefined, pageSize = 10) {
|
||||
const [page, setPage] = useState(1);
|
||||
const total = items?.length ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
||||
const safePage = Math.min(page, pageCount);
|
||||
const pageItems = useMemo(
|
||||
() => (items || []).slice((safePage - 1) * pageSize, safePage * pageSize),
|
||||
[items, safePage, pageSize],
|
||||
);
|
||||
|
||||
return {
|
||||
page: safePage,
|
||||
pageCount,
|
||||
pageItems,
|
||||
total,
|
||||
setPage,
|
||||
nextPage: () => setPage((p) => Math.min(pageCount, p + 1)),
|
||||
prevPage: () => setPage((p) => Math.max(1, p - 1)),
|
||||
};
|
||||
}
|
||||
+719
-4
@@ -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);
|
||||
@@ -805,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;
|
||||
@@ -1239,3 +1290,667 @@ button.death-pip {
|
||||
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);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* The campaign page: one part at a time, picked from a row of buttons; */
|
||||
/* lists as short rows; and the pop-out a row opens */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
.campaign-search {
|
||||
width: 100%;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.section-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.section-nav .jump-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.jump-link.is-active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-strong);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.section-count {
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.section-block + .section-block {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.search-group + .search-group {
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.entry-list {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.entry-list:empty {
|
||||
display: none;
|
||||
}
|
||||
.entry-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.entry-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.entry-row:hover,
|
||||
.entry-row:focus-visible {
|
||||
background: var(--bg-card-hover);
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
}
|
||||
.entry-row-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.entry-row-name {
|
||||
font-family: var(--font-display);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
/* A few words only: the whole of it is in the pop-out. */
|
||||
.entry-row-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.entry-row-badges {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.3rem;
|
||||
max-width: 45%;
|
||||
}
|
||||
.entry-row-chevron {
|
||||
flex: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 96;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 5vh 1rem;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(620px, 100%);
|
||||
max-height: 90dvh;
|
||||
background: linear-gradient(180deg, #1c160d 0%, #120e08 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-deep);
|
||||
}
|
||||
.modal:focus {
|
||||
outline: none;
|
||||
}
|
||||
.modal.is-wide {
|
||||
width: min(860px, 100%);
|
||||
}
|
||||
.modal-head {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 0.6rem 0.75rem 1.2rem;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.modal-body {
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.2rem 1.25rem;
|
||||
}
|
||||
.modal-body .field input,
|
||||
.modal-body .field select {
|
||||
width: 100%;
|
||||
}
|
||||
.modal-foot {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.pagination .pagination-status > select {
|
||||
min-width: 0 !important;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
/* The parts of the campaign are one line that scrolls sideways, edge to edge. */
|
||||
.section-nav {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
margin: 0 -0.85rem 1rem;
|
||||
padding: 0 0.85rem 0.35rem;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.section-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.section-nav .jump-link {
|
||||
flex: none;
|
||||
}
|
||||
.entry-row {
|
||||
padding: 0.75rem 0.7rem;
|
||||
}
|
||||
/* A pop-out is the whole screen, over the bottom bar too. */
|
||||
.modal-backdrop {
|
||||
padding: 0;
|
||||
}
|
||||
.modal,
|
||||
.modal.is-wide {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
max-height: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
.modal-head {
|
||||
padding: 0.7rem 0.4rem 0.6rem 0.85rem;
|
||||
}
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 0.9rem 0.85rem calc(1.5rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ABILITY_ORDER } from './previewCalc';
|
||||
import { formatMoney } from './format';
|
||||
import type { Monster, Npc } from '../api/types';
|
||||
|
||||
// One thing in a campaign's lists that opens in a pop-out.
|
||||
export type EntryKind = 'weapon' | 'item' | 'spell' | 'monster' | 'npc';
|
||||
export interface EntryRef {
|
||||
kind: EntryKind;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type MonsterInput = Omit<Monster, 'id' | 'campaignId'>;
|
||||
export type AbilityScores = Pick<Monster, 'strScore' | 'dexScore' | 'conScore' | 'intScore' | 'wisScore' | 'chaScore'>;
|
||||
export const scoreField = (ability: string) => `${ability.toLowerCase()}Score` as keyof AbilityScores;
|
||||
|
||||
// "STR 8 · DEX 14 · …", in stat-block order.
|
||||
export function abilityLine(scores: AbilityScores): string {
|
||||
return ABILITY_ORDER.map((a) => `${a} ${scores[scoreField(a)]}`).join(' · ');
|
||||
}
|
||||
|
||||
// "1 gp = $5": the rate an NPC changes gold and cash at, or null if they don't.
|
||||
export function rateLabel(npc: Pick<Npc, 'exchangeGold' | 'exchangeCash'>): string | null {
|
||||
if (!npc.exchangeGold || !npc.exchangeCash) return null;
|
||||
return `${formatMoney(npc.exchangeGold, 'GOLD')} = ${formatMoney(npc.exchangeCash, 'CASH')}`;
|
||||
}
|
||||
|
||||
// Whether every word of a search is somewhere in the text given: "fire bolt"
|
||||
// finds "Fire Bolt", and "bolt fire" does too.
|
||||
export function matchesSearch(query: string, ...fields: (string | number | null | undefined)[]): boolean {
|
||||
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const text = fields.filter((f) => f !== null && f !== undefined).join(' ').toLowerCase();
|
||||
return words.every((word) => text.includes(word));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+20
-11
@@ -1,4 +1,4 @@
|
||||
import type { Currency } from '../api/types';
|
||||
import type { Coin, Currency } from '../api/types';
|
||||
|
||||
export const SPECIES_LABELS: Record<string, string> = {
|
||||
HUMAN: 'Human',
|
||||
@@ -73,25 +73,34 @@ export function hpBarClass(current: number, max: number): string {
|
||||
}
|
||||
|
||||
// Coins. Silver is worth 0.1 gold and copper 0.01 gold.
|
||||
export const CURRENCIES: Currency[] = ['GOLD', 'SILVER', 'COPPER'];
|
||||
export const COIN_LABELS: Record<Currency, string> = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper' };
|
||||
export const COIN_SHORT: Record<Currency, string> = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' };
|
||||
export const COIN_FIELD: Record<Currency, 'gold' | 'silver' | 'copper'> = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
|
||||
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<Currency, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
|
||||
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' };
|
||||
|
||||
type Purse = { gold: number | null; silver: number | null; copper: number | null };
|
||||
// "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]}`;
|
||||
}
|
||||
|
||||
// "12 gp · 5 sp · 3 cp". Gold always shows; silver and copper only when held.
|
||||
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) => `${purse[COIN_FIELD[c]] ?? 0} ${COIN_SHORT[c]}`)
|
||||
.map((c) => formatMoney(purse[COIN_FIELD[c]] ?? 0, c))
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// The whole purse as one figure in gold, e.g. 12.65.
|
||||
// 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 = CURRENCIES.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
|
||||
const copper = COINS.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
|
||||
return copper / COIN_VALUE.GOLD;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { scaledDamageDice } from './previewCalc';
|
||||
import type { SaleOffer } from '../api/types';
|
||||
|
||||
// "🗡 Longsword · Lv 2 · 1d10": what a ware is, for anyone looking at it.
|
||||
export function wareName(ware: SaleOffer): string {
|
||||
const entry = ware.weapon ?? ware.item;
|
||||
if (!entry) return 'Removed from the pool';
|
||||
const dice = entry.damageDice ? ` · ${scaledDamageDice(entry.damageDice, ware.level, entry.damageScaling, entry.minLevel)}` : '';
|
||||
return `${ware.weapon ? '🗡' : '🎒'} ${entry.name} · Lv ${ware.level}${dice}`;
|
||||
}
|
||||
|
||||
export function stockLabel(ware: SaleOffer): string {
|
||||
return ware.stock === null ? 'always in stock' : ware.stock === 0 ? 'sold out' : `${ware.stock} left`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { apiClient } from '../api/client';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { extractErrorMessage } from '../lib/format';
|
||||
import { ErrorBanner } from '../components/Spinner';
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,13 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCampaigns, useCreateCampaign, useJoinCampaign, useSetCampaignArchived } from '../api/queries';
|
||||
import { useCampaigns, useCreateCampaign, useCreateCampaignFromSeed, useJoinCampaign, useSetCampaignArchived } from '../api/queries';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { extractErrorMessage } from '../lib/format';
|
||||
|
||||
export function CampaignListPage() {
|
||||
const { data: campaigns, isLoading, error } = useCampaigns();
|
||||
const createCampaign = useCreateCampaign();
|
||||
const createFromSeed = useCreateCampaignFromSeed();
|
||||
const joinCampaign = useJoinCampaign();
|
||||
const setArchived = useSetCampaignArchived();
|
||||
const navigate = useNavigate();
|
||||
@@ -15,6 +16,8 @@ export function CampaignListPage() {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [worldCode, setWorldCode] = useState('');
|
||||
const [worldError, setWorldError] = useState<string | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
@@ -32,6 +35,17 @@ export function CampaignListPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateFromSeed(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setWorldError(null);
|
||||
try {
|
||||
const campaign = await createFromSeed.mutateAsync(worldCode.trim().toUpperCase());
|
||||
navigate(`/campaigns/${campaign.id}`);
|
||||
} catch (err) {
|
||||
setWorldError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJoin(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
@@ -72,6 +86,26 @@ export function CampaignListPage() {
|
||||
{createCampaign.isPending ? 'Founding…' : 'Found Campaign'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<h2 className="section-label mt-lg">Or start from a ready-made world</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginTop: 0 }}>
|
||||
Have a world code? Enter it and the whole world is set up for you as its DM: locations, monsters, NPCs,
|
||||
gear, spells and encounters.
|
||||
</p>
|
||||
<form onSubmit={handleCreateFromSeed} className="flex-row flex-wrap">
|
||||
<input
|
||||
aria-label="World code"
|
||||
placeholder="e.g. ALDENF"
|
||||
maxLength={12}
|
||||
value={worldCode}
|
||||
onChange={(e) => setWorldCode(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 160, textTransform: 'uppercase' }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={createFromSeed.isPending || !worldCode.trim()}>
|
||||
{createFromSeed.isPending ? 'Building the world…' : 'Create From Code'}
|
||||
</button>
|
||||
</form>
|
||||
{worldError && <div className="mt-sm"><ErrorBanner message={worldError} /></div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -95,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);
|
||||
|
||||
@@ -28,27 +28,32 @@ import {
|
||||
useCopyCharacter,
|
||||
useCampaignLocations,
|
||||
} from '../api/queries';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
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 { QuickStatsFab } from '../components/QuickStatsDrawer';
|
||||
import { GearInForce } from '../components/CharacterPanel';
|
||||
import { effectsSummary } from '../lib/gear';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
import { usePagination, Pagination } from '../components/Paginated';
|
||||
import { Pagination } from '../components/Paginated';
|
||||
import { usePagination } from '../hooks/usePagination';
|
||||
import {
|
||||
ABILITY_LABELS,
|
||||
CLASS_LABELS,
|
||||
COINS,
|
||||
COIN_FIELD,
|
||||
COIN_LABELS,
|
||||
COIN_SHORT,
|
||||
COIN_VALUE,
|
||||
CURRENCIES,
|
||||
SKILL_LABELS,
|
||||
SPECIES_LABELS,
|
||||
fmtMod,
|
||||
extractErrorMessage,
|
||||
formatMoney,
|
||||
formatPurse,
|
||||
purseWorthInGold,
|
||||
spellLevelLabel,
|
||||
@@ -56,7 +61,7 @@ import {
|
||||
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, Currency } 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
|
||||
@@ -112,10 +117,10 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
|
||||
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
|
||||
const heldItem = character.inventoryItems.find((i) => i.itemId === id);
|
||||
// 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.
|
||||
// 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 = CURRENCIES.reduce((sum, c) => sum + held(c) * COIN_VALUE[c], 0);
|
||||
const affordable = (c: Currency) => Math.floor(purseInCopper / COIN_VALUE[c]);
|
||||
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));
|
||||
@@ -170,7 +175,7 @@ function GiveCard({ character, onError }: { character: CharacterDetail; onError:
|
||||
<option value="">What to give…</option>
|
||||
{coinsHeld.map((c) => (
|
||||
<option key={c} value={`gold:${c}`}>
|
||||
{COIN_LABELS[c]} ({held(c)} {COIN_SHORT[c]} held)
|
||||
{COIN_LABELS[c]} ({formatMoney(held(c), c)} held)
|
||||
</option>
|
||||
))}
|
||||
{character.weapons.length > 0 && (
|
||||
@@ -234,6 +239,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);
|
||||
|
||||
@@ -267,8 +273,8 @@ export function CharacterSheetPage() {
|
||||
const [goldReason, setGoldReason] = useState('');
|
||||
const [goldCurrency, setGoldCurrency] = useState<Currency>('GOLD');
|
||||
const [exchangeAmount, setExchangeAmount] = useState('');
|
||||
const [exchangeFrom, setExchangeFrom] = useState<Currency>('GOLD');
|
||||
const [exchangeTo, setExchangeTo] = useState<Currency>('SILVER');
|
||||
const [exchangeFrom, setExchangeFrom] = useState<Coin>('GOLD');
|
||||
const [exchangeTo, setExchangeTo] = useState<Coin>('SILVER');
|
||||
const [noteBody, setNoteBody] = useState('');
|
||||
const [pickedWeaponId, setPickedWeaponId] = useState('');
|
||||
const [pickedSpellId, setPickedSpellId] = useState('');
|
||||
@@ -329,6 +335,16 @@ 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;
|
||||
@@ -470,7 +486,8 @@ export function CharacterSheetPage() {
|
||||
onError={(err) => setFormError(extractErrorMessage(err))}
|
||||
/>
|
||||
|
||||
{canEdit && (
|
||||
{/* HP is the DM's to change; a player reads it. */}
|
||||
{isDM && (
|
||||
<div className="flex-row mt-sm flex-wrap">
|
||||
<NumberInput
|
||||
min={1}
|
||||
@@ -499,11 +516,12 @@ export function CharacterSheetPage() {
|
||||
onBlur={(e) => patchCharacter.mutate({ tempHp: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex-row gap-sm">
|
||||
Armor Bonus <InfoTip term="ARMOR_BONUS" />
|
||||
{/* Armor is an equipped item now; this is the DM's own adjustment on top. */}
|
||||
<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={0}
|
||||
min={-30}
|
||||
max={30}
|
||||
keepValue
|
||||
defaultValue={character.armorBonus}
|
||||
@@ -551,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) => (
|
||||
@@ -567,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>
|
||||
@@ -580,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>
|
||||
@@ -614,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>
|
||||
@@ -626,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">
|
||||
@@ -635,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 && (
|
||||
@@ -656,7 +689,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
))}
|
||||
{character.weapons.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No weapons. The DM hands these out.</p>}
|
||||
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} />
|
||||
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} onJump={weaponsPage.setPage} />
|
||||
{isDM && (
|
||||
<div className="flex-row mt-sm flex-wrap">
|
||||
<select value={pickedWeaponId} onChange={(e) => setPickedWeaponId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
|
||||
@@ -677,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)' }}>
|
||||
@@ -711,7 +744,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
))}
|
||||
{character.spells.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No spells known. The DM grants these.</p>}
|
||||
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} />
|
||||
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} onJump={spellsPage.setPage} />
|
||||
{isDM && (
|
||||
<div className="flex-row mt-sm flex-wrap">
|
||||
<select value={pickedSpellId} onChange={(e) => setPickedSpellId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
|
||||
@@ -741,7 +774,7 @@ export function CharacterSheetPage() {
|
||||
|
||||
{!character.restricted && (
|
||||
<>
|
||||
<div className="card mt-md">
|
||||
<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)' }}>
|
||||
@@ -752,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>
|
||||
)}
|
||||
@@ -764,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 && (
|
||||
@@ -800,7 +836,7 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
))}
|
||||
{character.inventoryItems.length === 0 && <p style={{ color: 'var(--text-muted)' }}>Nothing carried. The DM hands out items.</p>}
|
||||
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} />
|
||||
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} onJump={itemsPage.setPage} />
|
||||
{isDM && (
|
||||
<div className="flex-row mt-sm flex-wrap">
|
||||
<select value={pickedItemId} onChange={(e) => setPickedItemId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
|
||||
@@ -821,16 +857,18 @@ 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">{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.
|
||||
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 && (
|
||||
{/* Recording a gain or a spend is the DM's; a player gives, buys and exchanges. */}
|
||||
{isDM && (
|
||||
<div className="flex-row flex-wrap">
|
||||
<NumberInput
|
||||
min={1}
|
||||
@@ -876,16 +914,16 @@ export function CharacterSheetPage() {
|
||||
onChange={(e) => setExchangeAmount(e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<select aria-label="Exchange from" value={exchangeFrom} onChange={(e) => setExchangeFrom(e.target.value as Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<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 Currency)}>
|
||||
{CURRENCIES.map((c) => (
|
||||
<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>
|
||||
@@ -902,7 +940,8 @@ export function CharacterSheetPage() {
|
||||
<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)}`} {COIN_SHORT[t.currency ?? 'GOLD']}
|
||||
{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)' }}>
|
||||
@@ -911,12 +950,12 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
</div>
|
||||
<span className="mono" style={{ color: 'var(--text-muted)' }}>
|
||||
{t.balanceAfter} {COIN_SHORT[t.currency ?? 'GOLD']}
|
||||
{formatMoney(t.balanceAfter, t.currency ?? 'GOLD')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} />
|
||||
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} onJump={ledgerPage.setPage} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -996,7 +1035,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
|
||||
@@ -1020,10 +1059,9 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} />
|
||||
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} onJump={notesPage.setPage} />
|
||||
</div>
|
||||
|
||||
<QuickStatsFab characterId={characterId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
useRemoveParticipant,
|
||||
useAdvanceRound,
|
||||
useNextTurn,
|
||||
useSetTargets,
|
||||
useReviveParticipant,
|
||||
usePatchEncounter,
|
||||
useSetEncounterArchived,
|
||||
useDeleteEncounter,
|
||||
@@ -23,12 +25,16 @@ import {
|
||||
type NewReward,
|
||||
} from '../api/queries';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { DeathSaves } from '../components/DeathSaves';
|
||||
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
|
||||
import { 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,
|
||||
@@ -62,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' };
|
||||
@@ -96,19 +107,33 @@ function subline(p: EncounterParticipant): string {
|
||||
function CombatantCard({
|
||||
participant: p,
|
||||
isDM,
|
||||
isMine,
|
||||
isActive,
|
||||
onPatch,
|
||||
canRollInitiative,
|
||||
targetedBy,
|
||||
acPenalty,
|
||||
onTarget,
|
||||
onRemove,
|
||||
onError,
|
||||
}: {
|
||||
participant: EncounterParticipant;
|
||||
isDM: boolean;
|
||||
// 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;
|
||||
}) {
|
||||
@@ -131,6 +156,7 @@ function CombatantCard({
|
||||
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);
|
||||
@@ -149,10 +175,20 @@ function CombatantCard({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown && !isDying ? ' 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 ? (
|
||||
@@ -163,7 +199,31 @@ function CombatantCard({
|
||||
<span className="combatant-name">{p.displayName}</span>
|
||||
)}
|
||||
{npc && <span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</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>
|
||||
@@ -325,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>
|
||||
@@ -395,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} ${COIN_LABELS[r.currency ?? 'GOLD'].toLowerCase()}`;
|
||||
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}`;
|
||||
}
|
||||
@@ -624,6 +861,7 @@ export function EncounterPage() {
|
||||
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';
|
||||
@@ -639,6 +877,8 @@ export function EncounterPage() {
|
||||
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 ?? '');
|
||||
@@ -648,20 +888,41 @@ export function EncounterPage() {
|
||||
const [pickId, setPickId] = useState('');
|
||||
const [initiative, setInitiative] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
// The DM's own screen leaves the fallen out too, until asked for.
|
||||
const [showDead, setShowDead] = useState(false);
|
||||
|
||||
if (isLoading) return <Spinner label="Setting the scene…" />;
|
||||
if (error || !encounter) return <ErrorBanner message="Could not load this encounter." />;
|
||||
|
||||
const isBattle = encounter.mode === 'BATTLE';
|
||||
const participants = encounter.participants ?? []; // already in initiative order
|
||||
// The fallen: dead characters, and monsters and NPCs at 0 HP. Only the DM is
|
||||
// sent their cards, and they sit apart from those still in the fight.
|
||||
const standing = participants.filter((p) => !p.hidden);
|
||||
const outOfTheFight = participants.filter((p) => p.hidden);
|
||||
const fallen = encounter.fallen ?? [];
|
||||
const active = isBattle && encounter.status === 'ACTIVE' ? participants.find((p) => p.id === encounter.activeParticipantId) : undefined;
|
||||
// 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));
|
||||
@@ -680,23 +941,43 @@ export function EncounterPage() {
|
||||
key={p.id}
|
||||
participant={p}
|
||||
isDM={isDM}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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>
|
||||
@@ -791,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>
|
||||
@@ -819,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.'
|
||||
@@ -887,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}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { apiClient } from '../api/client';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import type { User } from '../api/types';
|
||||
import { extractErrorMessage } from '../lib/format';
|
||||
import { ErrorBanner } from '../components/Spinner';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useCampaign, useCampaignCharacters } from '../api/queries';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { usePagination, Pagination } from '../components/Paginated';
|
||||
import { Pagination } from '../components/Paginated';
|
||||
import { usePagination } from '../hooks/usePagination';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
import { CLASS_LABELS, SPECIES_LABELS, formatPurse } from '../lib/format';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
@@ -21,7 +22,7 @@ export function PartyOverviewPage() {
|
||||
|
||||
const { data, isLoading: campaignLoading, error: campaignError } = useCampaign(campaignId);
|
||||
const { data: characters, isLoading: charsLoading, error: charsError } = useCampaignCharacters(campaignId);
|
||||
const { pageItems, page, pageCount, prevPage, nextPage } = usePagination(characters, 12);
|
||||
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(characters, 12);
|
||||
|
||||
if (campaignLoading || charsLoading) return <Spinner label="Gathering the party…" />;
|
||||
if (campaignError || charsError || !data) return <ErrorBanner message="Could not load the party overview." />;
|
||||
@@ -66,17 +67,17 @@ 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">
|
||||
@@ -90,9 +91,9 @@ export function PartyOverviewPage() {
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td>{c.armorClass ?? '—'}</td>
|
||||
<td className="mono">{c.gold == null ? '—' : formatPurse(c)}</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>
|
||||
);
|
||||
})}
|
||||
@@ -100,7 +101,7 @@ export function PartyOverviewPage() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} />
|
||||
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { apiClient } from '../api/client';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import type { User } from '../api/types';
|
||||
import { extractErrorMessage } from '../lib/format';
|
||||
import { ErrorBanner } from '../components/Spinner';
|
||||
|
||||
+26
-2
@@ -1,9 +1,33 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import compression from 'compression'
|
||||
import { defineConfig, type Connect, type Plugin } from 'vite'
|
||||
|
||||
// `vite preview` is what serves the built site, so the two things a web
|
||||
// server would be set up to do are done here.
|
||||
const servePreview: Plugin = {
|
||||
name: 'serve-preview',
|
||||
configurePreviewServer(server) {
|
||||
const assets = join(server.config.root, server.config.build.outDir)
|
||||
// Typed for Express, and at home in any Node server.
|
||||
server.middlewares.use(compression() as Connect.NextHandleFunction)
|
||||
// A built file carries a hash of its contents in its name, so a browser
|
||||
// can keep it for good and never ask again. Only for files that are
|
||||
// there: a name from an older build must not be remembered as missing.
|
||||
server.middlewares.use((req, res, next) => {
|
||||
const path = (req.url ?? '').split('?')[0]
|
||||
if (path.startsWith('/assets/') && !path.includes('..') && existsSync(join(assets, path))) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
}
|
||||
next()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), servePreview],
|
||||
|
||||
preview: {
|
||||
host: true,
|
||||
|
||||
Generated
+4830
-2108
File diff suppressed because it is too large
Load Diff
+17
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.5.0",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
@@ -11,6 +11,21 @@
|
||||
"dev:backend": "npm run dev --workspace backend",
|
||||
"dev:frontend": "npm run dev --workspace frontend",
|
||||
"test:backend": "npm run test --workspace backend",
|
||||
"seed:hollowcrown": "npm run seed:hollowcrown --workspace backend"
|
||||
"seeds:check": "npm run seeds:check --workspace backend"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma": true,
|
||||
"@prisma/client": true,
|
||||
"@prisma/engines": true,
|
||||
"bcrypt": true,
|
||||
"@parcel/watcher": false,
|
||||
"unrs-resolver": false
|
||||
},
|
||||
"overrides": {
|
||||
"mysql2": "^3.24.5",
|
||||
"deepmerge-ts": "^8.0.2",
|
||||
"@istanbuljs/load-nyc-config": {
|
||||
"js-yaml": "^4.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user