Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a867fd1a1 | ||
|
|
37c561c5d3 | ||
|
|
7b3d121151 | ||
|
|
f04e3497f4 | ||
|
|
0ddf9846a2 | ||
|
|
9c97d61ce5 | ||
|
|
b82084276a | ||
|
|
0124e3bc53 | ||
|
|
2dcb41ae4a | ||
|
|
c852ecb2e4 | ||
|
|
bdce3399ee | ||
|
|
f764878960 | ||
|
|
6b466d497e | ||
|
|
f8462a6e31 | ||
|
|
754eb0ee68 | ||
|
|
f8cee43ca2 | ||
|
|
e4456b0df2 | ||
|
|
ec48b65481 | ||
|
|
4362b7423e | ||
|
|
98a89185c1 | ||
|
|
932e755895 | ||
|
|
2637a510df | ||
|
|
28e1a84b64 | ||
|
|
4a6a09bae8 |
@@ -6,3 +6,4 @@ build/
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
temp/
|
||||
@@ -2,21 +2,81 @@
|
||||
|
||||
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.
|
||||
|
||||
See `CLAUDE_BUILD_PROMPT.md` and `/docs` for the full spec this was built
|
||||
from (`GAME_RULES_REFERENCE.md` is the authoritative rules/math reference;
|
||||
`ROLL_YOUR_CHARACTER.md` is the character-builder flow spec).
|
||||
## How it works
|
||||
|
||||
`/docs` describes the app as it currently behaves, one subject per file:
|
||||
|
||||
| Document | Covers |
|
||||
|---|---|
|
||||
| `docs/CHARACTERS.md` | Building a character, every number on the sheet, levels, money, permissions |
|
||||
| `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 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, 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
|
||||
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. One added by hand opens its own pop-out, with an
|
||||
**"+ Add another"** button at the top that goes straight back to an empty
|
||||
form. 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
|
||||
@@ -28,7 +88,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 # creates a demo campaign + demo users (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
|
||||
@@ -44,9 +105,39 @@ npm run dev:backend # Express + Socket.io on http://localhost:4000
|
||||
npm run dev:frontend # Vite dev server, prints its own port (5173 by default)
|
||||
```
|
||||
|
||||
Open the frontend URL, register an account (or log in with the seeded demo
|
||||
users — see the seed script's console output for credentials and invite
|
||||
code), and start playing.
|
||||
Open the frontend URL, register an account (or log in with the seeded admin
|
||||
account — see the seed script's console output for the credentials), and
|
||||
start playing.
|
||||
|
||||
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.
|
||||
|
||||
## Worlds
|
||||
|
||||
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.
|
||||
|
||||
| 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
|
||||
("Account" in the navigation).
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -55,8 +146,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
|
||||
@@ -66,14 +158,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)
|
||||
|
||||
+14
-12
@@ -1,34 +1,36 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.13.1",
|
||||
"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",
|
||||
"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'),
|
||||
},
|
||||
});
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
-- AlterTable: starting-option flags. Existing pool entries keep the previous
|
||||
-- behaviour (everything was offered at character creation); new ones opt in.
|
||||
ALTER TABLE "weapons" ADD COLUMN "isStartingOption" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "spells" ADD COLUMN "isStartingOption" BOOLEAN NOT NULL DEFAULT false;
|
||||
UPDATE "weapons" SET "isStartingOption" = true;
|
||||
UPDATE "spells" SET "isStartingOption" = true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "items" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"isStartingOption" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Move free-text inventory into the campaign item pool: one pool item per
|
||||
-- distinct name (case-insensitive) per campaign, then point each row at it.
|
||||
INSERT INTO "items" ("id", "campaignId", "name", "description")
|
||||
SELECT DISTINCT ON (c."campaignId", lower(i."name"))
|
||||
'itm_' || md5(c."campaignId" || ':' || lower(i."name")),
|
||||
c."campaignId", i."name", i."description"
|
||||
FROM "inventory_items" i
|
||||
JOIN "characters" c ON c."id" = i."characterId"
|
||||
ORDER BY c."campaignId", lower(i."name"), i."id";
|
||||
|
||||
ALTER TABLE "inventory_items" ADD COLUMN "itemId" TEXT;
|
||||
|
||||
UPDATE "inventory_items" i
|
||||
SET "itemId" = 'itm_' || md5(c."campaignId" || ':' || lower(i."name"))
|
||||
FROM "characters" c
|
||||
WHERE c."id" = i."characterId";
|
||||
|
||||
-- A character that held the same item on several rows keeps one, with the
|
||||
-- quantities summed.
|
||||
UPDATE "inventory_items" keep
|
||||
SET "quantity" = agg.total, "equipped" = agg.any_equipped
|
||||
FROM (
|
||||
SELECT "characterId", "itemId", min("id") AS keep_id, sum("quantity")::int AS total, bool_or("equipped") AS any_equipped
|
||||
FROM "inventory_items"
|
||||
GROUP BY "characterId", "itemId"
|
||||
) agg
|
||||
WHERE keep."id" = agg.keep_id;
|
||||
|
||||
DELETE FROM "inventory_items" d
|
||||
USING (
|
||||
SELECT "characterId", "itemId", min("id") AS keep_id
|
||||
FROM "inventory_items"
|
||||
GROUP BY "characterId", "itemId"
|
||||
) agg
|
||||
WHERE d."characterId" = agg."characterId" AND d."itemId" = agg."itemId" AND d."id" <> agg.keep_id;
|
||||
|
||||
ALTER TABLE "inventory_items" ALTER COLUMN "itemId" SET NOT NULL;
|
||||
ALTER TABLE "inventory_items" DROP COLUMN "name";
|
||||
ALTER TABLE "inventory_items" DROP COLUMN "description";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "gold_transactions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"balanceAfter" INTEGER NOT NULL,
|
||||
"reason" TEXT,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "gold_transactions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Give characters that already hold gold an opening ledger entry.
|
||||
INSERT INTO "gold_transactions" ("id", "characterId", "amount", "balanceAfter", "reason")
|
||||
SELECT 'gtx_' || md5("id"), "id", "gold", "gold", 'Opening balance'
|
||||
FROM "characters"
|
||||
WHERE "gold" > 0;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "inventory_items_characterId_itemId_key" ON "inventory_items"("characterId", "itemId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "gold_transactions_characterId_createdAt_idx" ON "gold_transactions"("characterId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "items" ADD CONSTRAINT "items_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "inventory_items" ADD CONSTRAINT "inventory_items_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "gold_transactions" ADD CONSTRAINT "gold_transactions_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "gold_transactions" ADD CONSTRAINT "gold_transactions_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- AlterTable: how many ladder rungs a weapon climbs per level (1 standard, 2 powerful, 3 legendary)
|
||||
ALTER TABLE "weapons" ADD COLUMN "damageScaling" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- AlterTable: the level of the copy a character holds
|
||||
ALTER TABLE "character_weapons" ADD COLUMN "level" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- AlterTable: items can optionally carry dice (damage, healing, ...) that scale the same way
|
||||
ALTER TABLE "items" ADD COLUMN "damageDice" TEXT,
|
||||
ADD COLUMN "damageScaling" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "inventory_items" ADD COLUMN "level" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- AlterTable: spells get a fixed damage value; their classic level is the requirement
|
||||
ALTER TABLE "spells" ADD COLUMN "damageDice" TEXT;
|
||||
|
||||
-- A spell above its holder's level can no longer be prepared.
|
||||
UPDATE "character_spells" cs
|
||||
SET "prepared" = false
|
||||
FROM "spells" s, "characters" c
|
||||
WHERE s."id" = cs."spellId" AND c."id" = cs."characterId" AND s."level" > c."level";
|
||||
@@ -0,0 +1,7 @@
|
||||
-- AlterTable: the level a weapon or item starts at (its dice are the damage at this level)
|
||||
ALTER TABLE "weapons" ADD COLUMN "minLevel" INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE "items" ADD COLUMN "minLevel" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- Gear levels now stop at 9.
|
||||
UPDATE "character_weapons" SET "level" = 9 WHERE "level" > 9;
|
||||
UPDATE "inventory_items" SET "level" = 9 WHERE "level" > 9;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EncounterMode" AS ENUM ('BATTLE', 'QUEST');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NpcDisposition" AS ENUM ('FRIENDLY', 'NEUTRAL', 'HOSTILE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RewardType" AS ENUM ('GOLD', 'WEAPON', 'ITEM');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "encounter_participants" ADD COLUMN "npcId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "encounters" ADD COLUMN "activeParticipantId" TEXT,
|
||||
ADD COLUMN "description" TEXT,
|
||||
ADD COLUMN "dmNotes" TEXT,
|
||||
ADD COLUMN "mode" "EncounterMode" NOT NULL DEFAULT 'BATTLE';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "npcs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"role" TEXT,
|
||||
"disposition" "NpcDisposition" NOT NULL DEFAULT 'NEUTRAL',
|
||||
"description" TEXT,
|
||||
"dmNotes" TEXT,
|
||||
"ac" INTEGER,
|
||||
"maxHp" INTEGER,
|
||||
"attackBonus" INTEGER,
|
||||
"damageDice" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "npcs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_rewards" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounterId" TEXT NOT NULL,
|
||||
"type" "RewardType" NOT NULL,
|
||||
"gold" INTEGER,
|
||||
"weaponId" TEXT,
|
||||
"itemId" TEXT,
|
||||
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||
"level" INTEGER NOT NULL DEFAULT 1,
|
||||
"note" TEXT,
|
||||
"awardedTo" TEXT,
|
||||
"awardedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "encounter_rewards_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "npcs" ADD CONSTRAINT "npcs_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_rewards" ADD CONSTRAINT "encounter_rewards_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_rewards" ADD CONSTRAINT "encounter_rewards_weaponId_fkey" FOREIGN KEY ("weaponId") REFERENCES "weapons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_rewards" ADD CONSTRAINT "encounter_rewards_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_participants" ADD CONSTRAINT "encounter_participants_npcId_fkey" FOREIGN KEY ("npcId") REFERENCES "npcs"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterEnum: three story steps between skills and equipment
|
||||
ALTER TYPE "BuildStage" ADD VALUE 'CHOOSING_ABILITIES' BEFORE 'CHOOSING_EQUIPMENT';
|
||||
ALTER TYPE "BuildStage" ADD VALUE 'CHOOSING_BACKGROUND' BEFORE 'CHOOSING_EQUIPMENT';
|
||||
ALTER TYPE "BuildStage" ADD VALUE 'CHOOSING_APPEARANCE' BEFORE 'CHOOSING_EQUIPMENT';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: a campaign can be put away without deleting it
|
||||
ALTER TABLE "campaigns" ADD COLUMN "archivedAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- AlterTable: an encounter can be put away, and has a place in the campaign's own order
|
||||
ALTER TABLE "encounters" ADD COLUMN "archivedAt" TIMESTAMP(3);
|
||||
ALTER TABLE "encounters" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Existing encounters keep the order the list has always shown: newest first.
|
||||
UPDATE "encounters" AS e
|
||||
SET "sortOrder" = r.pos
|
||||
FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY "campaignId" ORDER BY "createdAt" DESC, id) - 1 AS pos
|
||||
FROM "encounters"
|
||||
) AS r
|
||||
WHERE e.id = r.id;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- AlterTable: whether players see a monster's exact hit points or only its health bar
|
||||
ALTER TABLE "campaigns" ADD COLUMN "showMonsterHp" BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- AlterTable: where an encounter takes place
|
||||
ALTER TABLE "encounters" ADD COLUMN "locationId" TEXT;
|
||||
ALTER TABLE "encounters" ADD CONSTRAINT "encounters_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- CreateTable: every location a character has been to
|
||||
CREATE TABLE "character_location_visits" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"locationId" TEXT NOT NULL,
|
||||
"firstVisitedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "character_location_visits_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
CREATE UNIQUE INDEX "character_location_visits_characterId_locationId_key" ON "character_location_visits"("characterId", "locationId");
|
||||
ALTER TABLE "character_location_visits" ADD CONSTRAINT "character_location_visits_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "character_location_visits" ADD CONSTRAINT "character_location_visits_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Characters have been to wherever they are now.
|
||||
INSERT INTO "character_location_visits" ("id", "characterId", "locationId")
|
||||
SELECT 'visit_' || "id", "id", "currentLocationId" FROM "characters" WHERE "currentLocationId" IS NOT NULL;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- AlterTable: the d20 a combatant rolled for initiative; "initiative" is now roll + bonus
|
||||
ALTER TABLE "encounter_participants" ADD COLUMN "initiativeRoll" INTEGER;
|
||||
|
||||
-- Initiatives typed in before this were totals. Keep them as they are, and
|
||||
-- treat the nearest possible die face as the roll so those combatants still
|
||||
-- count as having rolled.
|
||||
UPDATE "encounter_participants"
|
||||
SET "initiativeRoll" = LEAST(GREATEST("initiative", 1), 20)
|
||||
WHERE "initiative" <> 0;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- CreateEnum: the three coins. Silver is worth 0.1 gold, copper 0.01 gold.
|
||||
CREATE TYPE "Currency" AS ENUM ('GOLD', 'SILVER', 'COPPER');
|
||||
|
||||
-- AlterTable: a character's purse holds each coin separately
|
||||
ALTER TABLE "characters" ADD COLUMN "silver" INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE "characters" ADD COLUMN "copper" INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- AlterTable: every ledger entry and coin reward is in one coin; all existing ones are gold
|
||||
ALTER TABLE "gold_transactions" ADD COLUMN "currency" "Currency" NOT NULL DEFAULT 'GOLD';
|
||||
ALTER TABLE "encounter_rewards" ADD COLUMN "currency" "Currency" NOT NULL DEFAULT 'GOLD';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable: death saves, counted while a character is at 0 HP
|
||||
ALTER TABLE "characters" ADD COLUMN "deathSaveSuccesses" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "deathSaveFailures" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "stable" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: the combatant currently marked as the target
|
||||
ALTER TABLE "encounters" ADD COLUMN "targetParticipantId" TEXT;
|
||||
@@ -0,0 +1,9 @@
|
||||
-- AlterTable: several combatants can be marked as targets at once (an area attack)
|
||||
ALTER TABLE "encounters" ADD COLUMN "targetParticipantIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- Carry over a single target marked before this.
|
||||
UPDATE "encounters"
|
||||
SET "targetParticipantIds" = ARRAY["targetParticipantId"]
|
||||
WHERE "targetParticipantId" IS NOT NULL;
|
||||
|
||||
ALTER TABLE "encounters" DROP COLUMN "targetParticipantId";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: how much an area attack adds to each AC per extra target
|
||||
ALTER TABLE "campaigns" ADD COLUMN "areaAttackAcPenalty" INTEGER NOT NULL DEFAULT 2;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable: spells that bring back the dead
|
||||
ALTER TABLE "spells" ADD COLUMN "revives" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- The library's two revival spells, wherever a campaign already has them.
|
||||
UPDATE "spells" SET "revives" = true WHERE "name" IN ('Revivify', 'Raise Dead');
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterEnum: cash ($), a modern currency for crossover worlds. It has no fixed
|
||||
-- worth in gold, so it is never exchanged or used to make change.
|
||||
ALTER TYPE "Currency" ADD VALUE 'CASH';
|
||||
|
||||
-- AlterTable: cash is counted on its own, like each coin
|
||||
ALTER TABLE "characters" ADD COLUMN "cash" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- AlterTable: an NPC who changes money does it at their own rate, this many
|
||||
-- gold for that much cash. Both are null for an NPC who does not.
|
||||
ALTER TABLE "npcs" ADD COLUMN "exchangeGold" INTEGER;
|
||||
ALTER TABLE "npcs" ADD COLUMN "exchangeCash" INTEGER;
|
||||
|
||||
-- CreateTable: something for sale, by an NPC or at an encounter itself
|
||||
CREATE TABLE "sale_offers" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"npcId" TEXT,
|
||||
"encounterId" TEXT,
|
||||
"weaponId" TEXT,
|
||||
"itemId" TEXT,
|
||||
"price" INTEGER NOT NULL,
|
||||
"currency" "Currency" NOT NULL DEFAULT 'GOLD',
|
||||
"level" INTEGER NOT NULL DEFAULT 1,
|
||||
"stock" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "sale_offers_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_npcId_fkey" FOREIGN KEY ("npcId") REFERENCES "npcs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_weaponId_fkey" FOREIGN KEY ("weaponId") REFERENCES "weapons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- AlterTable: a weapon or item can change the numbers on the sheet of whoever
|
||||
-- has it equipped. `effects` is a list of { stat, amount }; `effectNote` is
|
||||
-- for what it does that is not a number ("Disadvantage on Stealth").
|
||||
ALTER TABLE "weapons" ADD COLUMN "effects" JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE "weapons" ADD COLUMN "effectNote" TEXT;
|
||||
ALTER TABLE "items" ADD COLUMN "effects" JSONB NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE "items" ADD COLUMN "effectNote" TEXT;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable: how much a character can have equipped at once. Only equipped
|
||||
-- gear can be used in an encounter or gives its effects.
|
||||
ALTER TABLE "campaigns" ADD COLUMN "maxEquipped" INTEGER NOT NULL DEFAULT 2;
|
||||
ALTER TABLE "campaigns" ADD COLUMN "maxEquippedWeapons" INTEGER NOT NULL DEFAULT 1;
|
||||
@@ -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");
|
||||
+289
-15
@@ -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")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -86,6 +86,9 @@ enum BuildStage {
|
||||
ROLLING_MOTIVATION
|
||||
ASSIGNING_SCORES
|
||||
ASSIGNING_PROFICIENCIES
|
||||
CHOOSING_ABILITIES
|
||||
CHOOSING_BACKGROUND
|
||||
CHOOSING_APPEARANCE
|
||||
CHOOSING_EQUIPMENT
|
||||
COMPLETE
|
||||
}
|
||||
@@ -96,6 +99,35 @@ enum EncounterStatus {
|
||||
COMPLETE
|
||||
}
|
||||
|
||||
// What kind of scene an encounter is: a fight run in initiative order, or a
|
||||
// quest scene built around NPCs and an objective.
|
||||
enum EncounterMode {
|
||||
BATTLE
|
||||
QUEST
|
||||
}
|
||||
|
||||
enum NpcDisposition {
|
||||
FRIENDLY
|
||||
NEUTRAL
|
||||
HOSTILE
|
||||
}
|
||||
|
||||
enum RewardType {
|
||||
GOLD
|
||||
WEAPON
|
||||
ITEM
|
||||
}
|
||||
|
||||
// The money a character carries. The coins' worth is fixed in utils/grants.js:
|
||||
// 1 gold = 10 silver = 100 copper. Cash ($) is modern money for crossover
|
||||
// worlds; it has no worth in coins and is never exchanged for them.
|
||||
enum Currency {
|
||||
GOLD
|
||||
SILVER
|
||||
COPPER
|
||||
CASH
|
||||
}
|
||||
|
||||
enum NoteVisibility {
|
||||
DM_ONLY
|
||||
SHARED
|
||||
@@ -122,6 +154,7 @@ model User {
|
||||
charactersOwned Character[] @relation("PlayerCharacters")
|
||||
charactersCreated Character[] @relation("CreatedBy")
|
||||
notesAuthored Note[]
|
||||
goldTransactions GoldTransaction[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -135,7 +168,21 @@ model Campaign {
|
||||
startingItemCount Int @default(0)
|
||||
startingSpellCount Int @default(0)
|
||||
skipMotivationRoll Boolean @default(false)
|
||||
// Off: players see only a health bar for monsters and NPCs, not the numbers.
|
||||
showMonsterHp Boolean @default(true)
|
||||
// An attack on several targets is harder to land: each target's AC counts
|
||||
// as this much higher for every target beyond the first. 0 turns it off.
|
||||
areaAttackAcPenalty Int @default(2)
|
||||
// How much a character can have equipped at once: weapons and items
|
||||
// together, and weapons alone. Only equipped gear can be used in an
|
||||
// encounter or gives its effects, so this is what a character can bring to
|
||||
// bear at one time. A pair of daggers is one weapon, made as such.
|
||||
maxEquipped Int @default(2)
|
||||
maxEquippedWeapons Int @default(1)
|
||||
ownerId String
|
||||
// Set while the campaign is archived: kept in full, but moved off the main
|
||||
// list and closed to new members until the DM restores it.
|
||||
archivedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -143,8 +190,11 @@ model Campaign {
|
||||
characters Character[]
|
||||
weapons Weapon[]
|
||||
spells Spell[]
|
||||
items Item[]
|
||||
monsters Monster[]
|
||||
npcs Npc[]
|
||||
encounters Encounter[]
|
||||
saleOffers SaleOffer[]
|
||||
notes Note[]
|
||||
locations Location[]
|
||||
|
||||
@@ -160,10 +210,29 @@ model Location {
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
characters Character[]
|
||||
encounters Encounter[]
|
||||
visits CharacterLocationVisit[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("locations")
|
||||
}
|
||||
|
||||
// A location a character has been to. Written whenever a character is put
|
||||
// somewhere (by the DM, or by an encounter held there), and never removed by
|
||||
// moving on: it is what decides which locations a player may see.
|
||||
model CharacterLocationVisit {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
locationId String
|
||||
firstVisitedAt DateTime @default(now())
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([characterId, locationId])
|
||||
@@map("character_location_visits")
|
||||
}
|
||||
|
||||
model CampaignMember {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
@@ -175,6 +244,7 @@ model CampaignMember {
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([campaignId, userId])
|
||||
@@index([userId])
|
||||
@@map("campaign_members")
|
||||
}
|
||||
|
||||
@@ -208,14 +278,26 @@ model Character {
|
||||
|
||||
currentHp Int @default(0)
|
||||
tempHp Int @default(0)
|
||||
armorBonus Int @default(0) // flat AC bonus from equipped armor/shield
|
||||
// Death saves, counted while the character is at 0 HP (utils/hp.js). Three
|
||||
// failures is dead; three successes sets `stable` and clears the tallies.
|
||||
deathSaveSuccesses Int @default(0)
|
||||
deathSaveFailures Int @default(0)
|
||||
stable Boolean @default(false) // at 0 HP but no longer rolling
|
||||
armorBonus Int @default(0) // a flat AC adjustment the DM sets by hand, on top of what equipped gear gives
|
||||
speedOverride Int?
|
||||
|
||||
specialAbilities String? // free-text list shown in the quick-stats panel
|
||||
// The next three are composed by the builder from the player's picks
|
||||
// (game/data/characterOptions.js). After that only the DM can edit them.
|
||||
specialAbilities String? // shown in the quick-stats panel
|
||||
// The purse: each coin (and cash) is counted on its own and only changes through the
|
||||
// money ledger (GoldTransaction).
|
||||
gold Int @default(0)
|
||||
silver Int @default(0)
|
||||
copper Int @default(0)
|
||||
cash Int @default(0) // whole dollars
|
||||
|
||||
background String? // free-text story background, player-editable
|
||||
appearance String? // free-text character look/description, player-editable
|
||||
background String?
|
||||
appearance String?
|
||||
privateNotes String? // visible to the owning player only — not even the DM
|
||||
|
||||
diceMode DiceMode @default(DIGITAL) // chosen once, before the first build roll
|
||||
@@ -236,11 +318,15 @@ model Character {
|
||||
skillProficiencies CharacterSkillProficiency[]
|
||||
saveProficiencies CharacterSaveProficiency[]
|
||||
inventoryItems InventoryItem[]
|
||||
goldTransactions GoldTransaction[]
|
||||
weapons CharacterWeapon[]
|
||||
spells CharacterSpell[]
|
||||
notes Note[]
|
||||
encounterEntries EncounterParticipant[]
|
||||
locationVisits CharacterLocationVisit[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([userId])
|
||||
@@map("characters")
|
||||
}
|
||||
|
||||
@@ -283,11 +369,28 @@ model Weapon {
|
||||
properties String[] // e.g. ["finesse", "light", "thrown"]
|
||||
rangeNormal Int?
|
||||
rangeLong Int?
|
||||
// damageDice is the level-1 damage; a held copy's dice climb the damage
|
||||
// ladder (game/calculations.js) by this many rungs per level above 1.
|
||||
damageScaling Int @default(1)
|
||||
// The level this weapon starts at: a held copy is never below it, and
|
||||
// damageDice is the damage at this level.
|
||||
minLevel Int @default(1)
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -297,6 +400,7 @@ model CharacterWeapon {
|
||||
weaponId String
|
||||
quantity Int @default(1)
|
||||
equipped Boolean @default(false)
|
||||
level Int @default(1) // this copy's level; the character must be at least this level to use it
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
weapon Weapon @relation(fields: [weaponId], references: [id], onDelete: Cascade)
|
||||
@@ -305,19 +409,66 @@ model CharacterWeapon {
|
||||
@@map("character_weapons")
|
||||
}
|
||||
|
||||
// Campaign-level item pool, DM-managed like weapons and spells.
|
||||
model Item {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
name String
|
||||
description String?
|
||||
damageDice String? // optional dice (damage, healing...) at its starting level; scales like a weapon's
|
||||
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")
|
||||
}
|
||||
|
||||
// An Item from the campaign pool held by a character.
|
||||
model InventoryItem {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
name String
|
||||
description String?
|
||||
itemId String
|
||||
quantity Int @default(1)
|
||||
equipped Boolean @default(false)
|
||||
level Int @default(1)
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([characterId, itemId])
|
||||
@@map("inventory_items")
|
||||
}
|
||||
|
||||
// Ledger behind a character's purse: every change to a coin balance is one
|
||||
// row here. (The name predates silver and copper.)
|
||||
model GoldTransaction {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
currency Currency @default(GOLD) // which coin this entry is in
|
||||
amount Int // signed: positive = gained, negative = spent
|
||||
balanceAfter Int // that coin's balance afterwards
|
||||
reason String?
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([characterId, createdAt])
|
||||
@@map("gold_transactions")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spells
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -326,16 +477,22 @@ model Spell {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
name String
|
||||
level Int // 0 = cantrip
|
||||
level Int // 0 = cantrip. Also the character level needed to use the spell.
|
||||
damageDice String? // fixed; spells don't level up
|
||||
school String?
|
||||
castingTime String?
|
||||
range String?
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -378,34 +535,146 @@ model Monster {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
encounterAppearances EncounterParticipant[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("monsters")
|
||||
}
|
||||
|
||||
// A non-player character the DM runs: a quest-giver, shopkeeper, rival...
|
||||
// Combat stats are optional — most NPCs are there to be talked to.
|
||||
model Npc {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
name String
|
||||
role String? // e.g. "Innkeeper", "Captain of the Watch"
|
||||
disposition NpcDisposition @default(NEUTRAL)
|
||||
description String? // what the party can see and knows
|
||||
dmNotes String? // secrets, motives — never sent to players
|
||||
ac Int?
|
||||
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")
|
||||
}
|
||||
|
||||
model Encounter {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
name String
|
||||
mode EncounterMode @default(BATTLE)
|
||||
status EncounterStatus @default(PLANNING)
|
||||
currentRound Int @default(1)
|
||||
// Whose turn it is in a battle; null until the fight starts.
|
||||
activeParticipantId String?
|
||||
// Who is being attacked (or healed) right now, shown to everyone, in the
|
||||
// order they were picked. More than one makes it an area attack. Picked by
|
||||
// the DM or by the player on turn, and cleared when the turn passes.
|
||||
targetParticipantIds String[] @default([])
|
||||
description String? // the scene / objective, shown to everyone
|
||||
dmNotes String? // never sent to players
|
||||
// Where the scene takes place. While the encounter is in progress, the
|
||||
// characters in it are there too.
|
||||
locationId String?
|
||||
// Set while archived: kept in full, but off the campaign's list — players
|
||||
// no longer see it there at all.
|
||||
archivedAt DateTime?
|
||||
// Place in the campaign's own order, lowest first. The DM rearranges it.
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
participants EncounterParticipant[]
|
||||
rewards EncounterReward[]
|
||||
wares SaleOffer[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("encounters")
|
||||
}
|
||||
|
||||
// A row per combatant in an encounter — either a character or a monster.
|
||||
// currentHp/maxHp are snapshotted here (not just read off Character/Monster)
|
||||
// so damage tracking during a fight doesn't need to mutate the character's or
|
||||
// monster's permanent record until the DM chooses to apply it back.
|
||||
// Loot the DM lines up for an encounter and later hands to a character. Until
|
||||
// it is awarded it is the DM's secret; awarding it is what actually changes
|
||||
// the character (gold ledger entry, or the weapon/item added to what they hold).
|
||||
model EncounterReward {
|
||||
id String @id @default(cuid())
|
||||
encounterId String
|
||||
type RewardType
|
||||
gold Int? // GOLD: how many coins
|
||||
currency Currency @default(GOLD) // GOLD: which coin they are
|
||||
weaponId String? // WEAPON
|
||||
itemId String? // ITEM
|
||||
quantity Int @default(1)
|
||||
level Int @default(1) // gear level the weapon/item is handed over at
|
||||
note String?
|
||||
awardedTo String? // who received it, as display text ("Aria", "Aria, Bo")
|
||||
awardedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
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([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
|
||||
// the columns here only hold the last values seen (used if the character is
|
||||
// later deleted and the row loses its link).
|
||||
model EncounterParticipant {
|
||||
id String @id @default(cuid())
|
||||
encounterId String
|
||||
characterId String?
|
||||
monsterId String?
|
||||
npcId String?
|
||||
displayName String // denormalized so a monster can be renamed per-instance, e.g. "Goblin #2"
|
||||
// The d20 rolled for initiative (1–20); null until it is rolled.
|
||||
initiativeRoll Int?
|
||||
// The roll plus the combatant's initiative bonus, worked out when the roll
|
||||
// is entered. This is what the turn order sorts on.
|
||||
initiative Int @default(0)
|
||||
currentHp Int
|
||||
maxHp Int
|
||||
@@ -415,7 +684,10 @@ model EncounterParticipant {
|
||||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
character Character? @relation(fields: [characterId], references: [id], onDelete: SetNull)
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -436,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")
|
||||
}
|
||||
|
||||
+39
-95
@@ -1,110 +1,54 @@
|
||||
// Resets the database and seeds the Aldenfell campaign (see
|
||||
// docs/Aldenfell - Full Campaign Book.docx and seed-data/aldenfell.js).
|
||||
// The database seed: makes sure the admin account exists, and nothing else.
|
||||
//
|
||||
// DESTRUCTIVE: every user, campaign, character, note, etc. is deleted first.
|
||||
// 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
|
||||
//
|
||||
// Species/classes/skills are NOT seeded as rows — they're fixed game-rule
|
||||
// tables ported into src/game/data/*.js, as is the default weapon/spell/monster
|
||||
// library DMs pull from. The default weapons (seed-data/weapons.json) ARE
|
||||
// seeded, into the campaign, since weapons are per-campaign in the schema.
|
||||
const path = require('path');
|
||||
// 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.
|
||||
//
|
||||
// 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 { PrismaClient } = require('@prisma/client');
|
||||
const { generateInviteCode } = require('../src/utils/inviteCode');
|
||||
const prisma = require('../src/db');
|
||||
const { loadSeeds } = require('../src/seeds');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const weapons = require(path.join(__dirname, '..', 'seed-data', 'weapons.json'));
|
||||
const aldenfell = require(path.join(__dirname, '..', 'seed-data', 'aldenfell.js'));
|
||||
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL || 'joshua@crosscode.co.za';
|
||||
const ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD || '12345678';
|
||||
const ADMIN_NAME = process.env.SEED_ADMIN_NAME || 'Joshua';
|
||||
|
||||
const ADMIN_EMAIL = 'joshua@crosscode.co.za';
|
||||
const ADMIN_PASSWORD = '12345678';
|
||||
|
||||
async function resetDatabase() {
|
||||
// Truncating the two roots cascades through every table that references them.
|
||||
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await resetDatabase();
|
||||
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
email: ADMIN_EMAIL,
|
||||
passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12),
|
||||
displayName: 'Joshua',
|
||||
},
|
||||
});
|
||||
|
||||
const campaign = await prisma.campaign.create({
|
||||
data: {
|
||||
...aldenfell.campaign,
|
||||
inviteCode: generateInviteCode(),
|
||||
ownerId: admin.id,
|
||||
members: { create: { userId: admin.id, role: 'DM' } },
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.weapon.createMany({ data: weapons.map((w) => ({ ...w, campaignId: campaign.id })) });
|
||||
|
||||
await prisma.location.createMany({
|
||||
data: aldenfell.locations.map((l) => ({ ...l, campaignId: campaign.id })),
|
||||
});
|
||||
|
||||
const monsterByName = {};
|
||||
for (const monster of aldenfell.monsters) {
|
||||
const created = await prisma.monster.create({ data: { ...monster, campaignId: campaign.id } });
|
||||
monsterByName[created.name] = created;
|
||||
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.');
|
||||
}
|
||||
|
||||
for (const enc of aldenfell.encounters) {
|
||||
const participants = [];
|
||||
for (const [name, count] of enc.monsters) {
|
||||
const monster = monsterByName[name];
|
||||
if (!monster) throw new Error(`Encounter "${enc.name}" references unknown monster "${name}"`);
|
||||
for (let i = 1; i <= count; i++) {
|
||||
participants.push({
|
||||
monsterId: monster.id,
|
||||
displayName: count > 1 ? `${monster.name} #${i}` : monster.name,
|
||||
currentHp: monster.maxHp,
|
||||
maxHp: monster.maxHp,
|
||||
isMonster: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
await prisma.encounter.create({
|
||||
data: { campaignId: campaign.id, name: enc.name, participants: { create: participants } },
|
||||
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.');
|
||||
}
|
||||
|
||||
// Notes list newest-first, so stamp them in reverse to keep book order on screen.
|
||||
const allNotes = [
|
||||
...aldenfell.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
|
||||
...aldenfell.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
|
||||
];
|
||||
const now = Date.now();
|
||||
await prisma.note.createMany({
|
||||
data: allNotes.map((n, i) => ({
|
||||
...n,
|
||||
campaignId: campaign.id,
|
||||
authorId: admin.id,
|
||||
createdAt: new Date(now - i * 1000),
|
||||
})),
|
||||
});
|
||||
|
||||
console.log('Seed complete.');
|
||||
console.log(` Admin/DM login: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
|
||||
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
|
||||
console.log(
|
||||
` ${weapons.length} weapons, ${aldenfell.locations.length} locations, ${aldenfell.monsters.length} monsters, ` +
|
||||
`${aldenfell.encounters.length} encounters, ${allNotes.length} notes`,
|
||||
);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
seed({ reset: process.argv.includes('--reset') })
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
.finally(() => prisma.$disconnect());
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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.
|
||||
//
|
||||
// npm run docs:lists (from /backend)
|
||||
//
|
||||
// Each list lives between a pair of markers in a doc:
|
||||
// <!-- BEGIN GENERATED: name --> ... <!-- END GENERATED: name -->
|
||||
// Only the text between the markers is replaced; everything else in the docs
|
||||
// is written by hand.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
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');
|
||||
|
||||
const DOCS = path.join(__dirname, '..', '..', 'docs');
|
||||
|
||||
const cell = (value) => (value === null || value === undefined || value === '' ? '—' : String(value).replace(/\|/g, '\\|'));
|
||||
const table = (headers, rows) =>
|
||||
[`| ${headers.join(' | ')} |`, `|${headers.map(() => '---').join('|')}|`, ...rows.map((r) => `| ${r.map(cell).join(' | ')} |`)].join('\n');
|
||||
const signed = (n) => (n >= 0 ? `+${n}` : `${n}`);
|
||||
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
||||
|
||||
function spellLibrary() {
|
||||
const levels = [...new Set(SPELL_LIBRARY.map((s) => s.level))].sort((a, b) => a - b);
|
||||
return levels
|
||||
.map((level) => {
|
||||
const spells = SPELL_LIBRARY.filter((s) => s.level === level).sort((a, b) => a.name.localeCompare(b.name));
|
||||
const heading = level === 0 ? `### Cantrips (${spells.length})` : `### Level ${level} (${plural(spells.length, 'spell')})`;
|
||||
const who = level === 0 ? 'Usable by any character.' : `Needs character level ${level}.`;
|
||||
return `${heading}\n\n${who}\n\n${table(['Spell', 'School', 'Damage', 'What it does'], spells.map((s) => [s.name, s.school, s.damageDice, s.description]))}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
const ATTACK_WITH = { STR: 'Strength', DEX: 'Dexterity', FINESSE: 'Strength or Dexterity (finesse)' };
|
||||
|
||||
function weaponLibrary() {
|
||||
const categories = [...new Set(WEAPON_LIBRARY.map((w) => w.category))];
|
||||
return categories
|
||||
.map((category) => {
|
||||
const weapons = WEAPON_LIBRARY.filter((w) => w.category === category);
|
||||
const rows = weapons.map((w) => [
|
||||
w.name,
|
||||
w.damageDice,
|
||||
w.damageType,
|
||||
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', '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');
|
||||
}
|
||||
|
||||
function monsterLibrary() {
|
||||
const rows = MONSTER_LIBRARY.map((m) => [
|
||||
m.name,
|
||||
m.challengeRating,
|
||||
m.ac,
|
||||
m.maxHp,
|
||||
m.speed,
|
||||
signed(m.attackBonus),
|
||||
m.damageDice,
|
||||
[m.strScore, m.dexScore, m.conScore, m.intScore, m.wisScore, m.chaScore].join(' / '),
|
||||
m.specialTrait,
|
||||
m.notes,
|
||||
]);
|
||||
return table(['Monster', 'CR', 'AC', 'HP', 'Speed', 'Attack', 'Damage', 'STR / DEX / CON / INT / WIS / CHA', 'Trait', 'Notes'], rows);
|
||||
}
|
||||
|
||||
function builderLists() {
|
||||
const abilities = (list) => list.map((a) => `**${a.name}** — ${a.text}`).join('<br>');
|
||||
const groups = (list) =>
|
||||
list
|
||||
.map((g) => {
|
||||
const pick = g.min === g.max ? `pick ${g.min}` : `pick ${g.min} to ${g.max}`;
|
||||
return `**${g.label}** — ${g.prompt} (${pick})\n\n${table(['Option', 'What it adds'], g.options.map((o) => [o.label, o.text]))}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return [
|
||||
`### Motivations\n\n${options.MOTIVATIONS.map((m) => `- ${m.label}`).join('\n')}`,
|
||||
`### Special abilities granted by species\n\n${table(['Species', 'Abilities'], Object.keys(SPECIES).map((key) => [SPECIES[key].name, abilities(options.SPECIES_TRAITS[key])]))}`,
|
||||
`### Special abilities granted by class\n\n${table(['Class', 'Abilities'], Object.keys(CLASSES).sort().map((key) => [CLASSES[key].name, abilities(options.CLASS_FEATURES[key])]))}`,
|
||||
`### Talents (pick ${options.TALENT_PICKS})\n\n${table(['Talent', 'What it does'], options.TALENTS.map((t) => [t.name, t.text]))}`,
|
||||
`### Background questions\n\n${groups(options.BACKGROUND_GROUPS)}`,
|
||||
`### Appearance questions\n\n${groups(options.APPEARANCE_GROUPS)}`,
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
for (const [file, sections] of Object.entries(SECTIONS)) {
|
||||
const filePath = path.join(DOCS, file);
|
||||
const original = fs.readFileSync(filePath, 'utf8');
|
||||
const newline = original.includes('\r\n') ? '\r\n' : '\n';
|
||||
let text = original.replace(/\r\n/g, '\n');
|
||||
for (const [name, render] of Object.entries(sections)) {
|
||||
const begin = `<!-- BEGIN GENERATED: ${name} -->`;
|
||||
const end = `<!-- END GENERATED: ${name} -->`;
|
||||
const from = text.indexOf(begin);
|
||||
const to = text.indexOf(end);
|
||||
if (from === -1 || to === -1 || to < from) throw new Error(`${file}: missing the "${name}" markers`);
|
||||
text = `${text.slice(0, from + begin.length)}\n${render()}\n${text.slice(to)}`;
|
||||
}
|
||||
const output = text.replace(/\n/g, newline);
|
||||
if (output !== original) fs.writeFileSync(filePath, output);
|
||||
console.log(`${output === original ? 'unchanged' : 'updated '} docs/${file}`);
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
// Aldenfell campaign content, transcribed (and condensed where a note would
|
||||
// otherwise be unreadable) from docs/Aldenfell - Full Campaign Book.docx.
|
||||
// Consumed by prisma/seed.js. Shapes match the Prisma models minus ids.
|
||||
|
||||
const campaign = {
|
||||
name: 'Aldenfell',
|
||||
description:
|
||||
'A kingdom of knights and dragons is standing on top of something that’s waking up — and you’re the ones who find out first. ' +
|
||||
'Heroic adventure first, real consequences, and horror as a spice rather than the whole meal.',
|
||||
startingGold: 15,
|
||||
startingItemCount: 2,
|
||||
startingSpellCount: 2,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gazetteer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const locations = [
|
||||
// Highcrown
|
||||
{ name: 'Highcrown — The Founding Chapel', description: 'Site of Session One’s bell mystery. Its cellar connects to the Sunken Vaults. Quiet by day, a good recurring “something’s off” location throughout Act 1.' },
|
||||
{ name: 'Highcrown — The Palace of Aldenfell', description: 'King Aldric’s seat. Court is held weekly; an audience without a sponsor (a knight, a noble, a Concord representative) takes real effort. Princess Isolde keeps a smaller, more accessible private study here.' },
|
||||
{ name: 'Highcrown — The Grey Dawn Hall', description: 'Order of the Grey Dawn headquarters. Recruitment, training yards, and Ser Kavan Thorne’s office. The natural starting point for a Fighter/Paladin-leaning party.' },
|
||||
{ name: 'Highcrown — The Concord Exchange', description: 'The Merchant Concord’s trading hall. Job boards, contracts, and Mireille Costa’s usual haunt. Best source of quick, paying side work.' },
|
||||
{ name: 'Highcrown — The Lantern Market', description: 'Highcrown’s main marketplace, busiest during the Founding Fair. Gear, rumors, and pickpockets in equal measure. The campaign opens (E1) and closes here.' },
|
||||
// Greywood
|
||||
{ name: 'Greywood — The Warden’s Lodge', description: 'Ashwen and the other Wardens’ base, a day’s ride from Highcrown. First place to hear rumors about the old shrines acting strangely.' },
|
||||
{ name: 'Greywood — The Old Shrine Sites', description: 'Scattered pre-kingdom shrines throughout the forest; several sit almost directly above Hollow Below cracks. Livestock/crop failures (a Stage 1 omen) cluster near these. → Side Quest: The Warden’s Silence.' },
|
||||
{ name: 'Greywood — The Feywild Grove', description: 'A grove where the boundary with the Feywild is thin. Home to a reclusive, capricious fey court — a “be careful what you promise” side encounter, unrelated to the main plot.' },
|
||||
{ name: 'Greywood — The Sunken Library', description: 'Ruins of a pre-kingdom archive, half-collapsed and root-choked. Contains fragments of the truth about the Hollow King’s origin — a place for the party to piece the mystery together on their own terms.' },
|
||||
// Spine
|
||||
{ name: 'The Spine — Threndir’s Eyrie', description: 'High on the eastern peaks, reachable by a hard climb or careful flight/magic. Threndir Ashclaw grants an audience to anyone who approaches openly and unarmed-in-spirit.' },
|
||||
{ name: 'The Spine — Sevrisk’s Hollow', description: 'A lower, damper cave system claimed by the young green dragon Sevrisk. Far more dangerous to simply wander into.' },
|
||||
{ name: 'The Spine — Stonejaw Hold', description: 'Korgath’s clan settlement in a Spine foothill valley. Outsiders are tested, not attacked on sight. → Side Quest: Trial of Stone.' },
|
||||
{ name: 'The Spine — Ironvein', description: 'A mining town at the Spine’s base, Aldenfell’s main source of iron. Practical resupply stop with its own local rumors (miners going quiet, deeper tunnels avoided).' },
|
||||
// Salt Coast & Bramble Marches
|
||||
{ name: 'Salt Coast — Tidewatch', description: 'The Salt Coast’s largest port town, proudly independent-minded. Good base for coastal side content. → Side Quest: What the Tide Brought In.' },
|
||||
{ name: 'Salt Coast — Smuggler’s Cove', description: 'A hidden inlet south of Tidewatch, used for exactly what the name suggests. → Side Quest: The Smugglers’ Tithe.' },
|
||||
{ name: 'Bramble Marches — Greyford Keep', description: 'A border fortress garrisoned by Aldenfell troops watching the disputed line. Tense, under-resourced, a pressure-cooker location (E11, E16).' },
|
||||
{ name: 'Bramble Marches — The Sorrow Camp', description: 'A refugee camp just inside Aldenfell territory, swollen by people fleeing the Marches’ border tension. → Side Quest: Refugees of the Marches.' },
|
||||
// Hollow Below
|
||||
{ name: 'Hollow Below — The Sunken Vaults (Layer 1)', description: 'Old crypts beneath Highcrown, reached from the Founding Chapel’s cellar or three other points in the city. Low real danger, mundane-spooky. Session One’s crawl (E3) happens here.' },
|
||||
{ name: 'Hollow Below — The Root (Layer 2)', description: 'Natural caves beneath the Greywood and the Spine’s lower slopes. Geography stops being reliable. Hollowed, Bellcrawlers, and the Root Stalker live here. Danger is real.' },
|
||||
{ name: 'Hollow Below — The Weeping Passage', description: 'A Root landmark: a long tunnel where the walls audibly “weep” — a faint, wrong, almost-voice sound. Set-piece location for the party’s first Hollowed.' },
|
||||
{ name: 'Hollow Below — The Hollow Court (Layer 3)', description: 'The seal itself. Off-limits until Act 4. Gravity, light, and memory all bend near it. The Hollow King’s Shadow operates here.' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bestiary (Aldenfell-specific stat blocks). attackBonus/damageDice hold the
|
||||
// creature's primary attack; the rest lives in specialTrait/notes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const monsters = [
|
||||
{
|
||||
name: 'Threndir Ashclaw (old copper dragon)', challengeRating: '8', ac: 17, maxHp: 165, speed: 40,
|
||||
strScore: 23, dexScore: 12, conScore: 17, intScore: 16, wisScore: 13, chaScore: 17, attackBonus: 11, damageDice: '2d10+6',
|
||||
specialTrait: 'Legendary talker — always offers to negotiate before fighting unless attacked first. Acid breath (recharge 5–6): 60-ft line, DC 15 Dex, 10d8 acid (half on success).',
|
||||
notes: 'Huge dragon, 3,900 XP. Climb 40 ft, fly 80 ft. Bite +11 (2d10+6 piercing), Claw +11 (2d6+6 slashing), Tail +11 (2d8+6 bludgeoning). Saves Dex +5, Con +7, Wis +5, Cha +7. Skills Deception +7, Insight +5, Perception +9, Stealth +5. Blindsight 60 ft, darkvision 120 ft, passive Perception 19. Wants proof the crown can still be trusted — the intended “win” is a negotiation.',
|
||||
},
|
||||
{
|
||||
name: 'Sevrisk (young green dragon)', challengeRating: '5', ac: 18, maxHp: 136, speed: 40,
|
||||
strScore: 19, dexScore: 12, conScore: 17, intScore: 16, wisScore: 13, chaScore: 15, attackBonus: 7, damageDice: '2d6+4',
|
||||
specialTrait: 'Poison breath (recharge 5–6): 30-ft cone, DC 14 Con, 8d6 poison (half on success).',
|
||||
notes: 'Large dragon, 1,800 XP. Swim 40 ft, fly 80 ft. Bite +7 (2d6+4 piercing +2d6 poison), Claw +7 (2d4+4 slashing). Territorial and opportunistic — a straightforward combat threat, in contrast to Threndir.',
|
||||
},
|
||||
{
|
||||
name: 'Stonejaw Hill Giant', challengeRating: '5', ac: 13, maxHp: 105, speed: 40,
|
||||
strScore: 21, dexScore: 8, conScore: 19, intScore: 5, wisScore: 9, chaScore: 6, attackBonus: 8, damageDice: '3d8+5',
|
||||
specialTrait: 'Honor-bound — fights fairly and expects the same.',
|
||||
notes: 'Huge giant, 1,800 XP each (Korgath Stonejaw’s Spine Clan warband). Greatclub +8 (3d8+5 bludgeoning), Rock +8, 60/240 ft (3d10+5 bludgeoning). Passive Perception 9, speaks Giant. A trial or contest (Trial of Stone) fits better than an ambush.',
|
||||
},
|
||||
{
|
||||
name: 'Spine Wyvern', challengeRating: '3', ac: 13, maxHp: 68, speed: 20,
|
||||
strScore: 19, dexScore: 10, conScore: 16, intScore: 5, wisScore: 12, chaScore: 6, attackBonus: 6, damageDice: '2d6+4',
|
||||
specialTrait: 'Territorial, not evil — attacks anything lingering near a nest, ignores parties that move on.',
|
||||
notes: 'Large dragon, 700 XP. Fly 80 ft. Bite +6 (2d6+4 piercing), Stinger +6 (2d6+4 piercing, DC 15 Con or poisoned 1 minute). Perception +4, darkvision 60 ft, passive Perception 14.',
|
||||
},
|
||||
{
|
||||
name: 'Reef Terror', challengeRating: '3', ac: 13, maxHp: 68, speed: 20,
|
||||
strScore: 19, dexScore: 10, conScore: 16, intScore: 5, wisScore: 12, chaScore: 6, attackBonus: 6, damageDice: '2d6+4',
|
||||
specialTrait: 'Territorial — drags fishers under near its reef.',
|
||||
notes: 'Reskinned Spine Wyvern for Side Quest: What the Tide Brought In — fins instead of wings, no flight, swim speed instead. Bite +6 (2d6+4 piercing), Stinger +6 (2d6+4 piercing, DC 15 Con or poisoned). NOT connected to the Hollow King.',
|
||||
},
|
||||
{
|
||||
name: 'Greywood Direwolf', challengeRating: '1', ac: 14, maxHp: 37, speed: 50,
|
||||
strScore: 17, dexScore: 15, conScore: 15, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 5, damageDice: '2d6+3',
|
||||
specialTrait: 'Pack tactics — advantage if an ally is within 5 ft of the target and not incapacitated.',
|
||||
notes: 'Large beast, 200 XP each. Bite +5 (2d6+3 piercing, DC 13 Str or prone). Perception +3, Stealth +4. Use 3–4 at once — the clearest Action Economy lesson for a new table.',
|
||||
},
|
||||
{
|
||||
name: 'Highcrown Cutthroat', challengeRating: '1/8', ac: 12, maxHp: 11, speed: 30,
|
||||
strScore: 11, dexScore: 12, conScore: 12, intScore: 10, wisScore: 10, chaScore: 10, attackBonus: 3, damageDice: '1d6+1',
|
||||
specialTrait: null,
|
||||
notes: 'Medium humanoid, 25 XP each. Shortsword +3 (1d6+1 piercing), Dagger (thrown) +3, 20/60 (1d4+1 piercing). Ordinary desperate people — kill them, scare them off, or find out why they’re desperate?',
|
||||
},
|
||||
{
|
||||
name: 'Bandit Captain', challengeRating: '2', ac: 15, maxHp: 65, speed: 30,
|
||||
strScore: 15, dexScore: 16, conScore: 14, intScore: 10, wisScore: 10, chaScore: 10, attackBonus: 5, damageDice: '1d6+3',
|
||||
specialTrait: 'Multiattack (two scimitar attacks).',
|
||||
notes: 'Medium humanoid, 450 XP. Scimitar +5 (1d6+3 slashing), Dagger (thrown) +5, 20/60 (1d4+3 piercing). Leads a band of 4–6 cutthroats.',
|
||||
},
|
||||
{
|
||||
name: 'Hollowed', challengeRating: '1', ac: 12, maxHp: 27, speed: 30,
|
||||
strScore: 12, dexScore: 10, conScore: 12, intScore: 10, wisScore: 10, chaScore: 8, attackBonus: 3, damageDice: '1d6+1',
|
||||
specialTrait: 'Almost right — DC 15 Insight notices the wrongness (no blink, a repeated phrase, no reflection) before it acts. Hollow whisper — 1/day speaks one sentence in Vessa Hollow’s voice to its target.',
|
||||
notes: 'Medium humanoid, 200 XP. HORROR register. Slam +3 (1d6+1 bludgeoning); on a hit DC 12 Wis save or frightened 1 round. Deception +3 while passing as themselves. The horror is in the roleplay, not the numbers.',
|
||||
},
|
||||
{
|
||||
name: 'Bellcrawler', challengeRating: '2', ac: 14, maxHp: 32, speed: 30,
|
||||
strScore: 12, dexScore: 15, conScore: 16, intScore: 3, wisScore: 12, chaScore: 4, attackBonus: 5, damageDice: '2d6+2',
|
||||
specialTrait: 'Mimic bell-toll — lures the curious. Stillness — motionless and silent the instant it’s observed; DC 17 Perception to spot while still.',
|
||||
notes: 'Small monstrosity, 450 XP. HORROR register. Climb 30 ft. Bite +5 (2d6+2 piercing), Skitter (disengage as a bonus action). Stealth/dread, not a straight fight.',
|
||||
},
|
||||
{
|
||||
name: 'Root Stalker', challengeRating: '4', ac: 15, maxHp: 78, speed: 30,
|
||||
strScore: 17, dexScore: 14, conScore: 13, intScore: 6, wisScore: 14, chaScore: 6, attackBonus: 6, damageDice: '2d8+3',
|
||||
specialTrait: 'Silent approach — advantage on its first attack each combat. Grasping roots — on a hit, target restrained (DC 14 Str to escape) until the Stalker is damaged.',
|
||||
notes: 'Large aberration, 1,100 XP. HORROR register — signature Aldenfell monster. Burrow 20 ft. Multiattack (two Root Lash +6, 2d8+3 bludgeoning). Tremorsense 60 ft (blind beyond), passive Perception 12. Run as a slow, dreadful stalk.',
|
||||
},
|
||||
{
|
||||
name: 'The Hollow King’s Shadow', challengeRating: '7', ac: 14, maxHp: 90, speed: 30,
|
||||
strScore: 10, dexScore: 14, conScore: 10, intScore: 16, wisScore: 16, chaScore: 18, attackBonus: 8, damageDice: '3d8',
|
||||
specialTrait: 'Not yet real — at 0 HP it disperses for the scene, never dies. Echoing dread — starting a turn within 30 ft: DC 15 Wis or frightened. Passes through walls.',
|
||||
notes: 'Large undead (incorporeal), 2,900 XP but uncounted — DO NOT run as a fair fight. Fly 30 ft (hover). Chilling touch +8 (3d8 necrotic). Treat HP as a ticking clock: interrupt, banish, or outlast it (close a ritual circle, reach sunlight/a warded threshold, survive N rounds). Save its true, killable form for Act 4.',
|
||||
},
|
||||
{
|
||||
name: 'Skeleton', challengeRating: '1/4', ac: 13, maxHp: 13, speed: 30,
|
||||
strScore: 10, dexScore: 14, conScore: 15, intScore: 6, wisScore: 8, chaScore: 5, attackBonus: 4, damageDice: '1d6+2',
|
||||
specialTrait: 'Undead — vulnerable to bludgeoning',
|
||||
notes: 'Ordinary restless dead for the Sunken Vaults’ Restless Ossuary (Room 4). Not tied to the Hollow King; DC 12 Religion identifies that proper deconsecration would settle them.',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encounters — pre-built (PLANNING) for the combat beats of the Campaign Book.
|
||||
// Each participant is [monster name, count]; instances get "#n" suffixes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const encounters = [
|
||||
{ name: 'E3 — Sunken Vaults: The Restless Ossuary', monsters: [['Skeleton', 2]] },
|
||||
{ name: 'E6 — The Bell-Ringer’s Debt: Collectors', monsters: [['Highcrown Cutthroat', 3]] },
|
||||
{ name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', monsters: [['Bandit Captain', 1], ['Highcrown Cutthroat', 4]] },
|
||||
{ name: 'Side Quest — The Warden’s Silence: Direwolf Pack', monsters: [['Greywood Direwolf', 3]] },
|
||||
{ name: 'Side Quest — The Warden’s Silence: The Shrine', monsters: [['Hollowed', 1]] },
|
||||
{ name: 'Side Quest — What the Tide Brought In: The Reef', monsters: [['Reef Terror', 1]] },
|
||||
{ name: 'E9 — The Root: Weeping Passage', monsters: [['Bellcrawler', 1]] },
|
||||
{ name: 'E9 — The Root: Hollowed Nest', monsters: [['Hollowed', 2]] },
|
||||
{ name: 'E9 — The Root: Root Stalker’s Warren', monsters: [['Root Stalker', 1]] },
|
||||
{ name: 'E9 — The Root: Bellcrawler Gallery', monsters: [['Bellcrawler', 3]] },
|
||||
{ name: 'E12 — A Line Thins', monsters: [['Root Stalker', 1]] },
|
||||
{ name: 'E14 — The Break', monsters: [['Hollowed', 4]] },
|
||||
{ name: 'Mid-campaign scare — The Hollow King’s Shadow', monsters: [['The Hollow King’s Shadow', 1]] },
|
||||
{ name: 'E25 — The Last Approach', monsters: [['Root Stalker', 1], ['Bellcrawler', 2]] },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notes. Listed in reading order; the seed timestamps them so the first entry
|
||||
// shows at the top of the campaign's note list.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sharedNotes = [
|
||||
`Welcome to Aldenfell — Player Primer
|
||||
|
||||
You’re a stranger arriving in Highcrown, the capital, right as the yearly Founding Fair kicks off — a good excuse for adventurers from every corner of the kingdom to cross paths in one place.
|
||||
|
||||
The setting: Aldenfell is a kingdom of knightly orders, old noble houses, and a royal court that’s held things together for generations. To the west, the ancient Greywood forest keeps its own secrets and its own guardians. To the north and east, the mountains of the Spine are home to giant clans and dragons who are neighbors and political players, not just monsters to slay. The Salt Coast to the south is full of independent-minded fishing towns and rumors of things in the water. A world built for classic adventure — quests, courts, monsters, treasure — with real weight behind the choices you make.
|
||||
|
||||
A note on tone: most of this campaign plays like a bright, exciting fantasy adventure. Every so often, especially if you go looking underground, it gets genuinely unsettling. You’ll always get a clear signal before things turn scary, so lean in when it happens.
|
||||
|
||||
Building your character: we’ll roll to discover your species and class, then roll your ability scores. Knights, wandering scholars, forest-raised rangers, self-taught spellcasters — everything has a place here.`,
|
||||
];
|
||||
|
||||
const dmNotes = [
|
||||
`Overview & Tone
|
||||
|
||||
Aldenfell looks whole from the road and is rotting from underneath. Knights ride under bright banners, dragons nest in the high peaks, the court still feasts — but something old and hungry is stirring beneath the capital, and every faction is maneuvering around that fact instead of dealing with it. The party arrives as the cracks start showing.
|
||||
|
||||
Tone pillars (in priority order):
|
||||
• Heroic adventure first — the classic fantasy toybox, played straight and fun.
|
||||
• Real consequences — choices stick; burned bridges stay burned; stalling on the central threat visibly worsens the world. Not punishing, just earned.
|
||||
• Horror as a spice — surface world plays bright; horror spikes in telegraphed places (the Hollow Below). Always signal before a scene turns scary.
|
||||
|
||||
Tone dial (out of 5): Heroism 5 · Intrigue 3 · Combat 4 · Horror 3 (5 in the Underworld) · Comedy 2.
|
||||
|
||||
Regions: Highcrown (capital, home base) · The Greywood (old forest, fey, Wardens) · The Spine (dragons & giants) · The Salt Coast (smuggling, sea monsters) · The Bramble Marches (contested border) · The Hollow Below (underneath everything).`,
|
||||
|
||||
`The Central Conflict — Campaign Clock
|
||||
|
||||
Something sealed beneath the capital centuries ago — the Hollow King — is waking. It doesn’t attack at first; it corrupts. Use this as a VISIBLE clock; it advances only between major story beats.
|
||||
|
||||
Stage 1 — Omens: livestock/crops fail near old shrines; shared identical nightmares in Highcrown; minor nobles acting strangely loyal to no one. (Session One sits here.)
|
||||
Stage 2 — Cracks: the boundary thins in mappable places; monsters surface near cracks; a Bramble Marches faction goes quiet; the corrupted noble house is revealed.
|
||||
Stage 3 — Open wound: a crack becomes a breach; a town/district is lost or quarantined; the crown publicly acknowledges it and fractures; factions jockey for power.
|
||||
Stage 4 — The Hollow King wakes: endgame, shaped by what the party did in stages 1–3.
|
||||
|
||||
Real consequences: an ally lost in stage 2 stays lost; a faction alienated in stage 1 won’t help in stage 3; a region left to fall stays changed.
|
||||
|
||||
Branch points to track: faction(s) sided with · named NPCs alive/allied/dead/estranged · regions with lasting damage · how much the public knows by Act 3.`,
|
||||
|
||||
`Factions
|
||||
|
||||
• The Crown — King Aldric Veyrmont, aging, cornered, in denial. Heir Princess Isolde Veyrmont: capable, well-liked, quietly building her own circle — a strong ally for the party.
|
||||
• Order of the Grey Dawn — premier knightly order sworn to the crown. Split between traditionalists and a faction that thinks the court is hiding something. “You’re recruited” is a clean session-one hook.
|
||||
• The Merchant Concord — trade coalition running Highcrown’s economy. Neutral, pragmatic, pays for side quests, quick to abandon anyone bad for business.
|
||||
• Wardens of the Greywood — rangers/druids who answer to no crown. Suspicious, not hostile; first to notice the cracks near the old shrines.
|
||||
• Spine Clans & dragon eyries — several giant clans and named dragons treating Aldenfell as a neighboring power. “Talk your way out of this” and “earn an ally” scenes.
|
||||
• A corrupted noble house — pick a minor house NOW; don’t reveal until Stage 2. Let their behavior be subtly off from session one.`,
|
||||
|
||||
`Notable NPCs
|
||||
|
||||
• King Aldric Veyrmont — ruler. Tired, proud, in denial. Pays well for quiet problem-solving; reacts badly to blunt truth.
|
||||
• Princess Isolde Veyrmont — heir. Sharp, pragmatic. Best long-term ally; recruits capable outsiders on purpose.
|
||||
• Ser Kavan Thorne — Grey Dawn knight-commander. Rigid, honorable, loyal to a fault. Recruits the party; struggles when evidence points at the crown.
|
||||
• Mireille Costa — Merchant Concord rep. Charming, transactional, always has a job.
|
||||
• Ashwen — Warden of the Greywood. Terse, watchful. First to notice the shrines; a slow-burn ally.
|
||||
• Threndir Ashclaw — old, weary copper dragon of the Spine. Blunt, wants the kingdom to survive. Talks before fighting.
|
||||
• Korgath Stonejaw — giant clan chieftain. Honor-bound, no patience for politics. Trials, contests, bargains.
|
||||
• Vessa Hollow — a voice, not yet a face. Calm, almost kind, wrong. Use in dreams/omens long before she appears.
|
||||
• Corporal Ressa Vane — Grey Dawn warden escort (E9C); reports everything back to Grey Dawn Hall.
|
||||
• The bell-ringer — rescued in Session One; returns in The Bell-Ringer’s Debt.`,
|
||||
|
||||
`DM SECRETS & TWISTS (eyes only)
|
||||
|
||||
• What the Hollow King is: the accumulated, imprisoned will of everyone Highcrown’s founders sacrificed to secure their claim to the land. The seal is a suppression of many, not a prison for one. The resolution is about what the party does with that truth (bury it, expose it, make amends) — not just a sword fight.
|
||||
• Vessa Hollow: one of the sacrificed, the Hollow King’s sympathetic “face.” She believes waking the rest is justice. Unsettling, never mustache-twirling. Negotiating with her is a legitimate outcome.
|
||||
• The corrupted noble house: choose whichever minor house you introduced most casually in Act 1. Their head bargained with Vessa years ago for prosperity and believes they’re managing the influence rather than being used.
|
||||
• Ser Kavan Thorne: the Grey Dawn’s founding is tied to the original sacrifice. Thorne doesn’t know. Whether/how the party tells him is theirs to choose.
|
||||
• Princess Isolde: already suspects something is deeply wrong; most likely to believe the party early.`,
|
||||
|
||||
`Session One — The Bell That Wasn’t Ringing
|
||||
|
||||
Setup: all PCs are in Highcrown for the Founding Fair. Build characters at the table (Roll-Your-Character System) — can be session one’s first half.
|
||||
|
||||
Hook: the old chapel bell, which has rung every hour for as long as anyone remembers, stops. A city guard (or Ser Kavan Thorne) gathers volunteers to check the chapel.
|
||||
|
||||
What they find: no break-in. The bell rope is neatly cut and left coiled. The bell-ringer is missing, not dead. A line of unfamiliar script is scratched inside the bell (Hollow King iconography). A frightened local saw “a woman who wasn’t blinking.” The trail leads to the Sunken Vaults beneath the chapel.
|
||||
|
||||
Light dungeon crawl: 2–4 easy/medium encounters (feral guard dog, low-level undead) — nothing horror-register yet. The bell-ringer is found alive but shaken, having seen “someone who looked like his neighbor, except wrong.”
|
||||
|
||||
Ending: the bell rings again, the fair goes on, nothing is solved. They leave with a taste of combat, one unanswered mystery, and at least one NPC connection.`,
|
||||
|
||||
`Act 1: Omens — E1–E8
|
||||
|
||||
E1 Founding Fair (Lantern Market): character creation, no combat → E2.
|
||||
E2 The Bell That Wasn’t Ringing (Founding Chapel): DC 13 Investigation finds the cut rope & script; DC 12 Persuasion/Insight gets the “woman who wasn’t blinking.”
|
||||
• E2A investigate immediately: full clues; skip Vault Room 1.
|
||||
• E2B distracted first: guard missed the script; DC 13 Investigation to find it; start Room 1.
|
||||
• E2C talk to the crowd first: 3 witness accounts, no script lead; E10 Insight later at advantage.
|
||||
E3 The Sunken Vaults: run Vault Rooms 1–6; bell-ringer found in Room 5 → E4.
|
||||
E4 Aftermath & Faction Hooks (all open doors, can run in parallel):
|
||||
• E4A Grey Dawn recruitment → Grey Dawn ending track.
|
||||
• E4B Concord job board → The Smugglers’ Tithe.
|
||||
• E4C Warden contact → The Warden’s Silence.
|
||||
• E4D Caravan to the Spine → Trial of Stone → Spine Clans track.
|
||||
• E4E Investigate independently (DC 15 Investigation) → Independent track.
|
||||
E5 Omens Montage: 2–3 short scenes across sessions (shrine livestock, shared nightmares — DC 12 Insight, the odd noble).
|
||||
E6 The Bell-Ringer’s Debt (optional side quest).
|
||||
E7 A Closer Look: investigating the noble house hits dead ends — unless E2C, then DC 13 Insight catches a servant who flinched.
|
||||
E8 Turning the Page: after 2+ E4 threads and one E5 omen, advance clock to Stage 2 → E9.`,
|
||||
|
||||
`Act 2: Cracks — E9–E13
|
||||
|
||||
E9 First Descent (The Root, Weeping Passage). Signal the tone shift. DC 14 Survival notices the dripping is a Hollowed’s footsteps.
|
||||
• E9A retreat: keep what they learned, full rest before re-entry.
|
||||
• E9B push through alone: Root Rooms 1–4; Weeping Passage marking matches the bell script.
|
||||
• E9C (took E4A): Corporal Ressa Vane escorts, skips to Room 2 — but reports back (matters at E10B, E15).
|
||||
• E9D (won Trial of Stone): Spine guide enters via Room 4; hears the legend of “the sleeper under the hollow crown”; DC 13 Survival or wake the Root Stalker at the entrance.
|
||||
E10 The House Unmasked (Highcrown). Ledger: every shrine “donation” falls three days after a livestock death.
|
||||
• E10A confront directly: DC 16 Persuasion/Intimidation with proof, DC 20 without.
|
||||
• E10B report to crown/Isolde/Kavan: sets up Act 3 politics; if E9C, Kavan already suspected (trust bump).
|
||||
• E10C (E2C + servant thread): private DC 13 check at advantage → confession + sealed letter; the house never learns it’s suspected. Best route into Act 3.
|
||||
E11 Silence on the Border (Greyford Keep): gate open, no bodies. DC 13 Perception: drag marks — the defenders left. Smugglers’ Tithe outcome decides rescue vs recovery. E11C (Spine allied): clans already there, share Root Stalker migration intel. Treasure: 2d6×10 gp trade goods + masterwork weapon.
|
||||
E12 A Line Thins: a Root Stalker surfaces near a location the party didn’t secure. DC 15 Arcana/Religion: deliberate erosion from below. Key item: root-fiber scrap (needed for E14/E18).
|
||||
E13 The Clock Turns → Stage 3.`,
|
||||
|
||||
`Act 3: Open Wound — E14–E20
|
||||
|
||||
E14 The Break: the E12 crack becomes a breach; 2–4 Hollowed pour out. DC 15 Arcana: shaped for something to come through.
|
||||
• E14A seal it (DC 17 Arcana or chapel ritual): closes, but a debt comes due at E17.
|
||||
• E14B evacuate instead: no deaths, but the breach stays open, worsening omens until E18.
|
||||
Key item: seal-stone fragment (needed at E19).
|
||||
E15 The House Falls (or Doesn’t):
|
||||
• E15A (after E10A): house prepared — DC 18 to break the cover story, or fight guards + a Hollowed retainer.
|
||||
• E15B (after E10B): public tribunal — DC 16 Persuasion/Insight for exile over execution.
|
||||
• E15C (after E10C): strike the cult infrastructure directly → E15C-1 capture a cult lieutenant who reveals the Hollow Court’s surface entrance.
|
||||
E16 What the Marches Cost (Greyford Keep):
|
||||
• E16A reinforced (E11C): the keep holds; Spine standing rises.
|
||||
• E16B under-supplied: the keep falls; a named refugee NPC dies unless DC 16 Athletics/Survival rescue.
|
||||
E17 The Debt Comes Due: pay off E14A, unresolved side quests, or ignored omens — or a quiet breather.
|
||||
E18 Into the Hollow Court: Court Rooms 1–3. E18A standard entry via the Root’s Descent; E18B (lieutenant captured) skip to Room 2.
|
||||
E19 Vessa’s Seat (Court Room 4): the party learns the Hollow King’s true nature from Vessa. Root-fiber + seal-stone (DC 15 Arcana, auto with a Religion-proficient caster) → advantage on the first check at E22.
|
||||
E20 The Clock Turns Again → Stage 4.`,
|
||||
|
||||
`Act 4: The Hollow King Wakes — E21–E27
|
||||
|
||||
E21 Rally Point (Highcrown or Greyford): who shows up depends on the branch list.
|
||||
• E21A Grey Dawn track: Kavan + wardens, by-the-book.
|
||||
• E21B Spine Clans track: war-band; advantage on the first Survival check at E22.
|
||||
• E21C Independent: no reinforcements; every choice stays theirs.
|
||||
• Multiple tracks: groups arrive and disagree — the party mediates.
|
||||
Treasure: a minor magic item as a going-away gift.
|
||||
E22 The Seal Chamber (Court Room 5) — the key branch:
|
||||
• E22A reinforce (DC 18 Arcana/Religion): Sealed Ending; the seal-stone becomes a unique magic item.
|
||||
• E22B break it deliberately: E23 on the party’s terms — the only path to true victory.
|
||||
• E22C do nothing: seal fails anyway; E24–E25 are harder.
|
||||
E23 The Waking Begins (Court Room 6): E22B = no surprise, full resources; E22C = apply one escalation stage first.
|
||||
E24 What the Court Remembers: vision encounter; DC 14 Insight recognizes manipulation vs truth.
|
||||
E25 The Last Approach: gauntlet of 2–3 horror-register creatures; allied faction fights alongside.
|
||||
E26 The Hollow King: build the stat block from DM Secrets, scaled to party. Grey Dawn = disciplined line; Spine Clans = terrain & ritual (collapse passages, the Memory Pool); Independent = hardest, entirely theirs.
|
||||
E27 Endings: Sealed (bought time) · Grey Dawn (institution absorbs the heroes) · Spine Clans (credit finally given) · Independent (their own story). Close back at the Lantern Market, a year later.`,
|
||||
|
||||
`Side Quest — The Smugglers’ Tithe
|
||||
Salt Coast (Tidewatch, Smuggler’s Cove) · Lv 1–3 · Highcrown Cutthroats, Bandit Captain
|
||||
|
||||
Hook: Mireille Costa hires the party to learn why a cut of Tidewatch’s smuggled goods is vanishing before reaching buyers.
|
||||
Truth: a Bandit Captain is extorting the smugglers and funneling the “tithe” to arm one side of the Bramble Marches conflict.
|
||||
Beats: investigate Tidewatch (smugglers distrust outsiders) → track a shipment to Smuggler’s Cove → confront the crew (fight, negotiate, or both).
|
||||
Outcomes: end it quietly (Mireille pays well) or expose where the tithe was headed (pulls the Marches plot forward). Decides whether Greyford is supplied at E11/E16.`,
|
||||
|
||||
`Side Quest — The Warden’s Silence
|
||||
The Greywood (Warden’s Lodge, an Old Shrine) · Lv 1–2 · Greywood Direwolves, one Hollowed
|
||||
|
||||
Hook: Ashwen asks the party to check on a Warden who stopped reporting in from a shrine site.
|
||||
Truth: the Warden was Hollowed after investigating the shrine’s crack alone — the party’s first, sympathetic Hollowed.
|
||||
Beats: track the Warden → direwolf pack on the way (agitated, not evil) → the Hollowed Warden at the shrine; DC 15 Insight reveals the wrongness.
|
||||
Outcomes: fight, or restrain and bring back to Ashwen (hard questions), or it escapes to report back. Either way Ashwen is convinced something is deeply wrong.`,
|
||||
|
||||
`Side Quest — Trial of Stone
|
||||
The Spine (Stonejaw Hold) · Lv 2–4 · Korgath Stonejaw’s Hill Giants
|
||||
|
||||
Hook: the party needs Korgath’s clan as an ally; he offers a trial instead of agreement.
|
||||
Truth: nothing sinister — a genuine test of character.
|
||||
Trials (per PC or shared): non-lethal wrestling with a giant; a test of honesty; proving their word is good (retrieve something, deliver a message honestly).
|
||||
Beats: arrive → formal challenge → trial → feast on success.
|
||||
Outcomes: success = standing alliance (Spine Clans track). Failure isn’t fatal — Korgath offers a second, harder trial.`,
|
||||
|
||||
`Side Quest — The Bell-Ringer’s Debt
|
||||
Highcrown · Lv 1–2 · Highcrown Cutthroats · Follows Session One
|
||||
|
||||
Hook: the rescued bell-ringer owes a Highcrown underworld lender; interest is compounding fast.
|
||||
Truth: the lender operates under the corrupted noble house’s protection (not knowable yet).
|
||||
Beats: hear him out → track the collectors → pay the debt, intimidate the lender, or dig into who backs the lender (harder, more rewarding).
|
||||
Outcomes: a loyal low-key ally either way. Digging plants a connect-the-dots clue toward the corrupted house before Act 2.`,
|
||||
|
||||
`Side Quest — Refugees of the Marches
|
||||
Bramble Marches (Greyford Keep, the Sorrow Camp) · Lv 2–4 · Cutthroats or a Bandit Captain · The clearest moral-choice quest
|
||||
|
||||
Hook: the Sorrow Camp is overwhelmed and preyed on by aid-skimmers; Greyford’s commander wants the camp “moved along.”
|
||||
Truth: no clean villain — the commander isn’t wrong about the strategic liability; the refugees have nowhere else to go.
|
||||
Beats: tension between camp and garrison → find who’s skimming aid → confront them → broker (or fail to broker) an arrangement.
|
||||
Outcomes: genuinely branching — lasting goodwill, or a hardened Marches conflict. Introduce a named refugee NPC here (at risk at E16B).`,
|
||||
|
||||
`Side Quest — What the Tide Brought In
|
||||
Salt Coast (Tidewatch) · Lv 2–3 · Reef Terror (reskinned Spine Wyvern) · Horror-lite
|
||||
|
||||
Hook: a fishing boat washes up empty — sails intact, catch in the hold, crew gone.
|
||||
Truth: NOT connected to the Hollow King (a deliberate palate-cleanser). A territorial sea creature drags fishers under near a reef.
|
||||
Beats: DC 13 Investigation finds claw marks, not violence → spooked fishers give the reef’s location → confront at sea or lure to shallows.
|
||||
Outcomes: safe fishing reopens and real goodwill in Tidewatch — an uncomplicated “you helped real people” win.`,
|
||||
|
||||
`Dungeon — Sunken Vaults (Layer 1)
|
||||
|
||||
1. The Collapsed Stair (entry) — spiral stair, rusted gate on one hinge. No monster. DC 12 Perception: fresh footprints (the bell-ringer’s). → Room 2.
|
||||
2. The Old Crypt Hall — hub; sealed niches, some open. DC 13 Investigation: just old theft. Good spot for a feral dog or low undead. Treasure: silver locket 15 gp (DC 12). → 3, 4, stair to 6.
|
||||
3. Smugglers’ Cache — crates with an unknown merchant sigil. DC 14 Investigation ties it to a lender linked to the corrupted house. Treasure: ~40 gp trade goods + ledger page with partial name. Dead end.
|
||||
4. The Restless Ossuary — 1–2 skeletons rise halfway through (ordinary dead). DC 12 Religion: deconsecration would settle them. Treasure: offering box (DC 13) with 25 gp. Dead end.
|
||||
5. The Bell-Ringer’s Refuge — bedroll, cold lantern. The bell-ringer is found here in E3; otherwise a squatter’s camp. Branch off Room 2 or 6.
|
||||
6. The Descent — ragged hole, wrong cold air. Transition to the Root; signal the tone shift. The party can always turn back. → Root Room 1.`,
|
||||
|
||||
`Dungeon — The Root (Layer 2)
|
||||
|
||||
1. The Threshold — almost-regular dripping. Nothing attacks. DC 15 Perception: the rhythm changes when someone speaks. Retreat always safe. → 2.
|
||||
2. The Weeping Passage — walls “weep.” E9B: marking matches the bell script (DC 16 Arcana/Religion). Maybe one Bellcrawler using Stillness (DC 17 Perception). → 3 (east), 4 (south).
|
||||
3. Hollowed Nest — tidy cavern, bedrolls. 1–2 Hollowed; DC 15 Insight before combat. Maybe a named missing person. Treasure: ~20 gp effects + (DC 14) half-burned letter hinting at the corrupted house. → 2, 5.
|
||||
4. Root Stalker’s Warren — moving roots. Root Stalker lair; DC 14 Perception or its first attack has advantage. Treasure: 60 gp + masterwork dagger (50 gp). Dead end (optional secret passage to 6).
|
||||
5. The Drowned Shrine — pre-kingdom altar in black water. DC 16 History/Religion: older than Aldenfell. Treasure: silver relic 75 gp (DC 13 Athletics/Investigation). → 2, crawl to 6.
|
||||
6. Bellcrawler Gallery — ledges over a drop; bell-toll lure. 2–3 Bellcrawlers; DC 15 Perception; DC 13 Dex or fall 2d6. Treasure: 90 gp + potion of healing. → 2, 7.
|
||||
7. The Descent to the Hollow Court — a stair too regular to exist. Off-limits before Act 4; let them see it and turn back. → Court Room 1.`,
|
||||
|
||||
`Dungeon — The Hollow Court (Layer 3)
|
||||
|
||||
1. The Bent Stair — the stair disagrees with itself. DC 15 Wis save or lose track of time (optionally lose 10 minutes). No monster. → 2.
|
||||
2. The Memory Pool — each PC sees a personal vision tied to their backstory/choices (payoff for the branch list). No damage; can choose not to look. DC 17 Insight to understand it. → 3.
|
||||
3. The Court of Whispers — overlapping voices; Vessa may speak plainly here. DC 16 Insight: she believes what she says. Any combat uses Hollowed guardians, not Vessa. → 4.
|
||||
4. Vessa’s Seat — a single worn stone seat. Meet or confront Vessa; strong candidate for a negotiated outcome. → 5.
|
||||
5. The Seal Chamber — a wound stitched shut, coming undone. DC 18 Arcana/Religion reveals the seal is a suppression of many. Optional DC 14 Wis saves vs disorientation. Treasure: the campaign’s single most significant magic item. → 6.
|
||||
6. The Waking — the end of the dungeon. Don’t decide its contents until Act 3 is complete.`,
|
||||
|
||||
`DM Tables — Skill DCs, Treasure, Downtime
|
||||
|
||||
DCs: Very Easy 5 · Easy 10 · Medium 15 · Hard 20 · Very Hard 25 · Nearly Impossible 30.
|
||||
Recurring: spot a Hollowed (Insight 15) · Hollow Below wrongness (Perception 14–17) · Hollow King iconography (Religion/Arcana 16) · read a political room (Insight 13) · talk down a dragon/giant (Persuasion 15–18) · track (Survival 13) · simple lock/trap (12) · recall lore (History 14) · haggle (Persuasion 12) · resist horror fear (Wis save 14–15).
|
||||
|
||||
Treasure Lv 1–4 (d10): 1 2d10×5 sp · 2 1d6×10 gp · 3 trade good 10–30 gp · 4 masterwork weapon/tool 50 gp · 5 2d6×10 gp · 6 NPC keepsake · 7 potion of healing · 8 1d4×25 gp gems · 9 1st-level scroll · 10 minor magic item.
|
||||
Treasure Lv 5–8 (d10): 1 2d6×25 gp · 2 art object 50–150 gp · 3 masterwork armor · 4 1d6×50 gp gems · 5 greater healing · 6 2nd/3rd-level scroll · 7 uncommon magic item · 8 3d6×25 gp · 9 faction reward · 10 rare magic item.
|
||||
Treasure Lv 9+ (d8): 1 3d6×50 gp · 2 rare item · 3 very rare item · 4 relic 500+ gp · 5 4th/5th-level scroll · 6 faction favor · 7 supreme healing · 8 DM’s choice.
|
||||
|
||||
Downtime (~1 week each): Carousing (1d6×10 gp, DC 12 Persuasion for a rumor/contact) · Crafting (half price in materials) · Training (~25 gp/week) · Faction work (standing, not gold) · Research (Sunken Library or faction records, DC 15 Investigation/History for one clue) · Recuperation (roleplay closure).`,
|
||||
|
||||
`DM Tables — Rumors
|
||||
|
||||
Act 1 (d8): 1 “The bell stopped once before, generations back.” (Misleading — never happened) · 2 “Old man Farrow’s cows went dry past the shrine road.” (True) · 3 “Princess Isolde’s been asking sharp questions of the temple scholars.” (True) · 4 “Ser Kavan turned away a recruit for asking about the crown.” (False) · 5 “There’s gold in the old crypts under the city.” (Misleading — modest and dangerous) · 6 “A noble house has been paying off debts all over the city.” (True — the corrupted house) · 7 “The Wardens haven’t been seen at the eastern shrine in weeks.” (True) · 8 “Something’s wrong with the water past Tidewatch.” (True — What the Tide Brought In)
|
||||
|
||||
Act 2 (d8): 1 “Salt Coast smugglers work for someone bigger now.” (True) · 2 “A Warden went missing near the shrines.” (True/resolved) · 3 “Something down there talked back.” (True) · 4 “That noble house is buying property near the docks.” (Misleading — the reason is worse than greed) · 5 “The King’s been ill.” (False — cornered, in denial) · 6 “The Spine dragons are stirring — war’s coming.” (False) · 7 “Refugees are pouring into the Marches.” (True) · 8 “A missing person was seen acting strange, then vanished.” (True — Hollowed)
|
||||
|
||||
Act 3–4 (d6): 1 “The crown’s lost control of [district].” (True) · 2 “Isolde will move against her father.” (Misleading) · 3 “The old families always knew what’s under the city.” (True) · 4 “A faction says the party caused all this.” (Depends on reputation) · 5 “The sky over Highcrown is different at night.” (True) · 6 “It’s almost over, one way or another.” (True)`,
|
||||
|
||||
`DM Tables — Random Encounters by Region (d6)
|
||||
|
||||
Highcrown: 1 Cutthroat pickpocket (DC 13 Perception) · 2 Grey Dawn patrol · 3 Concord merchant with a small job · 4 street preacher of omens (free rumor) · 5 quiet day · 6 a noble retinue — DC 12 Insight notices something off if it’s the corrupted house.
|
||||
The Greywood: 1 1d3 Greywood Direwolves · 2 Warden patrol · 3 unmarked old shrine · 4 harmless fey mischief · 5 traveler in trouble · 6 quiet — chance for a long rest.
|
||||
The Spine: 1 Spine Wyvern (avoidable) · 2 Stonejaw trading party · 3 signs of dragons · 4 treacherous pass (DC 14 Athletics or detour) · 5 a curious lone giant · 6 empty country.
|
||||
The Salt Coast: 1 smugglers wary of witnesses · 2 short-handed fishing crew · 3 storm (DC 12 Survival for shelter) · 4 rumor of something in the water · 5 petty lord’s toll collector · 6 calm seas.
|
||||
The Bramble Marches: 1 Bandit Captain’s crew · 2 refugees on the road · 3 tense border patrol · 4 signs of the neighboring power · 5 a skirmish in progress · 6 watchful quiet.`,
|
||||
];
|
||||
|
||||
module.exports = { campaign, locations, monsters, encounters, sharedNotes, dmNotes };
|
||||
@@ -0,0 +1,935 @@
|
||||
// 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',
|
||||
description:
|
||||
'A kingdom of knights and dragons is standing on top of something that’s waking up — and you’re the ones who find out first. ' +
|
||||
'Heroic adventure first, real consequences, and horror as a spice rather than the whole meal.',
|
||||
startingGold: 15,
|
||||
startingItemCount: 2,
|
||||
startingSpellCount: 2,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gazetteer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const locations = [
|
||||
// Highcrown
|
||||
{ name: 'Highcrown — The Founding Chapel', description: 'Site of Session One’s bell mystery. Its cellar connects to the Sunken Vaults. Quiet by day, a good recurring “something’s off” location throughout Act 1.' },
|
||||
{ name: 'Highcrown — The Palace of Aldenfell', description: 'King Aldric’s seat. Court is held weekly; an audience without a sponsor (a knight, a noble, a Concord representative) takes real effort. Princess Isolde keeps a smaller, more accessible private study here.' },
|
||||
{ name: 'Highcrown — The Grey Dawn Hall', description: 'Order of the Grey Dawn headquarters. Recruitment, training yards, and Ser Kavan Thorne’s office. The natural starting point for a Fighter/Paladin-leaning party.' },
|
||||
{ name: 'Highcrown — The Concord Exchange', description: 'The Merchant Concord’s trading hall. Job boards, contracts, and Mireille Costa’s usual haunt. Best source of quick, paying side work.' },
|
||||
{ name: 'Highcrown — The Lantern Market', description: 'Highcrown’s main marketplace, busiest during the Founding Fair. Gear, rumors, and pickpockets in equal measure. The campaign opens (E1) and closes here.' },
|
||||
// Greywood
|
||||
{ name: 'Greywood — The Warden’s Lodge', description: 'Ashwen and the other Wardens’ base, a day’s ride from Highcrown. First place to hear rumors about the old shrines acting strangely.' },
|
||||
{ name: 'Greywood — The Old Shrine Sites', description: 'Scattered pre-kingdom shrines throughout the forest; several sit almost directly above Hollow Below cracks. Livestock/crop failures (a Stage 1 omen) cluster near these. → Side Quest: The Warden’s Silence.' },
|
||||
{ name: 'Greywood — The Feywild Grove', description: 'A grove where the boundary with the Feywild is thin. Home to a reclusive, capricious fey court — a “be careful what you promise” side encounter, unrelated to the main plot.' },
|
||||
{ name: 'Greywood — The Sunken Library', description: 'Ruins of a pre-kingdom archive, half-collapsed and root-choked. Contains fragments of the truth about the Hollow King’s origin — a place for the party to piece the mystery together on their own terms.' },
|
||||
// Spine
|
||||
{ name: 'The Spine — Threndir’s Eyrie', description: 'High on the eastern peaks, reachable by a hard climb or careful flight/magic. Threndir Ashclaw grants an audience to anyone who approaches openly and unarmed-in-spirit.' },
|
||||
{ name: 'The Spine — Sevrisk’s Hollow', description: 'A lower, damper cave system claimed by the young green dragon Sevrisk. Far more dangerous to simply wander into.' },
|
||||
{ name: 'The Spine — Stonejaw Hold', description: 'Korgath’s clan settlement in a Spine foothill valley. Outsiders are tested, not attacked on sight. → Side Quest: Trial of Stone.' },
|
||||
{ name: 'The Spine — Ironvein', description: 'A mining town at the Spine’s base, Aldenfell’s main source of iron. Practical resupply stop with its own local rumors (miners going quiet, deeper tunnels avoided).' },
|
||||
// Salt Coast & Bramble Marches
|
||||
{ name: 'Salt Coast — Tidewatch', description: 'The Salt Coast’s largest port town, proudly independent-minded. Good base for coastal side content. → Side Quest: What the Tide Brought In.' },
|
||||
{ name: 'Salt Coast — Smuggler’s Cove', description: 'A hidden inlet south of Tidewatch, used for exactly what the name suggests. → Side Quest: The Smugglers’ Tithe.' },
|
||||
{ name: 'Bramble Marches — Greyford Keep', description: 'A border fortress garrisoned by Aldenfell troops watching the disputed line. Tense, under-resourced, a pressure-cooker location (E11, E16).' },
|
||||
{ name: 'Bramble Marches — The Sorrow Camp', description: 'A refugee camp just inside Aldenfell territory, swollen by people fleeing the Marches’ border tension. → Side Quest: Refugees of the Marches.' },
|
||||
// Hollow Below
|
||||
{ name: 'Hollow Below — The Sunken Vaults (Layer 1)', description: 'Old crypts beneath Highcrown, reached from the Founding Chapel’s cellar or three other points in the city. Low real danger, mundane-spooky. Session One’s crawl (E3) happens here.' },
|
||||
{ name: 'Hollow Below — The Root (Layer 2)', description: 'Natural caves beneath the Greywood and the Spine’s lower slopes. Geography stops being reliable. Hollowed, Bellcrawlers, and the Root Stalker live here. Danger is real.' },
|
||||
{ name: 'Hollow Below — The Weeping Passage', description: 'A Root landmark: a long tunnel where the walls audibly “weep” — a faint, wrong, almost-voice sound. Set-piece location for the party’s first Hollowed.' },
|
||||
{ name: 'Hollow Below — The Hollow Court (Layer 3)', description: 'The seal itself. Off-limits until Act 4. Gravity, light, and memory all bend near it. The Hollow King’s Shadow operates here.' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bestiary (Aldenfell-specific stat blocks). attackBonus/damageDice hold the
|
||||
// creature's primary attack; the rest lives in specialTrait/notes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const monsters = [
|
||||
{
|
||||
name: 'Threndir Ashclaw (old copper dragon)', challengeRating: '8', ac: 17, maxHp: 165, speed: 40,
|
||||
strScore: 23, dexScore: 12, conScore: 17, intScore: 16, wisScore: 13, chaScore: 17, attackBonus: 11, damageDice: '2d10+6',
|
||||
specialTrait: 'Legendary talker — always offers to negotiate before fighting unless attacked first. Acid breath (recharge 5–6): 60-ft line, DC 15 Dex, 10d8 acid (half on success).',
|
||||
notes: 'Huge dragon, 3,900 XP. Climb 40 ft, fly 80 ft. Bite +11 (2d10+6 piercing), Claw +11 (2d6+6 slashing), Tail +11 (2d8+6 bludgeoning). Saves Dex +5, Con +7, Wis +5, Cha +7. Skills Deception +7, Insight +5, Perception +9, Stealth +5. Blindsight 60 ft, darkvision 120 ft, passive Perception 19. Wants proof the crown can still be trusted — the intended “win” is a negotiation.',
|
||||
},
|
||||
{
|
||||
name: 'Sevrisk (young green dragon)', challengeRating: '5', ac: 18, maxHp: 136, speed: 40,
|
||||
strScore: 19, dexScore: 12, conScore: 17, intScore: 16, wisScore: 13, chaScore: 15, attackBonus: 7, damageDice: '2d6+4',
|
||||
specialTrait: 'Poison breath (recharge 5–6): 30-ft cone, DC 14 Con, 8d6 poison (half on success).',
|
||||
notes: 'Large dragon, 1,800 XP. Swim 40 ft, fly 80 ft. Bite +7 (2d6+4 piercing +2d6 poison), Claw +7 (2d4+4 slashing). Territorial and opportunistic — a straightforward combat threat, in contrast to Threndir.',
|
||||
},
|
||||
{
|
||||
name: 'Stonejaw Hill Giant', challengeRating: '5', ac: 13, maxHp: 105, speed: 40,
|
||||
strScore: 21, dexScore: 8, conScore: 19, intScore: 5, wisScore: 9, chaScore: 6, attackBonus: 8, damageDice: '3d8+5',
|
||||
specialTrait: 'Honor-bound — fights fairly and expects the same.',
|
||||
notes: 'Huge giant, 1,800 XP each (Korgath Stonejaw’s Spine Clan warband). Greatclub +8 (3d8+5 bludgeoning), Rock +8, 60/240 ft (3d10+5 bludgeoning). Passive Perception 9, speaks Giant. A trial or contest (Trial of Stone) fits better than an ambush.',
|
||||
},
|
||||
{
|
||||
name: 'Spine Wyvern', challengeRating: '3', ac: 13, maxHp: 68, speed: 20,
|
||||
strScore: 19, dexScore: 10, conScore: 16, intScore: 5, wisScore: 12, chaScore: 6, attackBonus: 6, damageDice: '2d6+4',
|
||||
specialTrait: 'Territorial, not evil — attacks anything lingering near a nest, ignores parties that move on.',
|
||||
notes: 'Large dragon, 700 XP. Fly 80 ft. Bite +6 (2d6+4 piercing), Stinger +6 (2d6+4 piercing, DC 15 Con or poisoned 1 minute). Perception +4, darkvision 60 ft, passive Perception 14.',
|
||||
},
|
||||
{
|
||||
name: 'Reef Terror', challengeRating: '3', ac: 13, maxHp: 68, speed: 20,
|
||||
strScore: 19, dexScore: 10, conScore: 16, intScore: 5, wisScore: 12, chaScore: 6, attackBonus: 6, damageDice: '2d6+4',
|
||||
specialTrait: 'Territorial — drags fishers under near its reef.',
|
||||
notes: 'Reskinned Spine Wyvern for Side Quest: What the Tide Brought In — fins instead of wings, no flight, swim speed instead. Bite +6 (2d6+4 piercing), Stinger +6 (2d6+4 piercing, DC 15 Con or poisoned). NOT connected to the Hollow King.',
|
||||
},
|
||||
{
|
||||
name: 'Greywood Direwolf', challengeRating: '1', ac: 14, maxHp: 37, speed: 50,
|
||||
strScore: 17, dexScore: 15, conScore: 15, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 5, damageDice: '2d6+3',
|
||||
specialTrait: 'Pack tactics — advantage if an ally is within 5 ft of the target and not incapacitated.',
|
||||
notes: 'Large beast, 200 XP each. Bite +5 (2d6+3 piercing, DC 13 Str or prone). Perception +3, Stealth +4. Use 3–4 at once — the clearest Action Economy lesson for a new table.',
|
||||
},
|
||||
{
|
||||
name: 'Highcrown Cutthroat', challengeRating: '1/8', ac: 12, maxHp: 11, speed: 30,
|
||||
strScore: 11, dexScore: 12, conScore: 12, intScore: 10, wisScore: 10, chaScore: 10, attackBonus: 3, damageDice: '1d6+1',
|
||||
specialTrait: null,
|
||||
notes: 'Medium humanoid, 25 XP each. Shortsword +3 (1d6+1 piercing), Dagger (thrown) +3, 20/60 (1d4+1 piercing). Ordinary desperate people — kill them, scare them off, or find out why they’re desperate?',
|
||||
},
|
||||
{
|
||||
name: 'Bandit Captain', challengeRating: '2', ac: 15, maxHp: 65, speed: 30,
|
||||
strScore: 15, dexScore: 16, conScore: 14, intScore: 10, wisScore: 10, chaScore: 10, attackBonus: 5, damageDice: '1d6+3',
|
||||
specialTrait: 'Multiattack (two scimitar attacks).',
|
||||
notes: 'Medium humanoid, 450 XP. Scimitar +5 (1d6+3 slashing), Dagger (thrown) +5, 20/60 (1d4+3 piercing). Leads a band of 4–6 cutthroats.',
|
||||
},
|
||||
{
|
||||
name: 'Hollowed', challengeRating: '1', ac: 12, maxHp: 27, speed: 30,
|
||||
strScore: 12, dexScore: 10, conScore: 12, intScore: 10, wisScore: 10, chaScore: 8, attackBonus: 3, damageDice: '1d6+1',
|
||||
specialTrait: 'Almost right — DC 15 Insight notices the wrongness (no blink, a repeated phrase, no reflection) before it acts. Hollow whisper — 1/day speaks one sentence in Vessa Hollow’s voice to its target.',
|
||||
notes: 'Medium humanoid, 200 XP. HORROR register. Slam +3 (1d6+1 bludgeoning); on a hit DC 12 Wis save or frightened 1 round. Deception +3 while passing as themselves. The horror is in the roleplay, not the numbers.',
|
||||
},
|
||||
{
|
||||
name: 'Bellcrawler', challengeRating: '2', ac: 14, maxHp: 32, speed: 30,
|
||||
strScore: 12, dexScore: 15, conScore: 16, intScore: 3, wisScore: 12, chaScore: 4, attackBonus: 5, damageDice: '2d6+2',
|
||||
specialTrait: 'Mimic bell-toll — lures the curious. Stillness — motionless and silent the instant it’s observed; DC 17 Perception to spot while still.',
|
||||
notes: 'Small monstrosity, 450 XP. HORROR register. Climb 30 ft. Bite +5 (2d6+2 piercing), Skitter (disengage as a bonus action). Stealth/dread, not a straight fight.',
|
||||
},
|
||||
{
|
||||
name: 'Root Stalker', challengeRating: '4', ac: 15, maxHp: 78, speed: 30,
|
||||
strScore: 17, dexScore: 14, conScore: 13, intScore: 6, wisScore: 14, chaScore: 6, attackBonus: 6, damageDice: '2d8+3',
|
||||
specialTrait: 'Silent approach — advantage on its first attack each combat. Grasping roots — on a hit, target restrained (DC 14 Str to escape) until the Stalker is damaged.',
|
||||
notes: 'Large aberration, 1,100 XP. HORROR register — signature Aldenfell monster. Burrow 20 ft. Multiattack (two Root Lash +6, 2d8+3 bludgeoning). Tremorsense 60 ft (blind beyond), passive Perception 12. Run as a slow, dreadful stalk.',
|
||||
},
|
||||
{
|
||||
name: 'The Hollow King’s Shadow', challengeRating: '7', ac: 14, maxHp: 90, speed: 30,
|
||||
strScore: 10, dexScore: 14, conScore: 10, intScore: 16, wisScore: 16, chaScore: 18, attackBonus: 8, damageDice: '3d8',
|
||||
specialTrait: 'Not yet real — at 0 HP it disperses for the scene, never dies. Echoing dread — starting a turn within 30 ft: DC 15 Wis or frightened. Passes through walls.',
|
||||
notes: 'Large undead (incorporeal), 2,900 XP but uncounted — DO NOT run as a fair fight. Fly 30 ft (hover). Chilling touch +8 (3d8 necrotic). Treat HP as a ticking clock: interrupt, banish, or outlast it (close a ritual circle, reach sunlight/a warded threshold, survive N rounds). Save its true, killable form for Act 4.',
|
||||
},
|
||||
{
|
||||
name: 'Skeleton', challengeRating: '1/4', ac: 13, maxHp: 13, speed: 30,
|
||||
strScore: 10, dexScore: 14, conScore: 15, intScore: 6, wisScore: 8, chaScore: 5, attackBonus: 4, damageDice: '1d6+2',
|
||||
specialTrait: 'Undead — vulnerable to bludgeoning',
|
||||
notes: 'Ordinary restless dead for the Sunken Vaults’ Restless Ossuary (Room 4). Not tied to the Hollow King; DC 12 Religion identifies that proper deconsecration would settle them.',
|
||||
},
|
||||
{
|
||||
name: 'Feral Guard Dog', challengeRating: '1/8', ac: 12, maxHp: 5, speed: 40,
|
||||
strScore: 13, dexScore: 14, conScore: 12, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 3, damageDice: '1d6+1',
|
||||
specialTrait: 'Keen hearing and smell — advantage on Perception checks that rely on them.',
|
||||
notes: 'Medium beast, 25 XP. Bite +3 (1d6+1 piercing, DC 11 Str or prone). A chapel mastiff gone half-wild in the Sunken Vaults’ Old Crypt Hall (Room 2). Starving, not vicious — DC 12 Animal Handling or a thrown ration ends the fight.',
|
||||
},
|
||||
{
|
||||
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 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 },
|
||||
{ name: 'Grey Dawn Longsword', damageDice: '1d10', damageType: 'slashing', attackAbility: 'STR', properties: ['versatile (1d12)'], rangeNormal: null, rangeLong: null, minLevel: 3, damageScaling: 2 },
|
||||
{ name: 'Greyford Halberd', damageDice: '1d12', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'reach', 'two-handed'], rangeNormal: null, rangeLong: null, minLevel: 3, damageScaling: 1 },
|
||||
{ name: 'Stonejaw Maul', damageDice: '2d8', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['heavy', 'two-handed'], rangeNormal: null, rangeLong: null, minLevel: 4, damageScaling: 2 },
|
||||
{ name: 'Threndir’s Fang', damageDice: '1d8', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light'], rangeNormal: null, rangeLong: null, minLevel: 5, damageScaling: 2 },
|
||||
{ name: 'Hollowbane', damageDice: '2d8', damageType: 'radiant', attackAbility: 'FINESSE', properties: ['finesse', 'versatile (2d10)'], rangeNormal: null, rangeLong: null, minLevel: 7, damageScaling: 3 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item pool. 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', 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.', 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.' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spells, pulled from the default library (src/game/data/spellsLibrary.js) by
|
||||
// key. A new character can only pick starting spells of their own level or
|
||||
// lower, so the starting set is cantrips and 1st level; the rest are for the
|
||||
// DM to teach later.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const 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',
|
||||
'thorn-whip', 'vicious-mockery',
|
||||
'bless', 'burning-hands', 'charm-person', 'command', 'cure-wounds', 'detect-magic', 'entangle', 'faerie-fire',
|
||||
'guiding-bolt', 'healing-word', 'mage-armor', 'magic-missile', 'shield', 'sleep', 'thunderwave',
|
||||
],
|
||||
earned: [
|
||||
'hold-person', 'invisibility', 'lesser-restoration', 'misty-step', 'moonbeam', 'scorching-ray', 'shatter',
|
||||
'spiritual-weapon',
|
||||
'counterspell', 'daylight', 'dispel-magic', 'fireball', 'remove-curse', 'revivify',
|
||||
'banishment',
|
||||
],
|
||||
};
|
||||
|
||||
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
|
||||
// plausible.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const npcs = [
|
||||
{
|
||||
name: 'King Aldric Veyrmont', role: 'King of Aldenfell', disposition: 'NEUTRAL',
|
||||
description: 'An aging king with a soldier’s posture and tired eyes. Courteous, proud, and quick to steer talk away from anything that sounds like bad news.',
|
||||
dmNotes: 'Tired, proud, in denial — cornered, not ill. Pays well for quiet problem-solving and reacts badly to blunt truth. An audience needs a sponsor (a knight, a noble, a Concord representative). The crown fractures publicly at Stage 3.',
|
||||
},
|
||||
{
|
||||
name: 'Princess Isolde Veyrmont', role: 'Heir to the throne', disposition: 'FRIENDLY',
|
||||
description: 'Sharp, pragmatic and well liked. She keeps a small private study at the palace and has a habit of asking capable outsiders exactly the right question.',
|
||||
dmNotes: 'The best long-term ally, and she recruits outsiders on purpose. Already suspects something is deeply wrong, so she is the most likely to believe the party early. Reporting to her at E10B sets up Act 3’s politics. Rumour says she will move against her father — misleading.',
|
||||
},
|
||||
{
|
||||
name: 'Ser Kavan Thorne', role: 'Knight-Commander of the Grey Dawn', disposition: 'FRIENDLY',
|
||||
description: 'Rigid, honorable and loyal to a fault. He runs Grey Dawn Hall by the book and judges people by whether they do what they said they would.',
|
||||
dmNotes: 'Recruits the party (E4A → Grey Dawn track). SECRET: the Order’s founding is tied to the original sacrifice, and Thorne does not know. He struggles when evidence points at the crown; whether and how the party tells him is theirs to choose. If Ressa Vane escorted them (E9C) he already suspects by E10B.',
|
||||
ac: 18, maxHp: 65, attackBonus: 6, damageDice: '1d8+4',
|
||||
},
|
||||
{
|
||||
name: 'Mireille Costa', role: 'Merchant Concord representative', disposition: 'NEUTRAL',
|
||||
description: 'Charming and transactional, usually found at the Concord Exchange with a contract already drafted. She always has a job, and she always pays what she promised.',
|
||||
dmNotes: 'Source of paying side work (E4B → The Smugglers’ Tithe). The Concord is neutral and pragmatic, and drops anyone who becomes bad for business — including the party. 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',
|
||||
description: 'Terse and watchful, with a longbow that never seems to be out of reach. She answers to the forest, not to the crown.',
|
||||
dmNotes: 'Suspicious, not hostile — a slow-burn ally. The first to notice the old shrines acting strangely (E4C → The Warden’s Silence). After that quest she is convinced something is deeply wrong, whatever happened to her Warden.',
|
||||
ac: 15, maxHp: 44, attackBonus: 6, damageDice: '1d8+3',
|
||||
},
|
||||
{
|
||||
name: 'Threndir Ashclaw', role: 'Old copper dragon of the Spine', disposition: 'NEUTRAL',
|
||||
description: 'Vast, scarred and weary, coiled in an eyrie high on the eastern peaks. He grants an audience to anyone who approaches openly, and he prefers words to fire.',
|
||||
dmNotes: 'Blunt, and wants the kingdom to survive. He wants proof the crown can still be trusted — the intended “win” is a negotiation (Persuasion DC 15–18). Always offers to talk unless attacked first. If it does come to a fight, use the “Threndir Ashclaw (old copper dragon)” monster stat block.',
|
||||
},
|
||||
{
|
||||
name: 'Korgath Stonejaw', role: 'Chieftain of the Stonejaw clan', disposition: 'NEUTRAL',
|
||||
description: 'A hill giant chieftain with a voice like a rockslide and no patience for politics. Outsiders in his hold are tested, not attacked.',
|
||||
dmNotes: 'Honor-bound: trials, contests and bargains, never ambushes. Offers the Trial of Stone instead of an agreement; success is a standing alliance (Spine Clans track), failure earns a second, harder trial. Nothing sinister here — it is a genuine test of character.',
|
||||
ac: 13, maxHp: 105, attackBonus: 8, damageDice: '3d8+5',
|
||||
},
|
||||
{
|
||||
name: 'Vessa Hollow', role: 'A voice in the dark', disposition: 'NEUTRAL',
|
||||
description: 'A woman’s voice — calm, patient, almost kind. She does not blink. She speaks as though she has been waiting a very long time and can wait a little longer.',
|
||||
dmNotes: 'SECRET: one of the people Highcrown’s founders sacrificed, and the Hollow King’s sympathetic “face”. She believes waking the rest is justice. Unsettling, never mustache-twirling; DC 16 Insight confirms she believes what she says. Use her in dreams and omens long before she appears. Negotiating with her is a legitimate outcome. She does not fight — any combat near her uses Hollowed guardians.',
|
||||
},
|
||||
{
|
||||
name: 'Corporal Ressa Vane', role: 'Grey Dawn warden escort', disposition: 'FRIENDLY',
|
||||
description: 'A steady, plain-spoken corporal of the Grey Dawn, assigned to see you safely through the tunnels. She writes things down.',
|
||||
dmNotes: 'Only appears if the party took Grey Dawn recruitment (E4A → E9C). She lets them skip to Root Room 2, and she reports everything back to Grey Dawn Hall — which matters at E10B and E15.',
|
||||
ac: 16, maxHp: 27, attackBonus: 4, damageDice: '1d8+2',
|
||||
},
|
||||
{
|
||||
name: 'The Bell-Ringer', role: 'Bell-ringer of the Founding Chapel', disposition: 'FRIENDLY',
|
||||
description: 'A stooped, soft-spoken man who has rung the chapel bell every hour for most of his life. Badly shaken, and very grateful to see a friendly face.',
|
||||
dmNotes: 'Found alive in Vault Room 5 (E3). He saw “someone who looked like his neighbor, except wrong”. Returns in The Bell-Ringer’s Debt: he owes a Highcrown lender who operates under the corrupted house’s protection (not knowable yet). A loyal low-key ally either way.',
|
||||
},
|
||||
{
|
||||
name: 'A Frightened Local', role: 'Witness outside the chapel', disposition: 'NEUTRAL',
|
||||
description: 'A market porter who was passing the chapel when the bell stopped. Pale, talking too fast, and not keen to go back inside.',
|
||||
dmNotes: 'DC 12 Persuasion or Insight gets the account of “a woman who wasn’t blinking”. If the party talks to the crowd first (E2C) they get three witness accounts but no script lead, and their E10 Insight check is at advantage.',
|
||||
},
|
||||
{
|
||||
name: 'The Lender', role: 'Highcrown moneylender', disposition: 'HOSTILE',
|
||||
description: 'A neat, unhurried man who keeps his ledgers tidier than his conscience. He never raises his voice; he has collectors for that.',
|
||||
dmNotes: 'Holds the bell-ringer’s debt. Operates under the corrupted noble house’s protection — digging into who backs him is harder but plants a clue toward the house before Act 2. Can be paid off, intimidated, or exposed.',
|
||||
ac: 12, maxHp: 22, attackBonus: 3, damageDice: '1d4+1',
|
||||
},
|
||||
{
|
||||
name: 'Greyford’s Commander', role: 'Commander of Greyford Keep', disposition: 'NEUTRAL',
|
||||
description: 'An exhausted officer holding a border fortress with half the troops and a third of the supplies it needs. Blunt, overworked, and out of patience.',
|
||||
dmNotes: 'Wants the Sorrow Camp “moved along” — and is not wrong about the strategic liability. No clean villain in Refugees of the Marches. Whether Greyford is supplied (The Smugglers’ Tithe outcome) decides rescue vs recovery at E11 and whether the keep holds at E16.',
|
||||
ac: 17, maxHp: 52, attackBonus: 5, damageDice: '1d8+3',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encounters — pre-built (PLANNING) scenes for the beats of the Campaign Book,
|
||||
// in book order. BATTLE scenes run in initiative order; QUEST scenes are built
|
||||
// around NPCs and an objective.
|
||||
// location — a location's name: where the scene is. Its characters move
|
||||
// there when it begins.
|
||||
// description — the scene, shown to the players in it: no spoilers.
|
||||
// dmNotes — DCs, branches and secrets, DM only.
|
||||
// monsters — [monster name, count, optional display name]; a count above
|
||||
// one gets "#n" suffixes.
|
||||
// npcs — NPC names.
|
||||
// rewards — lined up unawarded, so they stay the DM's secret until
|
||||
// handed out. { gold } | { weapon } | { item }, with optional
|
||||
// quantity, level (defaults to the entry's starting level) and
|
||||
// note.
|
||||
// wares — for sale at the scene itself (a stall), as on an NPC:
|
||||
// { weapon | item, price, currency?, level?, stock? }.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const encounters = [
|
||||
{
|
||||
name: 'E1 — The Founding Fair', mode: 'QUEST', location: 'Highcrown — The Lantern Market',
|
||||
description: 'Highcrown’s Lantern Market at the height of the Founding Fair: lantern-strung stalls, contests of strength and skill, minstrels, and strangers from every corner of the kingdom. This is where you meet.',
|
||||
dmNotes: 'Character creation happens here (Roll-Your-Character System) — it can be the whole first half of session one. No combat. Let each PC win or lose something small at a stall, and introduce Mireille and Ser Kavan in passing so they are familiar faces by E4. Ends when the chapel bell fails to ring → E2.',
|
||||
npcs: ['Mireille Costa', 'Ser Kavan Thorne'],
|
||||
// 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',
|
||||
description: 'The Founding Chapel’s bell has rung every hour for as long as anyone remembers. It has stopped. A crowd is gathering outside, and someone is asking for volunteers to go in and look.',
|
||||
dmNotes: 'No break-in. The bell rope is neatly cut and left coiled; the bell-ringer is missing, not dead; a line of unfamiliar script is scratched inside the bell (Hollow King iconography). DC 13 Investigation finds the rope and script; DC 12 Persuasion/Insight gets the “woman who wasn’t blinking”.\n• E2A investigate immediately: full clues; skip Vault Room 1.\n• E2B distracted first: the guard missed the script; DC 13 Investigation to find it; start at Room 1.\n• E2C talk to the crowd first: three witness accounts, no script lead; E10 Insight later at advantage.\nThe trail leads to the cellar → E3.',
|
||||
npcs: ['Ser Kavan Thorne', 'A Frightened Local'],
|
||||
},
|
||||
{
|
||||
name: 'E3 — Sunken Vaults: The Old Crypt Hall', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
|
||||
description: 'A long hall of burial niches beneath the chapel, some sealed, some broken open. Something is growling in the dark between the pillars.',
|
||||
dmNotes: 'Vault Room 2, the hub (→ Rooms 3, 4, stair to 6). The dog is starving, not vicious: DC 12 Animal Handling or a thrown ration ends it. DC 13 Investigation: the open niches are just old theft. DC 12 Investigation spots the locket.',
|
||||
monsters: [['Feral Guard Dog', 1]],
|
||||
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)',
|
||||
description: 'Bones stacked floor to ceiling, and an offering box furred with dust. The air is still. Then it isn’t.',
|
||||
dmNotes: 'Vault Room 4, a dead end. One or two skeletons rise halfway through the room — ordinary restless dead, not tied to the Hollow King. DC 12 Religion: proper deconsecration would settle them without a fight. DC 13 to open the offering box.',
|
||||
monsters: [['Skeleton', 2]],
|
||||
rewards: [{ gold: 25, note: 'Offering box — DC 13 to open' }],
|
||||
},
|
||||
{
|
||||
name: 'E3 — Sunken Vaults: The Bell-Ringer’s Refuge', mode: 'QUEST', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
|
||||
description: 'A side chamber with a bedroll, a cold lantern, and a frightened old man who very much hopes you are who you look like.',
|
||||
dmNotes: 'Vault Room 5. The bell-ringer is alive but shaken: he saw “someone who looked like his neighbor, except wrong”. Getting him out is the win — the bell rings again, the fair goes on, nothing is solved.\nRoom 3 (Smugglers’ Cache) is on the way: DC 14 Investigation ties the crates’ sigil to a lender linked to the corrupted house.\nRoom 6 (The Descent) leads to the Root: wrong, cold air. Signal the tone shift; the party can always turn back. → E4.',
|
||||
npcs: ['The Bell-Ringer'],
|
||||
rewards: [
|
||||
{ gold: 40, note: 'Room 3 — trade goods from the smugglers’ cache' },
|
||||
{ item: 'Smugglers’ Ledger Page', note: 'Room 3 — DC 14 Investigation' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E4 — Aftermath & Faction Hooks', mode: 'QUEST',
|
||||
description: 'Word travels fast in Highcrown. By the next morning three different people have sent for you, and each of them has work.',
|
||||
dmNotes: 'All open doors; they can run in parallel.\n• E4A Grey Dawn recruitment (Kavan) → Grey Dawn ending track; unlocks Ressa Vane at E9C.\n• E4B Concord job board (Mireille) → The Smugglers’ Tithe.\n• E4C Warden contact (Ashwen) → The Warden’s Silence.\n• E4D Caravan to the Spine → Trial of Stone → Spine Clans track.\n• E4E Investigate independently (DC 15 Investigation) → Independent track.\nAfter two or more threads and one E5 omen, advance the clock to Stage 2 (E8).',
|
||||
npcs: ['Ser Kavan Thorne', 'Mireille Costa', 'Ashwen'],
|
||||
rewards: [
|
||||
{ item: 'Grey Dawn Signet', note: 'E4A — if they accept recruitment' },
|
||||
{ item: 'Concord Writ of Passage', note: 'E4B — on taking Mireille’s contract' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '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.',
|
||||
dmNotes: 'Optional side quest, Lv 1–2. The collectors are ordinary desperate people: kill them, scare them off, or find out why they are desperate. Beats: hear the bell-ringer out → track the collectors → pay the debt, intimidate the lender, or dig into who backs him (harder, more rewarding — a clue toward the corrupted house before Act 2).',
|
||||
monsters: [['Highcrown Cutthroat', 3]],
|
||||
npcs: ['The Bell-Ringer', 'The Lender'],
|
||||
rewards: [
|
||||
{ gold: 30, note: 'The collectors’ take for the week' },
|
||||
{ item: 'Bell-Ringer’s Keepsake', note: 'Given once the debt is dealt with' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — The Smugglers’ Tithe: Tidewatch', mode: 'QUEST', location: 'Salt Coast — Tidewatch',
|
||||
description: 'Tidewatch, the Salt Coast’s largest port: proud, salt-stained, and suspicious of anyone asking questions. Someone is skimming the town’s quiet trade, and the Concord wants to know who.',
|
||||
dmNotes: 'Lv 1–3. Mireille hires the party to learn why a cut of Tidewatch’s smuggled goods vanishes before reaching buyers. TRUTH: a Bandit Captain is extorting the smugglers and funnelling the “tithe” to arm one side of the Bramble Marches conflict. Smugglers distrust outsiders. Track a shipment to Smuggler’s Cove → next scene.\nOutcome decides whether Greyford is supplied at E11/E16.',
|
||||
npcs: ['Mireille Costa'],
|
||||
rewards: [{ gold: 100, note: 'Mireille’s fee — paid in full if it ends quietly' }],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', mode: 'BATTLE', location: 'Salt Coast — Smuggler’s Cove',
|
||||
description: 'A hidden inlet south of Tidewatch. Lanterns hooded, a longboat drawn up on the shingle, and a crew that was not expecting company.',
|
||||
dmNotes: 'Fight, negotiate, or both. The Captain leads from the front (multiattack) and bolts for the boat below half HP. End it quietly (Mireille pays well) or expose where the tithe was headed (pulls the Marches plot forward).',
|
||||
monsters: [['Bandit Captain', 1], ['Highcrown Cutthroat', 4]],
|
||||
rewards: [
|
||||
{ gold: 60, note: 'The tithe chest' },
|
||||
{ weapon: 'Smuggler’s Cutlass', note: 'The Captain’s own blade' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — The Warden’s Silence: Direwolf Pack', mode: 'BATTLE', location: 'Greywood — The Old Shrine Sites',
|
||||
description: 'The trail to the shrine runs through old growth where the light barely reaches the ground. The forest has gone quiet, and there are eyes between the trees.',
|
||||
dmNotes: 'Lv 1–2. The pack is agitated by the shrine, not evil — DC 13 Animal Handling or Nature to back away without a fight. Pack tactics make three wolves the clearest action-economy lesson for a new table.',
|
||||
monsters: [['Greywood Direwolf', 3]],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — The Warden’s Silence: The Shrine', mode: 'BATTLE', location: 'Greywood — The Old Shrine Sites',
|
||||
description: 'A mossed-over shrine older than the kingdom, split by a crack you could lose an arm in. The missing Warden is standing in front of it. She turns and greets you by name.',
|
||||
dmNotes: 'HORROR register — signal it. The Warden was Hollowed after investigating the crack alone: the party’s first, sympathetic Hollowed. DC 15 Insight notices the wrongness (no blink, a repeated phrase) before it acts. Fight, restrain and bring back to Ashwen (hard questions), or it escapes to report back. Either way Ashwen is convinced.',
|
||||
monsters: [['Hollowed', 1, 'The Missing Warden']],
|
||||
rewards: [
|
||||
{ item: 'Warden’s Charm', note: 'From Ashwen, whatever the outcome' },
|
||||
{ weapon: 'Warden’s Longbow', note: 'The missing Warden’s bow — Ashwen says it should be used' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — Trial of Stone', mode: 'QUEST', location: 'The Spine — Stonejaw Hold',
|
||||
description: 'Stonejaw Hold, a giant clan’s valley in the Spine foothills. The chieftain has heard you out. He does not offer agreement. He offers a trial.',
|
||||
dmNotes: 'Lv 2–4. A genuine test of character, nothing sinister. Trials (per PC or shared): non-lethal wrestling with a giant (the Hill Giant here — HP is a “pin” clock, nobody dies); a test of honesty; proving their word is good (retrieve something, deliver a message honestly). Beats: arrive → formal challenge → trial → feast.\nSuccess = standing alliance (Spine Clans track, unlocks E9D and E11C). Failure is not fatal: Korgath offers a second, harder trial.',
|
||||
monsters: [['Stonejaw Hill Giant', 1, 'Stonejaw Champion']],
|
||||
npcs: ['Korgath Stonejaw'],
|
||||
rewards: [
|
||||
{ item: 'Stonejaw Oath-Token', note: 'On passing the trial' },
|
||||
{ weapon: 'Stonejaw Maul', note: 'Feast-gift for whoever wrestled the champion' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — Refugees of the Marches', mode: 'QUEST', location: 'Bramble Marches — The Sorrow Camp',
|
||||
description: 'The Sorrow Camp: hundreds of people under canvas just inside the border, and a garrison at Greyford Keep that wants them gone. Food sent for the camp is not all arriving.',
|
||||
dmNotes: 'Lv 2–4, the clearest moral-choice quest. No clean villain: the commander is not wrong about the strategic liability, and the refugees have nowhere else to go. Beats: tension between camp and garrison → find who is skimming aid (the Captain’s crew) → confront them → broker, or fail to broker, an arrangement.\nIntroduce a named refugee NPC here — they are at risk at E16B.',
|
||||
monsters: [['Bandit Captain', 1, 'Aid-Skimmer Boss'], ['Highcrown Cutthroat', 2]],
|
||||
npcs: ['Greyford’s Commander'],
|
||||
rewards: [{ gold: 50, note: 'Recovered aid money — keeping it is a choice the camp will notice' }],
|
||||
},
|
||||
{
|
||||
name: 'Side Quest — What the Tide Brought In: The Reef', mode: 'BATTLE', location: 'Salt Coast — Tidewatch',
|
||||
description: 'A fishing boat washed up empty this morning: sails intact, catch in the hold, crew gone. The fishers will point out the reef. None of them will row you there.',
|
||||
dmNotes: 'Lv 2–3, horror-lite. NOT connected to the Hollow King — a deliberate palate-cleanser. DC 13 Investigation on the boat finds claw marks, not violence. Confront it at sea (it swims, the party does not) or lure it to the shallows. Outcome: safe fishing reopens and real goodwill in Tidewatch.',
|
||||
monsters: [['Reef Terror', 1]],
|
||||
rewards: [
|
||||
{ gold: 80, note: 'Tidewatch’s collection for whoever cleared the reef' },
|
||||
{ item: 'Potion of Healing', quantity: 2, note: 'From the harbour apothecary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Audience — Threndir’s Eyrie', mode: 'QUEST', location: 'The Spine — Threndir’s Eyrie',
|
||||
description: 'A hard climb above the snow line, and a cave mouth wide enough to sail a ship into. The dragon inside already knows you are here. He has not moved, and he has not told you to leave.',
|
||||
dmNotes: 'A negotiation, not a fight: Threndir wants proof the crown can still be trusted (Persuasion DC 15–18; real evidence from E10 lowers it). He talks first unless attacked. If it goes wrong, add the “Threndir Ashclaw (old copper dragon)” monster to this scene — and remember he is far beyond a low-level party.',
|
||||
npcs: ['Threndir Ashclaw'],
|
||||
rewards: [{ weapon: 'Threndir’s Fang', note: 'A shed tooth, set in a hilt — given only if he comes to respect them' }],
|
||||
},
|
||||
{
|
||||
name: '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.',
|
||||
dmNotes: 'HORROR register — signal the tone shift. Root Room 2. DC 14 Survival: the dripping is a Hollowed’s footsteps. The Bellcrawler uses Stillness (DC 17 Perception to spot) and mimics the bell to lure the curious.\n• E9A retreat: keep what they learned, full rest before re-entry.\n• E9B push through: the marking here matches the bell script (DC 16 Arcana/Religion).\n• E9C (took E4A): Ressa Vane escorts and reports back — remove her from the scene otherwise.\n• E9D (won Trial of Stone): a Spine guide enters via Room 4.',
|
||||
monsters: [['Bellcrawler', 1]],
|
||||
npcs: ['Corporal Ressa Vane'],
|
||||
},
|
||||
{
|
||||
name: 'E9 — The Root: Hollowed Nest', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
|
||||
description: 'A cavern kept unnervingly tidy: bedrolls squared away, boots in a row. Two people sit facing the wall. They stand when you enter, and smile at exactly the same moment.',
|
||||
dmNotes: 'Root Room 3. DC 15 Insight before combat. Consider making one a named missing person. Slam hits force DC 12 Wis or frightened for a round; each can whisper one sentence in Vessa’s voice. The letter (DC 14) hints at the corrupted house.',
|
||||
monsters: [['Hollowed', 2]],
|
||||
rewards: [
|
||||
{ gold: 20, note: 'Personal effects' },
|
||||
{ item: 'Half-Burned Letter', note: 'DC 14 Investigation' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E9 — The Root: Root Stalker’s Warren', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
|
||||
description: 'The roots in this chamber are as thick as a man’s waist, and they are not growing from anything. One of them shifts when your light touches it.',
|
||||
dmNotes: 'Root Room 4, a dead end (optional secret passage to Room 6). DC 14 Perception or its first attack has advantage. Run it as a slow, dreadful stalk: tremorsense, burrow, grasping roots (restrained, DC 14 Str). E9D: DC 13 Survival or it wakes at the entrance.',
|
||||
monsters: [['Root Stalker', 1]],
|
||||
rewards: [
|
||||
{ gold: 60, note: 'From what the roots grew around' },
|
||||
{ weapon: 'Masterwork Dagger', note: 'Still in a dead hand' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E9 — The Root: 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.',
|
||||
dmNotes: 'Root Room 6. DC 15 Perception to place them; DC 13 Dex on the ledges or fall 2d6. They skitter (bonus-action disengage) and freeze when watched — stealth and dread, not a straight fight. → Room 7, the stair to the Hollow Court: let them see it and turn back.',
|
||||
monsters: [['Bellcrawler', 3]],
|
||||
rewards: [
|
||||
{ gold: 90, note: 'Scattered along the ledges' },
|
||||
{ item: 'Potion of Healing', note: 'In a fallen pack — the book has the coin or the potion, DM’s choice' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E10 — The House Unmasked', mode: 'QUEST', location: 'Highcrown — The Palace of Aldenfell',
|
||||
description: 'Back in Highcrown with a name, a ledger, and a question nobody at court wants asked out loud: what do you do with it?',
|
||||
dmNotes: 'The ledger: every shrine “donation” falls three days after a livestock death.\n• E10A confront the house directly: DC 16 Persuasion/Intimidation with proof, DC 20 without.\n• E10B report to the crown, Isolde or Kavan: sets up Act 3 politics; if E9C, Kavan already suspected (trust bump).\n• E10C (E2C + the servant thread): private DC 13 check at advantage → confession and the sealed letter; the house never learns it is suspected. Best route into Act 3.',
|
||||
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne'],
|
||||
rewards: [
|
||||
{ item: 'Sealed Letter', note: 'E10C only — 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',
|
||||
description: 'Greyford Keep. The gate stands open. No smoke from the chimneys, no one on the walls, no bodies. A kettle is still warm on a guardroom stove.',
|
||||
dmNotes: 'DC 13 Perception: drag marks — the defenders left. The Smugglers’ Tithe outcome decides rescue vs recovery. E11C (Spine allied): the clans are already here and share Root Stalker migration intel. Remove the Commander from the scene if this is a recovery.',
|
||||
npcs: ['Greyford’s Commander'],
|
||||
rewards: [
|
||||
{ gold: 70, note: 'Trade goods in the keep’s stores (2d6×10 gp)' },
|
||||
{ weapon: 'Greyford Halberd', note: 'From the armoury' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'E12 — A Line Thins', mode: 'BATTLE',
|
||||
description: 'The ground splits somewhere you thought was safe, and something comes up through it that should have stayed far below.',
|
||||
dmNotes: 'A Root Stalker surfaces near a location the party did not secure — pick it from what they left undone. DC 15 Arcana/Religion: this is deliberate erosion from below. The root-fiber scrap is a key item (needed at E14/E18/E19). Then E13: the clock turns to Stage 3.',
|
||||
monsters: [['Root Stalker', 1]],
|
||||
rewards: [{ item: 'Root-Fiber Scrap', note: 'Key item — cut from the Stalker' }],
|
||||
},
|
||||
{
|
||||
name: 'E14 — The Break', mode: 'BATTLE',
|
||||
description: 'The crack has become a breach. People are coming out of it — neighbours, guards, people you have met — walking calmly, smiling, and not blinking.',
|
||||
dmNotes: 'Two to four Hollowed (scale to the party). DC 15 Arcana: the breach is shaped for something to come through.\n• E14A seal it (DC 17 Arcana or the chapel ritual): closes, but a debt comes due at E17.\n• E14B evacuate instead: no deaths, but the breach stays open, worsening omens until E18.\nThe seal-stone fragment is a key item (needed at E19).',
|
||||
monsters: [['Hollowed', 4]],
|
||||
rewards: [{ item: 'Seal-Stone Fragment', note: 'Key item — from the breach’s edge' }],
|
||||
},
|
||||
{
|
||||
name: '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.',
|
||||
dmNotes: 'DO NOT run as a fair fight. At 0 HP it disperses for the scene and never dies; treat its HP as a ticking clock. Give the party a way out: close a ritual circle, reach sunlight or a warded threshold, or survive N rounds. Echoing dread: DC 15 Wis or frightened within 30 ft. Save its true, killable form for Act 4.',
|
||||
monsters: [['The Hollow King’s Shadow', 1]],
|
||||
},
|
||||
{
|
||||
name: 'E19 — Vessa’s Seat', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
|
||||
description: 'A single worn stone seat at the heart of the Hollow Court, and a woman who has been expecting you. She asks you to sit. She says she would like to explain.',
|
||||
dmNotes: 'Court Room 4. The party learns the Hollow King’s true nature from Vessa: the imprisoned will of everyone the founders sacrificed — a suppression of many, not a prison for one. DC 16 Insight: she believes what she says. A strong candidate for a negotiated outcome.\nRoot-fiber + seal-stone (DC 15 Arcana, automatic with a Religion-proficient caster) → advantage on the first check at E22. Then E20: the clock turns to Stage 4.',
|
||||
npcs: ['Vessa Hollow'],
|
||||
},
|
||||
{
|
||||
name: 'E21 — Rally Point', mode: 'QUEST',
|
||||
description: 'One last gathering before the descent. Whoever is going to stand with you is here now — and they do not all agree about what happens next.',
|
||||
dmNotes: 'Who shows up depends on the branch list — remove anyone the party lost or alienated.\n• E21A Grey Dawn track: Kavan and his wardens, by the book.\n• E21B Spine Clans track: Korgath’s war-band; advantage on the first Survival check at E22.\n• E21C Independent: no reinforcements; every choice stays theirs.\n• Several tracks: the groups arrive and disagree — the party mediates.',
|
||||
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne', 'Korgath Stonejaw', 'Ashwen'],
|
||||
rewards: [
|
||||
{ item: 'Potion of Greater Healing', quantity: 2, note: 'Going-away gift from whoever stands with them' },
|
||||
{ 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 — 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)',
|
||||
description: 'The final stretch of the Hollow Court, where gravity, light and memory have all stopped agreeing with one another. The things that live here know you are coming.',
|
||||
dmNotes: 'A gauntlet of horror-register creatures; whichever faction the party rallied fights alongside them (add those NPCs). Grey Dawn = a disciplined line; Spine Clans = terrain and ritual (collapse passages); Independent = hardest, and entirely theirs. → E26, the Hollow King: build that stat block from DM Secrets, scaled to the party.',
|
||||
monsters: [['Root Stalker', 1], ['Bellcrawler', 2]],
|
||||
},
|
||||
{
|
||||
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'],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notes. Listed in reading order; the seed timestamps them so the first entry
|
||||
// shows at the top of the campaign's note list.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sharedNotes = [
|
||||
`Welcome to Aldenfell — Player Primer
|
||||
|
||||
You’re a stranger arriving in Highcrown, the capital, right as the yearly Founding Fair kicks off — a good excuse for adventurers from every corner of the kingdom to cross paths in one place.
|
||||
|
||||
The setting: Aldenfell is a kingdom of knightly orders, old noble houses, and a royal court that’s held things together for generations. To the west, the ancient Greywood forest keeps its own secrets and its own guardians. To the north and east, the mountains of the Spine are home to giant clans and dragons who are neighbors and political players, not just monsters to slay. The Salt Coast to the south is full of independent-minded fishing towns and rumors of things in the water. A world built for classic adventure — quests, courts, monsters, treasure — with real weight behind the choices you make.
|
||||
|
||||
A note on tone: most of this campaign plays like a bright, exciting fantasy adventure. Every so often, especially if you go looking underground, it gets genuinely unsettling. You’ll always get a clear signal before things turn scary, so lean in when it happens.
|
||||
|
||||
Building your character: we’ll roll to discover your species and class, then roll your ability scores. Knights, wandering scholars, forest-raised rangers, self-taught spellcasters — everything has a place here.`,
|
||||
];
|
||||
|
||||
const dmNotes = [
|
||||
`Overview & Tone
|
||||
|
||||
Aldenfell looks whole from the road and is rotting from underneath. Knights ride under bright banners, dragons nest in the high peaks, the court still feasts — but something old and hungry is stirring beneath the capital, and every faction is maneuvering around that fact instead of dealing with it. The party arrives as the cracks start showing.
|
||||
|
||||
Tone pillars (in priority order):
|
||||
• Heroic adventure first — the classic fantasy toybox, played straight and fun.
|
||||
• Real consequences — choices stick; burned bridges stay burned; stalling on the central threat visibly worsens the world. Not punishing, just earned.
|
||||
• Horror as a spice — surface world plays bright; horror spikes in telegraphed places (the Hollow Below). Always signal before a scene turns scary.
|
||||
|
||||
Tone dial (out of 5): Heroism 5 · Intrigue 3 · Combat 4 · Horror 3 (5 in the Underworld) · Comedy 2.
|
||||
|
||||
Regions: Highcrown (capital, home base) · The Greywood (old forest, fey, Wardens) · The Spine (dragons & giants) · The Salt Coast (smuggling, sea monsters) · The Bramble Marches (contested border) · The Hollow Below (underneath everything).`,
|
||||
|
||||
`The Central Conflict — Campaign Clock
|
||||
|
||||
Something sealed beneath the capital centuries ago — the Hollow King — is waking. It doesn’t attack at first; it corrupts. Use this as a VISIBLE clock; it advances only between major story beats.
|
||||
|
||||
Stage 1 — Omens: livestock/crops fail near old shrines; shared identical nightmares in Highcrown; minor nobles acting strangely loyal to no one. (Session One sits here.)
|
||||
Stage 2 — Cracks: the boundary thins in mappable places; monsters surface near cracks; a Bramble Marches faction goes quiet; the corrupted noble house is revealed.
|
||||
Stage 3 — Open wound: a crack becomes a breach; a town/district is lost or quarantined; the crown publicly acknowledges it and fractures; factions jockey for power.
|
||||
Stage 4 — The Hollow King wakes: endgame, shaped by what the party did in stages 1–3.
|
||||
|
||||
Real consequences: an ally lost in stage 2 stays lost; a faction alienated in stage 1 won’t help in stage 3; a region left to fall stays changed.
|
||||
|
||||
Branch points to track: faction(s) sided with · named NPCs alive/allied/dead/estranged · regions with lasting damage · how much the public knows by Act 3.`,
|
||||
|
||||
`Factions
|
||||
|
||||
• The Crown — King Aldric Veyrmont, aging, cornered, in denial. Heir Princess Isolde Veyrmont: capable, well-liked, quietly building her own circle — a strong ally for the party.
|
||||
• Order of the Grey Dawn — premier knightly order sworn to the crown. Split between traditionalists and a faction that thinks the court is hiding something. “You’re recruited” is a clean session-one hook.
|
||||
• The Merchant Concord — trade coalition running Highcrown’s economy. Neutral, pragmatic, pays for side quests, quick to abandon anyone bad for business.
|
||||
• Wardens of the Greywood — rangers/druids who answer to no crown. Suspicious, not hostile; first to notice the cracks near the old shrines.
|
||||
• Spine Clans & dragon eyries — several giant clans and named dragons treating Aldenfell as a neighboring power. “Talk your way out of this” and “earn an ally” scenes.
|
||||
• A corrupted noble house — pick a minor house NOW; don’t reveal until Stage 2. Let their behavior be subtly off from session one.`,
|
||||
|
||||
`Notable NPCs
|
||||
|
||||
• King Aldric Veyrmont — ruler. Tired, proud, in denial. Pays well for quiet problem-solving; reacts badly to blunt truth.
|
||||
• Princess Isolde Veyrmont — heir. Sharp, pragmatic. Best long-term ally; recruits capable outsiders on purpose.
|
||||
• Ser Kavan Thorne — Grey Dawn knight-commander. Rigid, honorable, loyal to a fault. Recruits the party; struggles when evidence points at the crown.
|
||||
• Mireille Costa — Merchant Concord rep. Charming, transactional, always has a job.
|
||||
• Ashwen — Warden of the Greywood. Terse, watchful. First to notice the shrines; a slow-burn ally.
|
||||
• Threndir Ashclaw — old, weary copper dragon of the Spine. Blunt, wants the kingdom to survive. Talks before fighting.
|
||||
• Korgath Stonejaw — giant clan chieftain. Honor-bound, no patience for politics. Trials, contests, bargains.
|
||||
• Vessa Hollow — a voice, not yet a face. Calm, almost kind, wrong. Use in dreams/omens long before she appears.
|
||||
• Corporal Ressa Vane — Grey Dawn warden escort (E9C); reports everything back to Grey Dawn Hall.
|
||||
• The bell-ringer — rescued in Session One; returns in The Bell-Ringer’s Debt.`,
|
||||
|
||||
`DM SECRETS & TWISTS (eyes only)
|
||||
|
||||
• What the Hollow King is: the accumulated, imprisoned will of everyone Highcrown’s founders sacrificed to secure their claim to the land. The seal is a suppression of many, not a prison for one. The resolution is about what the party does with that truth (bury it, expose it, make amends) — not just a sword fight.
|
||||
• Vessa Hollow: one of the sacrificed, the Hollow King’s sympathetic “face.” She believes waking the rest is justice. Unsettling, never mustache-twirling. Negotiating with her is a legitimate outcome.
|
||||
• The corrupted noble house: choose whichever minor house you introduced most casually in Act 1. Their head bargained with Vessa years ago for prosperity and believes they’re managing the influence rather than being used.
|
||||
• Ser Kavan Thorne: the Grey Dawn’s founding is tied to the original sacrifice. Thorne doesn’t know. Whether/how the party tells him is theirs to choose.
|
||||
• Princess Isolde: already suspects something is deeply wrong; most likely to believe the party early.`,
|
||||
|
||||
`Session One — The Bell That Wasn’t Ringing
|
||||
|
||||
Setup: all PCs are in Highcrown for the Founding Fair. Build characters at the table (Roll-Your-Character System) — can be session one’s first half.
|
||||
|
||||
Hook: the old chapel bell, which has rung every hour for as long as anyone remembers, stops. A city guard (or Ser Kavan Thorne) gathers volunteers to check the chapel.
|
||||
|
||||
What they find: no break-in. The bell rope is neatly cut and left coiled. The bell-ringer is missing, not dead. A line of unfamiliar script is scratched inside the bell (Hollow King iconography). A frightened local saw “a woman who wasn’t blinking.” The trail leads to the Sunken Vaults beneath the chapel.
|
||||
|
||||
Light dungeon crawl: 2–4 easy/medium encounters (feral guard dog, low-level undead) — nothing horror-register yet. The bell-ringer is found alive but shaken, having seen “someone who looked like his neighbor, except wrong.”
|
||||
|
||||
Ending: the bell rings again, the fair goes on, nothing is solved. They leave with a taste of combat, one unanswered mystery, and at least one NPC connection.`,
|
||||
|
||||
`Act 1: Omens — E1–E8
|
||||
|
||||
E1 Founding Fair (Lantern Market): character creation, no combat → E2.
|
||||
E2 The Bell That Wasn’t Ringing (Founding Chapel): DC 13 Investigation finds the cut rope & script; DC 12 Persuasion/Insight gets the “woman who wasn’t blinking.”
|
||||
• E2A investigate immediately: full clues; skip Vault Room 1.
|
||||
• E2B distracted first: guard missed the script; DC 13 Investigation to find it; start Room 1.
|
||||
• E2C talk to the crowd first: 3 witness accounts, no script lead; E10 Insight later at advantage.
|
||||
E3 The Sunken Vaults: run Vault Rooms 1–6; bell-ringer found in Room 5 → E4.
|
||||
E4 Aftermath & Faction Hooks (all open doors, can run in parallel):
|
||||
• E4A Grey Dawn recruitment → Grey Dawn ending track.
|
||||
• E4B Concord job board → The Smugglers’ Tithe.
|
||||
• E4C Warden contact → The Warden’s Silence.
|
||||
• E4D Caravan to the Spine → Trial of Stone → Spine Clans track.
|
||||
• E4E Investigate independently (DC 15 Investigation) → Independent track.
|
||||
E5 Omens Montage: 2–3 short scenes across sessions (shrine livestock, shared nightmares — DC 12 Insight, the odd noble).
|
||||
E6 The Bell-Ringer’s Debt (optional side quest).
|
||||
E7 A Closer Look: investigating the noble house hits dead ends — unless E2C, then DC 13 Insight catches a servant who flinched.
|
||||
E8 Turning the Page: after 2+ E4 threads and one E5 omen, advance clock to Stage 2 → E9.`,
|
||||
|
||||
`Act 2: Cracks — E9–E13
|
||||
|
||||
E9 First Descent (The Root, Weeping Passage). Signal the tone shift. DC 14 Survival notices the dripping is a Hollowed’s footsteps.
|
||||
• E9A retreat: keep what they learned, full rest before re-entry.
|
||||
• E9B push through alone: Root Rooms 1–4; Weeping Passage marking matches the bell script.
|
||||
• E9C (took E4A): Corporal Ressa Vane escorts, skips to Room 2 — but reports back (matters at E10B, E15).
|
||||
• E9D (won Trial of Stone): Spine guide enters via Room 4; hears the legend of “the sleeper under the hollow crown”; DC 13 Survival or wake the Root Stalker at the entrance.
|
||||
E10 The House Unmasked (Highcrown). Ledger: every shrine “donation” falls three days after a livestock death.
|
||||
• E10A confront directly: DC 16 Persuasion/Intimidation with proof, DC 20 without.
|
||||
• E10B report to crown/Isolde/Kavan: sets up Act 3 politics; if E9C, Kavan already suspected (trust bump).
|
||||
• E10C (E2C + servant thread): private DC 13 check at advantage → confession + sealed letter; the house never learns it’s suspected. Best route into Act 3.
|
||||
E11 Silence on the Border (Greyford Keep): gate open, no bodies. DC 13 Perception: drag marks — the defenders left. Smugglers’ Tithe outcome decides rescue vs recovery. E11C (Spine allied): clans already there, share Root Stalker migration intel. Treasure: 2d6×10 gp trade goods + masterwork weapon.
|
||||
E12 A Line Thins: a Root Stalker surfaces near a location the party didn’t secure. DC 15 Arcana/Religion: deliberate erosion from below. Key item: root-fiber scrap (needed for E14/E18).
|
||||
E13 The Clock Turns → Stage 3.`,
|
||||
|
||||
`Act 3: Open Wound — E14–E20
|
||||
|
||||
E14 The Break: the E12 crack becomes a breach; 2–4 Hollowed pour out. DC 15 Arcana: shaped for something to come through.
|
||||
• E14A seal it (DC 17 Arcana or chapel ritual): closes, but a debt comes due at E17.
|
||||
• E14B evacuate instead: no deaths, but the breach stays open, worsening omens until E18.
|
||||
Key item: seal-stone fragment (needed at E19).
|
||||
E15 The House Falls (or Doesn’t):
|
||||
• E15A (after E10A): house prepared — DC 18 to break the cover story, or fight guards + a Hollowed retainer.
|
||||
• E15B (after E10B): public tribunal — DC 16 Persuasion/Insight for exile over execution.
|
||||
• E15C (after E10C): strike the cult infrastructure directly → E15C-1 capture a cult lieutenant who reveals the Hollow Court’s surface entrance.
|
||||
E16 What the Marches Cost (Greyford Keep):
|
||||
• E16A reinforced (E11C): the keep holds; Spine standing rises.
|
||||
• E16B under-supplied: the keep falls; a named refugee NPC dies unless DC 16 Athletics/Survival rescue.
|
||||
E17 The Debt Comes Due: pay off E14A, unresolved side quests, or ignored omens — or a quiet breather.
|
||||
E18 Into the Hollow Court: Court Rooms 1–3. E18A standard entry via the Root’s Descent; E18B (lieutenant captured) skip to Room 2.
|
||||
E19 Vessa’s Seat (Court Room 4): the party learns the Hollow King’s true nature from Vessa. Root-fiber + seal-stone (DC 15 Arcana, auto with a Religion-proficient caster) → advantage on the first check at E22.
|
||||
E20 The Clock Turns Again → Stage 4.`,
|
||||
|
||||
`Act 4: The Hollow King Wakes — E21–E27
|
||||
|
||||
E21 Rally Point (Highcrown or Greyford): who shows up depends on the branch list.
|
||||
• E21A Grey Dawn track: Kavan + wardens, by-the-book.
|
||||
• E21B Spine Clans track: war-band; advantage on the first Survival check at E22.
|
||||
• E21C Independent: no reinforcements; every choice stays theirs.
|
||||
• Multiple tracks: groups arrive and disagree — the party mediates.
|
||||
Treasure: a minor magic item as a going-away gift.
|
||||
E22 The Seal Chamber (Court Room 5) — the key branch:
|
||||
• E22A reinforce (DC 18 Arcana/Religion): Sealed Ending; the seal-stone becomes a unique magic item.
|
||||
• E22B break it deliberately: E23 on the party’s terms — the only path to true victory.
|
||||
• E22C do nothing: seal fails anyway; E24–E25 are harder.
|
||||
E23 The Waking Begins (Court Room 6): E22B = no surprise, full resources; E22C = apply one escalation stage first.
|
||||
E24 What the Court Remembers: vision encounter; DC 14 Insight recognizes manipulation vs truth.
|
||||
E25 The Last Approach: gauntlet of 2–3 horror-register creatures; allied faction fights alongside.
|
||||
E26 The Hollow King: build the stat block from DM Secrets, scaled to party. Grey Dawn = disciplined line; Spine Clans = terrain & ritual (collapse passages, the Memory Pool); Independent = hardest, entirely theirs.
|
||||
E27 Endings: Sealed (bought time) · Grey Dawn (institution absorbs the heroes) · Spine Clans (credit finally given) · Independent (their own story). Close back at the Lantern Market, a year later.`,
|
||||
|
||||
`Side Quest — The Smugglers’ Tithe
|
||||
Salt Coast (Tidewatch, Smuggler’s Cove) · Lv 1–3 · Highcrown Cutthroats, Bandit Captain
|
||||
|
||||
Hook: Mireille Costa hires the party to learn why a cut of Tidewatch’s smuggled goods is vanishing before reaching buyers.
|
||||
Truth: a Bandit Captain is extorting the smugglers and funneling the “tithe” to arm one side of the Bramble Marches conflict.
|
||||
Beats: investigate Tidewatch (smugglers distrust outsiders) → track a shipment to Smuggler’s Cove → confront the crew (fight, negotiate, or both).
|
||||
Outcomes: end it quietly (Mireille pays well) or expose where the tithe was headed (pulls the Marches plot forward). Decides whether Greyford is supplied at E11/E16.`,
|
||||
|
||||
`Side Quest — The Warden’s Silence
|
||||
The Greywood (Warden’s Lodge, an Old Shrine) · Lv 1–2 · Greywood Direwolves, one Hollowed
|
||||
|
||||
Hook: Ashwen asks the party to check on a Warden who stopped reporting in from a shrine site.
|
||||
Truth: the Warden was Hollowed after investigating the shrine’s crack alone — the party’s first, sympathetic Hollowed.
|
||||
Beats: track the Warden → direwolf pack on the way (agitated, not evil) → the Hollowed Warden at the shrine; DC 15 Insight reveals the wrongness.
|
||||
Outcomes: fight, or restrain and bring back to Ashwen (hard questions), or it escapes to report back. Either way Ashwen is convinced something is deeply wrong.`,
|
||||
|
||||
`Side Quest — Trial of Stone
|
||||
The Spine (Stonejaw Hold) · Lv 2–4 · Korgath Stonejaw’s Hill Giants
|
||||
|
||||
Hook: the party needs Korgath’s clan as an ally; he offers a trial instead of agreement.
|
||||
Truth: nothing sinister — a genuine test of character.
|
||||
Trials (per PC or shared): non-lethal wrestling with a giant; a test of honesty; proving their word is good (retrieve something, deliver a message honestly).
|
||||
Beats: arrive → formal challenge → trial → feast on success.
|
||||
Outcomes: success = standing alliance (Spine Clans track). Failure isn’t fatal — Korgath offers a second, harder trial.`,
|
||||
|
||||
`Side Quest — The Bell-Ringer’s Debt
|
||||
Highcrown · Lv 1–2 · Highcrown Cutthroats · Follows Session One
|
||||
|
||||
Hook: the rescued bell-ringer owes a Highcrown underworld lender; interest is compounding fast.
|
||||
Truth: the lender operates under the corrupted noble house’s protection (not knowable yet).
|
||||
Beats: hear him out → track the collectors → pay the debt, intimidate the lender, or dig into who backs the lender (harder, more rewarding).
|
||||
Outcomes: a loyal low-key ally either way. Digging plants a connect-the-dots clue toward the corrupted house before Act 2.`,
|
||||
|
||||
`Side Quest — Refugees of the Marches
|
||||
Bramble Marches (Greyford Keep, the Sorrow Camp) · Lv 2–4 · Cutthroats or a Bandit Captain · The clearest moral-choice quest
|
||||
|
||||
Hook: the Sorrow Camp is overwhelmed and preyed on by aid-skimmers; Greyford’s commander wants the camp “moved along.”
|
||||
Truth: no clean villain — the commander isn’t wrong about the strategic liability; the refugees have nowhere else to go.
|
||||
Beats: tension between camp and garrison → find who’s skimming aid → confront them → broker (or fail to broker) an arrangement.
|
||||
Outcomes: genuinely branching — lasting goodwill, or a hardened Marches conflict. Introduce a named refugee NPC here (at risk at E16B).`,
|
||||
|
||||
`Side Quest — What the Tide Brought In
|
||||
Salt Coast (Tidewatch) · Lv 2–3 · Reef Terror (reskinned Spine Wyvern) · Horror-lite
|
||||
|
||||
Hook: a fishing boat washes up empty — sails intact, catch in the hold, crew gone.
|
||||
Truth: NOT connected to the Hollow King (a deliberate palate-cleanser). A territorial sea creature drags fishers under near a reef.
|
||||
Beats: DC 13 Investigation finds claw marks, not violence → spooked fishers give the reef’s location → confront at sea or lure to shallows.
|
||||
Outcomes: safe fishing reopens and real goodwill in Tidewatch — an uncomplicated “you helped real people” win.`,
|
||||
|
||||
`Dungeon — Sunken Vaults (Layer 1)
|
||||
|
||||
1. The Collapsed Stair (entry) — spiral stair, rusted gate on one hinge. No monster. DC 12 Perception: fresh footprints (the bell-ringer’s). → Room 2.
|
||||
2. The Old Crypt Hall — hub; sealed niches, some open. DC 13 Investigation: just old theft. Good spot for a feral dog or low undead. Treasure: silver locket 15 gp (DC 12). → 3, 4, stair to 6.
|
||||
3. Smugglers’ Cache — crates with an unknown merchant sigil. DC 14 Investigation ties it to a lender linked to the corrupted house. Treasure: ~40 gp trade goods + ledger page with partial name. Dead end.
|
||||
4. The Restless Ossuary — 1–2 skeletons rise halfway through (ordinary dead). DC 12 Religion: deconsecration would settle them. Treasure: offering box (DC 13) with 25 gp. Dead end.
|
||||
5. The Bell-Ringer’s Refuge — bedroll, cold lantern. The bell-ringer is found here in E3; otherwise a squatter’s camp. Branch off Room 2 or 6.
|
||||
6. The Descent — ragged hole, wrong cold air. Transition to the Root; signal the tone shift. The party can always turn back. → Root Room 1.`,
|
||||
|
||||
`Dungeon — The Root (Layer 2)
|
||||
|
||||
1. The Threshold — almost-regular dripping. Nothing attacks. DC 15 Perception: the rhythm changes when someone speaks. Retreat always safe. → 2.
|
||||
2. The Weeping Passage — walls “weep.” E9B: marking matches the bell script (DC 16 Arcana/Religion). Maybe one Bellcrawler using Stillness (DC 17 Perception). → 3 (east), 4 (south).
|
||||
3. Hollowed Nest — tidy cavern, bedrolls. 1–2 Hollowed; DC 15 Insight before combat. Maybe a named missing person. Treasure: ~20 gp effects + (DC 14) half-burned letter hinting at the corrupted house. → 2, 5.
|
||||
4. Root Stalker’s Warren — moving roots. Root Stalker lair; DC 14 Perception or its first attack has advantage. Treasure: 60 gp + masterwork dagger (50 gp). Dead end (optional secret passage to 6).
|
||||
5. The Drowned Shrine — pre-kingdom altar in black water. DC 16 History/Religion: older than Aldenfell. Treasure: silver relic 75 gp (DC 13 Athletics/Investigation). → 2, crawl to 6.
|
||||
6. Bellcrawler Gallery — ledges over a drop; bell-toll lure. 2–3 Bellcrawlers; DC 15 Perception; DC 13 Dex or fall 2d6. Treasure: 90 gp + potion of healing. → 2, 7.
|
||||
7. The Descent to the Hollow Court — a stair too regular to exist. Off-limits before Act 4; let them see it and turn back. → Court Room 1.`,
|
||||
|
||||
`Dungeon — The Hollow Court (Layer 3)
|
||||
|
||||
1. The Bent Stair — the stair disagrees with itself. DC 15 Wis save or lose track of time (optionally lose 10 minutes). No monster. → 2.
|
||||
2. The Memory Pool — each PC sees a personal vision tied to their backstory/choices (payoff for the branch list). No damage; can choose not to look. DC 17 Insight to understand it. → 3.
|
||||
3. The Court of Whispers — overlapping voices; Vessa may speak plainly here. DC 16 Insight: she believes what she says. Any combat uses Hollowed guardians, not Vessa. → 4.
|
||||
4. Vessa’s Seat — a single worn stone seat. Meet or confront Vessa; strong candidate for a negotiated outcome. → 5.
|
||||
5. The Seal Chamber — a wound stitched shut, coming undone. DC 18 Arcana/Religion reveals the seal is a suppression of many. Optional DC 14 Wis saves vs disorientation. Treasure: the campaign’s single most significant magic item. → 6.
|
||||
6. The Waking — the end of the dungeon. Don’t decide its contents until Act 3 is complete.`,
|
||||
|
||||
`DM Tables — Skill DCs, Treasure, Downtime
|
||||
|
||||
DCs: Very Easy 5 · Easy 10 · Medium 15 · Hard 20 · Very Hard 25 · Nearly Impossible 30.
|
||||
Recurring: spot a Hollowed (Insight 15) · Hollow Below wrongness (Perception 14–17) · Hollow King iconography (Religion/Arcana 16) · read a political room (Insight 13) · talk down a dragon/giant (Persuasion 15–18) · track (Survival 13) · simple lock/trap (12) · recall lore (History 14) · haggle (Persuasion 12) · resist horror fear (Wis save 14–15).
|
||||
|
||||
Treasure Lv 1–4 (d10): 1 2d10×5 sp · 2 1d6×10 gp · 3 trade good 10–30 gp · 4 masterwork weapon/tool 50 gp · 5 2d6×10 gp · 6 NPC keepsake · 7 potion of healing · 8 1d4×25 gp gems · 9 1st-level scroll · 10 minor magic item.
|
||||
Treasure Lv 5–8 (d10): 1 2d6×25 gp · 2 art object 50–150 gp · 3 masterwork armor · 4 1d6×50 gp gems · 5 greater healing · 6 2nd/3rd-level scroll · 7 uncommon magic item · 8 3d6×25 gp · 9 faction reward · 10 rare magic item.
|
||||
Treasure Lv 9+ (d8): 1 3d6×50 gp · 2 rare item · 3 very rare item · 4 relic 500+ gp · 5 4th/5th-level scroll · 6 faction favor · 7 supreme healing · 8 DM’s choice.
|
||||
|
||||
Downtime (~1 week each): Carousing (1d6×10 gp, DC 12 Persuasion for a rumor/contact) · Crafting (half price in materials) · Training (~25 gp/week) · Faction work (standing, not gold) · Research (Sunken Library or faction records, DC 15 Investigation/History for one clue) · Recuperation (roleplay closure).`,
|
||||
|
||||
`DM Tables — Rumors
|
||||
|
||||
Act 1 (d8): 1 “The bell stopped once before, generations back.” (Misleading — never happened) · 2 “Old man Farrow’s cows went dry past the shrine road.” (True) · 3 “Princess Isolde’s been asking sharp questions of the temple scholars.” (True) · 4 “Ser Kavan turned away a recruit for asking about the crown.” (False) · 5 “There’s gold in the old crypts under the city.” (Misleading — modest and dangerous) · 6 “A noble house has been paying off debts all over the city.” (True — the corrupted house) · 7 “The Wardens haven’t been seen at the eastern shrine in weeks.” (True) · 8 “Something’s wrong with the water past Tidewatch.” (True — What the Tide Brought In)
|
||||
|
||||
Act 2 (d8): 1 “Salt Coast smugglers work for someone bigger now.” (True) · 2 “A Warden went missing near the shrines.” (True/resolved) · 3 “Something down there talked back.” (True) · 4 “That noble house is buying property near the docks.” (Misleading — the reason is worse than greed) · 5 “The King’s been ill.” (False — cornered, in denial) · 6 “The Spine dragons are stirring — war’s coming.” (False) · 7 “Refugees are pouring into the Marches.” (True) · 8 “A missing person was seen acting strange, then vanished.” (True — Hollowed)
|
||||
|
||||
Act 3–4 (d6): 1 “The crown’s lost control of [district].” (True) · 2 “Isolde will move against her father.” (Misleading) · 3 “The old families always knew what’s under the city.” (True) · 4 “A faction says the party caused all this.” (Depends on reputation) · 5 “The sky over Highcrown is different at night.” (True) · 6 “It’s almost over, one way or another.” (True)`,
|
||||
|
||||
`DM Tables — Random Encounters by Region (d6)
|
||||
|
||||
Highcrown: 1 Cutthroat pickpocket (DC 13 Perception) · 2 Grey Dawn patrol · 3 Concord merchant with a small job · 4 street preacher of omens (free rumor) · 5 quiet day · 6 a noble retinue — DC 12 Insight notices something off if it’s the corrupted house.
|
||||
The Greywood: 1 1d3 Greywood Direwolves · 2 Warden patrol · 3 unmarked old shrine · 4 harmless fey mischief · 5 traveler in trouble · 6 quiet — chance for a long rest.
|
||||
The Spine: 1 Spine Wyvern (avoidable) · 2 Stonejaw trading party · 3 signs of dragons · 4 treacherous pass (DC 14 Athletics or detour) · 5 a curious lone giant · 6 empty country.
|
||||
The Salt Coast: 1 smugglers wary of witnesses · 2 short-handed fishing crew · 3 storm (DC 12 Survival for shelter) · 4 rumor of something in the water · 5 petty lord’s toll collector · 6 calm seas.
|
||||
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 = { 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;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
const crypto = require('crypto');
|
||||
const { SPECIES } = require('./data/species');
|
||||
const { CLASSES } = require('./data/classes');
|
||||
const { SPECIES_TRAITS, CLASS_FEATURES, TALENTS, TALENT_PICKS } = require('./data/characterOptions');
|
||||
|
||||
const ALL_SPECIES = Object.keys(SPECIES);
|
||||
const ALL_CLASSES = Object.keys(CLASSES);
|
||||
@@ -28,15 +29,6 @@ const CLASS_ROLL_TABLE = {
|
||||
8: 'WILDCARD',
|
||||
};
|
||||
|
||||
const MOTIVATION_TABLE = {
|
||||
1: 'Revenge for a past wrong',
|
||||
2: 'Seeking fortune or wealth',
|
||||
3: 'Protecting someone they love',
|
||||
4: 'Redemption for a past mistake',
|
||||
5: 'Driven by curiosity or knowledge',
|
||||
6: 'Duty or oath to a cause or order',
|
||||
};
|
||||
|
||||
function rollDie(sides) {
|
||||
return crypto.randomInt(1, sides + 1);
|
||||
}
|
||||
@@ -87,6 +79,60 @@ function validateManualRoll(sides, value) {
|
||||
return Number.isInteger(value) && value >= 1 && value <= sides;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Story steps: background, appearance and special abilities are composed from
|
||||
// picks out of fixed lists, never typed in.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// `picks` maps each group's key to the option keys chosen in it. Returns a
|
||||
// message describing the first problem, or null if the picks are valid.
|
||||
function validatePicks(groups, picks) {
|
||||
for (const group of groups) {
|
||||
const chosen = picks[group.key] || [];
|
||||
if (!Array.isArray(chosen) || new Set(chosen).size !== chosen.length) return `${group.label}: pick each option once.`;
|
||||
if (chosen.some((key) => !group.options.some((o) => o.key === key))) return `${group.label}: that is not one of the options.`;
|
||||
if (chosen.length < group.min || chosen.length > group.max) {
|
||||
const range = group.min === group.max ? `${group.min}` : `${group.min} to ${group.max}`;
|
||||
return `${group.label}: pick ${range}.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// One line per group, in the groups' order: "Upbringing: Soldier — Served in
|
||||
// an army…" or, for several picks, "Personality: Brave, Curious".
|
||||
function composeFromPicks(groups, picks) {
|
||||
const lines = [];
|
||||
for (const group of groups) {
|
||||
const chosen = group.options.filter((o) => (picks[group.key] || []).includes(o.key));
|
||||
if (chosen.length === 0) continue;
|
||||
const detail = chosen.length === 1 && chosen[0].text ? ` — ${chosen[0].text}` : '';
|
||||
lines.push(`${group.label}: ${chosen.map((o) => o.label).join(', ')}${detail}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function validateTalents(talentKeys) {
|
||||
const valid = new Set(talentKeys).size === talentKeys.length && talentKeys.every((key) => TALENTS.some((t) => t.key === key));
|
||||
return valid && talentKeys.length === TALENT_PICKS ? null : `Pick ${TALENT_PICKS} different talents from the list.`;
|
||||
}
|
||||
|
||||
// Everything a new character can do beyond the numbers: what its species and
|
||||
// class grant automatically, then the talents the player picked.
|
||||
function composeSpecialAbilities(species, characterClass, talentKeys = []) {
|
||||
const abilities = [
|
||||
...(SPECIES_TRAITS[species] || []),
|
||||
...(CLASS_FEATURES[characterClass] || []),
|
||||
...TALENTS.filter((t) => talentKeys.includes(t.key)),
|
||||
];
|
||||
// A trait granted twice over (two sources of Darkvision, say) is listed once.
|
||||
const seen = new Set();
|
||||
return abilities
|
||||
.filter((a) => !seen.has(a.name) && seen.add(a.name))
|
||||
.map((a) => `${a.name} — ${a.text}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function speciesChoicesForRoll(roll) {
|
||||
const entry = SPECIES_ROLL_TABLE[roll];
|
||||
if (entry === 'WILDCARD') return { type: 'wildcard', options: ALL_SPECIES };
|
||||
@@ -103,7 +149,6 @@ function classChoicesForRoll(roll) {
|
||||
module.exports = {
|
||||
SPECIES_ROLL_TABLE,
|
||||
CLASS_ROLL_TABLE,
|
||||
MOTIVATION_TABLE,
|
||||
ALL_SPECIES,
|
||||
ALL_CLASSES,
|
||||
rollDie,
|
||||
@@ -114,4 +159,8 @@ module.exports = {
|
||||
randomSample,
|
||||
autoSelectSkills,
|
||||
validateManualRoll,
|
||||
validatePicks,
|
||||
composeFromPicks,
|
||||
validateTalents,
|
||||
composeSpecialAbilities,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
@@ -207,17 +262,122 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gear levels (house rule, not SRD). A held weapon or item has its own level,
|
||||
// 1-9 (the same span as spell levels), and its dice climb a fixed ladder as it
|
||||
// levels:
|
||||
//
|
||||
// 1d4 1d6 1d8 1d10 1d12 2d6 1d6+1d8 2d8 1d8+1d10 2d10 ...
|
||||
//
|
||||
// Each rung is the smallest possible step up in average damage: +1, or +0.5
|
||||
// where the ladder has to add a die (1d12 -> 2d6, 2d12 -> 3d8, ...).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_GEAR_LEVEL = 9;
|
||||
const DIE_SIZES = [4, 6, 8, 10, 12];
|
||||
// Rungs a piece of gear climbs per level: 1 = standard, 2 = powerful, 3 = legendary.
|
||||
const MAX_DAMAGE_SCALING = 3;
|
||||
|
||||
function formatDice(sizes) {
|
||||
const counts = new Map();
|
||||
for (const size of sizes) counts.set(size, (counts.get(size) || 0) + 1);
|
||||
return [...counts].map(([size, count]) => `${count}d${size}`).join('+');
|
||||
}
|
||||
|
||||
// k dice whose sizes are as even as possible, `steps` die-size steps above k d4s.
|
||||
function evenDice(k, steps) {
|
||||
const sizes = [];
|
||||
for (let i = 0; i < k; i++) {
|
||||
const share = Math.floor(steps / k) + (i >= k - (steps % k) ? 1 : 0);
|
||||
sizes.push(DIE_SIZES[share]);
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
function buildDamageLadder(rungs) {
|
||||
const ladder = [];
|
||||
let k = 1;
|
||||
let steps = 0;
|
||||
while (ladder.length < rungs) {
|
||||
const maxSteps = k * (DIE_SIZES.length - 1);
|
||||
if (steps > maxSteps) {
|
||||
// Out of room at this many dice: add one, landing just above the last rung.
|
||||
const previousAverage = ladder[ladder.length - 1].average;
|
||||
k += 1;
|
||||
steps = Math.floor(previousAverage - k * 2.5) + 1;
|
||||
continue;
|
||||
}
|
||||
ladder.push({ dice: formatDice(evenDice(k, steps)), average: k * 2.5 + steps });
|
||||
steps += 1;
|
||||
}
|
||||
return ladder;
|
||||
}
|
||||
|
||||
// Far longer than max level at legendary scaling needs, so heavy base dice
|
||||
// (a "10d6" item, say) still have room to climb.
|
||||
const DAMAGE_LADDER = buildDamageLadder(120);
|
||||
|
||||
// Average roll of a dice expression like "1d8", "2d6", "1d6+1d8" or "2d4+2".
|
||||
// Returns null for anything else (free text is allowed in the pool).
|
||||
function diceAverage(expression) {
|
||||
if (typeof expression !== 'string' || !expression.trim()) return null;
|
||||
let total = 0;
|
||||
for (const term of expression.replace(/\s+/g, '').toLowerCase().replace(/-/g, '+-').split('+')) {
|
||||
if (!term) continue;
|
||||
const die = /^(-?)(\d*)d(\d+)$/.exec(term);
|
||||
if (die) {
|
||||
total += (die[1] ? -1 : 1) * (Number(die[2]) || 1) * ((Number(die[3]) + 1) / 2);
|
||||
} else if (/^-?\d+$/.test(term)) {
|
||||
total += Number(term);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function clampGearLevel(level) {
|
||||
return Math.min(Math.max(Math.round(Number(level) || 1), 1), MAX_GEAR_LEVEL);
|
||||
}
|
||||
|
||||
// Dice for a piece of gear at a given level. `minLevel` is the level the gear
|
||||
// starts at (most gear: 1; a greatsword might only exist from level 3). At its
|
||||
// starting level it is always the base dice exactly as the DM wrote them;
|
||||
// above that it climbs `scaling` rungs per level, starting from the highest
|
||||
// rung that doesn't beat the base (so "1d4+1" starts from 1d6, and a flat "1"
|
||||
// starts below the ladder). Dice the ladder can't read (free text) never scale.
|
||||
function scaledDamageDice(baseDice, level = 1, scaling = 1, minLevel = 1) {
|
||||
const levelsGained = clampGearLevel(level) - clampGearLevel(minLevel);
|
||||
if (levelsGained <= 0) return baseDice;
|
||||
const baseAverage = diceAverage(baseDice);
|
||||
if (baseAverage === null) return baseDice;
|
||||
|
||||
let baseRung = -1;
|
||||
while (baseRung + 1 < DAMAGE_LADDER.length && DAMAGE_LADDER[baseRung + 1].average <= baseAverage) baseRung += 1;
|
||||
const rungsPerLevel = Math.min(Math.max(Math.round(Number(scaling) || 1), 1), MAX_DAMAGE_SCALING);
|
||||
const rung = Math.min(baseRung + levelsGained * rungsPerLevel, DAMAGE_LADDER.length - 1);
|
||||
return DAMAGE_LADDER[rung].dice;
|
||||
}
|
||||
|
||||
// A character can use gear and spells of their own level or lower. For
|
||||
// weapons and items the required level is the held copy's level; for spells
|
||||
// it is the classic spell level (cantrips, level 0, are open to everyone).
|
||||
function canUseGear(characterLevel, requiredLevel) {
|
||||
return characterLevel >= requiredLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attack/damage bonus for a specific weapon against a character's abilities —
|
||||
* used by the quick-stats panel and weapon list.
|
||||
* used by the quick-stats panel and weapon list. `gearLevel` is the level of
|
||||
* the copy the character holds.
|
||||
*/
|
||||
function computeWeaponStats(weapon, abilities, profBonus, proficient = true) {
|
||||
function computeWeaponStats(weapon, abilities, profBonus, proficient = true, gearLevel = 1) {
|
||||
const strMod = abilities.STR.modifier;
|
||||
const dexMod = abilities.DEX.modifier;
|
||||
return {
|
||||
attackBonus: weaponAttackBonus({ attackAbility: weapon.attackAbility, strMod, dexMod, proficient, profBonus }),
|
||||
damageBonus: weaponDamageBonus({ attackAbility: weapon.attackAbility, strMod, dexMod }),
|
||||
damageDice: weapon.damageDice,
|
||||
damageDice: scaledDamageDice(weapon.damageDice, gearLevel, weapon.damageScaling, weapon.minLevel),
|
||||
damageType: weapon.damageType,
|
||||
};
|
||||
}
|
||||
@@ -245,6 +405,14 @@ module.exports = {
|
||||
getClassSaveProficiencies,
|
||||
getClassHitDieSize,
|
||||
getClassSpellcastingAbility,
|
||||
GEAR_STATS,
|
||||
activeGear,
|
||||
computeCharacterSheet,
|
||||
computeWeaponStats,
|
||||
MAX_GEAR_LEVEL,
|
||||
MAX_DAMAGE_SCALING,
|
||||
DAMAGE_LADDER,
|
||||
diceAverage,
|
||||
scaledDamageDice,
|
||||
canUseGear,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,10 @@ const {
|
||||
weaponDamageBonus,
|
||||
computeCharacterSheet,
|
||||
computeWeaponStats,
|
||||
DAMAGE_LADDER,
|
||||
diceAverage,
|
||||
scaledDamageDice,
|
||||
canUseGear,
|
||||
} = require('./calculations');
|
||||
|
||||
describe('abilityModifier', () => {
|
||||
@@ -211,4 +215,110 @@ describe('computeWeaponStats', () => {
|
||||
expect(stats.damageBonus).toBe(sheet.abilities.STR.modifier);
|
||||
expect(stats.damageDice).toBe('1d8');
|
||||
});
|
||||
|
||||
test('uses the dice for the level of the copy the character holds', () => {
|
||||
const sheet = computeCharacterSheet({ level: 5, strScore: 16 });
|
||||
const spear = { attackAbility: 'STR', damageDice: '1d6', damageType: 'piercing', damageScaling: 1 };
|
||||
expect(computeWeaponStats(spear, sheet.abilities, sheet.proficiencyBonus, true, 3).damageDice).toBe('1d10');
|
||||
// The level changes the dice only, never the bonuses.
|
||||
expect(computeWeaponStats(spear, sheet.abilities, sheet.proficiencyBonus, true, 3).damageBonus).toBe(sheet.abilities.STR.modifier);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gear levels — the damage ladder', () => {
|
||||
test('starts with single dice, then pairs, in the smallest possible steps', () => {
|
||||
expect(DAMAGE_LADDER.slice(0, 13).map((r) => r.dice)).toEqual([
|
||||
'1d4', '1d6', '1d8', '1d10', '1d12',
|
||||
'2d6', '1d6+1d8', '2d8', '1d8+1d10', '2d10', '1d10+1d12', '2d12',
|
||||
'3d8',
|
||||
]);
|
||||
});
|
||||
|
||||
test('every rung is stronger than the last by 1 average damage, or 0.5 where a die is added', () => {
|
||||
for (let i = 1; i < DAMAGE_LADDER.length; i++) {
|
||||
const step = DAMAGE_LADDER[i].average - DAMAGE_LADDER[i - 1].average;
|
||||
expect([0.5, 1]).toContain(step);
|
||||
// The stated average really is the average of the dice shown.
|
||||
expect(diceAverage(DAMAGE_LADDER[i].dice)).toBe(DAMAGE_LADDER[i].average);
|
||||
}
|
||||
});
|
||||
|
||||
test('diceAverage reads the expressions DMs type, and rejects free text', () => {
|
||||
expect(diceAverage('1d8')).toBe(4.5);
|
||||
expect(diceAverage('d8')).toBe(4.5);
|
||||
expect(diceAverage('2d6')).toBe(7);
|
||||
expect(diceAverage('1d6 + 1d8')).toBe(8);
|
||||
expect(diceAverage('2d4+2')).toBe(7);
|
||||
expect(diceAverage('1d6-1')).toBe(2.5);
|
||||
expect(diceAverage('3')).toBe(3);
|
||||
expect(diceAverage('special')).toBeNull();
|
||||
expect(diceAverage('')).toBeNull();
|
||||
expect(diceAverage(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scaledDamageDice', () => {
|
||||
const byLevel = (base, scaling, levels) => levels.map((level) => scaledDamageDice(base, level, scaling));
|
||||
|
||||
test('standard gear climbs one rung per level', () => {
|
||||
expect(byLevel('1d4', 1, [1, 2, 3, 4, 5, 6])).toEqual(['1d4', '1d6', '1d8', '1d10', '1d12', '2d6']);
|
||||
expect(byLevel('1d8', 1, [1, 2, 3])).toEqual(['1d8', '1d10', '1d12']);
|
||||
});
|
||||
|
||||
test('powerful and legendary gear climb two and three rungs per level', () => {
|
||||
expect(byLevel('1d6', 2, [1, 2, 3, 4])).toEqual(['1d6', '1d10', '2d6', '2d8']);
|
||||
expect(byLevel('1d8', 3, [1, 2, 3])).toEqual(['1d8', '2d6', '1d8+1d10']);
|
||||
});
|
||||
|
||||
test('level 1 is always the base dice exactly as written', () => {
|
||||
expect(scaledDamageDice('2d4+2', 1, 1)).toBe('2d4+2');
|
||||
expect(scaledDamageDice('special', 1, 3)).toBe('special');
|
||||
});
|
||||
|
||||
test('dice that are not on the ladder start from the highest rung that does not beat them', () => {
|
||||
expect(scaledDamageDice('2d4+2', 2, 1)).toBe('1d6+1d8'); // avg 7 sits on 2d6
|
||||
expect(scaledDamageDice('1d4+1', 2, 1)).toBe('1d8'); // avg 3.5 sits on 1d6
|
||||
expect(scaledDamageDice('1', 2, 1)).toBe('1d4'); // below the ladder
|
||||
});
|
||||
|
||||
test('free text never scales, and levels and scaling are clamped to their ranges', () => {
|
||||
expect(scaledDamageDice('special', 9, 3)).toBe('special');
|
||||
expect(scaledDamageDice('1d4', 99, 1)).toBe(scaledDamageDice('1d4', 9, 1));
|
||||
expect(scaledDamageDice('1d4', 9, 1)).toBe('1d8+1d10');
|
||||
expect(scaledDamageDice('1d4', 0, 1)).toBe('1d4');
|
||||
expect(scaledDamageDice('1d4', 2, 99)).toBe(scaledDamageDice('1d4', 2, 3));
|
||||
expect(scaledDamageDice('1d4', 2, undefined)).toBe('1d6');
|
||||
});
|
||||
|
||||
test('gear with a starting level shows its base dice there and climbs from it', () => {
|
||||
// A greatsword that only exists from level 3: 2d6 at 3, then one rung per level.
|
||||
expect([3, 4, 5].map((level) => scaledDamageDice('2d6', level, 1, 3))).toEqual(['2d6', '1d6+1d8', '2d8']);
|
||||
expect(scaledDamageDice('2d6', 1, 1, 3)).toBe('2d6'); // never below its base
|
||||
expect(scaledDamageDice('2d4+2', 3, 2, 3)).toBe('2d4+2'); // as written at its starting level
|
||||
const sheet = computeCharacterSheet({ level: 5, strScore: 16 });
|
||||
const greatsword = { attackAbility: 'STR', damageDice: '2d6', damageType: 'slashing', damageScaling: 1, minLevel: 3 };
|
||||
expect(computeWeaponStats(greatsword, sheet.abilities, sheet.proficiencyBonus, true, 4).damageDice).toBe('1d6+1d8');
|
||||
});
|
||||
|
||||
test('a higher level is never weaker', () => {
|
||||
for (const base of ['1d4', '1d12', '2d6', '2d4+2']) {
|
||||
for (const scaling of [1, 2, 3]) {
|
||||
for (let level = 2; level <= 9; level++) {
|
||||
expect(diceAverage(scaledDamageDice(base, level, scaling))).toBeGreaterThan(
|
||||
diceAverage(scaledDamageDice(base, level - 1, scaling))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('canUseGear', () => {
|
||||
test('a character can use gear of their own level or lower', () => {
|
||||
expect(canUseGear(1, 1)).toBe(true);
|
||||
expect(canUseGear(3, 1)).toBe(true);
|
||||
expect(canUseGear(3, 3)).toBe(true);
|
||||
expect(canUseGear(2, 3)).toBe(false);
|
||||
expect(canUseGear(1, 0)).toBe(true); // cantrips
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
// What a player picks from in the story steps of the character builder:
|
||||
// motivation, special abilities, background and appearance. A character's
|
||||
// text for these is always composed from the entries below (see buildFlow.js),
|
||||
// never typed freely, so the builder page fetches this file's contents rather
|
||||
// than keeping its own copy.
|
||||
//
|
||||
// None of this feeds the calculation engine. Special abilities are written as
|
||||
// table rules for the DM to apply, in plain words.
|
||||
|
||||
const MOTIVATIONS = [
|
||||
{ key: 1, label: 'Revenge for a past wrong' },
|
||||
{ key: 2, label: 'Seeking fortune or wealth' },
|
||||
{ key: 3, label: 'Protecting someone they love' },
|
||||
{ key: 4, label: 'Redemption for a past mistake' },
|
||||
{ key: 5, label: 'Driven by curiosity or knowledge' },
|
||||
{ key: 6, label: 'Duty or oath to a cause or order' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Special abilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Granted automatically by species.
|
||||
const SPECIES_TRAITS = {
|
||||
HUMAN: [{ name: 'Versatile', text: 'Once per day, reroll one skill check you just failed and keep the new result.' }],
|
||||
HALF_ELF: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
|
||||
],
|
||||
ELF: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
|
||||
{ name: 'Trance', text: 'You do not sleep. Four hours of meditation rests you as well as a full night.' },
|
||||
],
|
||||
GNOME: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Gnome Cunning', text: 'Advantage on Intelligence, Wisdom and Charisma saves against magic.' },
|
||||
],
|
||||
DWARF: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Dwarven Resilience', text: 'Advantage on saves against poison, and poison damage against you is halved.' },
|
||||
],
|
||||
HALFLING: [
|
||||
{ name: 'Lucky', text: 'When you roll a 1 on a d20 for an attack, check or save, reroll it and use the new roll.' },
|
||||
{ name: 'Brave', text: 'Advantage on saves against being frightened.' },
|
||||
],
|
||||
DRAGONBORN: [
|
||||
{ name: 'Breath Weapon', text: 'Once per rest, exhale destructive energy: creatures in a 15-foot cone take 2d6 damage (Dexterity save for half).' },
|
||||
{ name: 'Draconic Resistance', text: 'Damage of your breath weapon’s type against you is halved.' },
|
||||
],
|
||||
TIEFLING: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Hellish Resistance', text: 'Fire damage against you is halved.' },
|
||||
],
|
||||
HALF_ORC: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Relentless Endurance', text: 'Once per day, when you would drop to 0 hit points, drop to 1 instead.' },
|
||||
],
|
||||
ORC: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Aggressive', text: 'As a bonus action, move up to your speed toward an enemy you can see.' },
|
||||
],
|
||||
};
|
||||
|
||||
// Granted automatically by class, at level 1.
|
||||
const CLASS_FEATURES = {
|
||||
FIGHTER: [
|
||||
{ name: 'Second Wind', text: 'Once per rest, as a bonus action, recover 1d10 + your level hit points.' },
|
||||
{ name: 'Fighting Style', text: 'Choose one with your DM: +2 to ranged attacks, +1 AC in armor, or +2 damage with a one-handed weapon.' },
|
||||
],
|
||||
BARBARIAN: [
|
||||
{ name: 'Rage', text: 'Twice per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved.' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor, add your Constitution modifier to your AC (ask your DM to set your armor bonus).' },
|
||||
],
|
||||
ROGUE: [
|
||||
{ name: 'Sneak Attack', text: 'Once per turn, deal an extra 1d6 damage to a target you have advantage against, or that has one of your allies next to it.' },
|
||||
{ name: 'Thieves’ Cant', text: 'You know the secret slang and signs of the criminal underworld.' },
|
||||
],
|
||||
RANGER: [
|
||||
{ name: 'Favored Enemy', text: 'Choose a kind of creature with your DM. Advantage on checks to track them and to recall what you know about them.' },
|
||||
{ name: 'Natural Explorer', text: 'In the wild you cannot get lost, you find twice as much food, and difficult terrain does not slow your group.' },
|
||||
],
|
||||
WIZARD: [
|
||||
{ name: 'Spellbook', text: 'Your spells are written in a book. You can copy new spells into it when you find them.' },
|
||||
{ name: 'Arcane Recovery', text: 'Once per day, after a short rest, regain some of your spent magic.' },
|
||||
],
|
||||
SORCERER: [
|
||||
{ name: 'Innate Magic', text: 'Your magic comes from within: you need no spellbook and no prayers.' },
|
||||
{ name: 'Wild Talent', text: 'Once per day, reroll the damage dice of one spell and keep the better result.' },
|
||||
],
|
||||
CLERIC: [
|
||||
{ name: 'Divine Domain', text: 'Choose with your DM what your god stands for (life, light, war, knowledge…). It shapes the favours you can ask.' },
|
||||
{ name: 'Healing Hands', text: 'Once per rest, touch a creature to restore 1d8 + your Wisdom modifier hit points.' },
|
||||
],
|
||||
PALADIN: [
|
||||
{ name: 'Divine Sense', text: 'A few times per day, sense any celestial, fiend or undead within 60 feet.' },
|
||||
{ name: 'Lay on Hands', text: 'A pool of healing equal to 5 × your level per day, spent by touch however you like.' },
|
||||
],
|
||||
BARD: [
|
||||
{ name: 'Bardic Inspiration', text: 'A few times per day, as a bonus action, give an ally a d6 to add to one attack, check or save.' },
|
||||
{ name: 'Jack of Many Tales', text: 'You have heard a story about nearly everything: ask your DM for one useful rumour per session.' },
|
||||
],
|
||||
WARLOCK: [
|
||||
{ name: 'Otherworldly Patron', text: 'Choose with your DM who granted your power, and what they want in return.' },
|
||||
{ name: 'Pact Magic', text: 'Your magic returns after a short rest, not only a long one.' },
|
||||
],
|
||||
DRUID: [
|
||||
{ name: 'Druidic', text: 'You know the secret language of druids and can leave hidden messages in it.' },
|
||||
{ name: 'Nature’s Voice', text: 'You can calm and make simple requests of ordinary animals.' },
|
||||
],
|
||||
MONK: [
|
||||
{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d4 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor, add your Wisdom modifier to your AC (ask your DM to set your armor bonus).' },
|
||||
],
|
||||
};
|
||||
|
||||
// The player picks TALENT_PICKS of these.
|
||||
const TALENT_PICKS = 2;
|
||||
const TALENTS = [
|
||||
{ key: 'ALERT', name: 'Alert', text: 'You cannot be surprised while you are awake.' },
|
||||
{ key: 'KEEN_EYE', name: 'Keen Eye', text: 'Advantage on checks to spot hidden doors, traps and things out of place.' },
|
||||
{ key: 'SILVER_TONGUE', name: 'Silver Tongue', text: 'Once per day, reroll a Persuasion or Deception check and keep the better result.' },
|
||||
{ key: 'LIGHT_SLEEPER', name: 'Light Sleeper', text: 'You wake at the slightest sound, and keeping watch does not tire you.' },
|
||||
{ key: 'IRON_STOMACH', name: 'Iron Stomach', text: 'Advantage on saves against spoiled food, strong drink and swallowed poisons.' },
|
||||
{ key: 'ANIMAL_FRIEND', name: 'Animal Friend', text: 'Ordinary animals are calm around you unless provoked.' },
|
||||
{ key: 'QUICK_HANDS', name: 'Quick Hands', text: 'Once per turn, draw, stow or pick up an object without using your action.' },
|
||||
{ key: 'SURE_FOOTED', name: 'Sure-Footed', text: 'Advantage on checks to keep your balance or avoid being knocked down.' },
|
||||
{ key: 'LINGUIST', name: 'Linguist', text: 'You speak two extra languages of your choice, agreed with your DM.' },
|
||||
{ key: 'FIELD_MEDIC', name: 'Field Medic', text: 'Steadying a dying creature is automatic for you; no roll needed.' },
|
||||
{ key: 'STRONG_BACK', name: 'Strong Back', text: 'You can carry, lift and drag twice as much as your size suggests.' },
|
||||
{ key: 'GOOD_MEMORY', name: 'Good Memory', text: 'You recall exactly anything you have seen or heard in the last month.' },
|
||||
{ key: 'STREETWISE', name: 'Streetwise', text: 'In any town you can find a place to sleep, a rumour, and someone who buys things without asking questions.' },
|
||||
{ key: 'TOUGH_AS_NAILS', name: 'Tough as Nails', text: 'Once per day, shrug off a blow: reduce one instance of damage by 1d6.' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background and appearance — groups of options. `min`/`max` say how many a
|
||||
// player picks in each group.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BACKGROUND_GROUPS = [
|
||||
{
|
||||
key: 'upbringing',
|
||||
label: 'Upbringing',
|
||||
prompt: 'What were you before you became an adventurer?',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'SOLDIER', label: 'Soldier', text: 'Served in an army or militia, and still thinks in terms of orders and watches.' },
|
||||
{ key: 'ACOLYTE', label: 'Acolyte', text: 'Raised in a temple, tending shrines and copying scripture.' },
|
||||
{ key: 'CRIMINAL', label: 'Criminal', text: 'Made a living outside the law, and knows who to ask for what.' },
|
||||
{ key: 'NOBLE', label: 'Noble', text: 'Born to wealth and a family name that opens doors — and makes enemies.' },
|
||||
{ key: 'SCHOLAR', label: 'Scholar', text: 'Spent years among books, chasing answers to old questions.' },
|
||||
{ key: 'FARMER', label: 'Farmer', text: 'Worked the land from dawn to dusk, and knows the value of a good harvest.' },
|
||||
{ key: 'SAILOR', label: 'Sailor', text: 'Grew up on deck, at home with rope, salt and bad weather.' },
|
||||
{ key: 'ARTISAN', label: 'Guild artisan', text: 'Learned a craft in a workshop and takes pride in good work.' },
|
||||
{ key: 'ENTERTAINER', label: 'Entertainer', text: 'Sang, juggled or acted for coin in taverns and market squares.' },
|
||||
{ key: 'OUTLANDER', label: 'Outlander', text: 'Raised far from towns, among hunters, herders or nomads.' },
|
||||
{ key: 'URCHIN', label: 'Urchin', text: 'Grew up alone on city streets, surviving on wits and quick feet.' },
|
||||
{ key: 'HERMIT', label: 'Hermit', text: 'Lived apart from the world for years, with only thoughts for company.' },
|
||||
{ key: 'MERCHANT', label: 'Merchant', text: 'Travelled the trade roads buying low and selling high.' },
|
||||
{ key: 'HEALER', label: 'Village healer', text: 'Set bones, brewed remedies and sat with the sick.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'home',
|
||||
label: 'Home',
|
||||
prompt: 'Where did you grow up?',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'CITY', label: 'A crowded city', text: 'Noise, alleys and a thousand strangers.' },
|
||||
{ key: 'VILLAGE', label: 'A small village', text: 'Everyone knew everyone, and nothing stayed secret.' },
|
||||
{ key: 'PORT', label: 'A harbour town', text: 'Ships, gulls and news from far away.' },
|
||||
{ key: 'MOUNTAIN', label: 'A mountain hold', text: 'Thin air, stone halls and long winters.' },
|
||||
{ key: 'FOREST', label: 'Deep in the forest', text: 'Under old trees, far from any road.' },
|
||||
{ key: 'FRONTIER', label: 'A frontier fort', text: 'The last wall before the wild.' },
|
||||
{ key: 'ROAD', label: 'On the road', text: 'Never in one place long enough to call it home.' },
|
||||
{ key: 'MONASTERY', label: 'A remote monastery', text: 'Bells, silence and routine.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'turningPoint',
|
||||
label: 'Turning point',
|
||||
prompt: 'What set you on the road?',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'LOSS', label: 'Lost someone dear', text: 'And have not been the same since.' },
|
||||
{ key: 'BETRAYED', label: 'Was betrayed', text: 'By someone they trusted completely.' },
|
||||
{ key: 'DISASTER', label: 'Survived a disaster', text: 'Fire, flood, plague or war — and walked away when others did not.' },
|
||||
{ key: 'SECRET', label: 'Found a secret', text: 'Something they were never meant to see.' },
|
||||
{ key: 'DEBT', label: 'Owes a debt', text: 'One that cannot be repaid by staying home.' },
|
||||
{ key: 'EXILED', label: 'Was cast out', text: 'Rightly or wrongly, there is no going back.' },
|
||||
{ key: 'RESCUED', label: 'Was saved by a stranger', text: 'And means to pass the favour on.' },
|
||||
{ key: 'CALLING', label: 'Heard a calling', text: 'A dream, a sign or a voice that would not let them rest.' },
|
||||
{ key: 'BOREDOM', label: 'Could not stand the quiet', text: 'An ordinary life was never going to be enough.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'personality',
|
||||
label: 'Personality',
|
||||
prompt: 'How do others describe you? Pick one or two.',
|
||||
min: 1,
|
||||
max: 2,
|
||||
options: [
|
||||
{ key: 'BRAVE', label: 'Brave' },
|
||||
{ key: 'CAUTIOUS', label: 'Cautious' },
|
||||
{ key: 'CURIOUS', label: 'Curious' },
|
||||
{ key: 'KIND', label: 'Kind' },
|
||||
{ key: 'HOT_HEADED', label: 'Hot-headed' },
|
||||
{ key: 'SARCASTIC', label: 'Sarcastic' },
|
||||
{ key: 'LOYAL', label: 'Loyal' },
|
||||
{ key: 'HONEST', label: 'Honest to a fault' },
|
||||
{ key: 'CHEERFUL', label: 'Cheerful' },
|
||||
{ key: 'QUIET', label: 'Quiet' },
|
||||
{ key: 'PROUD', label: 'Proud' },
|
||||
{ key: 'SUPERSTITIOUS', label: 'Superstitious' },
|
||||
{ key: 'GENEROUS', label: 'Generous' },
|
||||
{ key: 'STUBBORN', label: 'Stubborn' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'flaw',
|
||||
label: 'Flaw',
|
||||
prompt: 'What gets you into trouble?',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'GREEDY', label: 'Greedy', text: 'Cannot walk past treasure.' },
|
||||
{ key: 'RECKLESS', label: 'Reckless', text: 'Acts first and thinks later.' },
|
||||
{ key: 'VENGEFUL', label: 'Holds grudges', text: 'Never forgets a slight.' },
|
||||
{ key: 'GULLIBLE', label: 'Too trusting', text: 'Believes a good story.' },
|
||||
{ key: 'COWARDLY', label: 'Loses their nerve', text: 'When it matters most.' },
|
||||
{ key: 'BOASTFUL', label: 'Boastful', text: 'Promises more than they can deliver.' },
|
||||
{ key: 'SECRETIVE', label: 'Secretive', text: 'Keeps things even from friends.' },
|
||||
{ key: 'SOFT_HEARTED', label: 'Soft-hearted', text: 'Cannot refuse someone in need.' },
|
||||
{ key: 'TEMPER', label: 'Short temper', text: 'Easily provoked.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const APPEARANCE_GROUPS = [
|
||||
{
|
||||
key: 'build',
|
||||
label: 'Build',
|
||||
prompt: 'What shape are you?',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'SLIGHT', label: 'Slight' },
|
||||
{ key: 'LEAN', label: 'Lean' },
|
||||
{ key: 'AVERAGE', label: 'Average' },
|
||||
{ key: 'STOCKY', label: 'Stocky' },
|
||||
{ key: 'MUSCULAR', label: 'Muscular' },
|
||||
{ key: 'HEAVYSET', label: 'Heavyset' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'height',
|
||||
label: 'Height',
|
||||
prompt: 'For your species, are you…',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'SHORT', label: 'Short' },
|
||||
{ key: 'AVERAGE', label: 'Average height' },
|
||||
{ key: 'TALL', label: 'Tall' },
|
||||
{ key: 'TOWERING', label: 'Towering' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'hair',
|
||||
label: 'Hair',
|
||||
prompt: 'Hair (or what you have instead).',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'BLACK', label: 'Black' },
|
||||
{ key: 'BROWN', label: 'Brown' },
|
||||
{ key: 'BLONDE', label: 'Blonde' },
|
||||
{ key: 'RED', label: 'Red' },
|
||||
{ key: 'GREY', label: 'Grey' },
|
||||
{ key: 'WHITE', label: 'White' },
|
||||
{ key: 'UNUSUAL', label: 'An unusual colour' },
|
||||
{ key: 'BALD', label: 'Bald' },
|
||||
{ key: 'NONE', label: 'None — scales, horns or crest' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'eyes',
|
||||
label: 'Eyes',
|
||||
prompt: 'Eye colour.',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'BROWN', label: 'Brown' },
|
||||
{ key: 'BLUE', label: 'Blue' },
|
||||
{ key: 'GREEN', label: 'Green' },
|
||||
{ key: 'GREY', label: 'Grey' },
|
||||
{ key: 'AMBER', label: 'Amber' },
|
||||
{ key: 'BLACK', label: 'Black' },
|
||||
{ key: 'VIOLET', label: 'Violet' },
|
||||
{ key: 'MISMATCHED', label: 'Mismatched' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'complexion',
|
||||
label: 'Skin',
|
||||
prompt: 'Skin or scales.',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'PALE', label: 'Pale' },
|
||||
{ key: 'FAIR', label: 'Fair' },
|
||||
{ key: 'TANNED', label: 'Tanned' },
|
||||
{ key: 'BROWN', label: 'Brown' },
|
||||
{ key: 'DARK', label: 'Dark' },
|
||||
{ key: 'WEATHERED', label: 'Weathered' },
|
||||
{ key: 'ASHEN', label: 'Ashen grey' },
|
||||
{ key: 'GREEN', label: 'Green-tinged' },
|
||||
{ key: 'RED', label: 'Deep red' },
|
||||
{ key: 'SCALED', label: 'Scaled' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'features',
|
||||
label: 'Distinguishing features',
|
||||
prompt: 'What would a stranger remember? Pick up to two, or none.',
|
||||
min: 0,
|
||||
max: 2,
|
||||
options: [
|
||||
{ key: 'SCAR', label: 'A scar across the face' },
|
||||
{ key: 'TATTOO', label: 'A tattoo' },
|
||||
{ key: 'FRECKLES', label: 'Freckles' },
|
||||
{ key: 'BROKEN_NOSE', label: 'A broken nose' },
|
||||
{ key: 'EYEPATCH', label: 'An eyepatch' },
|
||||
{ key: 'BEARD', label: 'A braided beard' },
|
||||
{ key: 'PIERCINGS', label: 'Piercings' },
|
||||
{ key: 'BIRTHMARK', label: 'A birthmark' },
|
||||
{ key: 'BURN', label: 'An old burn' },
|
||||
{ key: 'MISSING_FINGER', label: 'A missing finger' },
|
||||
{ key: 'LIMP', label: 'A slight limp' },
|
||||
{ key: 'BIG_GRIN', label: 'An easy grin' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'style',
|
||||
label: 'Usually wears',
|
||||
prompt: 'What are you usually seen in?',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'LEATHERS', label: 'Travel-worn leathers' },
|
||||
{ key: 'ARMOR', label: 'Well-kept armor' },
|
||||
{ key: 'CLOAK', label: 'A hooded cloak' },
|
||||
{ key: 'FINE', label: 'Fine clothes' },
|
||||
{ key: 'PATCHED', label: 'Patched and mended clothes' },
|
||||
{ key: 'ROBES', label: 'Long robes' },
|
||||
{ key: 'FURS', label: 'Furs and hides' },
|
||||
{ key: 'BRIGHT', label: 'Bright colours' },
|
||||
{ key: 'PLAIN', label: 'Plain, practical clothes' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
MOTIVATIONS,
|
||||
SPECIES_TRAITS,
|
||||
CLASS_FEATURES,
|
||||
TALENT_PICKS,
|
||||
TALENTS,
|
||||
BACKGROUND_GROUPS,
|
||||
APPEARANCE_GROUPS,
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -3,16 +3,22 @@
|
||||
// isn't hand-typing spells from scratch. Shape matches the `Spell` Prisma
|
||||
// model minus `id`/`campaignId`, plus a stable `key` for "add to campaign"
|
||||
// lookups and a `classes` tag array the library picker can filter by.
|
||||
//
|
||||
// `level` is the classic spell level, which in this app is also the character
|
||||
// level needed to use the spell. `damageDice` is a default for spells that
|
||||
// deal damage — the dice at the spell's own level, per the SRD (per ray for
|
||||
// Scorching Ray, total for Magic Missile's three darts). Spells that only
|
||||
// heal, protect or control have none. A DM can change either after adding.
|
||||
|
||||
const SPELL_LIBRARY = [
|
||||
// ---- Cantrips (level 0) ----
|
||||
{ key: 'acid-splash', name: 'Acid Splash', level: 0, school: 'Conjuration', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'Hurl a bubble of acid at one or two creatures within 5 feet of each other.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'acid-splash', name: 'Acid Splash', level: 0, school: 'Conjuration', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'Hurl a bubble of acid at one or two creatures within 5 feet of each other.', damageDice: '1d6', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'blade-ward', name: 'Blade Ward', level: 0, school: 'Abjuration', castingTime: '1 action', range: 'Self', duration: '1 round', description: 'Resist bludgeoning, piercing, and slashing damage until your next turn.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'chill-touch', name: 'Chill Touch', level: 0, school: 'Necromancy', castingTime: '1 action', range: '120 feet', duration: '1 round', description: 'A ghostly hand deals necrotic damage and prevents healing.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'chill-touch', name: 'Chill Touch', level: 0, school: 'Necromancy', castingTime: '1 action', range: '120 feet', duration: '1 round', description: 'A ghostly hand deals necrotic damage and prevents healing.', damageDice: '1d8', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'dancing-lights', name: 'Dancing Lights', level: 0, school: 'Illusion', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Create up to four torch-sized lights that you can move.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'druidcraft', name: 'Druidcraft', level: 0, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: 'Instantaneous', description: 'A small, harmless nature-flavored effect: predict weather, sprout a seedling, and the like.', classes: ['DRUID'] },
|
||||
{ key: 'eldritch-blast', name: 'Eldritch Blast', level: 0, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'A beam of crackling energy strikes one creature for force damage.', classes: ['WARLOCK'] },
|
||||
{ key: 'fire-bolt', name: 'Fire Bolt', level: 0, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'Hurl a mote of fire that deals fire damage on a hit.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'eldritch-blast', name: 'Eldritch Blast', level: 0, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'A beam of crackling energy strikes one creature for force damage.', damageDice: '1d10', classes: ['WARLOCK'] },
|
||||
{ key: 'fire-bolt', name: 'Fire Bolt', level: 0, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'Hurl a mote of fire that deals fire damage on a hit.', damageDice: '1d10', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'friends', name: 'Friends', level: 0, school: 'Enchantment', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 1 minute', description: 'Advantage on Charisma checks against one creature for the duration.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'guidance', name: 'Guidance', level: 0, school: 'Divination', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 minute', description: 'Add 1d4 to one ability check of your choice.', classes: ['CLERIC', 'DRUID'] },
|
||||
{ key: 'light', name: 'Light', level: 0, school: 'Evocation', castingTime: '1 action', range: 'Touch', duration: '1 hour', description: 'An object sheds bright light in a 20-foot radius.', classes: ['BARD', 'CLERIC', 'SORCERER', 'WIZARD'] },
|
||||
@@ -20,25 +26,25 @@ const SPELL_LIBRARY = [
|
||||
{ key: 'mending', name: 'Mending', level: 0, school: 'Transmutation', castingTime: '1 minute', range: 'Touch', duration: 'Instantaneous', description: 'Repairs a single break or tear in an object.', classes: ['BARD', 'CLERIC', 'DRUID', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'message', name: 'Message', level: 0, school: 'Transmutation', castingTime: '1 action', range: '120 feet', duration: '1 round', description: 'Whisper a message to a target that only they can hear, with a whispered reply.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'minor-illusion', name: 'Minor Illusion', level: 0, school: 'Illusion', castingTime: '1 action', range: '30 feet', duration: '1 minute', description: 'Create a sound or an image of an object for the duration.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'poison-spray', name: 'Poison Spray', level: 0, school: 'Conjuration', castingTime: '1 action', range: '10 feet', duration: 'Instantaneous', description: 'Project a puff of noxious gas that deals poison damage.', classes: ['DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'poison-spray', name: 'Poison Spray', level: 0, school: 'Conjuration', castingTime: '1 action', range: '10 feet', duration: 'Instantaneous', description: 'Project a puff of noxious gas that deals poison damage.', damageDice: '1d12', classes: ['DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'prestidigitation', name: 'Prestidigitation', level: 0, school: 'Transmutation', castingTime: '1 action', range: '10 feet', duration: 'Up to 1 hour', description: 'A minor magical trick — light a candle, clean an object, chill or warm food, and the like.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'produce-flame', name: 'Produce Flame', level: 0, school: 'Conjuration', castingTime: '1 action', range: 'Self', duration: '10 minutes', description: 'A flickering flame in your hand sheds light and can be hurled for fire damage.', classes: ['DRUID'] },
|
||||
{ key: 'ray-of-frost', name: 'Ray of Frost', level: 0, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A ray of freezing energy deals cold damage and slows the target.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'produce-flame', name: 'Produce Flame', level: 0, school: 'Conjuration', castingTime: '1 action', range: 'Self', duration: '10 minutes', description: 'A flickering flame in your hand sheds light and can be hurled for fire damage.', damageDice: '1d8', classes: ['DRUID'] },
|
||||
{ key: 'ray-of-frost', name: 'Ray of Frost', level: 0, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A ray of freezing energy deals cold damage and slows the target.', damageDice: '1d8', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'resistance', name: 'Resistance', level: 0, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 minute', description: 'The target adds 1d4 to one saving throw of its choice.', classes: ['CLERIC', 'DRUID'] },
|
||||
{ key: 'sacred-flame', name: 'Sacred Flame', level: 0, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'Radiant flame descends on a creature that must succeed a Dexterity save or take damage.', classes: ['CLERIC'] },
|
||||
{ key: 'sacred-flame', name: 'Sacred Flame', level: 0, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'Radiant flame descends on a creature that must succeed a Dexterity save or take damage.', damageDice: '1d8', classes: ['CLERIC'] },
|
||||
{ key: 'shillelagh', name: 'Shillelagh', level: 0, school: 'Transmutation', castingTime: '1 bonus action', range: 'Touch', duration: '1 minute', description: 'Imbue a club or quarterstaff with magic, using Wisdom for its attack and damage.', classes: ['DRUID'] },
|
||||
{ key: 'shocking-grasp', name: 'Shocking Grasp', level: 0, school: 'Evocation', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Lightning springs from your hand, dealing damage and preventing reactions.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'shocking-grasp', name: 'Shocking Grasp', level: 0, school: 'Evocation', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Lightning springs from your hand, dealing damage and preventing reactions.', damageDice: '1d8', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'spare-the-dying', name: 'Spare the Dying', level: 0, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature you touch that has 0 hit points becomes stable.', classes: ['CLERIC'] },
|
||||
{ key: 'thorn-whip', name: 'Thorn Whip', level: 0, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: 'Instantaneous', description: 'A vine-like whip deals damage and drags the target closer.', classes: ['DRUID'] },
|
||||
{ key: 'thorn-whip', name: 'Thorn Whip', level: 0, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: 'Instantaneous', description: 'A vine-like whip deals damage and drags the target closer.', damageDice: '1d6', classes: ['DRUID'] },
|
||||
{ key: 'true-strike', name: 'True Strike', level: 0, school: 'Divination', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 round', description: 'Gain advantage on your next attack roll against the target.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'vicious-mockery', name: 'Vicious Mockery', level: 0, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A string of insults deals psychic damage and imposes disadvantage on the target’s next attack.', classes: ['BARD'] },
|
||||
{ key: 'vicious-mockery', name: 'Vicious Mockery', level: 0, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A string of insults deals psychic damage and imposes disadvantage on the target’s next attack.', damageDice: '1d4', classes: ['BARD'] },
|
||||
|
||||
// ---- Level 1 ----
|
||||
{ key: 'alarm', name: 'Alarm', level: 1, school: 'Abjuration', castingTime: '1 minute', range: '30 feet', duration: '8 hours', description: 'Set an audible or mental alarm against intruders.', classes: ['RANGER', 'WIZARD'] },
|
||||
{ key: 'animal-friendship', name: 'Animal Friendship', level: 1, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: '24 hours', description: 'Convince a beast that you mean it no harm.', classes: ['BARD', 'DRUID', 'RANGER'] },
|
||||
{ key: 'bane', name: 'Bane', level: 1, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'Up to three creatures subtract 1d4 from attack rolls and saves.', classes: ['BARD', 'CLERIC'] },
|
||||
{ key: 'bless', name: 'Bless', level: 1, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'Up to three creatures add 1d4 to attack rolls and saves.', classes: ['CLERIC', 'PALADIN'] },
|
||||
{ key: 'burning-hands', name: 'Burning Hands', level: 1, school: 'Evocation', castingTime: '1 action', range: 'Self (15-foot cone)', duration: 'Instantaneous', description: 'A thin sheet of flame sweeps out from your hands.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'burning-hands', name: 'Burning Hands', level: 1, school: 'Evocation', castingTime: '1 action', range: 'Self (15-foot cone)', duration: 'Instantaneous', description: 'A thin sheet of flame sweeps out from your hands.', damageDice: '3d6', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'charm-person', name: 'Charm Person', level: 1, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: '1 hour', description: 'A humanoid you can see regards you as a friendly acquaintance.', classes: ['BARD', 'DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'color-spray', name: 'Color Spray', level: 1, school: 'Illusion', castingTime: '1 action', range: 'Self (15-foot cone)', duration: '1 round', description: 'A dazzling array of flashing colors can blind weak creatures.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'command', name: 'Command', level: 1, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: '1 round', description: 'Speak a one-word command a creature must obey.', classes: ['CLERIC', 'PALADIN'] },
|
||||
@@ -50,20 +56,20 @@ const SPELL_LIBRARY = [
|
||||
{ key: 'faerie-fire', name: 'Faerie Fire', level: 1, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'Objects in a 20-foot cube are outlined in light, granting attackers advantage.', classes: ['DRUID'] },
|
||||
{ key: 'feather-fall', name: 'Feather Fall', level: 1, school: 'Transmutation', castingTime: '1 reaction', range: '60 feet', duration: '1 minute', description: 'Falling creatures slow to a gentle descent.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'fog-cloud', name: 'Fog Cloud', level: 1, school: 'Conjuration', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 hour', description: 'A 20-foot-radius sphere of fog heavily obscures the area.', classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'guiding-bolt', name: 'Guiding Bolt', level: 1, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: '1 round', description: 'A flash of light streaks to a target, dealing radiant damage.', classes: ['CLERIC'] },
|
||||
{ key: 'guiding-bolt', name: 'Guiding Bolt', level: 1, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: '1 round', description: 'A flash of light streaks to a target, dealing radiant damage.', damageDice: '4d6', classes: ['CLERIC'] },
|
||||
{ key: 'healing-word', name: 'Healing Word', level: 1, school: 'Evocation', castingTime: '1 bonus action', range: '60 feet', duration: 'Instantaneous', description: 'A creature you can see regains hit points.', classes: ['BARD', 'CLERIC', 'DRUID'] },
|
||||
{ key: 'hunters-mark', name: "Hunter's Mark", level: 1, school: 'Divination', castingTime: '1 bonus action', range: '90 feet', duration: 'Concentration, up to 1 hour', description: 'Deal extra damage to a marked target and track it magically.', classes: ['RANGER'] },
|
||||
{ key: 'hunters-mark', name: "Hunter's Mark", level: 1, school: 'Divination', castingTime: '1 bonus action', range: '90 feet', duration: 'Concentration, up to 1 hour', description: 'Deal extra damage to a marked target and track it magically.', damageDice: '1d6', classes: ['RANGER'] },
|
||||
{ key: 'identify', name: 'Identify', level: 1, school: 'Divination', castingTime: '1 minute', range: 'Touch', duration: 'Instantaneous', description: 'Learn the properties of a magic item or the nature of a spell affecting a creature.', classes: ['BARD', 'WIZARD'] },
|
||||
{ key: 'jump', name: 'Jump', level: 1, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: '1 minute', description: "A creature's jump distance triples.", classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'longstrider', name: 'Longstrider', level: 1, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: '1 hour', description: "A creature's speed increases by 10 feet.", classes: ['BARD', 'DRUID', 'RANGER', 'WIZARD'] },
|
||||
{ key: 'mage-armor', name: 'Mage Armor', level: 1, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: '8 hours', description: "A protective magical force gives the target a base AC of 13 + Dex modifier.", classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'magic-missile', name: 'Magic Missile', level: 1, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'Three darts of magical force each strike a target unerringly.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'magic-missile', name: 'Magic Missile', level: 1, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'Three darts of magical force each strike a target unerringly.', damageDice: '3d4+3', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'protection-from-evil-and-good', name: 'Protection from Evil and Good', level: 1, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 10 minutes', description: 'Protect a creature from aberrations, celestials, elementals, fey, fiends, and undead.', classes: ['CLERIC', 'PALADIN', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'shield', name: 'Shield', level: 1, school: 'Abjuration', castingTime: '1 reaction', range: 'Self', duration: '1 round', description: 'An invisible barrier grants +5 AC until the start of your next turn.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'sleep', name: 'Sleep', level: 1, school: 'Enchantment', castingTime: '1 action', range: '90 feet', duration: '1 minute', description: 'Creatures in a 20-foot radius fall unconscious, weakest hit points first.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'speak-with-animals', name: 'Speak with Animals', level: 1, school: 'Divination', castingTime: '1 action', range: 'Self', duration: '10 minutes', description: 'Comprehend and verbally communicate with beasts.', classes: ['BARD', 'DRUID', 'RANGER'] },
|
||||
{ key: 'thunderwave', name: 'Thunderwave', level: 1, school: 'Evocation', castingTime: '1 action', range: 'Self (15-foot cube)', duration: 'Instantaneous', description: 'A wave of thunderous force sweeps out, damaging and pushing creatures.', classes: ['BARD', 'DRUID', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'witch-bolt', name: 'Witch Bolt', level: 1, school: 'Evocation', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'A beam of lightning forms a sustained arc that damages the target each turn.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'thunderwave', name: 'Thunderwave', level: 1, school: 'Evocation', castingTime: '1 action', range: 'Self (15-foot cube)', duration: 'Instantaneous', description: 'A wave of thunderous force sweeps out, damaging and pushing creatures.', damageDice: '2d8', classes: ['BARD', 'DRUID', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'witch-bolt', name: 'Witch Bolt', level: 1, school: 'Evocation', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'A beam of lightning forms a sustained arc that damages the target each turn.', damageDice: '1d12', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
|
||||
// ---- Level 2 ----
|
||||
{ key: 'aid', name: 'Aid', level: 2, school: 'Abjuration', castingTime: '1 action', range: '30 feet', duration: '8 hours', description: 'Bolster allies with additional hit points.', classes: ['CLERIC', 'PALADIN'] },
|
||||
@@ -74,7 +80,7 @@ const SPELL_LIBRARY = [
|
||||
{ key: 'darkvision', name: 'Darkvision', level: 2, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: '8 hours', description: 'Grant a creature darkvision out to 60 feet.', classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'enhance-ability', name: 'Enhance Ability', level: 2, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'Grant advantage on checks with one chosen ability.', classes: ['BARD', 'CLERIC', 'DRUID', 'SORCERER'] },
|
||||
{ key: 'enlarge-reduce', name: 'Enlarge/Reduce', level: 2, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'A creature or object doubles or halves in size.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'flaming-sphere', name: 'Flaming Sphere', level: 2, school: 'Conjuration', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'A 5-foot-diameter sphere of fire rolls where you direct it.', classes: ['DRUID', 'WIZARD'] },
|
||||
{ key: 'flaming-sphere', name: 'Flaming Sphere', level: 2, school: 'Conjuration', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'A 5-foot-diameter sphere of fire rolls where you direct it.', damageDice: '2d6', classes: ['DRUID', 'WIZARD'] },
|
||||
{ key: 'gust-of-wind', name: 'Gust of Wind', level: 2, school: 'Evocation', castingTime: '1 action', range: 'Self (60-foot line)', duration: 'Concentration, up to 1 minute', description: 'A line of strong wind pushes creatures and extinguishes flames.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'hold-person', name: 'Hold Person', level: 2, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'Paralyze a humanoid that fails a Wisdom save.', classes: ['BARD', 'CLERIC', 'DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'invisibility', name: 'Invisibility', level: 2, school: 'Illusion', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'A creature you touch becomes invisible.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
@@ -82,13 +88,13 @@ const SPELL_LIBRARY = [
|
||||
{ key: 'levitate', name: 'Levitate', level: 2, school: 'Transmutation', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 10 minutes', description: 'A creature or object rises vertically and can be moved.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'mirror-image', name: 'Mirror Image', level: 2, school: 'Illusion', castingTime: '1 action', range: 'Self', duration: '1 minute', description: 'Three illusory duplicates of yourself spring into being.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'misty-step', name: 'Misty Step', level: 2, school: 'Conjuration', castingTime: '1 bonus action', range: 'Self', duration: 'Instantaneous', description: 'Teleport up to 30 feet in a burst of silvery mist.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'moonbeam', name: 'Moonbeam', level: 2, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'A silvery beam of pale light shines down in a 5-foot-radius cylinder.', classes: ['DRUID'] },
|
||||
{ key: 'moonbeam', name: 'Moonbeam', level: 2, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'A silvery beam of pale light shines down in a 5-foot-radius cylinder.', damageDice: '2d10', classes: ['DRUID'] },
|
||||
{ key: 'pass-without-trace', name: 'Pass without Trace', level: 2, school: 'Abjuration', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 1 hour', description: 'The party becomes exceptionally difficult to track.', classes: ['DRUID', 'RANGER'] },
|
||||
{ key: 'scorching-ray', name: 'Scorching Ray', level: 2, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'Three rays of fire streak toward targets within range.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'shatter', name: 'Shatter', level: 2, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A sudden loud ringing noise deals thunder damage in a 10-foot-radius sphere.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'scorching-ray', name: 'Scorching Ray', level: 2, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'Three rays of fire streak toward targets within range.', damageDice: '2d6', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'shatter', name: 'Shatter', level: 2, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A sudden loud ringing noise deals thunder damage in a 10-foot-radius sphere.', damageDice: '3d8', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'silence', name: 'Silence', level: 2, school: 'Illusion', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'No sound can be created within or pass through a 20-foot-radius sphere.', classes: ['BARD', 'CLERIC', 'RANGER'] },
|
||||
{ key: 'spider-climb', name: 'Spider Climb', level: 2, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'A creature gains the ability to move on vertical surfaces and ceilings.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'spiritual-weapon', name: 'Spiritual Weapon', level: 2, school: 'Evocation', castingTime: '1 bonus action', range: '60 feet', duration: '1 minute', description: 'A spectral weapon attacks a target and can be moved each turn.', classes: ['CLERIC'] },
|
||||
{ key: 'spiritual-weapon', name: 'Spiritual Weapon', level: 2, school: 'Evocation', castingTime: '1 bonus action', range: '60 feet', duration: '1 minute', description: 'A spectral weapon attacks a target and can be moved each turn.', damageDice: '1d8', classes: ['CLERIC'] },
|
||||
{ key: 'suggestion', name: 'Suggestion', level: 2, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 8 hours', description: 'Plant a reasonable-sounding suggestion in a creature’s mind.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'web', name: 'Web', level: 2, school: 'Conjuration', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 hour', description: 'Thick, sticky webs fill a 20-foot cube.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'zone-of-truth', name: 'Zone of Truth', level: 2, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: '10 minutes', description: 'Creatures in a 15-foot-radius sphere can’t speak a deliberate lie.', classes: ['BARD', 'CLERIC', 'PALADIN'] },
|
||||
@@ -96,55 +102,55 @@ const SPELL_LIBRARY = [
|
||||
// ---- Level 3 ----
|
||||
{ key: 'animate-dead', name: 'Animate Dead', level: 3, school: 'Necromancy', castingTime: '1 minute', range: '10 feet', duration: 'Instantaneous', description: 'Raise a skeleton or zombie from a corpse to fight for you.', classes: ['CLERIC', 'WIZARD'] },
|
||||
{ key: 'beacon-of-hope', name: 'Beacon of Hope', level: 3, school: 'Abjuration', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'Bolster creatures against despair, granting advantage on saves and max healing.', classes: ['CLERIC'] },
|
||||
{ key: 'call-lightning', name: 'Call Lightning', level: 3, school: 'Conjuration', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A storm cloud forms and you can call down bolts of lightning.', classes: ['DRUID'] },
|
||||
{ key: 'call-lightning', name: 'Call Lightning', level: 3, school: 'Conjuration', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A storm cloud forms and you can call down bolts of lightning.', damageDice: '3d10', classes: ['DRUID'] },
|
||||
{ key: 'counterspell', name: 'Counterspell', level: 3, school: 'Abjuration', castingTime: '1 reaction', range: '60 feet', duration: 'Instantaneous', description: 'Interrupt a creature casting a spell.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'daylight', name: 'Daylight', level: 3, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: '1 hour', description: 'A 60-foot-radius sphere of bright light spreads out.', classes: ['CLERIC', 'DRUID', 'PALADIN', 'RANGER', 'SORCERER'] },
|
||||
{ key: 'dispel-magic', name: 'Dispel Magic', level: 3, school: 'Abjuration', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'End one active spell affecting a creature, object, or effect.', classes: ['BARD', 'CLERIC', 'DRUID', 'PALADIN', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'fear', name: 'Fear', level: 3, school: 'Illusion', castingTime: '1 action', range: 'Self (30-foot cone)', duration: 'Concentration, up to 1 minute', description: 'Creatures in the cone become frightened and drop what they’re holding.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'fireball', name: 'Fireball', level: 3, school: 'Evocation', castingTime: '1 action', range: '150 feet', duration: 'Instantaneous', description: 'A bright streak explodes into flame, dealing fire damage in a 20-foot radius.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'fireball', name: 'Fireball', level: 3, school: 'Evocation', castingTime: '1 action', range: '150 feet', duration: 'Instantaneous', description: 'A bright streak explodes into flame, dealing fire damage in a 20-foot radius.', damageDice: '8d6', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'fly', name: 'Fly', level: 3, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 10 minutes', description: 'The target gains a flying speed of 60 feet.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'haste', name: 'Haste', level: 3, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'Double the target’s speed and grant an extra action.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'lightning-bolt', name: 'Lightning Bolt', level: 3, school: 'Evocation', castingTime: '1 action', range: 'Self (100-foot line)', duration: 'Instantaneous', description: 'A stroke of lightning blasts out in a line, dealing lightning damage.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'lightning-bolt', name: 'Lightning Bolt', level: 3, school: 'Evocation', castingTime: '1 action', range: 'Self (100-foot line)', duration: 'Instantaneous', description: 'A stroke of lightning blasts out in a line, dealing lightning damage.', damageDice: '8d6', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'mass-healing-word', name: 'Mass Healing Word', level: 3, school: 'Evocation', castingTime: '1 bonus action', range: '60 feet', duration: 'Instantaneous', description: 'Up to six creatures you can see regain hit points.', classes: ['CLERIC'] },
|
||||
{ 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.', classes: ['CLERIC'] },
|
||||
{ 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'] },
|
||||
{ key: 'stinking-cloud', name: 'Stinking Cloud', level: 3, school: 'Conjuration', castingTime: '1 action', range: '90 feet', duration: 'Concentration, up to 1 minute', description: 'A 20-foot-radius sphere of yellow, nauseating gas spreads out.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'water-breathing', name: 'Water Breathing', level: 3, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: '24 hours', description: 'Up to ten creatures can breathe underwater.', classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
|
||||
|
||||
// ---- Level 4 ----
|
||||
{ key: 'banishment', name: 'Banishment', level: 4, school: 'Abjuration', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'Send a creature to a harmless demiplane.', classes: ['CLERIC', 'PALADIN', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'blight', name: 'Blight', level: 4, school: 'Necromancy', castingTime: '1 action', range: '30 feet', duration: 'Instantaneous', description: 'Necromantic energy withers a creature, dealing necrotic damage.', classes: ['DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'blight', name: 'Blight', level: 4, school: 'Necromancy', castingTime: '1 action', range: '30 feet', duration: 'Instantaneous', description: 'Necromantic energy withers a creature, dealing necrotic damage.', damageDice: '8d8', classes: ['DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'confusion', name: 'Confusion', level: 4, school: 'Enchantment', castingTime: '1 action', range: '90 feet', duration: 'Concentration, up to 1 minute', description: 'Creatures in a 10-foot-radius sphere act randomly.', classes: ['BARD', 'DRUID', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'dimension-door', name: 'Dimension Door', level: 4, school: 'Conjuration', castingTime: '1 action', range: '500 feet', duration: 'Instantaneous', description: 'Teleport yourself (and possibly a companion) to a spot you can visualize.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ key: 'fire-shield', name: 'Fire Shield', level: 4, school: 'Evocation', castingTime: '1 action', range: 'Self', duration: '10 minutes', description: 'Wreathe yourself in flame that damages attackers and grants resistance.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'fire-shield', name: 'Fire Shield', level: 4, school: 'Evocation', castingTime: '1 action', range: 'Self', duration: '10 minutes', description: 'Wreathe yourself in flame that damages attackers and grants resistance.', damageDice: '2d8', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'freedom-of-movement', name: 'Freedom of Movement', level: 4, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: '1 hour', description: 'The target’s movement is unaffected by difficult terrain and restraints.', classes: ['BARD', 'CLERIC', 'DRUID', 'RANGER'] },
|
||||
{ key: 'greater-invisibility', name: 'Greater Invisibility', level: 4, school: 'Illusion', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 minute', description: 'The target becomes invisible even while attacking.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'guardian-of-faith', name: 'Guardian of Faith', level: 4, school: 'Conjuration', castingTime: '1 action', range: '30 feet', duration: '8 hours', description: 'A Large spectral guardian appears and attacks enemies near it.', classes: ['CLERIC'] },
|
||||
{ key: 'ice-storm', name: 'Ice Storm', level: 4, school: 'Evocation', castingTime: '1 action', range: '300 feet', duration: 'Instantaneous', description: 'Hail hammers a 20-foot-radius cylinder, dealing bludgeoning and cold damage.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'guardian-of-faith', name: 'Guardian of Faith', level: 4, school: 'Conjuration', castingTime: '1 action', range: '30 feet', duration: '8 hours', description: 'A Large spectral guardian appears and attacks enemies near it.', damageDice: '20', classes: ['CLERIC'] },
|
||||
{ key: 'ice-storm', name: 'Ice Storm', level: 4, school: 'Evocation', castingTime: '1 action', range: '300 feet', duration: 'Instantaneous', description: 'Hail hammers a 20-foot-radius cylinder, dealing bludgeoning and cold damage.', damageDice: '2d8+4d6', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'polymorph', name: 'Polymorph', level: 4, school: 'Transmutation', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 hour', description: 'Transform a creature into a new form.', classes: ['BARD', 'DRUID', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'stone-shape', name: 'Stone Shape', level: 4, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Reshape a chunk of stone into a form you desire.', classes: ['CLERIC', 'DRUID', 'WIZARD'] },
|
||||
{ key: 'stoneskin', name: 'Stoneskin', level: 4, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'The target gains resistance to nonmagical bludgeoning, piercing, and slashing damage.', classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'wall-of-fire', name: 'Wall of Fire', level: 4, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Conjure a wall of fire that damages creatures passing through it.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'wall-of-fire', name: 'Wall of Fire', level: 4, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Conjure a wall of fire that damages creatures passing through it.', damageDice: '5d8', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
|
||||
// ---- Level 5 ----
|
||||
{ key: 'animate-objects', name: 'Animate Objects', level: 5, school: 'Transmutation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Up to ten nonmagical objects animate and fight for you.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'cloudkill', name: 'Cloudkill', level: 5, school: 'Conjuration', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A 20-foot-radius sphere of poisonous, yellow-green fog billows out.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'cone-of-cold', name: 'Cone of Cold', level: 5, school: 'Evocation', castingTime: '1 action', range: 'Self (60-foot cone)', duration: 'Instantaneous', description: 'A blast of cold air deals cold damage to everything in the cone.', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'cloudkill', name: 'Cloudkill', level: 5, school: 'Conjuration', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A 20-foot-radius sphere of poisonous, yellow-green fog billows out.', damageDice: '5d8', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'cone-of-cold', name: 'Cone of Cold', level: 5, school: 'Evocation', castingTime: '1 action', range: 'Self (60-foot cone)', duration: 'Instantaneous', description: 'A blast of cold air deals cold damage to everything in the cone.', damageDice: '8d8', classes: ['SORCERER', 'WIZARD'] },
|
||||
{ key: 'contagion', name: 'Contagion', level: 5, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: '7 days', description: 'Infect a creature with a magical disease.', classes: ['CLERIC', 'DRUID'] },
|
||||
{ key: 'dispel-evil-and-good', name: 'Dispel Evil and Good', level: 5, school: 'Abjuration', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 1 minute', description: 'Protection and the ability to banish otherworldly creatures.', classes: ['CLERIC', 'PALADIN'] },
|
||||
{ key: 'dominate-person', name: 'Dominate Person', level: 5, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'Attempt to beguile a humanoid and control its actions.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
|
||||
{ key: 'flame-strike', name: 'Flame Strike', level: 5, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A column of divine fire roars down, dealing fire and radiant damage.', classes: ['CLERIC'] },
|
||||
{ key: 'flame-strike', name: 'Flame Strike', level: 5, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A column of divine fire roars down, dealing fire and radiant damage.', damageDice: '8d6', classes: ['CLERIC'] },
|
||||
{ key: 'greater-restoration', name: 'Greater Restoration', level: 5, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Undo a debilitating effect: reduced ability score, exhaustion, or curse.', classes: ['BARD', 'CLERIC', 'DRUID'] },
|
||||
{ key: 'hold-monster', name: 'Hold Monster', level: 5, school: 'Enchantment', castingTime: '1 action', range: '90 feet', duration: 'Concentration, up to 1 minute', description: 'Paralyze one creature that fails a Wisdom save.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
|
||||
{ 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.', classes: ['CLERIC', 'DRUID', 'SORCERER'] },
|
||||
{ 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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,11 @@ const loginSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
|
||||
});
|
||||
|
||||
function issueToken(user) {
|
||||
return jwt.sign(
|
||||
{ sub: user.id, email: user.email, displayName: user.displayName },
|
||||
@@ -76,4 +81,28 @@ router.get('/me', requireAuth, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Tokens already issued stay valid until they expire; this only changes what
|
||||
// the next login needs.
|
||||
router.post('/password', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = changePasswordSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { currentPassword, newPassword } = parsed.data;
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!valid) return res.status(400).json({ error: 'Your current password is incorrect' });
|
||||
if (newPassword === currentPassword) {
|
||||
return res.status(400).json({ error: 'Your new password must be different from the current one' });
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: await bcrypt.hash(newPassword, 12) } });
|
||||
return res.status(204).end();
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -4,8 +4,14 @@ const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { requireCampaignMember, requireDM } = require('../middleware/campaignAccess');
|
||||
const { generateInviteCode } = require('../utils/inviteCode');
|
||||
const { computeCharacterSheet } = 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();
|
||||
|
||||
@@ -16,12 +22,18 @@ function campaignSummary(campaign, role) {
|
||||
id: campaign.id,
|
||||
name: campaign.name,
|
||||
description: campaign.description,
|
||||
inviteCode: campaign.inviteCode,
|
||||
// The invite code is the DM's to hand out.
|
||||
inviteCode: role === 'DM' ? campaign.inviteCode : undefined,
|
||||
startingGold: campaign.startingGold,
|
||||
startingItemCount: campaign.startingItemCount,
|
||||
startingSpellCount: campaign.startingSpellCount,
|
||||
skipMotivationRoll: campaign.skipMotivationRoll,
|
||||
showMonsterHp: campaign.showMonsterHp,
|
||||
areaAttackAcPenalty: campaign.areaAttackAcPenalty,
|
||||
maxEquipped: campaign.maxEquipped,
|
||||
maxEquippedWeapons: campaign.maxEquippedWeapons,
|
||||
ownerId: campaign.ownerId,
|
||||
archivedAt: campaign.archivedAt,
|
||||
role,
|
||||
createdAt: campaign.createdAt,
|
||||
};
|
||||
@@ -44,10 +56,17 @@ router.get('/', async (req, res, next) => {
|
||||
const createCampaignSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
startingGold: z.number().int().min(0).optional(),
|
||||
startingItemCount: z.number().int().min(0).optional(),
|
||||
startingSpellCount: z.number().int().min(0).optional(),
|
||||
startingGold: z.number().int().min(0).max(1000000).optional(),
|
||||
startingItemCount: z.number().int().min(0).max(50).optional(),
|
||||
startingSpellCount: z.number().int().min(0).max(50).optional(),
|
||||
skipMotivationRoll: z.boolean().optional(),
|
||||
showMonsterHp: z.boolean().optional(),
|
||||
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
|
||||
// How many weapons and items a character can have equipped together, and
|
||||
// how many of those can be weapons. Lowering them takes nothing off anyone:
|
||||
// they are checked when something is put on.
|
||||
maxEquipped: z.number().int().min(1).max(20).optional(),
|
||||
maxEquippedWeapons: z.number().int().min(0).max(20).optional(),
|
||||
});
|
||||
|
||||
// POST /campaigns — create, creator becomes DM/owner
|
||||
@@ -79,11 +98,34 @@ 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 {
|
||||
const campaign = await prisma.campaign.findUnique({ where: { inviteCode: req.params.inviteCode } });
|
||||
if (!campaign) return res.status(404).json({ error: 'Invalid invite code' });
|
||||
if (campaign.archivedAt) return res.status(403).json({ error: 'That campaign is archived and is not taking new members' });
|
||||
|
||||
const existing = await prisma.campaignMember.findUnique({
|
||||
where: { campaignId_userId: { campaignId: campaign.id, userId: req.user.id } },
|
||||
@@ -112,20 +154,41 @@ router.get('/:id', requireCampaignMember(), async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
const updateCampaignSchema = createCampaignSchema.partial();
|
||||
// `archived` puts the campaign away (true) or brings it back (false).
|
||||
const updateCampaignSchema = createCampaignSchema.partial().extend({ archived: z.boolean().optional() });
|
||||
|
||||
router.patch('/:id', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = updateCampaignSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const campaign = await prisma.campaign.update({ where: { id: req.campaign.id }, data: parsed.data });
|
||||
const { archived, ...data } = parsed.data;
|
||||
// Re-archiving keeps the original date.
|
||||
if (archived === true && !req.campaign.archivedAt) data.archivedAt = new Date();
|
||||
if (archived === false) data.archivedAt = null;
|
||||
|
||||
const campaign = await prisma.campaign.update({ where: { id: req.campaign.id }, data });
|
||||
res.json({ campaign: campaignSummary(campaign, 'DM') });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Deleting takes everything in the campaign with it — characters, pools,
|
||||
// encounters, notes — so it is the owner's call alone, not any DM's.
|
||||
router.delete('/:id', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
if (req.campaign.ownerId !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Only the campaign owner can delete it' });
|
||||
}
|
||||
await prisma.campaign.delete({ where: { id: req.campaign.id } });
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('campaign:deleted', { campaignId: req.campaign.id });
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/invite', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const regenerate = req.body && req.body.regenerate === true;
|
||||
@@ -167,6 +230,11 @@ function characterSummary(character) {
|
||||
buildStage: character.buildStage,
|
||||
currentHp: character.currentHp,
|
||||
tempHp: character.tempHp,
|
||||
deathSaves: deathSaveInfo(character),
|
||||
gold: character.gold,
|
||||
silver: character.silver,
|
||||
copper: character.copper,
|
||||
cash: character.cash,
|
||||
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
|
||||
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
|
||||
currentLocationId: character.currentLocationId,
|
||||
@@ -178,10 +246,22 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
|
||||
try {
|
||||
const characters = await prisma.character.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
include: { skillProficiencies: true, saveProficiencies: true, currentLocation: true },
|
||||
include: { ...SHEET_INCLUDE, currentLocation: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
res.json({ characters: characters.map(characterSummary) });
|
||||
const summaries = characters.map(characterSummary);
|
||||
if (req.membership.role === 'DM') return res.json({ characters: summaries });
|
||||
|
||||
// A player sees the party's headline numbers, but another character's
|
||||
// purse is their own business, and so is a place this player has not been.
|
||||
const visited = await visitedLocationIds(prisma, req.user.id, req.campaign.id);
|
||||
res.json({
|
||||
characters: summaries.map((c) => {
|
||||
if (c.userId === req.user.id) return c;
|
||||
const knowsPlace = c.currentLocationId && visited.has(c.currentLocationId);
|
||||
return { ...c, gold: null, silver: null, copper: null, cash: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -210,6 +290,7 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
|
||||
targetUserId = parsed.data.userId;
|
||||
}
|
||||
|
||||
const startingGold = req.campaign.startingGold;
|
||||
const character = await prisma.character.create({
|
||||
data: {
|
||||
campaignId: req.campaign.id,
|
||||
@@ -217,11 +298,17 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
|
||||
createdById: req.user.id,
|
||||
name: parsed.data.name,
|
||||
buildStage: 'ROLLING_SPECIES',
|
||||
gold: req.campaign.startingGold,
|
||||
gold: startingGold,
|
||||
goldTransactions: {
|
||||
create:
|
||||
startingGold > 0
|
||||
? [{ amount: startingGold, balanceAfter: startingGold, reason: 'Starting gold', createdById: req.user.id }]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:created', { character: characterSummary(character) });
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:created', { characterId: character.id });
|
||||
res.status(201).json({ character: characterSummary(character) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -229,18 +316,53 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weapons (campaign-level pool; DM manages, anyone in campaign can read)
|
||||
// Weapons (campaign-level pool; the DM manages it and sees all of it)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// What a player may see of a pool: whatever one of their own characters holds,
|
||||
// plus the starting options while they still have a character to build. The
|
||||
// rest of the pool — gear yet to be found, spells yet to be learned — is the
|
||||
// DM's. `held` is the pool model's relation to the per-character rows.
|
||||
async function poolScope(req, held) {
|
||||
const where = { campaignId: req.campaign.id };
|
||||
if (req.membership.role === 'DM') return where;
|
||||
|
||||
const mine = { userId: req.user.id, campaignId: req.campaign.id };
|
||||
const building = await prisma.character.count({ where: { ...mine, buildStage: { not: 'COMPLETE' } } });
|
||||
return {
|
||||
...where,
|
||||
OR: [{ [held]: { some: { character: mine } } }, ...(building > 0 ? [{ isStartingOption: true }] : [])],
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/:id/weapons', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const weapons = await prisma.weapon.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
const weapons = await prisma.weapon.findMany({ where: await poolScope(req, 'assigned'), orderBy: { name: 'asc' } });
|
||||
res.json({ weapons });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// What a weapon or item does to the sheet of whoever has it equipped: signed
|
||||
// amounts on the numbers in GEAR_STATS (armor is an item with an AC effect),
|
||||
// and a note for what is not a number.
|
||||
const gearEffects = {
|
||||
effects: z
|
||||
.array(z.object({ stat: z.enum(GEAR_STATS), amount: z.number().int().min(-100).max(100).refine((n) => n !== 0, 'An effect of 0 does nothing') }))
|
||||
.max(12)
|
||||
.optional(),
|
||||
effectNote: z.string().trim().max(300).optional().nullable(),
|
||||
};
|
||||
|
||||
// A change to what a pool entry does reaches everyone who has it equipped:
|
||||
// their maximum HP may have dropped, and every open sheet should refetch.
|
||||
async function gearChanged(req, heldModel, poolKey, poolId, holderIds) {
|
||||
const holders = holderIds || (await prisma[heldModel].findMany({ where: { [poolKey]: poolId }, select: { characterId: true } })).map((h) => h.characterId);
|
||||
await settleHp(prisma, holders);
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:updated', {});
|
||||
}
|
||||
|
||||
const weaponSchema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
damageDice: z.string().min(1).max(20),
|
||||
@@ -249,8 +371,36 @@ const weaponSchema = z.object({
|
||||
properties: z.array(z.string()).default([]),
|
||||
rangeNormal: z.number().int().positive().optional().nullable(),
|
||||
rangeLong: z.number().int().positive().optional().nullable(),
|
||||
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
|
||||
// below it. A lifted copy comes off, since its holder may no longer be high
|
||||
// enough level to use it.
|
||||
async function liftHeldCopies(heldModel, poolKey, poolId, minLevel) {
|
||||
if (minLevel === undefined) return;
|
||||
await prisma[heldModel].updateMany({
|
||||
where: { [poolKey]: poolId, level: { lt: minLevel } },
|
||||
data: { level: minLevel, equipped: false },
|
||||
});
|
||||
}
|
||||
|
||||
// Pool entries are addressed by their own id, so every edit/delete is scoped
|
||||
// to the campaign in the URL — a DM of one campaign can't reach into another.
|
||||
// Returns the updated row, or null if it isn't in this campaign.
|
||||
async function updatePoolEntry(model, id, campaignId, data) {
|
||||
const { count } = await prisma[model].updateMany({ where: { id, campaignId }, data });
|
||||
return count === 0 ? null : prisma[model].findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
async function deletePoolEntry(model, id, campaignId) {
|
||||
const { count } = await prisma[model].deleteMany({ where: { id, campaignId } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
router.post('/:id/weapons', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = weaponSchema.safeParse(req.body);
|
||||
@@ -266,10 +416,10 @@ router.patch('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), asy
|
||||
try {
|
||||
const parsed = weaponSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const weapon = await prisma.weapon.update({
|
||||
where: { id: req.params.weaponId },
|
||||
data: parsed.data,
|
||||
});
|
||||
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);
|
||||
@@ -278,7 +428,11 @@ router.patch('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), asy
|
||||
|
||||
router.delete('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
await prisma.weapon.delete({ where: { id: req.params.weaponId } });
|
||||
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);
|
||||
@@ -291,7 +445,7 @@ router.delete('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), as
|
||||
|
||||
router.get('/:id/spells', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const spells = await prisma.spell.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
const spells = await prisma.spell.findMany({ where: await poolScope(req, 'known'), orderBy: { name: 'asc' } });
|
||||
res.json({ spells });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -306,6 +460,9 @@ const spellSchema = z.object({
|
||||
range: z.string().max(60).optional().nullable(),
|
||||
duration: z.string().max(60).optional().nullable(),
|
||||
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) => {
|
||||
@@ -319,11 +476,96 @@ router.post('/:id/spells', requireCampaignMember(), requireDM(), async (req, res
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/spells/:spellId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = spellSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const spell = await updatePoolEntry('spell', req.params.spellId, req.campaign.id, parsed.data);
|
||||
if (!spell) return res.status(404).json({ error: 'Spell not found in this campaign' });
|
||||
res.json({ spell });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id/spells/:spellId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
if (!(await deletePoolEntry('spell', req.params.spellId, req.campaign.id))) {
|
||||
return res.status(404).json({ error: 'Spell not found in this campaign' });
|
||||
}
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monsters (DM only, campaign-scoped)
|
||||
// Items (campaign-level pool; DM manages and hands them out to characters)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/monsters', requireCampaignMember(), async (req, res, next) => {
|
||||
router.get('/:id/items', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const items = await prisma.item.findMany({ where: await poolScope(req, 'held'), orderBy: { name: 'asc' } });
|
||||
res.json({ items });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const itemSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
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) => {
|
||||
try {
|
||||
const parsed = itemSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const item = await prisma.item.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ item });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/items/:itemId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = itemSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monsters (DM only, campaign-scoped — stat blocks are the DM's; players see
|
||||
// a monster only as a name and a health bar in an encounter)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/monsters', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const monsters = await prisma.monster.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
res.json({ monsters });
|
||||
@@ -334,8 +576,8 @@ router.get('/:id/monsters', requireCampaignMember(), async (req, res, next) => {
|
||||
|
||||
const monsterSchema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
ac: z.number().int().min(0),
|
||||
maxHp: z.number().int().min(1),
|
||||
ac: z.number().int().min(0).max(50),
|
||||
maxHp: z.number().int().min(1).max(100000),
|
||||
speed: z.number().int().min(0).optional(),
|
||||
strScore: z.number().int().min(1).max(30).optional(),
|
||||
dexScore: z.number().int().min(1).max(30).optional(),
|
||||
@@ -343,8 +585,8 @@ const monsterSchema = z.object({
|
||||
intScore: z.number().int().min(1).max(30).optional(),
|
||||
wisScore: z.number().int().min(1).max(30).optional(),
|
||||
chaScore: z.number().int().min(1).max(30).optional(),
|
||||
attackBonus: z.number().int().optional(),
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
attackBonus: z.number().int().min(-20).max(50).optional(),
|
||||
damageDice: z.string().max(60).optional().nullable(), // may name its type: "1d6+2 necrotic"
|
||||
challengeRating: z.string().max(10).optional().nullable(),
|
||||
specialTrait: z.string().max(500).optional().nullable(),
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
@@ -365,7 +607,8 @@ router.patch('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(), a
|
||||
try {
|
||||
const parsed = monsterSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const monster = await prisma.monster.update({ where: { id: req.params.monsterId }, data: parsed.data });
|
||||
const monster = await updatePoolEntry('monster', req.params.monsterId, req.campaign.id, parsed.data);
|
||||
if (!monster) return res.status(404).json({ error: 'Monster not found in this campaign' });
|
||||
res.json({ monster });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -374,7 +617,9 @@ router.patch('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(), a
|
||||
|
||||
router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
await prisma.monster.delete({ where: { id: req.params.monsterId } });
|
||||
if (!(await deletePoolEntry('monster', req.params.monsterId, req.campaign.id))) {
|
||||
return res.status(404).json({ error: 'Monster not found in this campaign' });
|
||||
}
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -387,27 +632,161 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(),
|
||||
|
||||
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const isDM = req.membership.role === 'DM';
|
||||
// The DM sees every encounter. A player sees the live ones that one of
|
||||
// their own characters is in, and nothing of the rest.
|
||||
const mine = { archivedAt: null, participants: { some: { character: { userId: req.user.id } } } };
|
||||
const encounters = await prisma.encounter.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
where: { campaignId: req.campaign.id, ...(isDM ? {} : mine) },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
res.json({ encounters });
|
||||
// dmNotes never leaves the server for a player.
|
||||
res.json({ encounters: isDM ? encounters : encounters.map(({ dmNotes, ...encounter }) => encounter) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const createEncounterSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
locationId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ name: z.string().min(1).max(120) }).safeParse(req.body);
|
||||
const parsed = createEncounterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const encounter = await prisma.encounter.create({ data: { campaignId: req.campaign.id, name: parsed.data.name } });
|
||||
if (parsed.data.locationId) {
|
||||
const location = await prisma.location.findFirst({ where: { id: parsed.data.locationId, campaignId: req.campaign.id } });
|
||||
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
|
||||
}
|
||||
|
||||
// A new encounter goes to the top of the campaign's order.
|
||||
const first = await prisma.encounter.aggregate({ where: { campaignId: req.campaign.id }, _min: { sortOrder: true } });
|
||||
const encounter = await prisma.encounter.create({
|
||||
data: { ...parsed.data, campaignId: req.campaign.id, sortOrder: (first._min.sortOrder ?? 1) - 1 },
|
||||
});
|
||||
res.status(201).json({ encounter });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const encounterOrderSchema = z.object({ encounterIds: z.array(z.string()).min(1).max(1000) });
|
||||
|
||||
// Sets the campaign's own encounter order: the ids given take positions
|
||||
// 0, 1, 2... in the order sent.
|
||||
router.put('/:id/encounters/order', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = encounterOrderSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { encounterIds } = parsed.data;
|
||||
|
||||
const owned = await prisma.encounter.count({ where: { id: { in: encounterIds }, campaignId: req.campaign.id } });
|
||||
if (new Set(encounterIds).size !== encounterIds.length || owned !== encounterIds.length) {
|
||||
return res.status(400).json({ error: 'Send each encounter of this campaign once.' });
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
encounterIds.map((id, sortOrder) => prisma.encounter.update({ where: { id }, data: { sortOrder } }))
|
||||
);
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// NPCs (DM only — players meet them through encounters, without the DM's
|
||||
// notes or stats)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
const npcSchema = z.object({
|
||||
name: z.string().min(1).max(80),
|
||||
role: z.string().max(80).optional().nullable(),
|
||||
disposition: z.enum(['FRIENDLY', 'NEUTRAL', 'HOSTILE']).optional(),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
dmNotes: z.string().max(4000).optional().nullable(),
|
||||
ac: z.number().int().min(0).max(50).optional().nullable(),
|
||||
maxHp: z.number().int().min(1).max(100000).optional().nullable(),
|
||||
attackBonus: z.number().int().min(-20).max(50).optional().nullable(),
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
// The money changer's rate: exchangeGold gold for exchangeCash cash. Both
|
||||
// null for an NPC who does not change money.
|
||||
exchangeGold: z.number().int().min(1).max(1000000).optional().nullable(),
|
||||
exchangeCash: z.number().int().min(1).max(1000000).optional().nullable(),
|
||||
});
|
||||
|
||||
// A rate has two sides: both are given, or both are cleared.
|
||||
function halfARate(data) {
|
||||
const sides = [data.exchangeGold, data.exchangeCash];
|
||||
return sides.some((side) => side === undefined) !== sides.every((side) => side === undefined) || (sides[0] === null) !== (sides[1] === null);
|
||||
}
|
||||
const HALF_A_RATE = 'An exchange rate needs both sides: so much gold for so much cash.';
|
||||
|
||||
const WARE_INCLUDE = { weapon: true, item: true };
|
||||
|
||||
// Open encounter pages refetch: what is for sale in a scene just changed.
|
||||
function emitTradeChanged(req) {
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('encounter:participant-updated', {});
|
||||
}
|
||||
|
||||
router.post('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = npcSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
|
||||
const npc = await prisma.npc.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ npc });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/npcs/:npcId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id/npcs/:npcId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const npc = await prisma.npc.findFirst({ where: { id: req.params.npcId, campaignId: req.campaign.id } });
|
||||
if (!npc) return res.status(404).json({ error: 'NPC not found in this campaign' });
|
||||
// An NPC that no longer exists leaves the scenes it was in.
|
||||
await prisma.$transaction([
|
||||
prisma.encounterParticipant.deleteMany({ where: { npcId: npc.id } }),
|
||||
prisma.npc.delete({ where: { id: npc.id } }),
|
||||
]);
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notes
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -430,12 +809,17 @@ router.get('/:id/notes', requireCampaignMember(), async (req, res, next) => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Locations — DM-managed, used to track where each character currently is
|
||||
// Locations — DM-managed, used to track where each character currently is.
|
||||
// A player only sees the places one of their own characters has been to.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/locations', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const locations = await prisma.location.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
|
||||
const where = { campaignId: req.campaign.id };
|
||||
if (req.membership.role !== 'DM') {
|
||||
where.id = { in: [...(await visitedLocationIds(prisma, req.user.id, req.campaign.id))] };
|
||||
}
|
||||
const locations = await prisma.location.findMany({ where, orderBy: { name: 'asc' } });
|
||||
res.json({ locations });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -462,7 +846,8 @@ router.patch('/:id/locations/:locationId', requireCampaignMember(), requireDM(),
|
||||
try {
|
||||
const parsed = locationSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const location = await prisma.location.update({ where: { id: req.params.locationId }, data: parsed.data });
|
||||
const location = await updatePoolEntry('location', req.params.locationId, req.campaign.id, parsed.data);
|
||||
if (!location) return res.status(404).json({ error: 'Location not found in this campaign' });
|
||||
res.json({ location });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -471,7 +856,86 @@ router.patch('/:id/locations/:locationId', requireCampaignMember(), requireDM(),
|
||||
|
||||
router.delete('/:id/locations/:locationId', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
await prisma.location.delete({ where: { id: req.params.locationId } });
|
||||
if (!(await deletePoolEntry('location', req.params.locationId, req.campaign.id))) {
|
||||
return res.status(404).json({ error: 'Location not found in this campaign' });
|
||||
}
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
@@ -484,9 +948,17 @@ router.delete('/:id/locations/:locationId', requireCampaignMember(), requireDM()
|
||||
// backend/src/game/data/{weapons,spells,monsters}Library.js for the sets.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.get('/:id/library', requireCampaignMember(), async (req, res, next) => {
|
||||
// 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);
|
||||
}
|
||||
@@ -498,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 });
|
||||
@@ -506,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 });
|
||||
@@ -526,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 });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
|
||||
// Clients join a room per campaign (`campaign:<id>`) after authenticating with
|
||||
// the same JWT issued by /auth/login. Server-side route handlers emit into
|
||||
// these rooms (see character:updated, encounter:*-updated, character:created).
|
||||
// Events only ever carry ids: each client refetches its own view, since what
|
||||
// the DM, an owner and another player may see of the same thing differs.
|
||||
function attachSockets(io) {
|
||||
io.use((socket, next) => {
|
||||
const token = socket.handshake.auth && socket.handshake.auth.token;
|
||||
@@ -17,8 +20,11 @@ function attachSockets(io) {
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('campaign:join', (campaignId) => {
|
||||
if (typeof campaignId === 'string') socket.join(`campaign:${campaignId}`);
|
||||
socket.on('campaign:join', async (campaignId) => {
|
||||
if (typeof campaignId !== 'string') return;
|
||||
// Members only: a room's events are not for anyone who knows the id.
|
||||
const membership = await loadCampaignMembership(socket.user.id, campaignId).catch(() => null);
|
||||
if (membership) socket.join(`campaign:${campaignId}`);
|
||||
});
|
||||
socket.on('campaign:leave', (campaignId) => {
|
||||
if (typeof campaignId === 'string') socket.leave(`campaign:${campaignId}`);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// The two ways a character's belongings change: coins through the ledger, and
|
||||
// gear from the campaign pools. Shared by the character sheet routes and by
|
||||
// encounter rewards so both follow the same rules. Every function takes a
|
||||
// Prisma client or transaction client as `db`.
|
||||
|
||||
// The coins, and what each is worth counted in copper: silver is 0.1 gold and
|
||||
// copper is 0.01 gold, so 1 gold = 10 silver = 100 copper and 1 silver = 10
|
||||
// copper. Whole numbers keep exchanges exact.
|
||||
const COINS = ['GOLD', 'SILVER', 'COPPER'];
|
||||
const COIN_VALUE = { GOLD: 100, SILVER: 10, COPPER: 1 };
|
||||
// Everything a purse can hold: the coins, plus cash ($) — modern money for
|
||||
// crossover worlds. Cash has no worth in coins, so it is never exchanged for
|
||||
// them and never used to make change.
|
||||
const CURRENCIES = [...COINS, 'CASH'];
|
||||
// Each currency is its own column on the character.
|
||||
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' };
|
||||
|
||||
class InsufficientGoldError extends Error {}
|
||||
|
||||
// amount is signed: positive = gained, negative = spent, in `currency` coins.
|
||||
// Writes the ledger row and returns it; throws InsufficientGoldError if that
|
||||
// coin's balance would go below 0 (inside a transaction, that rolls the whole
|
||||
// thing back). This moves exactly the coins named and nothing else; to pay a
|
||||
// price out of whatever coins the purse holds, use spendCoins.
|
||||
async function adjustGold(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
|
||||
const column = COIN_COLUMN[currency];
|
||||
// The atomic increment is the balance check: two spends racing each other
|
||||
// can't both pass a stale read.
|
||||
const updated = await db.character.update({ where: { id: characterId }, data: { [column]: { increment: amount } } });
|
||||
if (updated[column] < 0) throw new InsufficientGoldError();
|
||||
return db.goldTransaction.create({
|
||||
data: { characterId, currency, amount, balanceAfter: updated[column], reason: reason || null, createdById: userId },
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
// Works out which coins leave (and come back as change) when a purse pays
|
||||
// `amount` coins of `currency`. Returns the change to each balance, e.g.
|
||||
// { GOLD: -1, SILVER: 7, COPPER: 0 } for 3 silver paid from a purse with only
|
||||
// gold in it — or null if the whole purse isn't worth that much.
|
||||
// 1. Coins of the kind asked for go first.
|
||||
// 2. Any shortfall is met by breaking the nearest bigger coin; the change
|
||||
// comes back in as few coins as possible.
|
||||
// 3. Only if there are no bigger coins left are smaller ones counted out.
|
||||
function planPayment(purse, currency, amount) {
|
||||
const worth = (coin) => COIN_VALUE[coin];
|
||||
const delta = { GOLD: 0, SILVER: 0, COPPER: 0 };
|
||||
const left = (coin) => purse[coin] + delta[coin];
|
||||
let owed = amount * worth(currency);
|
||||
if (COINS.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
|
||||
|
||||
const pay = (coin, count) => {
|
||||
delta[coin] -= count;
|
||||
owed -= count * worth(coin);
|
||||
};
|
||||
pay(currency, Math.min(purse[currency], amount));
|
||||
|
||||
const bigger = COINS.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
|
||||
for (const coin of bigger) {
|
||||
if (owed <= 0) break;
|
||||
pay(coin, Math.min(left(coin), Math.ceil(owed / worth(coin))));
|
||||
// Overpaid: hand the difference back, biggest coins first.
|
||||
let change = -owed;
|
||||
for (const small of COINS.filter((c) => worth(c) < worth(coin))) {
|
||||
if (change <= 0) break;
|
||||
const count = Math.floor(change / worth(small));
|
||||
delta[small] += count;
|
||||
change -= count * worth(small);
|
||||
}
|
||||
if (owed < 0) owed = 0;
|
||||
}
|
||||
|
||||
for (const coin of COINS.filter((c) => worth(c) < worth(currency))) {
|
||||
if (owed <= 0) break;
|
||||
pay(coin, Math.min(left(coin), Math.floor(owed / worth(coin))));
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
// Pays `amount` coins of `currency` out of a character's purse, making change
|
||||
// as needed (see planPayment). Every coin that moved gets its own ledger row;
|
||||
// the rows are returned, the one in `currency` first when there is one. Throws
|
||||
// InsufficientGoldError if the purse as a whole can't cover it. Cash is paid in
|
||||
// cash alone: coins don't cover it, and it doesn't cover coins.
|
||||
async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
|
||||
if (!COINS.includes(currency)) return [await adjustGold(db, { characterId, amount: -amount, currency, reason, userId, include })];
|
||||
const character = await db.character.findUnique({ where: { id: characterId }, select: { gold: true, silver: true, copper: true } });
|
||||
const purse = { GOLD: character.gold, SILVER: character.silver, COPPER: character.copper };
|
||||
const delta = planPayment(purse, currency, amount);
|
||||
if (!delta) throw new InsufficientGoldError();
|
||||
|
||||
const moved = COINS.filter((coin) => delta[coin] !== 0).sort((a, b) => (b === currency) - (a === currency));
|
||||
const madeChange = moved.some((coin) => coin !== currency);
|
||||
const label = reason || 'Spent';
|
||||
const rows = [];
|
||||
for (const coin of moved) {
|
||||
rows.push(
|
||||
await adjustGold(db, {
|
||||
characterId,
|
||||
amount: delta[coin],
|
||||
currency: coin,
|
||||
// Says what the payment was when the coins that moved aren't the ones asked for.
|
||||
reason: madeChange ? `${label} (paid ${amount} ${currency.toLowerCase()}, with change)` : reason,
|
||||
userId,
|
||||
include,
|
||||
})
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
// A purse's coins as one figure in gold, for messages: 265 copper-worth -> 2.65.
|
||||
function worthInGold(character) {
|
||||
const copper = COINS.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
|
||||
return copper / COIN_VALUE.GOLD;
|
||||
}
|
||||
|
||||
const HELD = {
|
||||
weapon: { model: 'characterWeapon', key: 'weaponId' },
|
||||
item: { model: 'inventoryItem', key: 'itemId' },
|
||||
};
|
||||
|
||||
// Gives `quantity` of a pool weapon/item to a character. Something they
|
||||
// already hold stacks (and keeps the level the stack has); otherwise it
|
||||
// arrives at `level`, never below the entry's starting level.
|
||||
async function giveGear(db, kind, { characterId, entry, quantity = 1, level, equipped = false }) {
|
||||
const { model, key } = HELD[kind];
|
||||
return db[model].upsert({
|
||||
where: { [`characterId_${key}`]: { characterId, [key]: entry.id } },
|
||||
create: { characterId, [key]: entry.id, quantity, equipped, level: Math.max(level ?? entry.minLevel, entry.minLevel) },
|
||||
update: { quantity: { increment: quantity } },
|
||||
include: { [kind]: true },
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
|
||||
@@ -0,0 +1,78 @@
|
||||
// Temporary hit points are a buffer: damage comes off them first, and only
|
||||
// what is left over reaches real HP. Takes the character's HP and temp HP as
|
||||
// they are and the HP a damage/heal asked for (which may be below zero), and
|
||||
// returns what both should become. Healing never touches temp HP.
|
||||
function absorbDamage(currentHp, tempHp, requestedHp) {
|
||||
const damage = currentHp - requestedHp;
|
||||
if (damage <= 0 || tempHp <= 0) return { currentHp: requestedHp, tempHp };
|
||||
const absorbed = Math.min(tempHp, damage);
|
||||
return { currentHp: requestedHp + absorbed, tempHp: tempHp - absorbed };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// At 0 HP — death saves
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A character at 0 HP is dying: they roll a d20 each turn, and three successes
|
||||
// or three failures settle it. The tallies and the "stable" flag live on the
|
||||
// Character row, next to the HP they belong to.
|
||||
|
||||
// Three successes steady a character; three failures kill them.
|
||||
const DEATH_SAVE_LIMIT = 3;
|
||||
|
||||
const NO_DEATH_SAVES = { deathSaveSuccesses: 0, deathSaveFailures: 0, stable: false };
|
||||
|
||||
// UP: on their feet. DYING: at 0 HP and rolling death saves. STABLE: at 0 HP
|
||||
// but no longer rolling. DEAD: three failures, or one blow too big to survive.
|
||||
function lifeState(character) {
|
||||
if (character.deathSaveFailures >= DEATH_SAVE_LIMIT) return 'DEAD';
|
||||
if (character.currentHp > 0) return 'UP';
|
||||
return character.stable ? 'STABLE' : 'DYING';
|
||||
}
|
||||
|
||||
// What the API sends about it, the same shape wherever a character's HP goes.
|
||||
function deathSaveInfo(character) {
|
||||
return { state: lifeState(character), successes: character.deathSaveSuccesses, failures: character.deathSaveFailures };
|
||||
}
|
||||
|
||||
// Turns a pair of tallies into what gets stored: the third failure is death,
|
||||
// and the third success makes the character stable, which clears the tallies.
|
||||
function settleDeathSaves(successes, failures) {
|
||||
if (failures >= DEATH_SAVE_LIMIT) return { deathSaveSuccesses: successes, deathSaveFailures: DEATH_SAVE_LIMIT, stable: false };
|
||||
if (successes >= DEATH_SAVE_LIMIT) return { ...NO_DEATH_SAVES, stable: true };
|
||||
return { deathSaveSuccesses: successes, deathSaveFailures: failures, stable: false };
|
||||
}
|
||||
|
||||
// Damage or healing on a finished character. `requestedHp` is the HP that was
|
||||
// asked for, which may be below zero or above max. Returns every column that
|
||||
// should be written: HP and temp HP, plus the death-save columns when the
|
||||
// change touches them.
|
||||
//
|
||||
// - Damage comes off temp HP first.
|
||||
// - Dropping to 0 starts the death saves from a clean slate.
|
||||
// - Damage taken while already at 0 is one failed save, and ends "stable".
|
||||
// - Damage that would leave as much as max HP still to take once HP is at 0
|
||||
// kills outright.
|
||||
// - Any healing from 0 puts the character back up and clears the tallies.
|
||||
function changeHp(character, requestedHp, maxHp) {
|
||||
const soaked = absorbDamage(character.currentHp, character.tempHp, requestedHp);
|
||||
const after = { currentHp: Math.min(Math.max(soaked.currentHp, 0), maxHp), tempHp: soaked.tempHp };
|
||||
if (after.currentHp > 0) return { ...after, ...NO_DEATH_SAVES };
|
||||
|
||||
const damage = character.currentHp - soaked.currentHp; // what got past temp HP
|
||||
if (damage <= 0) return after;
|
||||
if (-soaked.currentHp >= maxHp) return { ...after, ...settleDeathSaves(0, DEATH_SAVE_LIMIT) };
|
||||
if (character.currentHp > 0) return { ...after, ...NO_DEATH_SAVES };
|
||||
return { ...after, ...settleDeathSaves(character.deathSaveSuccesses, character.deathSaveFailures + 1) };
|
||||
}
|
||||
|
||||
// A dying character's death save, from what the d20 showed: 10 or more is a
|
||||
// success, less is a failure, a 1 is two failures, and a 20 puts them back on
|
||||
// their feet with 1 HP.
|
||||
function rollDeathSave(character, roll) {
|
||||
if (roll === 20) return { currentHp: 1, ...NO_DEATH_SAVES };
|
||||
if (roll >= 10) return settleDeathSaves(character.deathSaveSuccesses + 1, character.deathSaveFailures);
|
||||
return settleDeathSaves(character.deathSaveSuccesses, character.deathSaveFailures + (roll === 1 ? 2 : 1));
|
||||
}
|
||||
|
||||
module.exports = { absorbDamage, DEATH_SAVE_LIMIT, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave };
|
||||
@@ -0,0 +1,84 @@
|
||||
const { lifeState, settleDeathSaves, changeHp, rollDeathSave } = require('./hp');
|
||||
|
||||
const CLEAR = { deathSaveSuccesses: 0, deathSaveFailures: 0, stable: false };
|
||||
const hero = (over = {}) => ({ currentHp: 10, tempHp: 0, ...CLEAR, ...over });
|
||||
|
||||
describe('lifeState', () => {
|
||||
test('reads up, dying, stable and dead off the row', () => {
|
||||
expect(lifeState(hero())).toBe('UP');
|
||||
expect(lifeState(hero({ currentHp: 0 }))).toBe('DYING');
|
||||
expect(lifeState(hero({ currentHp: 0, stable: true }))).toBe('STABLE');
|
||||
expect(lifeState(hero({ currentHp: 0, deathSaveFailures: 3 }))).toBe('DEAD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeHp', () => {
|
||||
test('ordinary damage and healing leave the death saves clear', () => {
|
||||
expect(changeHp(hero(), 4, 20)).toEqual({ currentHp: 4, tempHp: 0, ...CLEAR });
|
||||
expect(changeHp(hero(), 99, 20)).toEqual({ currentHp: 20, tempHp: 0, ...CLEAR });
|
||||
});
|
||||
|
||||
test('dropping to 0 starts the saves from nothing', () => {
|
||||
expect(changeHp(hero(), -5, 20)).toEqual({ currentHp: 0, tempHp: 0, ...CLEAR });
|
||||
});
|
||||
|
||||
test('damage at 0 HP is a failed save and ends stable', () => {
|
||||
const down = hero({ currentHp: 0, deathSaveSuccesses: 2, deathSaveFailures: 1 });
|
||||
expect(changeHp(down, -3, 20)).toMatchObject({ currentHp: 0, deathSaveSuccesses: 2, deathSaveFailures: 2, stable: false });
|
||||
expect(changeHp(hero({ currentHp: 0, stable: true }), -3, 20)).toMatchObject({ deathSaveFailures: 1, stable: false });
|
||||
expect(lifeState(changeHp(hero({ currentHp: 0, deathSaveFailures: 2 }), -1, 20))).toBe('DEAD');
|
||||
});
|
||||
|
||||
test('temp HP that soaks the whole blow costs no save', () => {
|
||||
const down = hero({ currentHp: 0, tempHp: 5, deathSaveFailures: 1 });
|
||||
expect(changeHp(down, -5, 20)).toEqual({ currentHp: 0, tempHp: 0 });
|
||||
});
|
||||
|
||||
test('asking for the HP they already have at 0 changes nothing', () => {
|
||||
expect(changeHp(hero({ currentHp: 0, deathSaveFailures: 2 }), 0, 20)).toEqual({ currentHp: 0, tempHp: 0 });
|
||||
});
|
||||
|
||||
test('damage of max HP or more past zero kills outright', () => {
|
||||
expect(lifeState(changeHp(hero(), -19, 20))).toBe('DYING');
|
||||
expect(lifeState(changeHp(hero(), -20, 20))).toBe('DEAD');
|
||||
expect(lifeState(changeHp(hero({ currentHp: 0 }), -20, 20))).toBe('DEAD');
|
||||
// Temp HP counts against the blow first.
|
||||
expect(lifeState(changeHp(hero({ tempHp: 1 }), -20, 20))).toBe('DYING');
|
||||
});
|
||||
|
||||
test('healing from 0 puts them back up with a clean slate', () => {
|
||||
const down = hero({ currentHp: 0, deathSaveSuccesses: 1, deathSaveFailures: 2 });
|
||||
expect(changeHp(down, 1, 20)).toEqual({ currentHp: 1, tempHp: 0, ...CLEAR });
|
||||
expect(changeHp(hero({ currentHp: 0, stable: true }), 6, 20)).toEqual({ currentHp: 6, tempHp: 0, ...CLEAR });
|
||||
});
|
||||
});
|
||||
|
||||
describe('rollDeathSave', () => {
|
||||
const down = (successes, failures) => hero({ currentHp: 0, deathSaveSuccesses: successes, deathSaveFailures: failures });
|
||||
|
||||
test('10 or more succeeds, less fails, a 1 fails twice', () => {
|
||||
expect(rollDeathSave(down(0, 0), 10)).toEqual({ deathSaveSuccesses: 1, deathSaveFailures: 0, stable: false });
|
||||
expect(rollDeathSave(down(0, 0), 19)).toEqual({ deathSaveSuccesses: 1, deathSaveFailures: 0, stable: false });
|
||||
expect(rollDeathSave(down(0, 0), 9)).toEqual({ deathSaveSuccesses: 0, deathSaveFailures: 1, stable: false });
|
||||
expect(rollDeathSave(down(0, 0), 1)).toEqual({ deathSaveSuccesses: 0, deathSaveFailures: 2, stable: false });
|
||||
});
|
||||
|
||||
test('the third success makes them stable and clears the tallies', () => {
|
||||
expect(rollDeathSave(down(2, 2), 14)).toEqual({ ...CLEAR, stable: true });
|
||||
});
|
||||
|
||||
test('the third failure is death, and never counts past three', () => {
|
||||
expect(rollDeathSave(down(2, 2), 5)).toMatchObject({ deathSaveFailures: 3 });
|
||||
expect(rollDeathSave(down(0, 2), 1)).toMatchObject({ deathSaveFailures: 3 });
|
||||
});
|
||||
|
||||
test('a 20 is 1 HP and a clean slate', () => {
|
||||
expect(rollDeathSave(down(0, 2), 20)).toEqual({ currentHp: 1, ...CLEAR });
|
||||
});
|
||||
});
|
||||
|
||||
describe('settleDeathSaves', () => {
|
||||
test('failures win a tie at three', () => {
|
||||
expect(settleDeathSaves(3, 3)).toMatchObject({ deathSaveFailures: 3, stable: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Where characters are, and where they have been. A visit is recorded every
|
||||
// time a character is put somewhere and is never removed by moving on: the
|
||||
// set of visited locations is what a player is allowed to see. Every function
|
||||
// takes a Prisma client or transaction client as `db`.
|
||||
|
||||
async function recordVisits(db, characterIds, locationId) {
|
||||
if (characterIds.length === 0) return;
|
||||
await db.characterLocationVisit.createMany({
|
||||
data: characterIds.map((characterId) => ({ characterId, locationId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function moveCharacters(db, characterIds, locationId) {
|
||||
if (characterIds.length === 0) return;
|
||||
await db.character.updateMany({ where: { id: { in: characterIds } }, data: { currentLocationId: locationId } });
|
||||
await recordVisits(db, characterIds, locationId);
|
||||
}
|
||||
|
||||
// Ids of the locations any of this user's characters in the campaign has been to.
|
||||
async function visitedLocationIds(db, userId, campaignId) {
|
||||
const visits = await db.characterLocationVisit.findMany({
|
||||
where: { character: { userId, campaignId } },
|
||||
select: { locationId: true },
|
||||
distinct: ['locationId'],
|
||||
});
|
||||
return new Set(visits.map((v) => v.locationId));
|
||||
}
|
||||
|
||||
module.exports = { recordVisits, moveCharacters, visitedLocationIds };
|
||||
@@ -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 };
|
||||
Binary file not shown.
+1072
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,481 @@
|
||||
# Encounters
|
||||
|
||||
How encounters work in this app: the two kinds of scene, who can be in one,
|
||||
how a battle is run, and how rewards are handed out.
|
||||
|
||||
This describes the code as it stands. The rules live in
|
||||
`backend/src/routes/encounters.routes.js`. Related:
|
||||
[CHARACTERS.md](CHARACTERS.md), [MONSTERS.md](MONSTERS.md),
|
||||
[NPCS.md](NPCS.md), [WEAPONS.md](WEAPONS.md), [ITEMS.md](ITEMS.md).
|
||||
|
||||
## Contents
|
||||
|
||||
1. [The basics](#1-the-basics)
|
||||
2. [Battle or quest](#2-battle-or-quest)
|
||||
3. [Status](#3-status)
|
||||
4. [The scene and DM notes](#4-the-scene-and-dm-notes)
|
||||
5. [Who is in the scene](#5-who-is-in-the-scene)
|
||||
6. [Hit points](#6-hit-points)
|
||||
7. [Initiative, turns and rounds](#7-initiative-turns-and-rounds)
|
||||
8. [Conditions](#8-conditions)
|
||||
9. [Rewards](#9-rewards)
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
## 1. The basics
|
||||
|
||||
- An encounter is one **scene** the DM runs: a fight, or a meeting with
|
||||
people who want something.
|
||||
- Encounters belong to a campaign and are listed on the campaign page. The DM
|
||||
creates them and sees all of them.
|
||||
- A player only sees an encounter that **one of their own characters is in**.
|
||||
Until the DM adds their character it is not in their list and its page will
|
||||
not open, so scenes the DM is still preparing stay hidden, name included.
|
||||
Taking their character out hides it again.
|
||||
- The encounter page updates live for everyone who has it open.
|
||||
- The DM runs everything. Players watch, and see less than the DM does.
|
||||
|
||||
## 2. Battle or quest
|
||||
|
||||
Every encounter has a mode, chosen when it is created and switchable at any
|
||||
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; 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.
|
||||
|
||||
## 3. Status
|
||||
|
||||
| Status | Meaning | How to get there |
|
||||
|---|---|---|
|
||||
| Planning | Being set up | Every new encounter starts here |
|
||||
| In progress | Being played | Begin Battle / Begin Quest, or Reopen |
|
||||
| Complete | Finished | End Battle / Complete Quest |
|
||||
|
||||
Status is a label. Nothing is locked when an encounter is complete: the DM
|
||||
can still change HP, add people and award rewards. Encounters cannot be
|
||||
renamed from the screen.
|
||||
|
||||
### Location
|
||||
|
||||
The DM can give an encounter a location from the campaign's list, with the
|
||||
picker at the top of the page. While the encounter is **in progress** its
|
||||
characters are at that location: beginning it, changing its location, or
|
||||
adding a character to it moves them there. Each place a character is moved to
|
||||
(this way or by the DM on the sheet) is remembered, and a player can see only
|
||||
the locations one of their own characters has been to — on the campaign page
|
||||
under "Places", 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 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
|
||||
open its page from a bookmark. "Restore" puts it back on the list.
|
||||
|
||||
**Delete.** "Delete" (on the encounter page, or on an archived encounter in
|
||||
the list) removes the scene, who was in it and its reward list, after a
|
||||
confirmation. There is no undo. Rewards already handed out stay with the
|
||||
characters, and characters keep their current HP.
|
||||
|
||||
## 4. The scene and DM notes
|
||||
|
||||
| Box | For | Who sees it |
|
||||
|---|---|---|
|
||||
| The Scene / Quest | Describing the battlefield, or what is being asked of the party | Everyone |
|
||||
| DM Notes | Tactics, secrets, what happens if the party fails | **DM only** |
|
||||
|
||||
Both save when the DM clicks out of the box.
|
||||
|
||||
## 5. Who is in the scene
|
||||
|
||||
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, 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 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.
|
||||
- **NPCs:** role, attitude and description; for the DM also AC, attack,
|
||||
damage and DM notes.
|
||||
|
||||
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.
|
||||
|
||||
## 6. Hit points
|
||||
|
||||
The DM types an amount and clicks Damage or Heal on a card.
|
||||
|
||||
| Combatant | Where the HP lives |
|
||||
|---|---|
|
||||
| Character | On the **character sheet**. The encounter shows the sheet's HP and changes it directly, so the two always agree. Max HP comes from the sheet. |
|
||||
| Monster | In the encounter, per copy. Starts at the monster's max. |
|
||||
| NPC | In the encounter. An NPC with no HP set has no health bar. |
|
||||
|
||||
- HP always stays between 0 and max.
|
||||
- **What players see of it.** A character's HP is always shown in numbers. For
|
||||
monsters and NPCs it depends on the campaign setting "Players see the exact
|
||||
hit points of monsters and NPCs": on (the default) shows the numbers, off
|
||||
shows only the health bar. The DM always sees the numbers.
|
||||
- At 0 HP a monster or NPC is **out of the fight**: see "The fallen" below.
|
||||
- A **character** at 0 HP is **Dying** instead, and its card grows a death-save
|
||||
box: see below.
|
||||
- A character's temporary HP is shown beside its HP but is not used up
|
||||
automatically.
|
||||
|
||||
### A character at 0 HP
|
||||
|
||||
The full rules are in [CHARACTERS.md](CHARACTERS.md), section 7; the box on
|
||||
the card is the same one as on the sheet, and both change the same character.
|
||||
|
||||
- **Dying.** The box shows two tallies, Saved and Failed, three pips each.
|
||||
Type what the d20 showed and click **Death save**: 10 or more is a success,
|
||||
less is a failure, a 1 is two failures, a 20 puts the character back up at
|
||||
1 HP. The DM can do this for anyone; a player for their own character.
|
||||
- **Damage while down** is one failed save, added when the DM clicks Damage.
|
||||
For a critical hit the DM clicks the next Failed pip to add the second.
|
||||
- **Stabilise** stops the saves without a roll (a Medicine check, a spell).
|
||||
Three successes do the same. A stable character stays at 0 HP.
|
||||
- **Heal** on a dying or stable character puts them back up and clears the
|
||||
tallies.
|
||||
- **Dead** after three failures, or when one blow leaves as much as the
|
||||
character's max HP still to take after reaching 0. Healing is refused; the
|
||||
DM clicks **Revive at 1 HP**, or an ally revives them (below).
|
||||
- The DM can click any pip to set a tally by hand.
|
||||
|
||||
Nothing rolls by itself: the app does not prompt for a save when the turn
|
||||
comes round. A dying or stable character keeps its place in the order and
|
||||
still gets its turn.
|
||||
|
||||
### The fallen
|
||||
|
||||
A combatant is **out of the fight** when it is:
|
||||
|
||||
- a **monster or NPC at 0 HP** (one with hit points to track), or
|
||||
- a **dead character**. Dying and stable characters are not: they stay in
|
||||
view.
|
||||
|
||||
What that does:
|
||||
|
||||
- **Players no longer see it.** Its card is gone from their screen, along
|
||||
with any target mark on it; the server does not send it to them at all.
|
||||
- **The DM's screen leaves it out as well**, until the DM ticks **Show dead**
|
||||
above the list (the box shows how many there are, and only appears when
|
||||
there are any). Ticked, they are listed in a group of their own at the
|
||||
bottom, "Out of the fight, hidden from players", each with a "Hidden from
|
||||
players" badge, and everything on the card still works. The tick is not
|
||||
remembered: it is off again when the page is reopened.
|
||||
- **It has no turn.** Next Turn, Next Round and Begin Battle pass over it.
|
||||
- It stays in the encounter: nothing is removed, and gold is still split with
|
||||
a dead character.
|
||||
|
||||
It is worked out from hit points each time, not stored, so it undoes itself:
|
||||
heal a monster or NPC above 0, or revive a character, and they are back on
|
||||
everyone's screen and back in the turn order.
|
||||
|
||||
**Reviving a dead character.** Players see dead characters by name only, in a
|
||||
**☠ Fallen** box above the list. There are two ways back:
|
||||
|
||||
- The **DM** ticks Show dead and clicks **Revive at 1 HP** on the dead
|
||||
character's card (or does it on the sheet), at any time.
|
||||
- An **ally** tries it: in a battle in progress, the player whose character is
|
||||
**on turn** gets a **Try revive** button beside each name in the Fallen box,
|
||||
if that character is capable. Capable means on their feet (above 0 HP) and
|
||||
knowing a spell that is ticked **Revives** in the campaign's spell list
|
||||
([SPELLS.md](SPELLS.md)), at a level they can cast. The library's Revivify
|
||||
(level 3) and Raise Dead (level 5) are ticked already.
|
||||
|
||||
Either way the character comes back at 1 HP with the death saves cleared. The
|
||||
app does not use up the spell, check how long ago they died, or end the
|
||||
reviver's turn.
|
||||
|
||||
## 7. Initiative, turns and rounds
|
||||
|
||||
Initiative is entered in battles and quests alike. Turns and rounds are for
|
||||
battles only.
|
||||
|
||||
- Each combatant has an **initiative roll** field. Type what the d20 showed,
|
||||
1 to 20; the field accepts nothing else. The app does not roll the die, but
|
||||
it adds the bonus for you and shows the total, which can go past 20 (a 20 on
|
||||
the die with a +3 bonus is 23): a character's initiative
|
||||
bonus from their sheet, a monster's Dexterity modifier, nothing for an NPC.
|
||||
- The DM can enter the roll for anyone, when adding them or on the card. A
|
||||
player can enter the roll for **their own character** on its card.
|
||||
- The list is ordered by the total, highest first; ties keep the order they
|
||||
were added in. Anyone who has not rolled yet waits at the bottom.
|
||||
- The bonus is worked out when the roll is entered. If a character's sheet
|
||||
changes afterwards, enter the roll again to pick up the new bonus.
|
||||
- **Begin Battle** puts the first combatant on turn. Their card is
|
||||
highlighted and the header names them.
|
||||
- **Next Turn** passes the turn to the next in the order. After the last
|
||||
combatant it returns to the top and the **round** goes up by one.
|
||||
- **Next Round** skips straight to the top of the next round.
|
||||
- Anyone out of the fight (section 6, "The fallen") is passed over.
|
||||
- **The list follows the turn.** While a battle is in progress, whoever is on
|
||||
turn is at the top of "Initiative order" with those still to act this round
|
||||
beneath them. Each combatant whose turn has passed moves down to a **Next
|
||||
Round** section at the bottom, still in initiative order. When the round
|
||||
rolls over everyone is back in "Initiative order". Everyone sees the same
|
||||
arrangement. Before the battle begins, and once it is complete, it is one
|
||||
plain list.
|
||||
|
||||
### Targets
|
||||
|
||||
Combatants can be marked as **targets**: whoever is being attacked (or
|
||||
healed) right now. One target is an ordinary attack; more than one is an
|
||||
**area attack**.
|
||||
|
||||
- Click **🎯 Target** beside a combatant's name to mark them; click it again
|
||||
to unmark. Each one marked is added to the targets.
|
||||
- The **DM** can mark anyone at any time, in a battle or a quest. A **player**
|
||||
can while their own character is on turn in a battle, so they can show the
|
||||
DM who they are going for. A player cannot mark someone who is out of the
|
||||
fight, and is not shown a mark the DM leaves on one.
|
||||
- The combatant **on turn** cannot be marked: nobody targets themselves, and
|
||||
their card has no Target button until the turn passes. To heal or hurt them
|
||||
on their own turn, use the buttons on their card.
|
||||
- A target's card is outlined in red for everyone and reads "Targeted by
|
||||
<whoever is on turn>".
|
||||
- While anyone is marked the DM gets an **attack bar** above the list, which
|
||||
stays in view while scrolling. It shows who is attacking whom, each target's
|
||||
AC and HP, and the attacker's attacks: a character's equipped weapons (none,
|
||||
if nothing is equipped) and damaging spells, or a monster's or NPC's
|
||||
attack and damage. The amount box is ready to type in; **Damage** (or
|
||||
Enter) and **Heal** change HP exactly as the buttons on a card do.
|
||||
- Targets stay marked after damage, for a second attack. They are cleared
|
||||
when the turn or round moves on, when the encounter begins, ends or is
|
||||
reopened; a target that is removed from the encounter is unmarked.
|
||||
|
||||
**Area attacks**
|
||||
|
||||
An attack on several targets is harder to land and its damage is shared.
|
||||
|
||||
- **Harder to hit.** Every target's AC counts as higher for each target
|
||||
beyond the first, by the campaign's **area attack AC penalty**. The DM sets
|
||||
it in the campaign settings, from 0 (no penalty) to 10; it starts at 2, which
|
||||
gives:
|
||||
|
||||
| Targets | Added to each AC |
|
||||
|---|---|
|
||||
| 1 | 0 |
|
||||
| 2 | +2 |
|
||||
| 3 | +4 |
|
||||
| 4 | +6 |
|
||||
|
||||
The attack bar shows the AC to beat with the penalty already added
|
||||
("17 (15 +2)"), and each target's card says "area attack, AC +2".
|
||||
- **Who was hit.** Each target has a tick box, ticked to begin with. Untick
|
||||
anyone the roll missed; they take nothing.
|
||||
- **Sharing the amount.** Type the total. "Split between those hit" (the
|
||||
default) shares it evenly, any remainder going one point each to the first
|
||||
targets marked: 7 between three is 3, 2 and 2. "Full amount to each" gives
|
||||
every target hit the whole amount. Each target's share is shown before it
|
||||
is applied.
|
||||
- A target with no hit points to track is left out of the sharing.
|
||||
|
||||
The app does not roll the attack or decide whether it hits: the DM compares
|
||||
the roll with the AC shown and types the damage.
|
||||
|
||||
## 8. Conditions
|
||||
|
||||
The DM adds a condition to any card from the "+ Condition" list, and removes
|
||||
one by clicking its ✕. Everyone sees them.
|
||||
|
||||
The list: Blinded, Charmed, Deafened, Frightened, Grappled, Incapacitated,
|
||||
Invisible, Paralyzed, Petrified, Poisoned, Prone, Restrained, Stunned,
|
||||
Unconscious, Concentrating.
|
||||
|
||||
Conditions are labels to remind the table. They do not change any numbers,
|
||||
and they do not expire on their own.
|
||||
|
||||
## 9. Rewards
|
||||
|
||||
The DM lines up loot in the Rewards panel, on any encounter at any time.
|
||||
|
||||
| Reward | What the DM sets |
|
||||
|---|---|
|
||||
| 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 |
|
||||
|
||||
Each can carry a short note ("Goblin chief's purse").
|
||||
|
||||
**A reward does nothing until it is awarded**, and it can be awarded once.
|
||||
|
||||
| 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 (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. |
|
||||
|
||||
- **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
|
||||
level they hold it but cannot use it yet.
|
||||
- Removing a reward from the list does **not** take back what was awarded.
|
||||
- Spells cannot be rewards; the DM grants those on the character sheet.
|
||||
|
||||
Until a reward is awarded only the DM can see it. Afterwards players see what
|
||||
it was and who received it.
|
||||
|
||||
## 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.
|
||||
|
||||
## 12. Permissions
|
||||
|
||||
| Action | DM | Players |
|
||||
|---|---|---|
|
||||
| Create an encounter | Yes | No |
|
||||
| See and open an encounter | Yes, all of them | Only ones their own character is in |
|
||||
| Change mode, status, scene or DM notes | Yes | No |
|
||||
| Rearrange, archive, restore or delete encounters | Yes | No |
|
||||
| See archived encounters in the list | Yes | No |
|
||||
| Add or remove anyone | Yes | No |
|
||||
| Change HP or conditions | Yes | No |
|
||||
| Enter an initiative roll | Yes, for anyone | For their own character only |
|
||||
| Mark targets | Yes, any time | On their own character's turn |
|
||||
| Enter a death save, or stabilise | Yes, for anyone | For their own character only |
|
||||
| Set death-save tallies by hand | Yes | No |
|
||||
| Revive a dead character outright | Yes | No |
|
||||
| Try to revive a dead character | Yes, for the character on turn | On their own character's turn, if it has a reviving spell |
|
||||
| Advance turns and rounds | Yes | No |
|
||||
| Add, remove or award rewards | Yes | No |
|
||||
| Set exchange rates, and put things up for sale | Yes | No |
|
||||
| Change money or buy, in a quest in progress | Yes, for any character in it | For their own character in it |
|
||||
|
||||
People outside the campaign cannot open its encounters.
|
||||
|
||||
## 13. What is not modelled
|
||||
|
||||
- Rolling initiative, attacks, damage or saves (use the dice roller, bottom
|
||||
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, 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
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
# Items and Inventory
|
||||
|
||||
How items work in this app: the campaign's item pool, what a character
|
||||
carries, item levels, and how items are handed out.
|
||||
|
||||
This describes the code as it stands. Related: [WEAPONS.md](WEAPONS.md) (items
|
||||
level up the same way weapons do), [SPELLS.md](SPELLS.md),
|
||||
[CHARACTERS.md](CHARACTERS.md) (money is covered there, in section 12).
|
||||
|
||||
## Contents
|
||||
|
||||
1. [The basics](#1-the-basics)
|
||||
2. [The item pool](#2-the-item-pool)
|
||||
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 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)
|
||||
|
||||
---
|
||||
|
||||
## 1. The basics
|
||||
|
||||
- Every campaign has its own **item pool**: rope, potions, keys, quest items,
|
||||
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,
|
||||
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" 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 |
|
||||
|---|---|
|
||||
| Name | Free text, up to 120 characters |
|
||||
| Description | Optional. Shown under the item in a character's inventory |
|
||||
| Base dice | Optional dice such as `2d4+2`, for items that deal damage or heal (section 5) |
|
||||
| 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:** 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, and "+ Add another item" at its top goes straight back
|
||||
to an empty form. 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 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
|
||||
|
||||
On a character's sheet, the DM picks an item from the pool and clicks Give.
|
||||
|
||||
| Each held item has | Who changes it |
|
||||
|---|---|
|
||||
| Quantity | The DM, with the − and + buttons |
|
||||
| Level | The DM, with the level picker |
|
||||
| Equipped | The owning player or the DM |
|
||||
|
||||
- Giving an item the character already holds **adds to the stack**. A
|
||||
character holds one stack per item, and the whole stack shares one level.
|
||||
- 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 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
|
||||
Character" card on the sheet. The items leave the giver and arrive with the
|
||||
recipient at the giver's level, unequipped. If the recipient already holds
|
||||
that item, their stack grows and keeps its own level. Gold is given the same
|
||||
way, and both characters' money logs record it.
|
||||
|
||||
## 4. Item levels
|
||||
|
||||
Items use the same level system as weapons (a house rule of this app):
|
||||
|
||||
- Each held stack has a **level from 1 to 9**, never below the pool entry's
|
||||
starting level.
|
||||
- A character must be **at least the item's level** to use it. A higher-level
|
||||
item is still held, but shows "Requires level N" and cannot be equipped.
|
||||
- Only the DM changes an item's level.
|
||||
- If the DM levels an item past its holder, or lowers the character's level,
|
||||
it is unequipped automatically.
|
||||
|
||||
For an item with no dice, the level is purely a requirement: a level 3 amulet
|
||||
is one a character cannot use until level 3.
|
||||
|
||||
## 5. Dice on items
|
||||
|
||||
An item can carry optional dice: a healing potion's `2d4+2`, a firebomb's
|
||||
`3d6`. The app does not know whether they heal or harm; the DM applies them.
|
||||
|
||||
The dice grow with the item's level exactly as a weapon's do, climbing the
|
||||
same ladder (1d4 → 1d6 → 1d8 → 1d10 → 1d12 → 2d6 → …) by one, two or three
|
||||
rungs per level depending on Growth. The full ladder is in
|
||||
[WEAPONS.md, section 6](WEAPONS.md#6-how-damage-grows).
|
||||
|
||||
| Healing Potion, `2d4+2`, Standard growth | Dice shown |
|
||||
|---|---|
|
||||
| Level 1 | 2d4+2 (as typed) |
|
||||
| Level 2 | 1d6+1d8 |
|
||||
| Level 3 | 2d8 |
|
||||
|
||||
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:
|
||||
|
||||
- ticked as a **starting option** in the pool, and
|
||||
- **starting level 1**.
|
||||
|
||||
Starting items and starting weapons share the campaign's "starting items"
|
||||
allowance. Each arrives at level 1, quantity 1, unequipped.
|
||||
|
||||
## 7. Items as rewards and for sale
|
||||
|
||||
On an encounter the DM can line up an item as a reward, at a chosen level and
|
||||
quantity, and later award it to a character. Awarding adds it to the
|
||||
character's inventory, stacking with any they already hold. See
|
||||
[ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
|
||||
The DM can also put an item **up for sale**, at a level, a price (in gold,
|
||||
silver, copper or cash) and an optional stock. An NPC can sell it, wherever
|
||||
that NPC is met, or an encounter can sell it itself. Players see what is for
|
||||
sale and buy it in an NPC quest that is in progress: the price leaves their
|
||||
purse and the item joins their inventory. See [NPCS.md](NPCS.md), section 5,
|
||||
and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
## 8. Permissions
|
||||
|
||||
| Action | DM | Owning player | Other players |
|
||||
|---|---|---|---|
|
||||
| See the item pool | Yes | Only what their own character holds, plus the starting options while they have a character to build | The same, for their own character |
|
||||
| Add, change or remove pool items | Yes | No | No |
|
||||
| Give an item from the pool, or take one away | Yes | No | No |
|
||||
| Give a held item to another character | Yes | Yes | No |
|
||||
| Put an item up for sale | Yes | No | No |
|
||||
| Buy an item that is for sale, in a quest in progress | Yes, for any character in it | Yes, if the character is in it | No |
|
||||
| Change quantity or level | Yes | No | No |
|
||||
| Equip or unequip | Yes | Yes, if the character can use it | No |
|
||||
| See a character's inventory | Yes | Yes | Yes |
|
||||
|
||||
## 9. What is not modelled
|
||||
|
||||
- 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
|
||||
- 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 -->
|
||||
@@ -0,0 +1,188 @@
|
||||
# Monsters
|
||||
|
||||
How monsters work in this app: the campaign's monster list, the built-in
|
||||
bestiary, and how monsters behave in an encounter.
|
||||
|
||||
This describes the code as it stands. The built-in list is
|
||||
`backend/src/game/data/monstersLibrary.js`. Related:
|
||||
[ENCOUNTERS.md](ENCOUNTERS.md), [NPCS.md](NPCS.md).
|
||||
|
||||
## Contents
|
||||
|
||||
1. [The basics](#1-the-basics)
|
||||
2. [What a monster holds](#2-what-a-monster-holds)
|
||||
3. [Managing monsters](#3-managing-monsters)
|
||||
4. [The built-in bestiary](#4-the-built-in-bestiary)
|
||||
5. [Monsters in encounters](#5-monsters-in-encounters)
|
||||
6. [What players see](#6-what-players-see)
|
||||
7. [Permissions](#7-permissions)
|
||||
8. [What is not modelled](#8-what-is-not-modelled)
|
||||
|
||||
---
|
||||
|
||||
## 1. The basics
|
||||
|
||||
- Every campaign has its own **monster list**. Only the DM can see or change
|
||||
it.
|
||||
- A monster in the list is a **template**. Each time it is added to an
|
||||
encounter it becomes a separate combatant with its own hit points.
|
||||
- Stat blocks are the DM's. Players see a monster only as a name and a health
|
||||
bar in an encounter.
|
||||
|
||||
## 2. What a monster holds
|
||||
|
||||
| Field | What it is |
|
||||
|---|---|
|
||||
| Name | Up to 80 characters |
|
||||
| AC | Armor Class |
|
||||
| Max HP | Hit points each copy starts an encounter with |
|
||||
| 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`, 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 |
|
||||
|
||||
None of these are calculated or used in formulas. They are reference numbers
|
||||
shown to the DM during a fight. A monster's ability modifiers are worked out
|
||||
from its scores for display only.
|
||||
|
||||
## 3. Managing monsters
|
||||
|
||||
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" 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.
|
||||
- Once added by hand, the monster's own pop-out opens. "+ Add another
|
||||
monster" at its top goes straight back to an empty form.
|
||||
- **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, 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
|
||||
monster's attack roll.
|
||||
|
||||
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: monster-library -->
|
||||
| Monster | CR | AC | HP | Speed | Attack | Damage | STR / DEX / CON / INT / WIS / CHA | Trait | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Commoner | 0 | 10 | 4 | 30 | +2 | 1d4 | 10 / 10 / 10 / 10 / 10 / 10 | — | An ordinary villager, armed with a club. |
|
||||
| Giant Rat | 1/8 | 12 | 7 | 30 | +4 | 1d4+2 | 7 / 15 / 11 / 2 / 10 / 4 | Pack Tactics | Bite, piercing damage. |
|
||||
| Kobold | 1/8 | 12 | 5 | 30 | +4 | 1d4+2 | 7 / 15 / 9 / 8 / 7 / 8 | Pack Tactics, Sunlight Sensitivity | Dagger, piercing damage. |
|
||||
| Bandit | 1/8 | 12 | 11 | 30 | +3 | 1d6+1 | 11 / 12 / 12 / 10 / 10 / 10 | — | Scimitar, slashing damage. |
|
||||
| Cultist | 1/8 | 12 | 9 | 30 | +3 | 1d6+1 | 11 / 12 / 10 / 10 / 11 / 10 | Dark Devotion | Scimitar, slashing damage. |
|
||||
| Stirge | 1/8 | 12 | 2 | 10 | +5 | 1d4 | 4 / 16 / 11 / 2 / 8 / 6 | Blood Drain | Proboscis; attaches and drains hp on a hit. |
|
||||
| Goblin | 1/4 | 15 | 7 | 30 | +4 | 1d6+2 | 8 / 14 / 10 / 10 / 8 / 8 | Nimble Escape | Scimitar, slashing damage. |
|
||||
| Skeleton | 1/4 | 13 | 13 | 30 | +4 | 1d6+2 | 10 / 14 / 15 / 6 / 8 / 5 | Undead — vulnerable to bludgeoning | Shortsword, piercing damage. |
|
||||
| Zombie | 1/4 | 8 | 22 | 20 | +3 | 1d6+1 | 13 / 6 / 16 / 3 / 6 / 5 | Undead Fortitude | Slam, bludgeoning damage. |
|
||||
| Wolf | 1/4 | 13 | 11 | 40 | +4 | 2d4+2 | 12 / 15 / 12 / 3 / 12 / 6 | Pack Tactics | Bite; can knock prone on a hit. |
|
||||
| Giant Wolf Spider | 1/4 | 13 | 11 | 40 | +3 | 1d6+1 | 12 / 16 / 13 / 3 / 12 / 4 | Poison bite | Spider climb; bite deals poison damage. |
|
||||
| Scout | 1/2 | 13 | 16 | 30 | +4 | 1d6+2 | 11 / 14 / 12 / 11 / 13 / 11 | Keen Hearing and Sight | Shortsword or longbow. |
|
||||
| Orc | 1/2 | 13 | 15 | 30 | +5 | 1d12+3 | 16 / 12 / 16 / 7 / 11 / 10 | Aggressive | Greataxe, slashing damage. |
|
||||
| Hobgoblin | 1/2 | 18 | 11 | 30 | +3 | 1d8+1 | 13 / 12 / 12 / 10 / 10 / 9 | Martial Advantage | Longsword, slashing damage. |
|
||||
| Thug | 1/2 | 11 | 32 | 30 | +4 | 1d6+2 | 15 / 11 / 14 / 10 / 10 / 11 | Pack Tactics | Mace, bludgeoning damage. |
|
||||
| Gnoll | 1/2 | 15 | 22 | 30 | +3 | 1d8+1 | 14 / 12 / 11 / 6 / 10 / 7 | Rampage | Spear, piercing damage. |
|
||||
| Lizardfolk | 1/2 | 15 | 22 | 30 | +3 | 1d6+1 | 15 / 10 / 13 / 7 / 12 / 7 | Hold Breath | Club or spear, piercing damage. |
|
||||
| Black Bear | 1/2 | 11 | 19 | 40 | +3 | 1d6+2 | 15 / 10 / 14 / 2 / 12 / 7 | Keen Smell | Bite and claw attacks. |
|
||||
| Giant Spider | 1 | 14 | 26 | 30 | +5 | 1d8+3 | 14 / 16 / 12 / 2 / 11 / 4 | Web, poison bite | Bite deals piercing plus poison damage. |
|
||||
| Ghoul | 1 | 12 | 22 | 30 | +2 | 2d6+2 | 13 / 15 / 10 / 7 / 10 / 6 | Claws paralyze on a hit | Undead. |
|
||||
| Dire Wolf | 1 | 14 | 37 | 50 | +5 | 2d6+3 | 17 / 15 / 15 / 3 / 12 / 7 | Pack Tactics | Bite; can knock prone on a hit. |
|
||||
| Brown Bear | 1 | 11 | 34 | 40 | +5 | 1d8+5 | 19 / 10 / 16 / 2 / 13 / 7 | Keen Smell | Multiattack: bite and claws. |
|
||||
| Harpy | 1 | 11 | 38 | 20 | +4 | 2d4+2 | 12 / 13 / 12 / 7 / 10 / 13 | Luring Song | Flying speed 40 ft; claws deal slashing damage. |
|
||||
| Ochre Jelly | 2 | 8 | 45 | 10 | +5 | 2d6+3 | 15 / 6 / 14 / 2 / 6 / 1 | Amorphous, splits when damaged by slashing | Pseudopod, bludgeoning plus acid damage. |
|
||||
| Ogre | 2 | 11 | 59 | 40 | +6 | 2d8+4 | 19 / 8 / 16 / 5 / 7 / 7 | — | Greatclub, bludgeoning damage. |
|
||||
| Griffon | 2 | 12 | 59 | 30 | +6 | 2d6+4 | 18 / 15 / 16 / 2 / 13 / 8 | Keen Sight | Flying speed 80 ft; multiattack beak and claws. |
|
||||
| Ankheg | 2 | 14 | 39 | 30 | +6 | 2d6+4 | 17 / 11 / 13 / 1 / 13 / 6 | Burrow speed 10 ft; can spit acid | Bite, piercing plus acid damage. |
|
||||
| Manticore | 3 | 14 | 68 | 30 | +5 | 1d8+3 | 17 / 16 / 17 / 7 / 12 / 8 | Tail spikes (ranged) | Flying speed 50 ft; multiattack claws or tail spikes. |
|
||||
| Basilisk | 3 | 15 | 52 | 20 | +5 | 2d6+3 | 16 / 8 / 15 / 2 / 8 / 7 | Petrifying Gaze | Bite, piercing damage. |
|
||||
| Owlbear | 3 | 13 | 59 | 40 | +7 | 1d10+5 | 20 / 12 / 17 / 3 / 12 / 7 | Keen Sight and Smell | Multiattack: beak and claws. |
|
||||
| Wight | 3 | 14 | 45 | 30 | +4 | 1d8+2 | 15 / 14 / 16 / 10 / 13 / 15 | Life Drain, Sunlight Sensitivity | Longsword or life-draining touch. |
|
||||
| Werewolf | 3 | 12 | 58 | 30 | +4 | 1d8+2 | 15 / 13 / 14 / 10 / 11 / 10 | Shapechanger, regeneration | Bite (hybrid/wolf form) or multiattack claws + bite. |
|
||||
| Doppelganger | 3 | 14 | 52 | 30 | +4 | 2d4+2 | 11 / 18 / 14 / 11 / 12 / 14 | Shapechanger, Ambusher | Multiattack slam. |
|
||||
| Displacer Beast | 3 | 13 | 85 | 40 | +7 | 2d6+4 | 18 / 15 / 16 / 6 / 12 / 8 | Displacement (attacks vs. it have disadvantage) | Multiattack: two tentacles. |
|
||||
| Ettin | 4 | 12 | 85 | 40 | +7 | 2d8+4 | 21 / 8 / 17 / 6 / 10 / 8 | Two heads — advantage on Perception, can’t be surprised easily | Multiattack: battleaxe and morningstar. |
|
||||
| Hill Giant | 5 | 13 | 105 | 40 | +8 | 3d8+5 | 21 / 8 / 19 / 5 / 9 / 6 | — | Greatclub, bludgeoning damage. |
|
||||
| Troll | 5 | 15 | 84 | 30 | +7 | 2d6+4 | 18 / 13 / 20 / 7 / 9 / 7 | Regeneration (vulnerable to fire/acid) | Multiattack: bite and two claws. |
|
||||
| Wraith | 5 | 13 | 67 | 0 | +6 | 4d8+4 | 6 / 16 / 16 / 12 / 14 / 15 | Incorporeal, life drain, flying 60 ft | Undead; life drain touch attack. |
|
||||
<!-- END GENERATED: monster-library -->
|
||||
|
||||
## 5. Monsters in encounters
|
||||
|
||||
On an encounter page, the DM chooses "Monster" in the "Add to the scene"
|
||||
panel, picks one, and types its initiative.
|
||||
|
||||
- The same monster can be added **any number of times**. Each copy has its
|
||||
own HP and conditions. Copies share the monster's name; the screen has no
|
||||
way to rename one (the API allows it).
|
||||
- A copy starts at the monster's max HP. Its HP stays between 0 and its max.
|
||||
- At 0 HP a copy is marked **Down** and dimmed, but keeps its place in the
|
||||
initiative order until the DM removes it.
|
||||
- HP belongs to the encounter: a monster starts fresh in the next one.
|
||||
|
||||
The DM's card for a monster shows AC, speed, attack bonus and damage at a
|
||||
glance, and under "details" its six ability scores with modifiers, its trait
|
||||
and its notes.
|
||||
|
||||
## 6. What players see
|
||||
|
||||
| Players see | Players never see |
|
||||
|---|---|
|
||||
| The monster's name | AC, speed, attack bonus, damage |
|
||||
| Its health bar and current/max HP | Ability scores, challenge rating |
|
||||
| Its conditions | Trait and notes |
|
||||
| Its place in the initiative order | The campaign's monster list |
|
||||
|
||||
This is enforced by the server: stat blocks are never sent to a player's
|
||||
browser.
|
||||
|
||||
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
|
||||
|
||||
| Action | DM | Players |
|
||||
|---|---|---|
|
||||
| See the monster list and stat blocks | Yes | No |
|
||||
| Browse the built-in bestiary | Yes | Yes (it is standard reference material) |
|
||||
| Add or remove monsters | Yes | No |
|
||||
| Add a monster to an encounter | Yes | No |
|
||||
| Change a monster's HP, initiative or conditions | Yes | No |
|
||||
| See a monster in an encounter | Everything | Name, HP, conditions |
|
||||
|
||||
## 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
|
||||
- Encounter difficulty calculated from challenge ratings
|
||||
- Monster hit dice, and rolling HP rather than using the fixed value
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
# NPCs
|
||||
|
||||
How non-player characters work in this app: what the DM records about them,
|
||||
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).
|
||||
|
||||
## Contents
|
||||
|
||||
1. [The basics](#1-the-basics)
|
||||
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. [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)
|
||||
|
||||
---
|
||||
|
||||
## 1. The basics
|
||||
|
||||
- An NPC is a person the DM plays: an innkeeper, a quest-giver, a rival, a
|
||||
villain.
|
||||
- Every campaign has its own **NPC list**. Only the DM can see or change it.
|
||||
- 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
|
||||
|
||||
| Field | What it is | Who sees it |
|
||||
|---|---|---|
|
||||
| Name | Up to 80 characters | Everyone, once in an encounter |
|
||||
| Role | A few words: "Innkeeper", "Captain of the Watch" | Everyone |
|
||||
| Attitude | Friendly, Neutral or Hostile toward the party | Everyone |
|
||||
| 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 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" 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.
|
||||
|
||||
- **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; "+ Add another NPC" at its top goes
|
||||
straight back to an empty form.
|
||||
- **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
|
||||
|
||||
On an encounter page, the DM chooses "NPC" in the "Add to the scene" panel
|
||||
and picks one. Each NPC can be in an encounter once.
|
||||
|
||||
- In an **NPC quest** encounter, NPCs are listed first under "People you
|
||||
meet", ahead of the party and any creatures.
|
||||
- In a **battle**, an NPC takes its place in the initiative order like anyone
|
||||
else, using the initiative the DM types in.
|
||||
- An NPC with HP gets a health bar, and the DM can damage and heal it. This
|
||||
HP belongs to the encounter: the same NPC starts at full HP in the next one.
|
||||
- An NPC with no HP has no health bar and cannot be marked Down.
|
||||
- Conditions (Prone, Charmed…) can be put on an NPC like any combatant.
|
||||
|
||||
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. 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:
|
||||
|
||||
| Players see | Players never see |
|
||||
|---|---|
|
||||
| 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.
|
||||
|
||||
Changing an NPC's attitude changes what players see straight away, so
|
||||
flipping a "Friendly" NPC to "Hostile" is itself a reveal.
|
||||
|
||||
## 7. NPC or monster?
|
||||
|
||||
| | NPC | Monster |
|
||||
|---|---|---|
|
||||
| Meant for | People with names and motives | Things to fight |
|
||||
| Has a description players can read | Yes | No |
|
||||
| Has an attitude | Yes | No |
|
||||
| Has DM-only notes | Yes | Yes |
|
||||
| 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.
|
||||
|
||||
## 8. Permissions
|
||||
|
||||
| Action | DM | Players |
|
||||
|---|---|---|
|
||||
| See the NPC list | Yes | No |
|
||||
| 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, 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 |
|
||||
|
||||
## 9. What is not modelled
|
||||
|
||||
- Ability scores, saves, skills and spells for NPCs
|
||||
- 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
|
||||
- Portraits
|
||||
+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.
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
# Spells
|
||||
|
||||
How spells work in this app: the campaign's spell list, how a character comes
|
||||
to know a spell, who can use which spells, and what the spellcasting numbers
|
||||
mean.
|
||||
|
||||
This describes the code as it stands. The built-in list is
|
||||
`backend/src/game/data/spellsLibrary.js`. Related: [WEAPONS.md](WEAPONS.md),
|
||||
[ITEMS.md](ITEMS.md), [CHARACTERS.md](CHARACTERS.md).
|
||||
|
||||
## Contents
|
||||
|
||||
1. [The basics](#1-the-basics)
|
||||
2. [The spell list](#2-the-spell-list)
|
||||
3. [The built-in library](#3-the-built-in-library)
|
||||
4. [Granting spells to characters](#4-granting-spells-to-characters)
|
||||
5. [Spell level and who can use it](#5-spell-level-and-who-can-use-it)
|
||||
6. [Damage](#6-damage)
|
||||
7. [Spellcasting numbers](#7-spellcasting-numbers)
|
||||
8. [Starting spells](#8-starting-spells)
|
||||
9. [Permissions](#9-permissions)
|
||||
10. [What is not modelled](#10-what-is-not-modelled)
|
||||
11. [The complete spell library](#11-the-complete-spell-library)
|
||||
|
||||
---
|
||||
|
||||
## 1. The basics
|
||||
|
||||
- Every campaign has its own **spell list**. A spell exists in one campaign
|
||||
only.
|
||||
- A character **knows** spells from that list, each with a Prepared tick box.
|
||||
- The DM runs the list and decides who learns what. Players can only prepare
|
||||
and unprepare the spells they know.
|
||||
- Unlike weapons and items, a spell has **no level of its own to raise**. Its
|
||||
level is fixed, and so is its damage.
|
||||
|
||||
## 2. The spell list
|
||||
|
||||
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 |
|
||||
|---|---|
|
||||
| Name | Free text, up to 80 characters |
|
||||
| Level | Cantrip, or level 1 to 9. Also the character level needed to use it (section 5) |
|
||||
| School | Free text: Evocation, Illusion… |
|
||||
| What it does | One sentence. Shown wherever the spell is shown |
|
||||
| Damage | Picked from a list, or "No damage". Fixed; it does not grow |
|
||||
| Starting option | Whether a brand-new character may pick it in the builder (section 8) |
|
||||
| Revives | Whether it brings back the dead. A character who can cast one may revive a dead ally on their turn in a battle ([ENCOUNTERS.md](ENCOUNTERS.md), section 6). Ticked already on the library's Revivify and Raise Dead |
|
||||
| Casting time, range, duration | Stored for library spells. 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 "+ Add Spell" (name, level, school, damage, what
|
||||
it does and the two tick boxes), or from the library. Both open in a
|
||||
pop-out. A spell added by hand opens its own pop-out, and "+ Add another
|
||||
spell" at its top goes straight back to an empty form.
|
||||
- **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
|
||||
values the library uses (`3d4+3`, `2d8+4d6`, `20`).
|
||||
- **Removing:** the ✕ button. Every character who knows the spell forgets it.
|
||||
There is no undo.
|
||||
|
||||
## 3. The built-in library
|
||||
|
||||
"📚 From Library" offers 131 standard spells to copy into the list:
|
||||
|
||||
| Level | Spells |
|
||||
|---|---|
|
||||
| Cantrips | 26 |
|
||||
| 1 | 30 |
|
||||
| 2 | 26 |
|
||||
| 3 | 21 |
|
||||
| 4 | 13 |
|
||||
| 5 | 15 |
|
||||
|
||||
Before you add one, each row shows:
|
||||
|
||||
- whether it is a **cantrip** or its **level**, and who can use it;
|
||||
- its **default damage**, or "no damage";
|
||||
- its school;
|
||||
- **what it does**, in a sentence.
|
||||
|
||||
Every library spell is listed in full in
|
||||
[section 11](#11-the-complete-spell-library).
|
||||
|
||||
35 of the library spells deal damage and come with a default, taken from the
|
||||
standard rules at the spell's own level. For example:
|
||||
|
||||
| Spell | Level | Default damage |
|
||||
|---|---|---|
|
||||
| Fire Bolt | Cantrip | 1d10 |
|
||||
| Sacred Flame | Cantrip | 1d8 |
|
||||
| Magic Missile | 1 | 3d4+3 (all three darts together) |
|
||||
| Burning Hands | 1 | 3d6 |
|
||||
| Scorching Ray | 2 | 2d6 (per ray; there are three) |
|
||||
| Fireball | 3 | 8d6 |
|
||||
| Ice Storm | 4 | 2d8+4d6 |
|
||||
| Cone of Cold | 5 | 8d8 |
|
||||
|
||||
Spells that heal, protect or control (Cure Wounds, Shield, Hold Person…) have
|
||||
no default. Pick a value from the Damage drop-down after adding if you want
|
||||
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.
|
||||
|
||||
- Any class can be granted any spell. Whether a non-spellcaster should have
|
||||
one is the DM's call.
|
||||
- The DM can remove a known spell with ✕.
|
||||
- The owning player ticks **Prepared**. Prepared is a label: there is no limit
|
||||
on how many spells are prepared.
|
||||
- **Passing a spell on.** A character's owner (or the DM) can give a known
|
||||
spell to another character in the campaign from the "Give to Another
|
||||
Character" card on the sheet. The spell moves: the giver no longer knows
|
||||
it. It cannot be given to someone who already knows it, and a recipient
|
||||
below the spell's level knows it but cannot use it yet.
|
||||
- The "Spells Known" card only appears on a sheet when the campaign's spell
|
||||
list has at least one spell.
|
||||
- Each known spell shows its level (or "Cantrip"), its damage, and what it
|
||||
does.
|
||||
|
||||
## 5. Spell level and who can use it
|
||||
|
||||
This is a house rule of this app. In standard D&D a 3rd-level spell needs a
|
||||
5th-level caster; here the two numbers are the same.
|
||||
|
||||
**A character must be at least the spell's level to use it.** Cantrips are
|
||||
open to everyone.
|
||||
|
||||
| Spell | Who can use it |
|
||||
|---|---|
|
||||
| Cantrip | Any character |
|
||||
| Level 1 | Any character (everyone starts at level 1) |
|
||||
| Level 2 | Characters of level 2 and above |
|
||||
| Level 3 | Characters of level 3 and above |
|
||||
| … | … |
|
||||
| Level 9 | Characters of level 9 and above |
|
||||
|
||||
A spell above the character's level can still be **known**, but it shows
|
||||
"Requires level N", cannot be prepared, and is left out of encounters. If the
|
||||
DM lowers a character's level, spells now out of reach are unprepared
|
||||
automatically.
|
||||
|
||||
## 6. Damage
|
||||
|
||||
A spell's damage is whatever is picked in its Damage drop-down, shown beside
|
||||
the spell on the sheet and in encounters. It does not change with the character's
|
||||
level, and nothing is added to it.
|
||||
|
||||
## 7. Spellcasting numbers
|
||||
|
||||
Shown only for classes with a spellcasting ability:
|
||||
|
||||
| Class | Spellcasting ability |
|
||||
|---|---|
|
||||
| Wizard | Intelligence |
|
||||
| Cleric, Druid, Ranger | Wisdom |
|
||||
| Bard, Paladin, Sorcerer, Warlock | Charisma |
|
||||
| Barbarian, Fighter, Monk, Rogue | None |
|
||||
|
||||
| Number | Formula | Used when |
|
||||
|---|---|---|
|
||||
| Spell save DC | 8 + proficiency bonus + spellcasting ability modifier | A target rolls to resist your spell |
|
||||
| Spell attack bonus | Proficiency bonus + spellcasting ability modifier | You roll to hit with a spell |
|
||||
|
||||
A character without a spellcasting class can still be granted spells, but has
|
||||
no save DC or attack bonus shown.
|
||||
|
||||
## 8. Starting spells
|
||||
|
||||
In the builder's equipment step a new character can pick spells that are:
|
||||
|
||||
- ticked as a **starting option** in the list,
|
||||
- **cantrips or level 1** (a new character is level 1), and
|
||||
- only if the character's **class has a spellcasting ability**.
|
||||
|
||||
The number allowed is the campaign's "starting spells" setting. Starting
|
||||
spells arrive unprepared.
|
||||
|
||||
## 9. Permissions
|
||||
|
||||
| Action | DM | Owning player | Other players |
|
||||
|---|---|---|---|
|
||||
| See the spell list | Yes | Only what their own character holds, plus the starting options while they have a character to build | The same, for their own character |
|
||||
| See the built-in library | Yes | No | No |
|
||||
| Add, change or remove spells in the list | Yes | No | No |
|
||||
| Grant a spell from the list, or remove one | Yes | No | No |
|
||||
| Give a known spell to another character | Yes | Yes | No |
|
||||
| Prepare or unprepare | Yes | Yes, if the character can use it | No |
|
||||
| See a character's spells | Yes | Yes | Yes |
|
||||
|
||||
## 10. What is not modelled
|
||||
|
||||
- Spell slots, and casting a spell at a higher level
|
||||
- Cantrip damage increasing at higher character levels
|
||||
- Limits on how many spells can be prepared or known
|
||||
- Concentration, components, rituals
|
||||
- Class spell lists (any spell can be granted to anyone)
|
||||
- Healing amounts as a separate value (pick them in Damage if you want them
|
||||
shown)
|
||||
- Applying spell damage to a target automatically
|
||||
|
||||
## 11. The complete spell library
|
||||
|
||||
Every spell "📚 From Library" offers, with the damage it arrives with.
|
||||
|
||||
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: spell-library -->
|
||||
### Cantrips (26)
|
||||
|
||||
Usable by any character.
|
||||
|
||||
| Spell | School | Damage | What it does |
|
||||
|---|---|---|---|
|
||||
| Acid Splash | Conjuration | 1d6 | Hurl a bubble of acid at one or two creatures within 5 feet of each other. |
|
||||
| Blade Ward | Abjuration | — | Resist bludgeoning, piercing, and slashing damage until your next turn. |
|
||||
| Chill Touch | Necromancy | 1d8 | A ghostly hand deals necrotic damage and prevents healing. |
|
||||
| Dancing Lights | Illusion | — | Create up to four torch-sized lights that you can move. |
|
||||
| Druidcraft | Transmutation | — | A small, harmless nature-flavored effect: predict weather, sprout a seedling, and the like. |
|
||||
| Eldritch Blast | Evocation | 1d10 | A beam of crackling energy strikes one creature for force damage. |
|
||||
| Fire Bolt | Evocation | 1d10 | Hurl a mote of fire that deals fire damage on a hit. |
|
||||
| Friends | Enchantment | — | Advantage on Charisma checks against one creature for the duration. |
|
||||
| Guidance | Divination | — | Add 1d4 to one ability check of your choice. |
|
||||
| Light | Evocation | — | An object sheds bright light in a 20-foot radius. |
|
||||
| Mage Hand | Conjuration | — | A spectral hand manipulates objects, opens doors, and carries small items. |
|
||||
| Mending | Transmutation | — | Repairs a single break or tear in an object. |
|
||||
| Message | Transmutation | — | Whisper a message to a target that only they can hear, with a whispered reply. |
|
||||
| Minor Illusion | Illusion | — | Create a sound or an image of an object for the duration. |
|
||||
| Poison Spray | Conjuration | 1d12 | Project a puff of noxious gas that deals poison damage. |
|
||||
| Prestidigitation | Transmutation | — | A minor magical trick — light a candle, clean an object, chill or warm food, and the like. |
|
||||
| Produce Flame | Conjuration | 1d8 | A flickering flame in your hand sheds light and can be hurled for fire damage. |
|
||||
| Ray of Frost | Evocation | 1d8 | A ray of freezing energy deals cold damage and slows the target. |
|
||||
| Resistance | Abjuration | — | The target adds 1d4 to one saving throw of its choice. |
|
||||
| Sacred Flame | Evocation | 1d8 | Radiant flame descends on a creature that must succeed a Dexterity save or take damage. |
|
||||
| Shillelagh | Transmutation | — | Imbue a club or quarterstaff with magic, using Wisdom for its attack and damage. |
|
||||
| Shocking Grasp | Evocation | 1d8 | Lightning springs from your hand, dealing damage and preventing reactions. |
|
||||
| Spare the Dying | Necromancy | — | A creature you touch that has 0 hit points becomes stable. |
|
||||
| Thorn Whip | Transmutation | 1d6 | A vine-like whip deals damage and drags the target closer. |
|
||||
| True Strike | Divination | — | Gain advantage on your next attack roll against the target. |
|
||||
| Vicious Mockery | Enchantment | 1d4 | A string of insults deals psychic damage and imposes disadvantage on the target’s next attack. |
|
||||
|
||||
### Level 1 (30 spells)
|
||||
|
||||
Needs character level 1.
|
||||
|
||||
| Spell | School | Damage | What it does |
|
||||
|---|---|---|---|
|
||||
| Alarm | Abjuration | — | Set an audible or mental alarm against intruders. |
|
||||
| Animal Friendship | Enchantment | — | Convince a beast that you mean it no harm. |
|
||||
| Bane | Enchantment | — | Up to three creatures subtract 1d4 from attack rolls and saves. |
|
||||
| Bless | Enchantment | — | Up to three creatures add 1d4 to attack rolls and saves. |
|
||||
| Burning Hands | Evocation | 3d6 | A thin sheet of flame sweeps out from your hands. |
|
||||
| Charm Person | Enchantment | — | A humanoid you can see regards you as a friendly acquaintance. |
|
||||
| Color Spray | Illusion | — | A dazzling array of flashing colors can blind weak creatures. |
|
||||
| Command | Enchantment | — | Speak a one-word command a creature must obey. |
|
||||
| Comprehend Languages | Divination | — | Understand any spoken or written language. |
|
||||
| Cure Wounds | Evocation | — | A creature you touch regains hit points. |
|
||||
| Detect Magic | Divination | — | Sense the presence of magic within 30 feet. |
|
||||
| Disguise Self | Illusion | — | Change your appearance, including clothing and gear. |
|
||||
| Entangle | Conjuration | — | Grasping weeds and vines sprout in a 20-foot square. |
|
||||
| Faerie Fire | Evocation | — | Objects in a 20-foot cube are outlined in light, granting attackers advantage. |
|
||||
| Feather Fall | Transmutation | — | Falling creatures slow to a gentle descent. |
|
||||
| Fog Cloud | Conjuration | — | A 20-foot-radius sphere of fog heavily obscures the area. |
|
||||
| Guiding Bolt | Evocation | 4d6 | A flash of light streaks to a target, dealing radiant damage. |
|
||||
| Healing Word | Evocation | — | A creature you can see regains hit points. |
|
||||
| Hunter's Mark | Divination | 1d6 | Deal extra damage to a marked target and track it magically. |
|
||||
| Identify | Divination | — | Learn the properties of a magic item or the nature of a spell affecting a creature. |
|
||||
| Jump | Transmutation | — | A creature's jump distance triples. |
|
||||
| Longstrider | Transmutation | — | A creature's speed increases by 10 feet. |
|
||||
| Mage Armor | Abjuration | — | A protective magical force gives the target a base AC of 13 + Dex modifier. |
|
||||
| Magic Missile | Evocation | 3d4+3 | Three darts of magical force each strike a target unerringly. |
|
||||
| Protection from Evil and Good | Abjuration | — | Protect a creature from aberrations, celestials, elementals, fey, fiends, and undead. |
|
||||
| Shield | Abjuration | — | An invisible barrier grants +5 AC until the start of your next turn. |
|
||||
| Sleep | Enchantment | — | Creatures in a 20-foot radius fall unconscious, weakest hit points first. |
|
||||
| Speak with Animals | Divination | — | Comprehend and verbally communicate with beasts. |
|
||||
| Thunderwave | Evocation | 2d8 | A wave of thunderous force sweeps out, damaging and pushing creatures. |
|
||||
| Witch Bolt | Evocation | 1d12 | A beam of lightning forms a sustained arc that damages the target each turn. |
|
||||
|
||||
### Level 2 (26 spells)
|
||||
|
||||
Needs character level 2.
|
||||
|
||||
| Spell | School | Damage | What it does |
|
||||
|---|---|---|---|
|
||||
| Aid | Abjuration | — | Bolster allies with additional hit points. |
|
||||
| Alter Self | Transmutation | — | Assume a variety of minor physical changes. |
|
||||
| Animal Messenger | Enchantment | — | Send a tiny animal to deliver a short message. |
|
||||
| Blindness/Deafness | Necromancy | — | Blind or deafen a creature that fails a Constitution save. |
|
||||
| Blur | Illusion | — | Your outline blurs, giving attackers disadvantage. |
|
||||
| Darkvision | Transmutation | — | Grant a creature darkvision out to 60 feet. |
|
||||
| Enhance Ability | Transmutation | — | Grant advantage on checks with one chosen ability. |
|
||||
| Enlarge/Reduce | Transmutation | — | A creature or object doubles or halves in size. |
|
||||
| Flaming Sphere | Conjuration | 2d6 | A 5-foot-diameter sphere of fire rolls where you direct it. |
|
||||
| Gust of Wind | Evocation | — | A line of strong wind pushes creatures and extinguishes flames. |
|
||||
| Hold Person | Enchantment | — | Paralyze a humanoid that fails a Wisdom save. |
|
||||
| Invisibility | Illusion | — | A creature you touch becomes invisible. |
|
||||
| Lesser Restoration | Abjuration | — | End one disease or condition afflicting the target. |
|
||||
| Levitate | Transmutation | — | A creature or object rises vertically and can be moved. |
|
||||
| Mirror Image | Illusion | — | Three illusory duplicates of yourself spring into being. |
|
||||
| Misty Step | Conjuration | — | Teleport up to 30 feet in a burst of silvery mist. |
|
||||
| Moonbeam | Evocation | 2d10 | A silvery beam of pale light shines down in a 5-foot-radius cylinder. |
|
||||
| Pass without Trace | Abjuration | — | The party becomes exceptionally difficult to track. |
|
||||
| Scorching Ray | Evocation | 2d6 | Three rays of fire streak toward targets within range. |
|
||||
| Shatter | Evocation | 3d8 | A sudden loud ringing noise deals thunder damage in a 10-foot-radius sphere. |
|
||||
| Silence | Illusion | — | No sound can be created within or pass through a 20-foot-radius sphere. |
|
||||
| Spider Climb | Transmutation | — | A creature gains the ability to move on vertical surfaces and ceilings. |
|
||||
| Spiritual Weapon | Evocation | 1d8 | A spectral weapon attacks a target and can be moved each turn. |
|
||||
| Suggestion | Enchantment | — | Plant a reasonable-sounding suggestion in a creature’s mind. |
|
||||
| Web | Conjuration | — | Thick, sticky webs fill a 20-foot cube. |
|
||||
| Zone of Truth | Enchantment | — | Creatures in a 15-foot-radius sphere can’t speak a deliberate lie. |
|
||||
|
||||
### Level 3 (21 spells)
|
||||
|
||||
Needs character level 3.
|
||||
|
||||
| Spell | School | Damage | What it does |
|
||||
|---|---|---|---|
|
||||
| Animate Dead | Necromancy | — | Raise a skeleton or zombie from a corpse to fight for you. |
|
||||
| Beacon of Hope | Abjuration | — | Bolster creatures against despair, granting advantage on saves and max healing. |
|
||||
| Call Lightning | Conjuration | 3d10 | A storm cloud forms and you can call down bolts of lightning. |
|
||||
| Counterspell | Abjuration | — | Interrupt a creature casting a spell. |
|
||||
| Daylight | Evocation | — | A 60-foot-radius sphere of bright light spreads out. |
|
||||
| Dispel Magic | Abjuration | — | End one active spell affecting a creature, object, or effect. |
|
||||
| Fear | Illusion | — | Creatures in the cone become frightened and drop what they’re holding. |
|
||||
| Fireball | Evocation | 8d6 | A bright streak explodes into flame, dealing fire damage in a 20-foot radius. |
|
||||
| Fly | Transmutation | — | The target gains a flying speed of 60 feet. |
|
||||
| Haste | Transmutation | — | Double the target’s speed and grant an extra action. |
|
||||
| Lightning Bolt | Evocation | 8d6 | A stroke of lightning blasts out in a line, dealing lightning damage. |
|
||||
| Mass Healing Word | Evocation | — | Up to six creatures you can see regain hit points. |
|
||||
| Nondetection | Abjuration | — | Hide a target from divination magic. |
|
||||
| Protection from Energy | Abjuration | — | Resistance to one damage type of your choice. |
|
||||
| Remove Curse | Abjuration | — | Lift a curse from a creature or object. |
|
||||
| Revivify | Necromancy | — | A creature that died within the last minute returns to life with 1 hit point. |
|
||||
| Sleet Storm | Conjuration | — | Freezing rain falls in a 40-foot-radius cylinder, icing the ground. |
|
||||
| Slow | Transmutation | — | Up to six creatures are hindered, halving their speed and worse. |
|
||||
| Spirit Guardians | Conjuration | 3d8 | Protective spirits swirl around you, damaging enemies that enter. |
|
||||
| Stinking Cloud | Conjuration | — | A 20-foot-radius sphere of yellow, nauseating gas spreads out. |
|
||||
| Water Breathing | Transmutation | — | Up to ten creatures can breathe underwater. |
|
||||
|
||||
### Level 4 (13 spells)
|
||||
|
||||
Needs character level 4.
|
||||
|
||||
| Spell | School | Damage | What it does |
|
||||
|---|---|---|---|
|
||||
| Banishment | Abjuration | — | Send a creature to a harmless demiplane. |
|
||||
| Blight | Necromancy | 8d8 | Necromantic energy withers a creature, dealing necrotic damage. |
|
||||
| Confusion | Enchantment | — | Creatures in a 10-foot-radius sphere act randomly. |
|
||||
| Dimension Door | Conjuration | — | Teleport yourself (and possibly a companion) to a spot you can visualize. |
|
||||
| Fire Shield | Evocation | 2d8 | Wreathe yourself in flame that damages attackers and grants resistance. |
|
||||
| Freedom of Movement | Abjuration | — | The target’s movement is unaffected by difficult terrain and restraints. |
|
||||
| Greater Invisibility | Illusion | — | The target becomes invisible even while attacking. |
|
||||
| Guardian of Faith | Conjuration | 20 | A Large spectral guardian appears and attacks enemies near it. |
|
||||
| Ice Storm | Evocation | 2d8+4d6 | Hail hammers a 20-foot-radius cylinder, dealing bludgeoning and cold damage. |
|
||||
| Polymorph | Transmutation | — | Transform a creature into a new form. |
|
||||
| Stone Shape | Transmutation | — | Reshape a chunk of stone into a form you desire. |
|
||||
| Stoneskin | Abjuration | — | The target gains resistance to nonmagical bludgeoning, piercing, and slashing damage. |
|
||||
| Wall of Fire | Evocation | 5d8 | Conjure a wall of fire that damages creatures passing through it. |
|
||||
|
||||
### Level 5 (15 spells)
|
||||
|
||||
Needs character level 5.
|
||||
|
||||
| Spell | School | Damage | What it does |
|
||||
|---|---|---|---|
|
||||
| Animate Objects | Transmutation | — | Up to ten nonmagical objects animate and fight for you. |
|
||||
| Cloudkill | Conjuration | 5d8 | A 20-foot-radius sphere of poisonous, yellow-green fog billows out. |
|
||||
| Cone of Cold | Evocation | 8d8 | A blast of cold air deals cold damage to everything in the cone. |
|
||||
| Contagion | Necromancy | — | Infect a creature with a magical disease. |
|
||||
| Dispel Evil and Good | Abjuration | — | Protection and the ability to banish otherworldly creatures. |
|
||||
| Dominate Person | Enchantment | — | Attempt to beguile a humanoid and control its actions. |
|
||||
| Flame Strike | Evocation | 8d6 | A column of divine fire roars down, dealing fire and radiant damage. |
|
||||
| Greater Restoration | Abjuration | — | Undo a debilitating effect: reduced ability score, exhaustion, or curse. |
|
||||
| Hold Monster | Enchantment | — | Paralyze one creature that fails a Wisdom save. |
|
||||
| Insect Plague | Conjuration | 4d10 | A swarm of biting, stinging insects fills a 20-foot-radius sphere. |
|
||||
| Legend Lore | Divination | — | Learn what you know, or have heard, about a name, place, or object. |
|
||||
| Mass Cure Wounds | Evocation | — | Up to six creatures in a 30-foot-radius sphere regain hit points. |
|
||||
| Raise Dead | Necromancy | — | Return a dead creature to life, as long as its body is mostly intact. |
|
||||
| Wall of Force | Evocation | — | An invisible wall of force springs into existence, impenetrable to physical force. |
|
||||
| Wall of Stone | Evocation | — | A nonmagical wall of solid stone springs into existence. |
|
||||
<!-- END GENERATED: spell-library -->
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
# Weapons
|
||||
|
||||
How weapons work in this app: the campaign's weapon pool, how a character
|
||||
comes to hold a weapon, weapon levels, and how attack and damage numbers are
|
||||
worked out.
|
||||
|
||||
This describes the code as it stands. The formulas live in
|
||||
`backend/src/game/calculations.js`; the built-in list is
|
||||
`backend/src/game/data/weaponsLibrary.js`. Related: [ITEMS.md](ITEMS.md),
|
||||
[SPELLS.md](SPELLS.md), [CHARACTERS.md](CHARACTERS.md).
|
||||
|
||||
## Contents
|
||||
|
||||
1. [The basics](#1-the-basics)
|
||||
2. [The weapon pool](#2-the-weapon-pool)
|
||||
3. [The built-in library](#3-the-built-in-library)
|
||||
4. [Giving weapons to characters](#4-giving-weapons-to-characters)
|
||||
5. [Weapon levels](#5-weapon-levels)
|
||||
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 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)
|
||||
|
||||
---
|
||||
|
||||
## 1. The basics
|
||||
|
||||
- Every campaign has its own **weapon pool**. A weapon exists in one campaign
|
||||
only.
|
||||
- A character never owns a free-standing weapon: it holds a **copy** of a pool
|
||||
entry, with its own quantity, level and equipped flag.
|
||||
- The DM runs the pool and decides who gets what. Players can only equip and
|
||||
unequip what they hold.
|
||||
|
||||
## 2. The weapon pool
|
||||
|
||||
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 |
|
||||
|---|---|
|
||||
| Name | Free text, up to 80 characters |
|
||||
| Base damage | Dice such as `1d8` or `2d6` — the damage at the weapon's starting level |
|
||||
| Damage type | Free text: slashing, piercing, fire… |
|
||||
| Attack ability | Strength, Dexterity, or Finesse (whichever of the two is higher for the wielder) |
|
||||
| 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…). 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 "+ 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; "+ Add another weapon" at its top goes straight back to
|
||||
an empty form.
|
||||
- **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 41 weapons to copy into the pool:
|
||||
|
||||
| Category | Count | Examples |
|
||||
|---|---|---|
|
||||
| Simple melee | 10 | Club 1d4, Dagger 1d4, Mace 1d6, Spear 1d6 |
|
||||
| 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.
|
||||
|
||||
- It arrives at the weapon's starting level, quantity 1, unequipped.
|
||||
- Giving a weapon the character already holds **adds to the stack**; the stack
|
||||
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, 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
|
||||
Character" card on the sheet. Some or all of the stack moves: it leaves the
|
||||
giver and arrives with the recipient at the giver's level, unequipped. If the
|
||||
recipient already holds that weapon, their stack grows and keeps its own
|
||||
level. A recipient below the weapon's level holds it but cannot use it yet.
|
||||
|
||||
## 5. Weapon levels
|
||||
|
||||
This is a house rule of this app, not standard D&D.
|
||||
|
||||
- Each held copy has a **level from 1 to 9**. Two characters can hold the same
|
||||
weapon at different levels.
|
||||
- Only the DM changes a copy's level, with the level picker beside it on the
|
||||
sheet. Each option shows the damage at that level.
|
||||
- A copy can never go below the pool entry's **starting level**. Raising a
|
||||
pool entry's starting level lifts any copies below it and unequips them.
|
||||
|
||||
**Who can use it.** A character must be at least the copy's level:
|
||||
|
||||
| Character level | Can use weapons of level |
|
||||
|---|---|
|
||||
| 1 | 1 |
|
||||
| 2 | 1–2 |
|
||||
| 5 | 1–5 |
|
||||
| 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 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.
|
||||
|
||||
## 6. How damage grows
|
||||
|
||||
The dice in the pool are the damage at the weapon's starting level. Each level
|
||||
above that moves the dice up a fixed ladder, in the smallest steps dice allow:
|
||||
|
||||
| Rung | Dice | Average |
|
||||
|---|---|---|
|
||||
| 1 | 1d4 | 2.5 |
|
||||
| 2 | 1d6 | 3.5 |
|
||||
| 3 | 1d8 | 4.5 |
|
||||
| 4 | 1d10 | 5.5 |
|
||||
| 5 | 1d12 | 6.5 |
|
||||
| 6 | 2d6 | 7 |
|
||||
| 7 | 1d6+1d8 | 8 |
|
||||
| 8 | 2d8 | 9 |
|
||||
| 9 | 1d8+1d10 | 10 |
|
||||
| 10 | 2d10 | 11 |
|
||||
| 11 | 1d10+1d12 | 12 |
|
||||
| 12 | 2d12 | 13 |
|
||||
| 13 | 3d8 | 13.5 |
|
||||
| … | adding a die each time the current dice max out | |
|
||||
|
||||
Every rung is +1 average damage over the last, or +0.5 where a die is added.
|
||||
|
||||
**Growth** sets how many rungs a weapon climbs per level:
|
||||
|
||||
| Growth | Rungs per level | A 1d6 weapon at levels 1, 2, 3, 4 | At level 9 |
|
||||
|---|---|---|---|
|
||||
| Standard | 1 | 1d6, 1d8, 1d10, 1d12 | 2d10 |
|
||||
| Powerful | 2 | 1d6, 1d10, 2d6, 2d8 | 1d10+2d12 |
|
||||
| Legendary | 3 | 1d6, 1d12, 2d8, 1d10+1d12 | 4d12 |
|
||||
|
||||
A weapon with a higher starting level counts from there: a 2d6 greatsword that
|
||||
starts at level 3 with Standard growth is 2d6 at level 3, 1d6+1d8 at level 4
|
||||
and 2d8 at level 5.
|
||||
|
||||
- At its starting level a weapon always shows its dice exactly as typed.
|
||||
- Dice that are not on the ladder (`1d4+1`, `2d4+2`) climb from the highest
|
||||
rung that is not stronger than them: `2d4+2` (average 7) continues from 2d6.
|
||||
- Dice the app cannot read (free text such as "special") never change.
|
||||
|
||||
## 7. Attack and damage numbers
|
||||
|
||||
| Number | Formula |
|
||||
|---|---|
|
||||
| Attack bonus | Ability modifier + proficiency bonus |
|
||||
| Damage | The dice at the copy's level + ability modifier |
|
||||
|
||||
- The ability modifier is Strength, Dexterity, or the higher of the two for a
|
||||
Finesse weapon.
|
||||
- The weapon's level changes the **dice only**, never the bonuses.
|
||||
- Every character is treated as proficient with every weapon.
|
||||
|
||||
These numbers appear on the character sheet, in the quick-stats drawer, and
|
||||
on the character's card in an encounter.
|
||||
|
||||
## 8. Starting weapons
|
||||
|
||||
In the builder's equipment step a new character can pick weapons that are:
|
||||
|
||||
- ticked as a **starting option** in the pool, and
|
||||
- **starting level 1** (a new character is level 1).
|
||||
|
||||
Starting weapons and starting items share the campaign's "starting items"
|
||||
allowance. They arrive at level 1, unequipped.
|
||||
|
||||
## 9. Weapons as rewards and for sale
|
||||
|
||||
On an encounter the DM can line up a weapon as a reward, at a chosen level
|
||||
(not below its starting level) and quantity, and later award it to a
|
||||
character. Awarding adds it to what the character holds, exactly as giving it
|
||||
on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
|
||||
|
||||
The DM can also put a weapon **up for sale**, at a level, a price (in gold,
|
||||
silver, copper or cash) and an optional stock. An NPC can sell it, wherever
|
||||
that NPC is met, or an encounter can sell it itself. Players see what is for
|
||||
sale and buy it in an NPC quest that is in progress: the price leaves their
|
||||
purse and the weapon joins what they hold, unequipped. See
|
||||
[NPCS.md](NPCS.md), section 5, and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
|
||||
|
||||
## 10. Permissions
|
||||
|
||||
| Action | DM | Owning player | Other players |
|
||||
|---|---|---|---|
|
||||
| See the pool | Yes | Only what their own character holds, plus the starting options while they have a character to build | The same, for their own character |
|
||||
| See the built-in library | Yes | No | No |
|
||||
| Add, change or remove pool entries | Yes | No | No |
|
||||
| Give a weapon from the pool, or take one away | Yes | No | No |
|
||||
| Give a held weapon to another character | Yes | Yes | No |
|
||||
| Put a weapon up for sale | Yes | No | No |
|
||||
| Buy a weapon that is for sale, in a quest in progress | Yes, for any character in it | Yes, if the character is in it | No |
|
||||
| Change a held weapon's level or quantity | Yes | No | No |
|
||||
| Equip or unequip | Yes | Yes, if the character can use it | No |
|
||||
| See a character's weapons | Yes | Yes | Yes |
|
||||
|
||||
## 11. What is not modelled
|
||||
|
||||
- Weapon proficiency by class (everyone is proficient with everything)
|
||||
- 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 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 | 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 | 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 | 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 | 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": "0.0.0",
|
||||
"version": "1.13.1",
|
||||
"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
-22
@@ -1,33 +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';
|
||||
|
||||
// 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 />} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+505
-26
@@ -1,18 +1,25 @@
|
||||
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,
|
||||
Campaign,
|
||||
CampaignLibrary,
|
||||
CampaignMemberSummary,
|
||||
CharacterDetail,
|
||||
CharacterSummary,
|
||||
Currency,
|
||||
DiceMode,
|
||||
Encounter,
|
||||
EncounterMode,
|
||||
EncounterParticipant,
|
||||
InventoryItem,
|
||||
GearEffect,
|
||||
GoldTransaction,
|
||||
Item,
|
||||
Location,
|
||||
Monster,
|
||||
Note,
|
||||
Npc,
|
||||
SaleOffer,
|
||||
QuickStats,
|
||||
Spell,
|
||||
Weapon,
|
||||
@@ -55,10 +62,20 @@ 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({
|
||||
mutationFn: async (data: Partial<Campaign>) =>
|
||||
mutationFn: async (data: Partial<Campaign> & { archived?: boolean }) =>
|
||||
(await apiClient.patch<{ campaign: Campaign }>(`/campaigns/${campaignId}`, data)).data.campaign,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
|
||||
@@ -67,6 +84,31 @@ export function useUpdateCampaign(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Archived campaigns stay whole; they only move off the main list.
|
||||
export function useSetCampaignArchived() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ campaignId, archived }: { campaignId: string; archived: boolean }) =>
|
||||
(await apiClient.patch<{ campaign: Campaign }>(`/campaigns/${campaignId}`, { archived })).data.campaign,
|
||||
onSuccess: (campaign) => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaign.id] });
|
||||
qc.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Permanent: takes every character, encounter and note in the campaign with it.
|
||||
export function useDeleteCampaign() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (campaignId: string) => apiClient.delete(`/campaigns/${campaignId}`),
|
||||
onSuccess: (_res, campaignId) => {
|
||||
qc.removeQueries({ queryKey: ['campaign', campaignId] });
|
||||
qc.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useJoinCampaign() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -120,10 +162,11 @@ export function useCharacter(characterId: string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
// `gold` is deliberately not patchable — it only moves through useAdjustGold.
|
||||
export function usePatchCharacter(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: Partial<CharacterDetail>) =>
|
||||
mutationFn: async (data: Partial<Omit<CharacterDetail, 'gold'>>) =>
|
||||
(await apiClient.patch<{ character: CharacterDetail }>(`/characters/${characterId}`, data)).data.character,
|
||||
onSuccess: (character) => {
|
||||
qc.setQueryData(['character', characterId], character);
|
||||
@@ -132,6 +175,28 @@ export function usePatchCharacter(characterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// What can be done for a character at 0 HP: enter a death save (what the d20
|
||||
// showed), steady them, or — the DM only — set the tallies by hand or bring
|
||||
// back the dead.
|
||||
export type DeathSaveAction =
|
||||
| { action: 'roll'; roll: number }
|
||||
| { action: 'stabilize' }
|
||||
| { action: 'set'; successes: number; failures: number }
|
||||
| { action: 'revive' };
|
||||
|
||||
export function useDeathSave(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: DeathSaveAction) =>
|
||||
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/death-saves`, data)).data.character,
|
||||
onSuccess: (character) => {
|
||||
invalidateCharacter(qc, characterId); // the sheet and its quick stats
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
|
||||
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCharacter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -140,6 +205,38 @@ export function useDeleteCharacter(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// What one character can hand to another. Whatever is given leaves the giver.
|
||||
export type Gift =
|
||||
| { kind: 'gold'; amount: number; currency?: Currency }
|
||||
| { kind: 'weapon' | 'item'; id: string; quantity: number }
|
||||
| { kind: 'spell'; id: string };
|
||||
|
||||
export function useGive(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (gift: Gift & { toCharacterId: string }) =>
|
||||
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/give`, gift)).data.character,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['character'] }); // both sheets, and both money logs
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCopyCharacter(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (targetCampaignId: string) =>
|
||||
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/copy`, { targetCampaignId })).data
|
||||
.character,
|
||||
onSuccess: (character) => {
|
||||
qc.setQueryData(['character', character.id], character);
|
||||
// The copy can add missing weapons/spells to the target campaign's pools.
|
||||
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useQuickStats(characterId: string | undefined, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['character', characterId, 'quick-stats'],
|
||||
@@ -149,10 +246,28 @@ export function useQuickStats(characterId: string | undefined, enabled: boolean)
|
||||
});
|
||||
}
|
||||
|
||||
// Changes `amount` coins of one kind into another at their fixed worth.
|
||||
export function useExchangeCoins(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { from: Currency; to: Currency; amount: number }) =>
|
||||
(await apiClient.post<{ character: CharacterDetail; received: number }>(`/characters/${characterId}/gold/exchange`, data)).data,
|
||||
onSuccess: () => invalidateCharacter(qc, characterId),
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Character build flow
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export function useBuildOptions() {
|
||||
return useQuery({
|
||||
queryKey: ['build-options'],
|
||||
staleTime: Infinity, // fixed lists — they never change while the app is open
|
||||
queryFn: async () => (await apiClient.get<BuildOptions>('/characters/build/options')).data,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateCharacter(qc: ReturnType<typeof useQueryClient>, characterId: string) {
|
||||
qc.invalidateQueries({ queryKey: ['character', characterId] });
|
||||
}
|
||||
@@ -191,6 +306,16 @@ export function useBuildChoose(characterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Steps back one step so its choice can be changed. Dice are never re-rolled.
|
||||
export function useBuildBack(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () =>
|
||||
(await apiClient.post<{ buildStage: string; buildState: Record<string, unknown> }>(`/characters/${characterId}/build/back`)).data,
|
||||
onSuccess: () => invalidateCharacter(qc, characterId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useBuildFinalize(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -208,6 +333,8 @@ export function useBuildFinalize(characterId: string) {
|
||||
// Weapons / inventory / spells on a character
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Handing out, removing and re-counting gear is DM-only on the server; the
|
||||
// owning player can only toggle equipped / prepared.
|
||||
export function useAssignWeapon(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -217,20 +344,29 @@ export function useAssignWeapon(characterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveCharacterWeapon(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (weaponId: string) => apiClient.delete(`/characters/${characterId}/weapons/${weaponId}`),
|
||||
onSuccess: () => invalidateCharacter(qc, characterId),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePatchCharacterWeapon(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ weaponId, ...data }: { weaponId: string; equipped?: boolean; quantity?: number }) =>
|
||||
mutationFn: async ({ weaponId, ...data }: { weaponId: string; equipped?: boolean; quantity?: number; level?: number }) =>
|
||||
(await apiClient.patch(`/characters/${characterId}/weapons/${weaponId}`, data)).data,
|
||||
onSuccess: () => invalidateCharacter(qc, characterId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddInventoryItem(characterId: string) {
|
||||
// itemId here is the campaign pool item's id, as with weapons and spells.
|
||||
export function useAssignItem(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { name: string; description?: string; quantity?: number }) =>
|
||||
(await apiClient.post<{ item: InventoryItem }>(`/characters/${characterId}/inventory`, data)).data.item,
|
||||
mutationFn: async (data: { itemId: string; quantity?: number }) =>
|
||||
(await apiClient.post(`/characters/${characterId}/inventory`, data)).data,
|
||||
onSuccess: () => invalidateCharacter(qc, characterId),
|
||||
});
|
||||
}
|
||||
@@ -238,13 +374,13 @@ export function useAddInventoryItem(characterId: string) {
|
||||
export function usePatchInventoryItem(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ itemId, ...data }: { itemId: string; quantity?: number; equipped?: boolean }) =>
|
||||
mutationFn: async ({ itemId, ...data }: { itemId: string; quantity?: number; equipped?: boolean; level?: number }) =>
|
||||
(await apiClient.patch(`/characters/${characterId}/inventory/${itemId}`, data)).data,
|
||||
onSuccess: () => invalidateCharacter(qc, characterId),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteInventoryItem(characterId: string) {
|
||||
export function useRemoveInventoryItem(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (itemId: string) => apiClient.delete(`/characters/${characterId}/inventory/${itemId}`),
|
||||
@@ -270,6 +406,39 @@ export function usePatchCharacterSpell(characterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveCharacterSpell(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (spellId: string) => apiClient.delete(`/characters/${characterId}/spells/${spellId}`),
|
||||
onSuccess: () => invalidateCharacter(qc, characterId),
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Money tracker
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// Only the character's own player and the DM may read it.
|
||||
export function useGoldLedger(characterId: string | undefined, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['character', characterId, 'gold'],
|
||||
enabled: Boolean(characterId) && enabled,
|
||||
queryFn: async () =>
|
||||
(await apiClient.get<{ gold: number; transactions: GoldTransaction[] }>(`/characters/${characterId}/gold`)).data
|
||||
.transactions,
|
||||
});
|
||||
}
|
||||
|
||||
// amount is signed: positive = gained, negative = spent.
|
||||
export function useAdjustGold(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { amount: number; currency?: Currency; reason?: string }) =>
|
||||
(await apiClient.post<{ transaction: GoldTransaction }>(`/characters/${characterId}/gold`, data)).data,
|
||||
onSuccess: () => invalidateCharacter(qc, characterId), // prefix match also refreshes the ledger
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Campaign weapons / spells / monsters pools
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -291,6 +460,68 @@ export function useCreateWeapon(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Edits (or, with useDeletePoolEntry, removes) one entry of a campaign pool.
|
||||
// Used for the "starting option" toggle on weapons, spells and items.
|
||||
type Pool = 'weapons' | 'spells' | 'items' | 'npcs';
|
||||
|
||||
export function usePatchPoolEntry(campaignId: string, pool: Pool) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
...data
|
||||
}: {
|
||||
id: string;
|
||||
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] });
|
||||
// What an entry does while equipped changes the sheet of whoever holds it.
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePoolEntry(campaignId: string, pool: Pool) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => apiClient.delete(`/campaigns/${campaignId}/${pool}/${id}`),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, pool] });
|
||||
qc.invalidateQueries({ queryKey: ['character'] }); // characters holding it just lost it
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCampaignItems(campaignId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['campaign', campaignId, 'items'],
|
||||
enabled: Boolean(campaignId),
|
||||
queryFn: async () => (await apiClient.get<{ items: Item[] }>(`/campaigns/${campaignId}/items`)).data.items,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateItem(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: Omit<Item, 'id' | 'campaignId'>) =>
|
||||
(await apiClient.post<{ item: Item }>(`/campaigns/${campaignId}/items`, data)).data.item,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'items'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCampaignSpells(campaignId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['campaign', campaignId, 'spells'],
|
||||
@@ -308,10 +539,11 @@ export function useCreateSpell(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCampaignMonsters(campaignId: string | undefined) {
|
||||
// Monster stat blocks are DM-only on the server, so only the DM asks for them.
|
||||
export function useCampaignMonsters(campaignId: string | undefined, isDM: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['campaign', campaignId, 'monsters'],
|
||||
enabled: Boolean(campaignId),
|
||||
enabled: Boolean(campaignId) && isDM,
|
||||
queryFn: async () =>
|
||||
(await apiClient.get<{ monsters: Monster[] }>(`/campaigns/${campaignId}/monsters`)).data.monsters,
|
||||
});
|
||||
@@ -326,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({
|
||||
@@ -338,10 +584,10 @@ export function useDeleteMonster(campaignId: string) {
|
||||
// Default content library — search/browse, "add to campaign"
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export function useCampaignLibrary(campaignId: string | undefined) {
|
||||
export function useCampaignLibrary(campaignId: string | undefined, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['campaign', campaignId, 'library'],
|
||||
enabled: Boolean(campaignId),
|
||||
enabled: Boolean(campaignId) && enabled, // DM only
|
||||
staleTime: Infinity, // static reference data — never changes under a campaign
|
||||
queryFn: async () => (await apiClient.get<CampaignLibrary>(`/campaigns/${campaignId}/library`)).data,
|
||||
});
|
||||
@@ -357,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({
|
||||
@@ -422,13 +676,57 @@ export function useCampaignEncounters(campaignId: string | undefined) {
|
||||
export function useCreateEncounter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (name: string) =>
|
||||
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, { name })).data
|
||||
.encounter,
|
||||
mutationFn: async (data: { name: string; mode?: EncounterMode }) =>
|
||||
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, data)).data.encounter,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// Sets the campaign's own encounter order to the ids given, first to last.
|
||||
export function useReorderEncounters(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
const key = ['campaign', campaignId, 'encounters'];
|
||||
return useMutation({
|
||||
mutationFn: async (encounterIds: string[]) => apiClient.put(`/campaigns/${campaignId}/encounters/order`, { encounterIds }),
|
||||
// Show the move straight away; the refetch below confirms or undoes it.
|
||||
onMutate: (encounterIds) => {
|
||||
qc.setQueryData<Encounter[]>(key, (old) => {
|
||||
if (!old) return old;
|
||||
const byId = new Map(old.map((e) => [e.id, e]));
|
||||
return encounterIds.flatMap((id, sortOrder) => {
|
||||
const encounter = byId.get(id);
|
||||
return encounter ? [{ ...encounter, sortOrder }] : [];
|
||||
});
|
||||
});
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: key }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetEncounterArchived(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ encounterId, archived }: { encounterId: string; archived: boolean }) =>
|
||||
(await apiClient.patch(`/encounters/${encounterId}`, { archived })).data,
|
||||
onSuccess: (_data, { encounterId }) => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Permanent. Rewards already handed out stay with the characters.
|
||||
export function useDeleteEncounter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (encounterId: string) => apiClient.delete(`/encounters/${encounterId}`),
|
||||
onSuccess: (_res, encounterId) => {
|
||||
qc.removeQueries({ queryKey: ['encounter', encounterId] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEncounter(encounterId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['encounter', encounterId],
|
||||
@@ -437,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; displayName?: string; initiative?: number }) =>
|
||||
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiativeRoll?: number }) =>
|
||||
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -451,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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -459,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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -468,20 +775,192 @@ 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),
|
||||
});
|
||||
}
|
||||
|
||||
export function usePatchEncounter(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { status?: EncounterStatusInput; name?: string }) =>
|
||||
(await apiClient.patch(`/encounters/${encounterId}`, data)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
mutationFn: async (data: {
|
||||
status?: EncounterStatusInput;
|
||||
mode?: EncounterMode;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
dmNotes?: string | null;
|
||||
locationId?: string | null;
|
||||
}) => (await apiClient.patch(`/encounters/${encounterId}`, data)).data,
|
||||
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) {
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] });
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
type EncounterStatusInput = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
|
||||
|
||||
// Sets which combatants are marked as targets (an empty list clears them).
|
||||
// The DM at any time; a player while their own character is on turn.
|
||||
export function useSetTargets(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (participantIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/targets`, { participantIds })).data,
|
||||
onSuccess: (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: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Encounter rewards — DM lines loot up, then awards it to a character
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export type NewReward =
|
||||
| { type: 'GOLD'; gold: number; currency?: Currency; note?: string }
|
||||
| { type: 'WEAPON'; weaponId: string; quantity?: number; level?: number; note?: string }
|
||||
| { type: 'ITEM'; itemId: string; quantity?: number; level?: number; note?: string };
|
||||
|
||||
export function useAddReward(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: NewReward) => (await apiClient.post(`/encounters/${encounterId}/rewards`, data)).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteReward(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (rewardId: string) => (await apiClient.delete(`/encounters/${encounterId}/rewards/${rewardId}`)).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
// Awarding is what actually changes the character: a gold ledger entry, or
|
||||
// the weapon/item added to what they hold.
|
||||
export function useAwardReward(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ rewardId, ...target }: { rewardId: string } & ({ characterId: string } | { split: true })) =>
|
||||
(await apiClient.post(`/encounters/${encounterId}/rewards/${rewardId}/award`, target)).data,
|
||||
onSuccess: (data) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// NPCs — a DM-only pool; players meet them through encounters
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export function useCampaignNpcs(campaignId: string | undefined, isDM: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['campaign', campaignId, 'npcs'],
|
||||
enabled: Boolean(campaignId) && isDM,
|
||||
queryFn: async () => (await apiClient.get<{ npcs: Npc[] }>(`/campaigns/${campaignId}/npcs`)).data.npcs,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateNpc(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: Partial<Omit<Npc, 'id'>> & { name: string }) =>
|
||||
(await apiClient.post<{ npc: Npc }>(`/campaigns/${campaignId}/npcs`, data)).data.npc,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'npcs'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
+321
-11
@@ -33,11 +33,50 @@ export type BuildStage =
|
||||
| 'ROLLING_MOTIVATION'
|
||||
| 'ASSIGNING_SCORES'
|
||||
| 'ASSIGNING_PROFICIENCIES'
|
||||
| 'CHOOSING_ABILITIES'
|
||||
| 'CHOOSING_BACKGROUND'
|
||||
| 'CHOOSING_APPEARANCE'
|
||||
| 'CHOOSING_EQUIPMENT'
|
||||
| 'COMPLETE';
|
||||
|
||||
export type DiceMode = 'DIGITAL' | 'PHYSICAL';
|
||||
|
||||
// The fixed lists the builder's story steps are made of (served by the API so
|
||||
// the page never keeps its own copy).
|
||||
export interface NamedAbility {
|
||||
name: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface PickOption {
|
||||
key: string;
|
||||
label: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// One question in the background or appearance step; the player picks between
|
||||
// `min` and `max` of its options.
|
||||
export interface PickGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
prompt: string;
|
||||
min: number;
|
||||
max: number;
|
||||
options: PickOption[];
|
||||
}
|
||||
|
||||
export type Picks = Record<string, string[]>;
|
||||
|
||||
export interface BuildOptions {
|
||||
motivations: { key: number; label: string }[];
|
||||
speciesTraits: Record<Species, NamedAbility[]>;
|
||||
classFeatures: Record<CharacterClass, NamedAbility[]>;
|
||||
talents: (NamedAbility & { key: string })[];
|
||||
talentPicks: number;
|
||||
background: PickGroup[];
|
||||
appearance: PickGroup[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -48,12 +87,20 @@ export interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
inviteCode: string;
|
||||
inviteCode?: string; // DM only
|
||||
startingGold: number;
|
||||
startingItemCount: number;
|
||||
startingSpellCount: number;
|
||||
skipMotivationRoll: boolean;
|
||||
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
|
||||
// Added to each target's AC for every target beyond the first in an area attack; 0 is off.
|
||||
areaAttackAcPenalty: number;
|
||||
// How many weapons and items a character can have equipped together, and
|
||||
// how many of those can be weapons. Only equipped gear is used in a fight.
|
||||
maxEquipped: number;
|
||||
maxEquippedWeapons: number;
|
||||
ownerId: string;
|
||||
archivedAt: string | null; // set while the campaign is archived
|
||||
role: 'DM' | 'PLAYER';
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -64,6 +111,22 @@ export interface CampaignMemberSummary {
|
||||
role: 'DM' | 'PLAYER';
|
||||
}
|
||||
|
||||
// The coins a character carries. 1 gold = 10 silver = 100 copper.
|
||||
export type Coin = 'GOLD' | 'SILVER' | 'COPPER';
|
||||
// Coins, plus cash ($): modern money for crossover worlds, with no worth in coins.
|
||||
export type Currency = Coin | 'CASH';
|
||||
|
||||
// Where a character stands at 0 HP. UP: on their feet. DYING: rolling death
|
||||
// saves, three of either kind settles it. STABLE: at 0 HP but no longer
|
||||
// rolling. DEAD: three failures, or one blow too big to survive.
|
||||
export type LifeState = 'UP' | 'DYING' | 'STABLE' | 'DEAD';
|
||||
|
||||
export interface DeathSaves {
|
||||
state: LifeState;
|
||||
successes: number;
|
||||
failures: number;
|
||||
}
|
||||
|
||||
export interface CharacterSummary {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
@@ -77,6 +140,12 @@ export interface CharacterSummary {
|
||||
buildStage: BuildStage;
|
||||
currentHp: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
// The purse; all four are null for another player's character.
|
||||
gold: number | null;
|
||||
silver: number | null;
|
||||
copper: number | null;
|
||||
cash: number | null;
|
||||
maxHp: number | null;
|
||||
armorClass: number | null;
|
||||
currentLocationId: string | null;
|
||||
@@ -97,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;
|
||||
@@ -109,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 {
|
||||
@@ -121,6 +206,14 @@ export interface Weapon {
|
||||
properties: string[];
|
||||
rangeNormal: number | null;
|
||||
rangeLong: number | null;
|
||||
// damageDice is the damage at minLevel (the level the weapon starts at); a
|
||||
// held copy climbs this many rungs of the damage ladder per level above
|
||||
// that (1 standard, 2 powerful, 3 legendary).
|
||||
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 {
|
||||
@@ -129,6 +222,9 @@ export interface CharacterWeapon {
|
||||
weaponId: string;
|
||||
quantity: number;
|
||||
equipped: boolean;
|
||||
level: number;
|
||||
scaledDamageDice: string; // the dice at this copy's level
|
||||
usable: boolean; // false while the character is below this copy's level
|
||||
weapon: Weapon;
|
||||
}
|
||||
|
||||
@@ -142,6 +238,9 @@ export interface Spell {
|
||||
range: string | null;
|
||||
duration: string | null;
|
||||
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 {
|
||||
@@ -149,16 +248,45 @@ export interface CharacterSpell {
|
||||
characterId: string;
|
||||
spellId: string;
|
||||
prepared: boolean;
|
||||
usable: boolean; // false while the character is below the spell's level
|
||||
spell: Spell;
|
||||
}
|
||||
|
||||
// An entry in the campaign's DM-managed item pool.
|
||||
export interface Item {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
damageDice: string | null; // optional dice (damage, healing…) at its starting level
|
||||
damageScaling: number;
|
||||
minLevel: number;
|
||||
isStartingOption: boolean;
|
||||
effects: GearEffect[]; // armor is an item with an AC effect
|
||||
effectNote: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
characterId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
equipped: boolean;
|
||||
level: number;
|
||||
scaledDamageDice: string | null;
|
||||
usable: boolean;
|
||||
item: Item;
|
||||
}
|
||||
|
||||
export interface GoldTransaction {
|
||||
id: string;
|
||||
characterId: string;
|
||||
currency: Currency; // which coin this entry is in
|
||||
amount: number; // signed: positive = gained, negative = spent
|
||||
balanceAfter: number; // that coin's balance afterwards
|
||||
reason: string | null;
|
||||
createdAt: string;
|
||||
createdBy: { displayName: string } | null;
|
||||
}
|
||||
|
||||
export interface SkillProficiency {
|
||||
@@ -188,6 +316,7 @@ export interface CharacterDetail {
|
||||
rolledScores: Record<Ability, number>;
|
||||
currentHp: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
armorBonus: number;
|
||||
specialAbilities: string | null;
|
||||
background: string | null;
|
||||
@@ -198,7 +327,13 @@ export interface CharacterDetail {
|
||||
diceMode: DiceMode;
|
||||
currentLocationId: string | null;
|
||||
currentLocation: Location | null;
|
||||
gold: number;
|
||||
gold: number | null;
|
||||
silver: number | null;
|
||||
copper: number | null;
|
||||
cash: number | null;
|
||||
// True when this is another player's character: no gold, inventory or
|
||||
// location is sent, only what the party can see across the table.
|
||||
restricted: boolean;
|
||||
buildStage: BuildStage;
|
||||
buildState: Record<string, unknown> | null;
|
||||
skillProficiencies: SkillProficiency[];
|
||||
@@ -230,10 +365,70 @@ export interface Monster {
|
||||
}
|
||||
|
||||
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;
|
||||
name: string;
|
||||
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.
|
||||
export interface Npc extends NpcPublic {
|
||||
dmNotes: string | null;
|
||||
ac: number | null;
|
||||
maxHp: number | null;
|
||||
attackBonus: number | null;
|
||||
damageDice: string | null;
|
||||
}
|
||||
|
||||
export interface EncounterReward {
|
||||
id: string;
|
||||
encounterId: string;
|
||||
type: 'GOLD' | 'WEAPON' | 'ITEM';
|
||||
gold: number | null; // for a GOLD reward: how many coins...
|
||||
currency: Currency; // ...and which coin
|
||||
weaponId: string | null;
|
||||
itemId: string | null;
|
||||
quantity: number;
|
||||
level: number;
|
||||
note: string | null;
|
||||
awardedTo: string | null;
|
||||
awardedAt: string | null;
|
||||
weapon: Weapon | null;
|
||||
item: Item | null;
|
||||
}
|
||||
|
||||
export interface CombatWeapon {
|
||||
characterWeaponId: string;
|
||||
name: string;
|
||||
level: number;
|
||||
equipped: boolean;
|
||||
quantity: number;
|
||||
attackBonus: number;
|
||||
@@ -242,16 +437,42 @@ 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;
|
||||
level: number;
|
||||
description: string | null;
|
||||
damageDice: string | null;
|
||||
prepared: boolean;
|
||||
}
|
||||
|
||||
export interface CharacterCombatInfo {
|
||||
level: number;
|
||||
species: Species | null;
|
||||
characterClass: CharacterClass | null;
|
||||
armorClass: number;
|
||||
weapons: CombatWeapon[];
|
||||
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;
|
||||
proficiencyBonus: number;
|
||||
abilities: Record<Ability, AbilityDetail>;
|
||||
saves: Record<Ability, { proficient: boolean; bonus: number }>;
|
||||
specialAbilities: string | null;
|
||||
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;
|
||||
}
|
||||
@@ -262,13 +483,27 @@ export interface EncounterParticipant {
|
||||
characterId: string | null;
|
||||
monsterId: string | null;
|
||||
displayName: string;
|
||||
// The d20 rolled for initiative (1–20), null until rolled. `initiative` is
|
||||
// that roll plus the combatant's bonus, and is what the order sorts on.
|
||||
initiativeRoll: number | null;
|
||||
initiative: number;
|
||||
currentHp: number;
|
||||
maxHp: number;
|
||||
initiativeBonus?: number; // a monster's or NPC's is sent to the DM only
|
||||
// Null for a player when the campaign hides monster hit points; hpPercent
|
||||
// (0–100) is sent in their place to draw the health bar.
|
||||
currentHp: number | null;
|
||||
maxHp: number | null;
|
||||
hpPercent?: number | null;
|
||||
conditions: string[];
|
||||
isMonster: boolean;
|
||||
character?: { id: string; name: string } | null;
|
||||
monster?: { id: string; name: string } | null;
|
||||
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.
|
||||
monster?: ({ id: string; name: string } & Partial<Monster>) | null;
|
||||
npc?: (NpcPublic & Partial<Npc>) | null;
|
||||
characterCombatInfo?: CharacterCombatInfo;
|
||||
}
|
||||
|
||||
@@ -276,10 +511,27 @@ export interface Encounter {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
name: string;
|
||||
mode: EncounterMode;
|
||||
status: EncounterStatus;
|
||||
currentRound: number;
|
||||
activeParticipantId: string | null;
|
||||
// The combatants marked as targets right now, for everyone to see, in the
|
||||
// order they were picked. More than one is an area attack.
|
||||
targetParticipantIds: string[];
|
||||
description: string | null;
|
||||
dmNotes?: string | null; // DM only
|
||||
// Where the scene is. A player is only told once their character has been there.
|
||||
locationId: string | null;
|
||||
location?: Pick<Location, 'id' | 'name' | 'description'> | null;
|
||||
archivedAt: string | null; // archived encounters only reach the DM's list
|
||||
sortOrder: number; // place in the campaign's own order, lowest first
|
||||
createdAt: string;
|
||||
participants?: EncounterParticipant[];
|
||||
participants?: EncounterParticipant[]; // a player's list leaves out the fallen
|
||||
// The dead characters in the scene. Players do not get their cards, only
|
||||
// this, so that an ally can try to revive them.
|
||||
fallen?: { participantId: string; characterId: string; name: string }[];
|
||||
rewards?: EncounterReward[]; // players only receive the awarded ones
|
||||
wares?: SaleOffer[]; // for sale at the scene itself, not by an NPC in it
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
@@ -303,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 {
|
||||
@@ -314,6 +578,8 @@ export interface LibrarySpell {
|
||||
range: string | null;
|
||||
duration: string | null;
|
||||
description: string | null;
|
||||
damageDice?: string; // default damage; absent for spells that deal none
|
||||
revives?: boolean; // brings back the dead
|
||||
classes: CharacterClass[];
|
||||
}
|
||||
|
||||
@@ -338,6 +604,7 @@ export interface LibraryMonster {
|
||||
|
||||
export interface CampaignLibrary {
|
||||
weapons: LibraryWeapon[];
|
||||
items: LibraryItem[];
|
||||
spells: LibrarySpell[];
|
||||
monsters: LibraryMonster[];
|
||||
}
|
||||
@@ -346,24 +613,67 @@ export interface QuickStatsWeapon {
|
||||
characterWeaponId: string;
|
||||
weaponId: string;
|
||||
name: string;
|
||||
level: number;
|
||||
equipped: boolean;
|
||||
quantity: number;
|
||||
usable: boolean; // false while the character is below this copy's level
|
||||
effects: GearEffect[];
|
||||
effectNote: string | null;
|
||||
attackBonus: number;
|
||||
damageBonus: number;
|
||||
damageDice: string;
|
||||
damageType: string;
|
||||
}
|
||||
|
||||
export interface QuickStats {
|
||||
export interface QuickStatsItem {
|
||||
inventoryItemId: string;
|
||||
itemId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
level: number;
|
||||
equipped: boolean;
|
||||
quantity: number;
|
||||
usable: boolean;
|
||||
damageDice: string | null; // at this copy's level
|
||||
effects: GearEffect[];
|
||||
effectNote: string | null;
|
||||
}
|
||||
|
||||
export interface QuickStatsSpell {
|
||||
characterSpellId: string;
|
||||
name: string;
|
||||
level: number;
|
||||
damageDice: string | null;
|
||||
description: string | null;
|
||||
prepared: boolean;
|
||||
usable: boolean;
|
||||
}
|
||||
|
||||
// A character at a glance, with everything it holds. The pack, the spell
|
||||
// list and the purse are only sent to the owning player and the DM
|
||||
// (`restricted` is true for anyone else).
|
||||
export interface QuickStats {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
species: Species | null;
|
||||
characterClass: CharacterClass | null;
|
||||
currentHp: number;
|
||||
maxHp: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
armorClass: number;
|
||||
initiative: number;
|
||||
speed: number;
|
||||
proficiencyBonus: number;
|
||||
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
|
||||
passivePerception: number;
|
||||
abilities: Record<Ability, AbilityDetail>;
|
||||
specialAbilities: string | null;
|
||||
gear: ActiveGear;
|
||||
weapons: QuickStatsWeapon[];
|
||||
restricted: boolean;
|
||||
inventory: QuickStatsItem[];
|
||||
spells: QuickStatsSpell[];
|
||||
purse: { gold: number; silver: number; copper: number; cash: number } | null;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useState } from 'react';
|
||||
import { useDeathSave } from '../api/queries';
|
||||
import { InfoTip } from './InfoTip';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import type { DeathSaves as DeathSavesInfo } from '../api/types';
|
||||
|
||||
const STATE_LABELS = { DYING: 'Dying', STABLE: 'Stable', DEAD: '☠ Dead' } as const;
|
||||
|
||||
// Nothing for a character on their feet; otherwise where they stand at 0 HP.
|
||||
export function LifeBadge({ deathSaves }: { deathSaves: DeathSavesInfo }) {
|
||||
if (deathSaves.state === 'UP') return null;
|
||||
return <span className={`badge ${deathSaves.state === 'STABLE' ? '' : 'badge-hostile'}`}>{STATE_LABELS[deathSaves.state]}</span>;
|
||||
}
|
||||
|
||||
// Three pips for one tally. For the DM each is a button: clicking a pip sets
|
||||
// the tally to it, and clicking the last filled one takes it back off.
|
||||
function Pips({ kind, count, onSet }: { kind: 'success' | 'failure'; count: number; onSet?: (count: number) => void }) {
|
||||
const noun = kind === 'success' ? 'successes' : 'failures';
|
||||
return (
|
||||
<span className="death-pips" role="group" aria-label={`${count} of 3 ${noun}`}>
|
||||
{[1, 2, 3].map((n) => {
|
||||
const className = `death-pip is-${kind}${n <= count ? ' is-filled' : ''}`;
|
||||
return onSet ? (
|
||||
<button key={n} type="button" className={className} aria-label={`Set ${noun} to ${n === count ? n - 1 : n}`} onClick={() => onSet(n === count ? n - 1 : n)} />
|
||||
) : (
|
||||
<span key={n} className={className} />
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// What happens to a character at 0 HP: the death-save tallies, and the ways
|
||||
// out of them. `canAct` is the character's own player or the DM; setting the
|
||||
// tallies by hand and raising the dead from here are the DM's alone.
|
||||
export function DeathSaves({
|
||||
characterId,
|
||||
name,
|
||||
deathSaves,
|
||||
canAct,
|
||||
isDM,
|
||||
onError,
|
||||
}: {
|
||||
characterId: string;
|
||||
name: string;
|
||||
deathSaves: DeathSavesInfo;
|
||||
canAct: boolean;
|
||||
isDM: boolean;
|
||||
onError?: (err: unknown) => void;
|
||||
}) {
|
||||
const deathSave = useDeathSave(characterId);
|
||||
const [roll, setRoll] = useState('');
|
||||
const { state, successes, failures } = deathSaves;
|
||||
if (state === 'UP') return null;
|
||||
|
||||
const send = (action: Parameters<typeof deathSave.mutate>[0]) => deathSave.mutate(action, { onError });
|
||||
|
||||
function handleRoll() {
|
||||
const value = Number(roll);
|
||||
if (!roll || !Number.isInteger(value)) return;
|
||||
send({ action: 'roll', roll: value });
|
||||
setRoll('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`death-saves is-${state.toLowerCase()}`}>
|
||||
<div className="flex-row flex-wrap">
|
||||
<LifeBadge deathSaves={deathSaves} />
|
||||
{state === 'DYING' && (
|
||||
<>
|
||||
<span className="death-tally">
|
||||
Saved <Pips kind="success" count={successes} onSet={isDM ? (n) => send({ action: 'set', successes: n, failures }) : undefined} />
|
||||
</span>
|
||||
<span className="death-tally">
|
||||
Failed <Pips kind="failure" count={failures} onSet={isDM ? (n) => send({ action: 'set', successes, failures: n }) : undefined} />
|
||||
</span>
|
||||
<InfoTip term="DEATH_SAVES" />
|
||||
</>
|
||||
)}
|
||||
{state === 'STABLE' && <span className="combatant-sub">Out cold at 0 HP, but no longer rolling. Any healing wakes them; damage starts the saves again.</span>}
|
||||
{state === 'DEAD' && <span className="combatant-sub">{isDM ? 'Healing does nothing now.' : 'The DM can bring them back, or an ally with a reviving spell on their turn in a battle.'}</span>}
|
||||
</div>
|
||||
|
||||
{state === 'DYING' && canAct && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="d20"
|
||||
aria-label={`Death save roll for ${name}`}
|
||||
title="Type what the d20 showed (1 to 20)."
|
||||
value={roll}
|
||||
onChange={(e) => setRoll(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleRoll()}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
<button className="btn" onClick={handleRoll} disabled={!roll || deathSave.isPending}>
|
||||
Death save
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
title="Someone steadied them: a Medicine check, a healer's kit or a spell. They stay at 0 HP but stop rolling."
|
||||
onClick={() => send({ action: 'stabilize' })}
|
||||
disabled={deathSave.isPending}
|
||||
>
|
||||
Stabilise
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'DEAD' && isDM && (
|
||||
<button className="btn mt-sm" onClick={() => send({ action: 'revive' })} disabled={deathSave.isPending}>
|
||||
Revive at 1 HP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fmtMod } from '../lib/format';
|
||||
|
||||
const DICE = [4, 6, 8, 10, 12, 20, 100];
|
||||
const MAX_DICE = 20;
|
||||
const HISTORY_LENGTH = 8;
|
||||
|
||||
interface Roll {
|
||||
id: number;
|
||||
sides: number;
|
||||
modifier: number;
|
||||
dice: number[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// One fair roll of an n-sided die. Uses the browser's cryptographic random
|
||||
// source, discarding values that would make some faces slightly likelier.
|
||||
function rollDie(sides: number): number {
|
||||
const limit = Math.floor(0x100000000 / sides) * sides;
|
||||
const buffer = new Uint32Array(1);
|
||||
do {
|
||||
crypto.getRandomValues(buffer);
|
||||
} while (buffer[0] >= limit);
|
||||
return (buffer[0] % sides) + 1;
|
||||
}
|
||||
|
||||
function notation(count: number, sides: number, modifier: number): string {
|
||||
return `${count}d${sides}${modifier ? fmtMod(modifier) : ''}`;
|
||||
}
|
||||
|
||||
// A table-side dice roller, reachable from every page. These rolls are for
|
||||
// the player's own use at the table: they are not sent to the server and do
|
||||
// not feed the character builder, whose dice stay locked in server-side.
|
||||
export function DiceRoller() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sides, setSides] = useState(20);
|
||||
const [count, setCount] = useState(1);
|
||||
const [modifier, setModifier] = useState(0);
|
||||
const [history, setHistory] = useState<Roll[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open]);
|
||||
|
||||
const latest = history[0];
|
||||
|
||||
function roll() {
|
||||
const dice = Array.from({ length: count }, () => rollDie(sides));
|
||||
const total = dice.reduce((sum, d) => sum + d, 0) + modifier;
|
||||
setHistory((prev) => [{ id: (prev[0]?.id ?? 0) + 1, sides, modifier, dice, total }, ...prev].slice(0, HISTORY_LENGTH));
|
||||
}
|
||||
|
||||
// The natural 20 and natural 1 only mean something on a single d20.
|
||||
const natural = latest && latest.sides === 20 && latest.dice.length === 1 ? latest.dice[0] : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="fab-dice"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title="Roll dice"
|
||||
aria-label={open ? 'Close the dice roller' : 'Open the dice roller'}
|
||||
aria-expanded={open}
|
||||
>
|
||||
🎲
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="dice-panel" role="dialog" aria-label="Dice roller">
|
||||
<div className="flex-between">
|
||||
<div className="card-title" style={{ margin: 0 }}>
|
||||
Roll Dice
|
||||
</div>
|
||||
<button className="btn btn-ghost" onClick={() => setOpen(false)} aria-label="Close the dice roller">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="dice-choices mt-sm" role="group" aria-label="Which die">
|
||||
{DICE.map((d) => (
|
||||
<button key={d} className={`die-btn${d === sides ? ' is-selected' : ''}`} aria-pressed={d === sides} onClick={() => setSides(d)}>
|
||||
d{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-between mt-sm">
|
||||
<div className="flex-row gap-sm">
|
||||
<span className="dice-label">Dice</span>
|
||||
<button className="btn" aria-label="One fewer die" disabled={count <= 1} onClick={() => setCount(count - 1)}>
|
||||
−
|
||||
</button>
|
||||
<span className="mono" aria-live="polite" style={{ minWidth: '1.5ch', textAlign: 'center' }}>
|
||||
{count}
|
||||
</span>
|
||||
<button className="btn" aria-label="One more die" disabled={count >= MAX_DICE} onClick={() => setCount(count + 1)}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex-row gap-sm">
|
||||
<span className="dice-label">Bonus</span>
|
||||
<input
|
||||
type="number"
|
||||
min={-99}
|
||||
max={99}
|
||||
value={modifier}
|
||||
onChange={(e) => setModifier(Math.max(-99, Math.min(99, Math.trunc(Number(e.target.value)) || 0)))}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary btn-block btn-lg mt-sm" onClick={roll}>
|
||||
🎲 Roll {notation(count, sides, modifier)}
|
||||
</button>
|
||||
|
||||
<div className="dice-result" aria-live="polite">
|
||||
{latest ? (
|
||||
<>
|
||||
{/* Keyed on the roll so the pop animation replays every time, even on a repeat result. */}
|
||||
<div key={latest.id} className={`roll-result animate${natural === 20 ? ' is-crit' : natural === 1 ? ' is-fumble' : ''}`}>
|
||||
{latest.total}
|
||||
</div>
|
||||
{natural === 20 && <div className="dice-callout">Natural 20!</div>}
|
||||
{natural === 1 && <div className="dice-callout is-fumble">Natural 1</div>}
|
||||
{(latest.dice.length > 1 || latest.modifier !== 0) && (
|
||||
<div className="dice-breakdown mono">
|
||||
{latest.dice.join(' + ')}
|
||||
{latest.modifier !== 0 && ` ${latest.modifier > 0 ? '+' : '−'} ${Math.abs(latest.modifier)}`} = {latest.total}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="subtitle" style={{ margin: 0 }}>
|
||||
Pick a die and roll.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{history.length > 1 && (
|
||||
<>
|
||||
<div className="flex-between">
|
||||
<span className="dice-label">Earlier rolls</span>
|
||||
<button className="btn btn-ghost" onClick={() => setHistory([])}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{history.slice(1).map((r) => (
|
||||
<div key={r.id} className="flex-between dice-history-row">
|
||||
<span>
|
||||
{notation(r.dice.length, r.sides, r.modifier)}
|
||||
{r.dice.length > 1 && <span className="dice-history-dice"> ({r.dice.join(', ')})</span>}
|
||||
</span>
|
||||
<strong className="mono">{r.total}</strong>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { hpBarClass } from '../lib/format';
|
||||
|
||||
// A health bar with temporary hit points on it. Temp HP sits on top of real
|
||||
// HP, so it is drawn as its own blue segment after the HP fill, and the bar
|
||||
// stretches to fit when the two together come to more than the maximum (full
|
||||
// health plus a buffer). The fill's colour still reflects real HP only: temp
|
||||
// HP doesn't make a bloodied character look healthy.
|
||||
export function HpBar({ current, max, temp = 0, height, className = 'mt-sm' }: {
|
||||
current: number;
|
||||
max: number;
|
||||
temp?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const hp = Math.max(0, Math.min(current, max));
|
||||
const buffer = Math.max(0, temp);
|
||||
const scale = Math.max(max, hp + buffer, 1);
|
||||
return (
|
||||
<div className={`hp-bar-track ${className}`} style={height ? { height } : undefined}>
|
||||
<div className={hpBarClass(hp, max)} style={{ width: `${(hp / scale) * 100}%` }} />
|
||||
{buffer > 0 && <div className="hp-bar-fill hp-temp" style={{ width: `${(buffer / scale) * 100}%` }} title="Temporary HP" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useId, useState } from 'react';
|
||||
import { useEffect, useId, useRef, useState, type CSSProperties } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
// Short explanations for the stat labels sprinkled across the sheet, quick
|
||||
// stats, party table, and builder — "what is this, and how is it worked
|
||||
// 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).',
|
||||
DEATH_SAVES: 'Death saves — at 0 HP you roll a d20 at the start of each of your turns. 10 or more is a success, less is a failure; a 1 counts as two failures and a 20 puts you back up with 1 HP. Three successes and you are stable; three failures and you die. Being hit while down is a failure (two if it was a critical hit). Any healing ends it.',
|
||||
INITIATIVE: 'Initiative — added to a d20 roll at the start of a fight to decide turn order. Equal to your Dexterity modifier.',
|
||||
PROFICIENCY_BONUS: 'Proficiency Bonus — added to attacks, saves, and skills you are trained in. Grows with level (+2 at level 1, up to +6 at level 20).',
|
||||
PASSIVE_PERCEPTION: 'Passive Perception — your always-on alertness score, used instead of rolling when you are not actively searching. 10 + your Perception skill bonus.',
|
||||
@@ -14,7 +16,7 @@ export const STAT_HELP: Record<string, string> = {
|
||||
SKILL_BONUS: 'Skill Bonus — added when you roll to use a skill. The governing ability modifier, plus your proficiency bonus if you are proficient (doubled if you have expertise).',
|
||||
EXPERTISE: 'Expertise — doubles your proficiency bonus for this skill, on top of being proficient.',
|
||||
ABILITY_MODIFIER: 'Ability Modifier — the number added to rolls that use this ability. floor((score − 10) / 2).',
|
||||
ARMOR_BONUS: 'Armor Bonus — a flat bonus to AC from equipped armor or a shield, added on top of 10 + Dex modifier.',
|
||||
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item.',
|
||||
SPEED: 'Speed — how far you can move on your turn, in feet. Set by your species, unless overridden.',
|
||||
SPELL_SAVE_DC: 'Spell Save DC — the difficulty a target must beat to resist your spell. 8 + proficiency bonus + your spellcasting ability modifier.',
|
||||
SPELL_ATTACK_BONUS: 'Spell Attack Bonus — added when you make a spell attack roll. Proficiency bonus + your spellcasting ability modifier.',
|
||||
@@ -22,12 +24,52 @@ export const STAT_HELP: Record<string, string> = {
|
||||
DAMAGE_BONUS: 'Damage Bonus — added to the weapon’s damage dice on a hit. Same ability modifier as the attack bonus.',
|
||||
CHALLENGE_RATING: 'Challenge Rating — a rough measure of how dangerous a monster is on its own, used to gauge encounter difficulty.',
|
||||
HIT_DICE: 'Hit Dice — the die your class rolls (or uses the fixed average of) when your HP increases at each level.',
|
||||
// Character builder
|
||||
DICE_MODE: 'Digital dice are rolled by the app. Physical dice are rolled by you at the table, and you type in what came up. Either way a roll is final once it is made.',
|
||||
WILDCARD: 'Wildcard — the luckiest result. Instead of choosing between two options you may pick any of them.',
|
||||
SPECIES_BONUS: 'Species bonus — points your species adds on top of the ability scores you roll. They are added automatically.',
|
||||
SAVE_PROFICIENCY: 'Each class is trained in two saving throws. On those you add your proficiency bonus when resisting spells, traps and other effects.',
|
||||
KEY_ABILITY: 'Key abilities — the scores this class leans on most. Put your best rolls here.',
|
||||
SPELLCASTING: 'Spellcasting ability — the score that powers this class’s spells. A higher score makes your spells harder to resist and more accurate.',
|
||||
ROLL_4D6: 'Roll four six-sided dice and add up the best three (the lowest is dropped). Do that six times to get six scores, usually between 8 and 16.',
|
||||
STARTING_EQUIPMENT: 'Your DM chooses which weapons, items and spells are available to brand-new characters, and how many you may take.',
|
||||
DAMAGE_DICE: 'Damage dice — what you roll when this weapon hits. “1d8” means one eight-sided die. Your Strength or Dexterity modifier is added on top.',
|
||||
};
|
||||
|
||||
export function InfoTip({ term, label = 'What is this?' }: { term: keyof typeof STAT_HELP; label?: string }) {
|
||||
// `term` looks up a standard explanation above; `text` supplies a one-off
|
||||
// (a species, a skill, an ability) without adding it to the shared list.
|
||||
export function InfoTip({ term, text: custom, label = 'What is this?' }: { term?: keyof typeof STAT_HELP; text?: string; label?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [place, setPlace] = useState<CSSProperties | null>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const id = useId();
|
||||
const text = STAT_HELP[term];
|
||||
const text = custom ?? (term ? STAT_HELP[term] : undefined);
|
||||
|
||||
// The bubble is drawn on the page itself, fixed to where the ⓘ is on
|
||||
// screen, rather than inside whatever holds the ⓘ: a scrolling table or a
|
||||
// drawer would otherwise cut it off. It sits above the ⓘ when there is room
|
||||
// and below when there isn't, and never runs off the sides.
|
||||
useEffect(() => {
|
||||
if (!open || !trigger.current) return;
|
||||
const rect = trigger.current.getBoundingClientRect();
|
||||
const halfWidth = Math.min(130, window.innerWidth / 2 - 8);
|
||||
const centre = Math.min(Math.max(rect.left + rect.width / 2, halfWidth + 8), window.innerWidth - halfWidth - 8);
|
||||
const above = rect.top > 160;
|
||||
setPlace({
|
||||
left: centre,
|
||||
...(above ? { bottom: window.innerHeight - rect.top + 8 } : { top: rect.bottom + 8 }),
|
||||
});
|
||||
// Its position is only right until the page moves.
|
||||
const close = () => setOpen(false);
|
||||
window.addEventListener('scroll', close, true);
|
||||
window.addEventListener('resize', close);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', close, true);
|
||||
window.removeEventListener('resize', close);
|
||||
setPlace(null); // so the next opening is measured afresh, not shown where the last one was
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
@@ -37,6 +79,7 @@ export function InfoTip({ term, label = 'What is this?' }: { term: keyof typeof
|
||||
className="info-tip-trigger"
|
||||
aria-describedby={id}
|
||||
aria-label={label}
|
||||
ref={trigger}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
@@ -44,11 +87,14 @@ export function InfoTip({ term, label = 'What is this?' }: { term: keyof typeof
|
||||
>
|
||||
ⓘ
|
||||
</button>
|
||||
{open && (
|
||||
<span role="tooltip" id={id} className="info-tip-bubble">
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
{open &&
|
||||
place &&
|
||||
createPortal(
|
||||
<span role="tooltip" id={id} className="info-tip-bubble" style={place}>
|
||||
{text}
|
||||
</span>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,9 +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">
|
||||
@@ -13,20 +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">
|
||||
<span>{user.displayName}</span>
|
||||
<span className="topbar-name">{user.displayName}</span>
|
||||
<button className="btn btn-ghost" onClick={logout}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
<main className="page">
|
||||
<Outlet />
|
||||
{/* 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
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ChangeEvent, FocusEvent, InputHTMLAttributes } from 'react';
|
||||
|
||||
type NumberInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'min' | 'max' | 'step'> & {
|
||||
min?: number;
|
||||
max?: number;
|
||||
// For a field that always holds a number (a level, a bonus): if it is left
|
||||
// empty, it goes back to its starting value instead of being saved blank.
|
||||
keepValue?: boolean;
|
||||
};
|
||||
|
||||
// A whole-number field that cannot hold a value outside its limits. A plain
|
||||
// <input type="number" max={20}> only limits the arrow buttons; typing 500
|
||||
// into it works. This one corrects the text as it is typed: anything above
|
||||
// `max` becomes `max`, anything below `min` becomes `min`, decimals are
|
||||
// dropped. Works the same whether the field is controlled (`value`) or not
|
||||
// (`defaultValue`): the handlers passed in see the corrected value.
|
||||
export function NumberInput({ min, max, keepValue, onChange, onBlur, ...rest }: NumberInputProps) {
|
||||
function limit(input: HTMLInputElement, leaving: boolean) {
|
||||
const raw = input.value;
|
||||
if (raw === '' || raw === '-') {
|
||||
// Nothing typed yet, or the start of a negative number.
|
||||
if (leaving) input.value = keepValue ? String(rest.defaultValue ?? rest.value ?? min ?? 0) : '';
|
||||
return;
|
||||
}
|
||||
let value = Math.trunc(Number(raw));
|
||||
if (Number.isNaN(value)) {
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
if (max !== undefined && value > max) value = max;
|
||||
// While typing, a value below a minimum above 1 may just be unfinished
|
||||
// ("1" on the way to "15"), so that case waits until the field is left.
|
||||
if (min !== undefined && value < min && (leaving || min <= 1)) value = min;
|
||||
if (String(value) !== raw) input.value = String(value);
|
||||
}
|
||||
|
||||
function handleChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
limit(e.target, false);
|
||||
onChange?.(e);
|
||||
}
|
||||
|
||||
function handleBlur(e: FocusEvent<HTMLInputElement>) {
|
||||
limit(e.target, true);
|
||||
onBlur?.(e);
|
||||
}
|
||||
|
||||
return <input {...rest} type="number" inputMode="numeric" step={1} min={min} max={max} onChange={handleChange} onBlur={handleBlur} />;
|
||||
}
|
||||
@@ -1,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,96 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuickStats } from '../api/queries';
|
||||
import { Spinner, ErrorBanner } from './Spinner';
|
||||
import { fmtMod, hpBarClass } from '../lib/format';
|
||||
|
||||
export function QuickStatsFab({ characterId }: { characterId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button className="fab-quickstats" onClick={() => setOpen(true)} title="Quick Stats" aria-label="Open quick stats">
|
||||
⚡
|
||||
</button>
|
||||
{open && <QuickStatsDrawer characterId={characterId} onClose={() => setOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickStatsDrawer({ characterId, onClose }: { characterId: string; onClose: () => void }) {
|
||||
const { data, isLoading, error } = useQuickStats(characterId, true);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-backdrop" onClick={onClose} />
|
||||
<div className="drawer">
|
||||
<div className="flex-between">
|
||||
<h2 style={{ margin: 0 }}>Quick Stats</h2>
|
||||
<button className="btn btn-ghost" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{isLoading && <Spinner label="Reading the sheet…" />}
|
||||
{error && <ErrorBanner message="Could not load quick stats." />}
|
||||
{data && (
|
||||
<div className="mt-md">
|
||||
<h3 style={{ margin: 0 }}>{data.name}</h3>
|
||||
<div className="grid grid-cols-3 mt-sm">
|
||||
<div className="stat-block">
|
||||
<span className="stat-label">HP</span>
|
||||
<span className="stat-value">
|
||||
{data.currentHp}/{data.maxHp}
|
||||
</span>
|
||||
{data.tempHp > 0 && <span className="stat-sub">+{data.tempHp} temp</span>}
|
||||
</div>
|
||||
<div className="stat-block">
|
||||
<span className="stat-label">AC</span>
|
||||
<span className="stat-value">{data.armorClass}</span>
|
||||
</div>
|
||||
<div className="stat-block">
|
||||
<span className="stat-label">Initiative</span>
|
||||
<span className="stat-value">{fmtMod(data.initiative)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hp-bar-track mt-sm">
|
||||
<div className={hpBarClass(data.currentHp, data.maxHp)} style={{ width: `${Math.max(0, Math.min(100, (data.currentHp / data.maxHp) * 100))}%` }} />
|
||||
</div>
|
||||
|
||||
{data.spellcasting && (
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">Spellcasting</div>
|
||||
<p style={{ margin: 0 }}>
|
||||
Save DC {data.spellcasting.saveDC} · Attack {fmtMod(data.spellcasting.attackBonus)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">Weapons</div>
|
||||
{data.weapons.length === 0 && <p style={{ color: 'var(--text-muted)', margin: 0 }}>No weapons equipped.</p>}
|
||||
{data.weapons.map((w) => (
|
||||
<div key={w.characterWeaponId} className="flex-between mt-sm">
|
||||
<div>
|
||||
<strong>{w.name}</strong>
|
||||
{w.equipped && <span className="badge" style={{ marginLeft: 6 }}>equipped</span>}
|
||||
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
{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,363 @@
|
||||
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 EntryKind, 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 KIND_NOUNS: Record<EntryKind, string> = { weapon: 'weapon', item: 'item', spell: 'spell', monster: 'monster', npc: 'NPC' };
|
||||
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, added, onClose }: { added: ReactNode; 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}>
|
||||
{added}
|
||||
{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, added, onClose }: { added: ReactNode; 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}>
|
||||
{added}
|
||||
{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, added, onClose }: { added: ReactNode; 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}>
|
||||
{added}
|
||||
{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, added, onClose }: { added: ReactNode; 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}
|
||||
>
|
||||
{added}
|
||||
{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,
|
||||
onAddAnother,
|
||||
onClose,
|
||||
}: {
|
||||
campaignId: string;
|
||||
entry: EntryRef;
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
spells: Spell[];
|
||||
monsters: Monster[];
|
||||
npcs: Npc[];
|
||||
// Given for something added by hand a moment ago: opens the form it came from again.
|
||||
onAddAnother?: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const added = onAddAnother && (
|
||||
<div className="added-bar">
|
||||
<span>✓ Added to the campaign.</span>
|
||||
<button className="btn btn-primary" onClick={onAddAnother}>
|
||||
+ Add another {KIND_NOUNS[entry.kind]}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
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} added={added} campaignId={campaignId} pool="weapons" entry={weapon} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'item': {
|
||||
const item = find(items);
|
||||
return item ? <GearDetail key={item.id} added={added} campaignId={campaignId} pool="items" entry={item} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'spell': {
|
||||
const spell = find(spells);
|
||||
return spell ? <SpellDetail key={spell.id} added={added} campaignId={campaignId} spell={spell} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'monster': {
|
||||
const monster = find(monsters);
|
||||
return monster ? <MonsterDetail key={monster.id} added={added} campaignId={campaignId} monster={monster} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'npc': {
|
||||
const npc = find(npcs);
|
||||
return npc ? <NpcDetail key={npc.id} added={added} campaignId={campaignId} npc={npc} weapons={weapons} items={items} onClose={onClose} /> : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
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;
|
||||
// The "add by hand" pop-out is wanted from outside: "add another", pressed
|
||||
// in the pop-out of the one just added.
|
||||
formRequested: boolean;
|
||||
onFormClosed: () => void;
|
||||
}
|
||||
|
||||
// Which of a list's two "add" pop-outs is open: the form, or the library.
|
||||
type Adding = 'form' | 'library' | null;
|
||||
|
||||
// Which "add" pop-out a list has open, and what went wrong in it.
|
||||
function useAdding(formRequested: boolean, onFormClosed: () => void) {
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
return {
|
||||
adding: formRequested ? ('form' as const) : adding,
|
||||
error,
|
||||
setError,
|
||||
open: (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
},
|
||||
close: () => {
|
||||
setError(null);
|
||||
setAdding(null);
|
||||
onFormClosed();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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, formRequested, onFormClosed }: SectionProps<Weapon>) {
|
||||
const createWeapon = useCreateWeapon(campaignId);
|
||||
const addFromLibrary = useAddWeaponFromLibrary(campaignId);
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
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,
|
||||
});
|
||||
close();
|
||||
onOpen({ kind: 'weapon', id: weapon.id, isNew: true });
|
||||
} 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={close}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add a weapon" subtitle="What it does while equipped is set once it is added." onClose={close}>
|
||||
{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, formRequested, onFormClosed }: SectionProps<Item>) {
|
||||
const createItem = useCreateItem(campaignId);
|
||||
const addFromLibrary = useAddItemFromLibrary(campaignId);
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
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,
|
||||
});
|
||||
close();
|
||||
onOpen({ kind: 'item', id: item.id, isNew: true });
|
||||
} 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={close}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add an item" subtitle="What it does while equipped is set once it is added." onClose={close}>
|
||||
{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, formRequested, onFormClosed }: SectionProps<Spell>) {
|
||||
const createSpell = useCreateSpell(campaignId);
|
||||
const addFromLibrary = useAddSpellFromLibrary(campaignId);
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
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',
|
||||
});
|
||||
close();
|
||||
onOpen({ kind: 'spell', id: spell.id, isNew: true });
|
||||
} 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={close}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add a spell" onClose={close}>
|
||||
{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, formRequested, onFormClosed }: SectionProps<Monster>) {
|
||||
const createMonster = useCreateMonster(campaignId);
|
||||
const addFromLibrary = useAddMonsterFromLibrary(campaignId);
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
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={close}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add a monster" wide onClose={close}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<MonsterForm
|
||||
saving={createMonster.isPending}
|
||||
onSave={async (stats) => {
|
||||
setError(null);
|
||||
try {
|
||||
const monster = await createMonster.mutateAsync(stats);
|
||||
close();
|
||||
onOpen({ kind: 'monster', id: monster.id, isNew: true });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
throw err;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Npc>) {
|
||||
const createNpc = useCreateNpc(campaignId);
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
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'),
|
||||
});
|
||||
close();
|
||||
onOpen({ kind: 'npc', id: npc.id, isNew: true });
|
||||
} 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={close}>
|
||||
{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,31 +32,63 @@ 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'] });
|
||||
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.
|
||||
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.
|
||||
invalidate(['campaign', campaignId, 'encounters']);
|
||||
};
|
||||
const onRoundAdvanced = () => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||
const onRoundAdvanced = ({ encounterId }: { encounterId?: string } = {}) => {
|
||||
one('encounter', encounterId);
|
||||
};
|
||||
const onCampaignDeleted = () => {
|
||||
invalidate(['campaigns']);
|
||||
invalidate(['campaign', campaignId]);
|
||||
};
|
||||
|
||||
socket.on('character:updated', onCharacterUpdated);
|
||||
socket.on('character:created', onCharacterCreated);
|
||||
socket.on('encounter:participant-updated', onParticipantUpdated);
|
||||
socket.on('encounter:round-advanced', onRoundAdvanced);
|
||||
socket.on('campaign:deleted', onCampaignDeleted);
|
||||
|
||||
return () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
flush();
|
||||
}
|
||||
socket.emit('campaign:leave', campaignId);
|
||||
socket.off('character:updated', onCharacterUpdated);
|
||||
socket.off('character:created', onCharacterCreated);
|
||||
socket.off('encounter:participant-updated', onParticipantUpdated);
|
||||
socket.off('encounter:round-advanced', onRoundAdvanced);
|
||||
socket.off('campaign:deleted', onCampaignDeleted);
|
||||
};
|
||||
}, [campaignId, token, qc]);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
+1244
-8
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
// It was added by hand a moment ago: its pop-out offers to add another.
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Coin, Currency } from '../api/types';
|
||||
|
||||
export const SPECIES_LABELS: Record<string, string> = {
|
||||
HUMAN: 'Human',
|
||||
HALF_ELF: 'Half-Elf',
|
||||
@@ -70,6 +72,38 @@ export function hpBarClass(current: number, max: number): string {
|
||||
return 'hp-bar-fill';
|
||||
}
|
||||
|
||||
// Coins. Silver is worth 0.1 gold and copper 0.01 gold.
|
||||
export const COINS: Coin[] = ['GOLD', 'SILVER', 'COPPER'];
|
||||
// Everything a purse can hold: the coins, plus cash ($) for crossover worlds.
|
||||
// Cash has no worth in coins, so it is never exchanged or used to make change.
|
||||
export const CURRENCIES: Currency[] = [...COINS, 'CASH'];
|
||||
export const COIN_LABELS: Record<Currency, string> = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper', CASH: 'Cash ($)' };
|
||||
export const COIN_FIELD: Record<Currency, 'gold' | 'silver' | 'copper' | 'cash'> = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' };
|
||||
// What one coin is worth, counted in copper.
|
||||
export const COIN_VALUE: Record<Coin, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
|
||||
const COIN_SHORT: Record<Coin, string> = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' };
|
||||
|
||||
// "12 gp", "5 sp", "$40". With `long`, coins are spelled out: "12 gold".
|
||||
export function formatMoney(amount: number, currency: Currency, long = false): string {
|
||||
if (currency === 'CASH') return `$${amount}`;
|
||||
return `${amount} ${long ? COIN_LABELS[currency].toLowerCase() : COIN_SHORT[currency]}`;
|
||||
}
|
||||
|
||||
type Purse = { gold: number | null; silver: number | null; copper: number | null; cash: number | null };
|
||||
|
||||
// "12 gp · 5 sp · 3 cp · $40". Gold always shows; the rest only when held.
|
||||
export function formatPurse(purse: Purse): string {
|
||||
return CURRENCIES.filter((c) => c === 'GOLD' || (purse[COIN_FIELD[c]] ?? 0) > 0)
|
||||
.map((c) => formatMoney(purse[COIN_FIELD[c]] ?? 0, c))
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
// The purse's coins as one figure in gold, e.g. 12.65. Cash is not counted.
|
||||
export function purseWorthInGold(purse: Purse): number {
|
||||
const copper = COINS.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
|
||||
return copper / COIN_VALUE.GOLD;
|
||||
}
|
||||
|
||||
export function extractErrorMessage(err: unknown): string {
|
||||
const anyErr = err as { response?: { data?: { error?: unknown } } };
|
||||
const apiError = anyErr?.response?.data?.error;
|
||||
@@ -86,3 +120,24 @@ export function extractErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return 'Something went wrong.';
|
||||
}
|
||||
|
||||
// A cantrip is spell level 0. It is always called a cantrip, never "level 0"
|
||||
// or "level 1".
|
||||
export function spellLevelLabel(level: number): string {
|
||||
return level === 0 ? 'Cantrip' : `Level ${level}`;
|
||||
}
|
||||
|
||||
// Who can use a spell: a character of at least its level; cantrips, anyone.
|
||||
export function spellRequirementLabel(level: number): string {
|
||||
return level === 0 ? 'any character can use it' : `needs character level ${level}`;
|
||||
}
|
||||
|
||||
// The damage values offered for a spell, grouped by how many dice are rolled.
|
||||
const SPELL_DIE_SIZES = [4, 6, 8, 10, 12];
|
||||
const SPELL_DIE_COUNTS = [1, 2, 3, 4, 5, 6, 8, 10];
|
||||
export const SPELL_DAMAGE_GROUPS: { label: string; options: string[] }[] = SPELL_DIE_COUNTS.map((count) => ({
|
||||
label: count === 1 ? '1 die' : `${count} dice`,
|
||||
options: SPELL_DIE_SIZES.map((size) => `${count}d${size}`),
|
||||
}));
|
||||
// Values the spell library uses that don't fit the pattern above.
|
||||
export const SPELL_DAMAGE_SPECIALS = ['3d4+3', '2d8+4d6', '20'];
|
||||
|
||||
@@ -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,229 @@
|
||||
import type { Ability, CharacterClass, Species } from '../api/types';
|
||||
|
||||
// Plain-language explanations for someone building their first character.
|
||||
// Everything here describes what a choice does to the *numbers* — plus a line
|
||||
// of flavour. The special abilities a species or class grants are listed
|
||||
// separately, from the server's own lists (useBuildOptions).
|
||||
|
||||
export const SPECIES_SPEED: Record<Species, number> = {
|
||||
HUMAN: 30,
|
||||
HALF_ELF: 30,
|
||||
ELF: 30,
|
||||
GNOME: 25,
|
||||
DWARF: 25,
|
||||
HALFLING: 25,
|
||||
DRAGONBORN: 30,
|
||||
TIEFLING: 30,
|
||||
HALF_ORC: 30,
|
||||
ORC: 30,
|
||||
};
|
||||
|
||||
export const SPECIES_GUIDE: Record<Species, { blurb: string; goodFor: string }> = {
|
||||
HUMAN: {
|
||||
blurb: 'Adaptable and ambitious, found in every corner of the world.',
|
||||
goodFor: 'A little better at everything (+1 to all six abilities). A safe pick for any class.',
|
||||
},
|
||||
HALF_ELF: {
|
||||
blurb: 'Walks between two worlds, with human drive and elven grace.',
|
||||
goodFor: 'Very charming (+2 Charisma), and you choose two more abilities to raise by 1. Great for talkers and Charisma spellcasters.',
|
||||
},
|
||||
ELF: {
|
||||
blurb: 'Graceful, long-lived and quick on their feet.',
|
||||
goodFor: 'Nimble (+2 Dexterity): harder to hit, acts sooner in a fight, better with bows and light blades.',
|
||||
},
|
||||
GNOME: {
|
||||
blurb: 'Small, curious tinkerers with sharp minds.',
|
||||
goodFor: 'Clever (+2 Intelligence): ideal for Wizards and knowledge skills. A little slower on foot.',
|
||||
},
|
||||
DWARF: {
|
||||
blurb: 'Stout, stubborn mountain folk who are hard to put down.',
|
||||
goodFor: 'Tough (+2 Constitution): more hit points at every level. A little slower on foot.',
|
||||
},
|
||||
HALFLING: {
|
||||
blurb: 'Small, cheerful and surprisingly brave.',
|
||||
goodFor: 'Nimble (+2 Dexterity): harder to hit and good at sneaking. A little slower on foot.',
|
||||
},
|
||||
DRAGONBORN: {
|
||||
blurb: 'Proud, scaled warriors with dragon blood.',
|
||||
goodFor: 'Strong (+2 Strength) with presence (+1 Charisma): hits hard up close. Great for Paladins and Fighters.',
|
||||
},
|
||||
TIEFLING: {
|
||||
blurb: 'Marked by an infernal bloodline, and used to being stared at.',
|
||||
goodFor: 'Magnetic (+2 Charisma) and bright (+1 Intelligence): great for Warlocks, Sorcerers and Bards.',
|
||||
},
|
||||
HALF_ORC: {
|
||||
blurb: 'Fierce and enduring, with something to prove.',
|
||||
goodFor: 'Strong (+2 Strength) and hardy (+1 Constitution): hits hard and takes hits. Great for Barbarians and Fighters.',
|
||||
},
|
||||
ORC: {
|
||||
blurb: 'Powerful and relentless, raised to survive.',
|
||||
goodFor: 'Strong (+2 Strength) and hardy (+1 Constitution): built for the front line.',
|
||||
},
|
||||
};
|
||||
|
||||
export type Toughness = 'Fragile' | 'Average' | 'Sturdy' | 'Very tough';
|
||||
|
||||
export const CLASS_GUIDE: Record<
|
||||
CharacterClass,
|
||||
{ blurb: string; playsLike: string; keyAbilities: Ability[]; skillCount: number; difficulty: 'Easy' | 'Medium' | 'Tricky' }
|
||||
> = {
|
||||
FIGHTER: {
|
||||
blurb: 'A trained warrior, at home with any weapon.',
|
||||
playsLike: 'Stand at the front, hit things, take hits. The simplest class to play well.',
|
||||
keyAbilities: ['STR', 'DEX', 'CON'],
|
||||
skillCount: 2,
|
||||
difficulty: 'Easy',
|
||||
},
|
||||
BARBARIAN: {
|
||||
blurb: 'A fierce warrior driven by raw fury.',
|
||||
playsLike: 'Charge in and soak up damage — you have the most hit points of any class.',
|
||||
keyAbilities: ['STR', 'CON'],
|
||||
skillCount: 2,
|
||||
difficulty: 'Easy',
|
||||
},
|
||||
ROGUE: {
|
||||
blurb: 'A sneak, scout and scoundrel who relies on cunning.',
|
||||
playsLike: 'Strike from the shadows and solve problems with skills — you get more skills than anyone.',
|
||||
keyAbilities: ['DEX'],
|
||||
skillCount: 4,
|
||||
difficulty: 'Medium',
|
||||
},
|
||||
RANGER: {
|
||||
blurb: 'A hunter and tracker of the wild places.',
|
||||
playsLike: 'Fight from range or up close, with a little nature magic and plenty of outdoor skills.',
|
||||
keyAbilities: ['DEX', 'WIS'],
|
||||
skillCount: 3,
|
||||
difficulty: 'Medium',
|
||||
},
|
||||
WIZARD: {
|
||||
blurb: 'A scholar who has studied magic from books.',
|
||||
playsLike: 'Stay back and cast spells. Powerful but fragile — the fewest hit points of any class.',
|
||||
keyAbilities: ['INT'],
|
||||
skillCount: 2,
|
||||
difficulty: 'Tricky',
|
||||
},
|
||||
SORCERER: {
|
||||
blurb: 'Born with magic in the blood.',
|
||||
playsLike: 'Stay back and cast spells through sheer force of personality. Powerful but fragile.',
|
||||
keyAbilities: ['CHA'],
|
||||
skillCount: 2,
|
||||
difficulty: 'Tricky',
|
||||
},
|
||||
CLERIC: {
|
||||
blurb: 'A priest who channels the power of a god.',
|
||||
playsLike: 'Heal and protect the party, and hold your own in a fight. Every group is glad to have one.',
|
||||
keyAbilities: ['WIS'],
|
||||
skillCount: 2,
|
||||
difficulty: 'Medium',
|
||||
},
|
||||
PALADIN: {
|
||||
blurb: 'A holy knight sworn to an oath.',
|
||||
playsLike: 'A front-line fighter with a little divine magic to back up the sword.',
|
||||
keyAbilities: ['STR', 'CHA'],
|
||||
skillCount: 2,
|
||||
difficulty: 'Medium',
|
||||
},
|
||||
BARD: {
|
||||
blurb: 'A performer whose words and music carry magic.',
|
||||
playsLike: 'The party’s talker and helper: spells, skills, and a way out of trouble.',
|
||||
keyAbilities: ['CHA'],
|
||||
skillCount: 3,
|
||||
difficulty: 'Medium',
|
||||
},
|
||||
WARLOCK: {
|
||||
blurb: 'Made a pact with a powerful being in exchange for magic.',
|
||||
playsLike: 'A spellcaster with a few strong tricks used often, rather than many used rarely.',
|
||||
keyAbilities: ['CHA'],
|
||||
skillCount: 2,
|
||||
difficulty: 'Medium',
|
||||
},
|
||||
DRUID: {
|
||||
blurb: 'A guardian of nature who draws magic from the wild.',
|
||||
playsLike: 'A flexible spellcaster: healing, nature magic and control of the battlefield.',
|
||||
keyAbilities: ['WIS'],
|
||||
skillCount: 2,
|
||||
difficulty: 'Tricky',
|
||||
},
|
||||
MONK: {
|
||||
blurb: 'A martial artist who has trained body and mind.',
|
||||
playsLike: 'Fast and precise: dart around the fight striking quickly, with little need for gear.',
|
||||
keyAbilities: ['DEX', 'WIS'],
|
||||
skillCount: 2,
|
||||
difficulty: 'Medium',
|
||||
},
|
||||
};
|
||||
|
||||
export function hitDieToughness(hitDie: number): Toughness {
|
||||
if (hitDie <= 6) return 'Fragile';
|
||||
if (hitDie <= 8) return 'Average';
|
||||
if (hitDie <= 10) return 'Sturdy';
|
||||
return 'Very tough';
|
||||
}
|
||||
|
||||
// What each ability changes on the sheet this app builds.
|
||||
export const ABILITY_GUIDE: Record<Ability, { short: string; affects: string }> = {
|
||||
STR: {
|
||||
short: 'Physical power',
|
||||
affects: 'Strength — hitting and damage with heavy melee weapons, the Athletics skill (climbing, jumping, grappling), and Strength saves.',
|
||||
},
|
||||
DEX: {
|
||||
short: 'Agility and reflexes',
|
||||
affects:
|
||||
'Dexterity — your Armor Class (how hard you are to hit), your Initiative (how early you act), attacks with bows and light blades, and Acrobatics, Sleight of Hand and Stealth.',
|
||||
},
|
||||
CON: {
|
||||
short: 'Health and stamina',
|
||||
affects: 'Constitution — your hit points, at level 1 and again every level after. Also Constitution saves. Useful for everyone.',
|
||||
},
|
||||
INT: {
|
||||
short: 'Memory and reasoning',
|
||||
affects: 'Intelligence — the Arcana, History, Investigation, Nature and Religion skills, and a Wizard’s spells.',
|
||||
},
|
||||
WIS: {
|
||||
short: 'Awareness and intuition',
|
||||
affects:
|
||||
'Wisdom — noticing things (Perception and Passive Perception), plus Insight, Medicine, Survival and Animal Handling, and the spells of Clerics, Druids and Rangers.',
|
||||
},
|
||||
CHA: {
|
||||
short: 'Force of personality',
|
||||
affects: 'Charisma — Persuasion, Deception, Intimidation and Performance, and the spells of Bards, Paladins, Sorcerers and Warlocks.',
|
||||
},
|
||||
};
|
||||
|
||||
export const SKILL_GUIDE: Record<string, { ability: Ability; what: string }> = {
|
||||
ACROBATICS: { ability: 'DEX', what: 'Staying on your feet: balancing, tumbling, flips.' },
|
||||
ANIMAL_HANDLING: { ability: 'WIS', what: 'Calming, controlling or reading the mood of an animal.' },
|
||||
ARCANA: { ability: 'INT', what: 'Knowing about magic, spells and magical creatures.' },
|
||||
ATHLETICS: { ability: 'STR', what: 'Climbing, jumping, swimming and wrestling.' },
|
||||
DECEPTION: { ability: 'CHA', what: 'Lying convincingly and hiding the truth.' },
|
||||
HISTORY: { ability: 'INT', what: 'Recalling past events, kingdoms, wars and legends.' },
|
||||
INSIGHT: { ability: 'WIS', what: 'Reading people: spotting lies and sensing intentions.' },
|
||||
INTIMIDATION: { ability: 'CHA', what: 'Getting your way through threats and menace.' },
|
||||
INVESTIGATION: { ability: 'INT', what: 'Searching for clues and working out what they mean.' },
|
||||
MEDICINE: { ability: 'WIS', what: 'Steadying the dying and diagnosing illness.' },
|
||||
NATURE: { ability: 'INT', what: 'Knowing about plants, animals, weather and terrain.' },
|
||||
PERCEPTION: { ability: 'WIS', what: 'Noticing things: hidden enemies, traps, faint sounds. Rolled more than any other skill.' },
|
||||
PERFORMANCE: { ability: 'CHA', what: 'Entertaining a crowd with music, acting or storytelling.' },
|
||||
PERSUASION: { ability: 'CHA', what: 'Winning people over with honest charm and good arguments.' },
|
||||
RELIGION: { ability: 'INT', what: 'Knowing about gods, rites, holy symbols and cults.' },
|
||||
SLEIGHT_OF_HAND: { ability: 'DEX', what: 'Picking pockets, palming objects, planting things unseen.' },
|
||||
STEALTH: { ability: 'DEX', what: 'Moving quietly and staying hidden.' },
|
||||
SURVIVAL: { ability: 'WIS', what: 'Tracking, finding food and shelter, not getting lost.' },
|
||||
};
|
||||
|
||||
// One line under each step heading: what this step is and why it matters.
|
||||
export const STEP_GUIDE = {
|
||||
SPECIES: 'Your species is who your character was born as. It nudges a few ability scores up and sets how fast you move.',
|
||||
CLASS: 'Your class is what your character does — warrior, spellcaster, sneak. It sets your hit points, which saves you are good at, your skills, and whether you cast spells.',
|
||||
MOTIVATION: 'Why is your character out adventuring? This is pure story — it changes no numbers, but it gives you something to play.',
|
||||
SCORES:
|
||||
'Six numbers that describe your character. Higher is better: 10 is average, and every 2 points above 10 adds +1 to related rolls. You roll six scores, then decide which ability gets which.',
|
||||
SKILLS: 'Skills are things your character is trained in. When you roll for one of these, you add your proficiency bonus on top.',
|
||||
ABILITIES:
|
||||
'Special abilities are the tricks that make your character more than numbers. Your species and class each grant some automatically, and you pick a couple of talents of your own. Your DM applies them at the table.',
|
||||
BACKGROUND: 'Who was your character before the adventure began? Pick one answer to each question. It changes no numbers — it tells you and your DM who you are playing.',
|
||||
APPEARANCE: 'What do people see when your character walks in? Pick from each list to build the description.',
|
||||
EQUIPMENT: 'What you set out with. Your DM decides what is on offer here, and can hand out more as you play.',
|
||||
} as const;
|
||||
|
||||
export const STEP_NAMES = ['Species', 'Class', 'Motivation', 'Scores', 'Skills', 'Abilities', 'Background', 'Appearance', 'Equipment'] as const;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user