Compare commits

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:58:54 +02:00
joshuaandClaude Opus 5.5 4a6a09bae8 V1.1.0: gear levels, encounter overhaul, guided builder, trading, docs
Characters
- Builder: pick-then-Next on every step with live preview, Back button,
  plain-language help throughout, and "Suggest for me" for ability scores
- Motivation is picked from a list; new steps for special abilities,
  background and appearance, all composed from fixed lists and DM-only
  to edit afterwards
- Copy a character to another campaign
- Characters can give gold, weapons, items and spells to each other
- Money tracker: gold only changes through a logged ledger
- Fixes: level/XP/location are DM-only, HP stays within 0..max, physical
  dice ability rolls, every die is rolled once

Gear
- DM-managed item pool; weapons, items and spells are handed out by the DM
- Starting-option flag per pool entry
- Gear levels 1-9 per held copy with a damage ladder, growth rate and
  starting level; spells gated by spell level with a fixed damage value
- Spell library carries default damage; damage is picked from a list

Encounters
- Battle and NPC quest modes, turn tracking, scene description, DM notes
- NPCs, richer combatant cards, conditions from a list
- Rewards (gold, weapons, items) lined up and awarded, with gold splitting
- Character HP is shared between the sheet and the encounter
- Monster stat blocks and NPC secrets are never sent to players

Other
- Dice roller on every page
- Docs for characters, weapons, spells, items, encounters, monsters and
  NPCs, with full lists generated from the data (npm run docs:lists)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:47:28 +02:00
67 changed files with 16848 additions and 931 deletions
+1
View File
@@ -6,3 +6,4 @@ build/
*.log
.DS_Store
.idea/
temp/
+45 -6
View File
@@ -5,9 +5,25 @@ together: campaign management, a guided dice-roll character builder, live
character sheets, a mid-combat "quick stats" panel, 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, weapon levels and how damage grows |
| `docs/SPELLS.md` | The spell list, who can use which spells, the spell library |
| `docs/ITEMS.md` | The item pool and character inventory |
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, what players see |
| `docs/MONSTERS.md` | The monster list and built-in bestiary |
| `docs/NPCS.md` | Non-player characters |
`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.
## Stack
@@ -28,7 +44,7 @@ npm install # installs both workspaces (backend + frontend)
cd backend
cp .env.example .env # edit DATABASE_URL and JWT_SECRET
npx prisma migrate dev --name init
npx prisma db seed # creates a demo campaign + demo users (see output for login/invite code)
npx prisma db seed # adds the admin account + the Aldenfell campaign if missing (see output for login/invite code)
# Frontend
cd ../frontend
@@ -44,10 +60,33 @@ npm run dev:backend # Express + Socket.io on http://localhost:4000
npm run dev:frontend # Vite dev server, prints its own port (5173 by default)
```
Open the frontend URL, register an account (or log in with the seeded demo
users — see the seed script's console output for credentials and invite
Open the frontend URL, register an account (or log in with the seeded admin
account — see the seed script's console output for credentials and invite
code), and start playing.
The seed only adds: it never touches an existing account or campaign, so it is
safe to run on a server with real data. It skips the campaign if the admin
already has an active "Aldenfell"; archive or delete that one in the app
(Campaign Settings) and run it again for a fresh copy. `SEED_ADMIN_EMAIL`,
`SEED_ADMIN_PASSWORD` and `SEED_ADMIN_NAME` set the account it creates.
`npx prisma db seed -- --reset` wipes every user and campaign first.
A second campaign, Hollowcrown: The Rift Between Worlds, has its own seed and
is not part of the default one. It follows the same rules (it only adds, and
skips if the admin already has an active Hollowcrown):
```bash
cd backend
npm run seed:hollowcrown
```
On a server, after pulling a new version, run `npm run deploy` in `backend`
and then restart the backend. It applies new migrations **and** regenerates
the Prisma client; without the second half the server keeps running with the
old data model and pages that use anything new fail to load (for example
"Could not load this encounter"). Change your password from the account page
(click your name in the top bar).
## Tests
```bash
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.0.0",
"version": "1.4.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -11,9 +11,12 @@
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"docs:lists": "node scripts/generate-doc-lists.js",
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:seed": "prisma db seed",
"deploy": "prisma migrate deploy && prisma generate",
"seed:hollowcrown": "node prisma/seed-hollowcrown.js",
"prisma:studio": "prisma studio"
},
"dependencies": {
@@ -0,0 +1,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';
+197 -10
View File
@@ -86,6 +86,9 @@ enum BuildStage {
ROLLING_MOTIVATION
ASSIGNING_SCORES
ASSIGNING_PROFICIENCIES
CHOOSING_ABILITIES
CHOOSING_BACKGROUND
CHOOSING_APPEARANCE
CHOOSING_EQUIPMENT
COMPLETE
}
@@ -96,6 +99,33 @@ 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 coins a character carries. Their worth is fixed in utils/grants.js:
// 1 gold = 10 silver = 100 copper.
enum Currency {
GOLD
SILVER
COPPER
}
enum NoteVisibility {
DM_ONLY
SHARED
@@ -122,6 +152,7 @@ model User {
charactersOwned Character[] @relation("PlayerCharacters")
charactersCreated Character[] @relation("CreatedBy")
notesAuthored Note[]
goldTransactions GoldTransaction[]
@@map("users")
}
@@ -135,7 +166,12 @@ 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)
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,7 +179,9 @@ model Campaign {
characters Character[]
weapons Weapon[]
spells Spell[]
items Item[]
monsters Monster[]
npcs Npc[]
encounters Encounter[]
notes Note[]
locations Location[]
@@ -160,10 +198,28 @@ model Location {
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
characters Character[]
encounters Encounter[]
visits CharacterLocationVisit[]
@@map("locations")
}
// A location a character has been to. Written whenever a character is put
// somewhere (by the DM, or by an encounter held there), and never removed by
// moving on: it is what decides which locations a player may see.
model CharacterLocationVisit {
id String @id @default(cuid())
characterId String
locationId String
firstVisitedAt DateTime @default(now())
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
@@unique([characterId, locationId])
@@map("character_location_visits")
}
model CampaignMember {
id String @id @default(cuid())
campaignId String
@@ -211,11 +267,17 @@ model Character {
armorBonus Int @default(0) // flat AC bonus from equipped armor/shield
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 is counted on its own and only changes through the
// money ledger (GoldTransaction).
gold Int @default(0)
silver Int @default(0)
copper Int @default(0)
background String? // 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,10 +298,12 @@ model Character {
skillProficiencies CharacterSkillProficiency[]
saveProficiencies CharacterSaveProficiency[]
inventoryItems InventoryItem[]
goldTransactions GoldTransaction[]
weapons CharacterWeapon[]
spells CharacterSpell[]
notes Note[]
encounterEntries EncounterParticipant[]
locationVisits CharacterLocationVisit[]
@@map("characters")
}
@@ -283,10 +347,20 @@ 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)
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
assigned CharacterWeapon[]
rewards EncounterReward[]
@@map("weapons")
}
@@ -297,6 +371,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 +380,60 @@ 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)
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
held InventoryItem[]
rewards EncounterReward[]
@@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,12 +442,14 @@ 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)
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
known CharacterSpell[]
@@ -381,31 +499,99 @@ model Monster {
@@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?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
encounterAppearances EncounterParticipant[]
@@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?
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[]
@@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)
@@map("encounter_rewards")
}
// 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,6 +601,7 @@ 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)
@@map("encounter_participants")
}
+32
View File
@@ -0,0 +1,32 @@
// Adds the Hollowcrown: The Rift Between Worlds campaign
// (seed-data/hollowcrown.js) for the admin account. Not part of the default
// seed — run it on its own:
//
// npm run seed:hollowcrown
//
// Like every seed it only adds (see seedCampaign.js): it never touches other
// campaigns, and does nothing if the admin already has an active Hollowcrown.
const { run, fromLibrary } = require('./seedCampaign');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../src/game/data/library');
const hollowcrown = require('../seed-data/hollowcrown.js');
const startingSpells = new Set(hollowcrown.startingSpells);
for (const key of startingSpells) fromLibrary(SPELL_LIBRARY, key); // fail early on a misspelt key
run({
...hollowcrown,
// The book's starting picks come from the site library; its modern relics
// and magic weapons are never starting options.
weapons: [
...hollowcrown.libraryWeapons.map((key) => fromLibrary(WEAPON_LIBRARY, key, { isStartingOption: true })),
...hollowcrown.weapons,
],
// The whole spell library goes in, so a spell is there to hand out the
// moment a character unlocks it; only the book's Session 0 list is offered
// in the builder.
spells: [
...SPELL_LIBRARY.map(({ key }) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: startingSpells.has(key) })),
...hollowcrown.spells,
],
monsters: [...hollowcrown.libraryMonsters.map((key) => fromLibrary(MONSTER_LIBRARY, key)), ...hollowcrown.monsters],
});
+25 -103
View File
@@ -1,110 +1,32 @@
// Resets the database and seeds the Aldenfell campaign (see
// docs/Aldenfell - Full Campaign Book.docx and seed-data/aldenfell.js).
// The default seed: the Aldenfell campaign (seed-data/aldenfell.js) for the
// admin account. See seedCampaign.js for what a seed does and why it is safe
// to run against a live database.
//
// DESTRUCTIVE: every user, campaign, character, note, etc. is deleted first.
// npx prisma db seed add what is missing
// npx prisma db seed -- --reset DESTRUCTIVE: delete every user, campaign,
// character, note, etc. first
//
// Other campaigns have their own command (npm run seed:hollowcrown).
//
// Species/classes/skills are NOT seeded as rows — they're fixed game-rule
// tables ported into src/game/data/*.js, as is the default weapon/spell/monster
// library DMs pull from. The default weapons (seed-data/weapons.json) ARE
// seeded, into the campaign, since weapons are per-campaign in the schema.
const path = require('path');
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
const { generateInviteCode } = require('../src/utils/inviteCode');
const { run, fromLibrary } = require('./seedCampaign');
const { SPELL_LIBRARY } = require('../src/game/data/library');
const defaultWeapons = require('../seed-data/weapons.json');
const aldenfell = require('../seed-data/aldenfell.js');
const prisma = new PrismaClient();
const weapons = require(path.join(__dirname, '..', 'seed-data', 'weapons.json'));
const aldenfell = require(path.join(__dirname, '..', 'seed-data', 'aldenfell.js'));
const ADMIN_EMAIL = 'joshua@crosscode.co.za';
const ADMIN_PASSWORD = '12345678';
async function resetDatabase() {
// Truncating the two roots cascades through every table that references them.
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
}
async function main() {
await resetDatabase();
const admin = await prisma.user.create({
data: {
email: ADMIN_EMAIL,
passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12),
displayName: 'Joshua',
},
});
const campaign = await prisma.campaign.create({
data: {
...aldenfell.campaign,
inviteCode: generateInviteCode(),
ownerId: admin.id,
members: { create: { userId: admin.id, role: 'DM' } },
},
});
await prisma.weapon.createMany({ data: weapons.map((w) => ({ ...w, campaignId: campaign.id })) });
await prisma.location.createMany({
data: aldenfell.locations.map((l) => ({ ...l, campaignId: campaign.id })),
});
const monsterByName = {};
for (const monster of aldenfell.monsters) {
const created = await prisma.monster.create({ data: { ...monster, campaignId: campaign.id } });
monsterByName[created.name] = created;
}
for (const enc of aldenfell.encounters) {
const participants = [];
for (const [name, count] of enc.monsters) {
const monster = monsterByName[name];
if (!monster) throw new Error(`Encounter "${enc.name}" references unknown monster "${name}"`);
for (let i = 1; i <= count; i++) {
participants.push({
monsterId: monster.id,
displayName: count > 1 ? `${monster.name} #${i}` : monster.name,
currentHp: monster.maxHp,
maxHp: monster.maxHp,
isMonster: true,
});
}
}
await prisma.encounter.create({
data: { campaignId: campaign.id, name: enc.name, participants: { create: participants } },
});
}
// Notes list newest-first, so stamp them in reverse to keep book order on screen.
const allNotes = [
...aldenfell.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
...aldenfell.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
];
const now = Date.now();
await prisma.note.createMany({
data: allNotes.map((n, i) => ({
...n,
campaignId: campaign.id,
authorId: admin.id,
createdAt: new Date(now - i * 1000),
})),
});
console.log('Seed complete.');
console.log(` Admin/DM login: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
console.log(
` ${weapons.length} weapons, ${aldenfell.locations.length} locations, ${aldenfell.monsters.length} monsters, ` +
`${aldenfell.encounters.length} encounters, ${allNotes.length} notes`,
);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
run(
{
...aldenfell,
// Everyday weapons are what a new character picks from; the rest are earned.
weapons: [...defaultWeapons.map((w) => ({ ...w, isStartingOption: true })), ...aldenfell.weapons],
items: aldenfell.items.map(({ starting, ...item }) => ({ ...item, isStartingOption: Boolean(starting) })),
spells: [
...aldenfell.spells.starting.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: true })),
...aldenfell.spells.earned.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: false })),
],
},
{ reset: process.argv.includes('--reset') },
);
+210
View File
@@ -0,0 +1,210 @@
// Shared by the campaign seeds (seed.js, seed-hollowcrown.js): makes sure the
// admin account exists, then adds one fully-built campaign for it.
//
// A seed only ever ADDS, so it is safe against a live database. The admin
// account is created if it is missing (an existing one, and its password, is
// left alone), and the campaign is added unless that account already owns an
// active campaign of the same name. To get a fresh copy, archive or delete the
// old one in the app and run the seed again.
//
// SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD / SEED_ADMIN_NAME override the
// account that is created.
//
// `content` is one campaign, with every pool as rows shaped like the Prisma
// models minus ids:
// campaign, locations, weapons, items, spells, monsters, npcs,
// sharedNotes, dmNotes (note bodies, in reading order), and
// encounters, in the order they should list:
// { name, mode, description, dmNotes,
// location: a location's name (optional) — where the scene is,
// monsters: [[monster name, count, display name?, max HP?], ...],
// npcs: [NPC name | { name, hp }, ...],
// rewards: [{ gold } | { weapon } | { item }, each with optional
// quantity, level (defaults to the entry's starting level)
// and note] }
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
const { generateInviteCode } = require('../src/utils/inviteCode');
const prisma = new PrismaClient();
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL || 'joshua@crosscode.co.za';
const ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD || '12345678';
const ADMIN_NAME = process.env.SEED_ADMIN_NAME || 'Joshua';
// Notes list newest-first on screen, so they are stamped a second apart,
// backwards: the first entry in the seed data is the one at the top.
function bookOrder(index) {
return new Date(Date.now() - index * 1000);
}
function byName(rows) {
return new Map(rows.map((row) => [row.name, row]));
}
function lookup(map, name, what, encounterName) {
const row = map.get(name);
if (!row) throw new Error(`Encounter "${encounterName}" references unknown ${what} "${name}"`);
return row;
}
async function createCampaign(db, admin, content) {
const campaign = await db.campaign.create({
data: {
...content.campaign,
inviteCode: generateInviteCode(),
ownerId: admin.id,
members: { create: { userId: admin.id, role: 'DM' } },
},
});
const campaignId = campaign.id;
const inCampaign = (rows) => rows.map((row) => ({ ...row, campaignId }));
await db.location.createMany({ data: inCampaign(content.locations) });
await db.weapon.createMany({ data: inCampaign(content.weapons) });
await db.item.createMany({ data: inCampaign(content.items) });
await db.spell.createMany({ data: inCampaign(content.spells) });
await db.monster.createMany({ data: inCampaign(content.monsters) });
await db.npc.createMany({ data: inCampaign(content.npcs) });
const monsters = byName(await db.monster.findMany({ where: { campaignId } }));
const npcs = byName(await db.npc.findMany({ where: { campaignId } }));
const weapons = byName(await db.weapon.findMany({ where: { campaignId } }));
const items = byName(await db.item.findMany({ where: { campaignId } }));
const locations = byName(await db.location.findMany({ where: { campaignId } }));
let rewardCount = 0;
for (const [index, enc] of content.encounters.entries()) {
const participants = [];
for (const [name, count, displayName, hp] of enc.monsters || []) {
const monster = lookup(monsters, name, 'monster', enc.name);
const label = displayName || monster.name;
const maxHp = hp ?? monster.maxHp;
for (let i = 1; i <= count; i++) {
participants.push({
monsterId: monster.id,
displayName: count > 1 ? `${label} #${i}` : label,
currentHp: maxHp,
maxHp,
isMonster: true,
});
}
}
for (const entry of enc.npcs || []) {
const { name, hp } = typeof entry === 'string' ? { name: entry } : entry;
const npc = lookup(npcs, name, 'NPC', enc.name);
// An NPC with no combat stats takes part with no hit points to track.
const maxHp = hp ?? npc.maxHp ?? 0;
participants.push({ npcId: npc.id, displayName: npc.name, currentHp: maxHp, maxHp, isMonster: false });
}
// Rewards list oldest-first, so these are stamped forwards.
const rewards = (enc.rewards || []).map(({ gold, weapon, item, quantity, level, note }, i) => {
const createdAt = new Date(Date.now() + i);
if (gold) return { type: 'GOLD', gold, note, createdAt };
const entry = weapon ? lookup(weapons, weapon, 'weapon', enc.name) : lookup(items, item, 'item', enc.name);
return {
type: weapon ? 'WEAPON' : 'ITEM',
[weapon ? 'weaponId' : 'itemId']: entry.id,
quantity: quantity ?? 1,
level: Math.max(level ?? entry.minLevel, entry.minLevel),
note,
createdAt,
};
});
rewardCount += rewards.length;
await db.encounter.create({
data: {
campaignId,
name: enc.name,
mode: enc.mode,
description: enc.description,
dmNotes: enc.dmNotes,
locationId: enc.location ? lookup(locations, enc.location, 'location', enc.name).id : null,
sortOrder: index,
participants: { create: participants },
rewards: { create: rewards },
},
});
}
const notes = [
...content.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
...content.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
];
await db.note.createMany({
data: notes.map((n, i) => ({ ...n, campaignId, authorId: admin.id, createdAt: bookOrder(i) })),
});
const starting = (rows) => rows.filter((row) => row.isStartingOption).length;
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
console.log(
` ${content.weapons.length} weapons (${starting(content.weapons)} starting), ${content.items.length} items ` +
`(${starting(content.items)} starting), ${content.spells.length} spells (${starting(content.spells)} starting), ` +
`${content.locations.length} locations`,
);
console.log(
` ${content.monsters.length} monsters, ${content.npcs.length} NPCs, ${content.encounters.length} encounters ` +
`(${rewardCount} rewards lined up), ${notes.length} notes`,
);
}
async function resetDatabase() {
// Truncating the two roots cascades through every table that references them.
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
}
// `reset` is DESTRUCTIVE: every user, campaign, character, note, etc. is
// deleted before anything is added.
async function seed(content, { reset = false } = {}) {
if (reset) {
await resetDatabase();
console.log('Database reset: every user and campaign was deleted.');
}
let admin = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } });
if (admin) {
console.log(`Admin account ${ADMIN_EMAIL} already exists — left as it is.`);
} else {
admin = await prisma.user.create({
data: { email: ADMIN_EMAIL, passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12), displayName: ADMIN_NAME },
});
console.log(`Admin/DM login created: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
console.log(' Change that password from the account page (click your name, top right) once you are logged in.');
}
const existing = await prisma.campaign.findFirst({
where: { ownerId: admin.id, name: content.campaign.name, archivedAt: null },
});
if (existing) {
console.log(`${ADMIN_EMAIL} already has an active "${existing.name}" campaign — nothing added.`);
console.log(' For a fresh copy, archive or delete it under Campaign Settings and run the seed again.');
return;
}
// One transaction, so a failed seed doesn't leave half a campaign behind.
console.log('Seeding campaign...');
await prisma.$transaction((tx) => createCampaign(tx, admin, content), { timeout: 120000, maxWait: 20000 });
console.log('Seed complete.');
}
// Runs a seed script to completion: exit code 1 on failure, connection closed either way.
function run(content, options) {
return seed(content, options)
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
}
// A copy of a library entry as a pool row: its `key` and picker-only tags dropped.
function fromLibrary(library, key, extra = {}) {
const template = library.find((entry) => entry.key === key);
if (!template) throw new Error(`Unknown library entry "${key}"`);
const { key: _key, category, classes, ...row } = template;
return { ...row, ...extra };
}
module.exports = { run, fromLibrary };
+117
View File
@@ -0,0 +1,117 @@
// Writes the full lists (every library spell, weapon 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, 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,
]);
return `### ${category} (${weapons.length})\n\n${table(['Weapon', 'Base damage', 'Type', 'Attacks with', 'Properties', 'Range'], 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 },
'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}`);
}
+409 -20
View File
@@ -1,6 +1,8 @@
// Aldenfell campaign content, transcribed (and condensed where a note would
// otherwise be unreadable) from docs/Aldenfell - Full Campaign Book.docx.
// Consumed by prisma/seed.js. Shapes match the Prisma models minus ids.
// Aldenfell campaign content: the gazetteer, bestiary and notes are condensed
// from the Aldenfell Campaign Book; the gear, item pool, NPC list and scenes
// are built out from it to use everything a campaign can hold.
// Consumed by prisma/seed.js. Shapes match the Prisma models minus ids, except
// where a section's comment says otherwise.
const campaign = {
name: 'Aldenfell',
@@ -129,28 +131,415 @@ const monsters = [
specialTrait: 'Undead — vulnerable to bludgeoning',
notes: 'Ordinary restless dead for the Sunken Vaults’ Restless Ossuary (Room 4). Not tied to the Hollow King; DC 12 Religion identifies that proper deconsecration would settle them.',
},
{
name: 'Feral Guard Dog', challengeRating: '1/8', ac: 12, maxHp: 5, speed: 40,
strScore: 13, dexScore: 14, conScore: 12, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 3, damageDice: '1d6+1',
specialTrait: 'Keen hearing and smell — advantage on Perception checks that rely on them.',
notes: 'Medium beast, 25 XP. Bite +3 (1d6+1 piercing, DC 11 Str or prone). A chapel mastiff gone half-wild in the Sunken Vaults’ Old Crypt Hall (Room 2). Starving, not vicious — DC 12 Animal Handling or a thrown ration ends the fight.',
},
];
// ---------------------------------------------------------------------------
// Encounters — pre-built (PLANNING) for the combat beats of the Campaign Book.
// Each participant is [monster name, count]; instances get "#n" suffixes.
// Weapons the party earns. The everyday weapons (seed-data/weapons.json) are
// seeded as starting options; these are not, so they only reach a character as
// a reward or a gift. damageDice is the damage at minLevel, and a character
// must be at least a held copy's level to use it. damageScaling: 1 standard,
// 2 powerful, 3 legendary.
// ---------------------------------------------------------------------------
const weapons = [
{ name: 'Masterwork Dagger', damageDice: '1d6', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'thrown'], rangeNormal: 20, rangeLong: 60, minLevel: 2, damageScaling: 1 },
{ name: 'Smuggler’s Cutlass', damageDice: '1d8', damageType: 'slashing', attackAbility: 'FINESSE', properties: ['finesse'], rangeNormal: null, rangeLong: null, minLevel: 2, damageScaling: 1 },
{ name: 'Warden’s Longbow', damageDice: '1d10', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'heavy', 'two-handed'], rangeNormal: 150, rangeLong: 600, minLevel: 2, damageScaling: 2 },
{ name: 'Grey Dawn Longsword', damageDice: '1d10', damageType: 'slashing', attackAbility: 'STR', properties: ['versatile (1d12)'], rangeNormal: null, rangeLong: null, minLevel: 3, damageScaling: 2 },
{ name: 'Greyford Halberd', damageDice: '1d12', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'reach', 'two-handed'], rangeNormal: null, rangeLong: null, minLevel: 3, damageScaling: 1 },
{ name: 'Stonejaw Maul', damageDice: '2d8', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['heavy', 'two-handed'], rangeNormal: null, rangeLong: null, minLevel: 4, damageScaling: 2 },
{ name: 'Threndir’s Fang', damageDice: '1d8', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light'], rangeNormal: null, rangeLong: null, minLevel: 5, damageScaling: 2 },
{ name: 'Hollowbane', damageDice: '2d8', damageType: 'radiant', attackAbility: 'FINESSE', properties: ['finesse', 'versatile (2d10)'], rangeNormal: null, rangeLong: null, minLevel: 7, damageScaling: 3 },
];
// ---------------------------------------------------------------------------
// Item pool. Items are descriptive: holding one never changes a number on the
// sheet. `starting: true` offers the item in the character builder.
// ---------------------------------------------------------------------------
const items = [
// Starting kit
{ name: 'Potion of Healing', description: 'A red liquid that glimmers when shaken. Drink it (an action) to regain the hit points shown.', damageDice: '2d4+2', starting: true },
{ name: 'Traveller’s Pack', description: 'Bedroll, mess kit, tinderbox, waterskin and five days of rations.', starting: true },
{ name: 'Rope (50 ft)', description: 'Hempen rope. Holds a climber; DC 17 Strength to burst.', starting: true },
{ name: 'Torches (bundle of 5)', description: 'Each burns for an hour: bright light for 20 feet, dim for 20 more.', starting: true },
{ name: 'Healer’s Kit', description: 'Ten uses. Spend one to stabilise a creature at 0 hit points without a Medicine check.', starting: true },
{ name: 'Thieves’ Tools', description: 'Picks, a small file and a mirror on a handle. Needed to pick a lock or disarm a trap.', starting: true },
{ name: 'Shield', description: 'A wooden or steel shield, +2 Armor Class while carried. Ask the DM to add it to your armor bonus.', starting: true },
{ name: 'Leather Armor', description: 'Boiled leather, Armor Class 11 + Dexterity. Ask the DM to set your armor bonus.', starting: true },
{ name: 'Spellcasting Focus', description: 'A wand, holy symbol, crystal or carved staff that your spells are cast through.', starting: true },
{ name: 'Founding Fair Token', description: 'A stamped brass token from the Lantern Market. Good for one drink at any stall, or a small favour from whoever ran the game you won it at.', starting: true },
// Found and earned
{ name: 'Potion of Greater Healing', description: 'A deeper red than the common potion. Drink it (an action) to regain the hit points shown.', damageDice: '4d4+4', minLevel: 3 },
{ name: 'Alchemist’s Fire', description: 'A sticky flask that ignites in air. Throw it up to 20 feet: the target burns for the damage shown at the start of each of its turns until someone spends an action putting it out.', damageDice: '1d4' },
{ name: 'Silver Locket', description: 'A tarnished locket from the Old Crypt Hall, worth about 15 gp. The portrait inside has been scratched out.' },
{ name: 'Smugglers’ Ledger Page', description: 'A torn page from a crate in the Sunken Vaults, stamped with a merchant sigil nobody at the Concord Exchange recognises. Half a name survives in the margin.' },
{ name: 'Bell-Ringer’s Keepsake', description: 'A worn brass bell-clapper charm, pressed into your hand with more gratitude than its owner could put into words.' },
{ name: 'Grey Dawn Signet', description: 'An iron ring bearing the Order’s rising sun. Shown at Grey Dawn Hall or to any patrol, it marks you as someone the Order vouches for.' },
{ name: 'Concord Writ of Passage', description: 'A sealed Merchant Concord contract. Waives tolls and gate fees across Aldenfell while the Concord still considers you good for business.' },
{ name: 'Warden’s Charm', description: 'A knot of greywood bark and red thread. The Wardens will know you by it, and the forest’s paths seem a little easier to find while you carry it.' },
{ name: 'Stonejaw Oath-Token', description: 'A river stone the size of a fist, carved with the Stonejaw clan mark. Proof that your word was tested and held.' },
{ name: 'Half-Burned Letter', description: 'Found among a missing person’s belongings in the Root. Most of it is ash. What is left mentions payments, a shrine, and a family crest.' },
{ name: 'Silver Shrine Relic', description: 'A small silver figure lifted from black water beneath the Greywood, worth about 75 gp. Its style is older than the kingdom.' },
{ name: 'Sealed Letter', description: 'Heavy paper, an unbroken wax seal, and a servant’s whispered warning about who must never know you have it.' },
{ name: 'Root-Fiber Scrap', description: 'A length of pale root that is still, very slightly, moving. It is cold, and it does not rot.' },
{ name: 'Seal-Stone Fragment', description: 'A shard of worked stone from the edge of the breach. Script runs along one face. It is warm, and holding it makes the bell script easier to look at.' },
];
// ---------------------------------------------------------------------------
// Spells, pulled from the default library (src/game/data/spellsLibrary.js) by
// key. A new character can only pick starting spells of their own level or
// lower, so the starting set is cantrips and 1st level; the rest are for the
// DM to teach later.
// ---------------------------------------------------------------------------
const spells = {
starting: [
'chill-touch', 'eldritch-blast', 'fire-bolt', 'guidance', 'light', 'mage-hand', 'minor-illusion',
'prestidigitation', 'produce-flame', 'ray-of-frost', 'sacred-flame', 'shocking-grasp', 'spare-the-dying',
'thorn-whip', 'vicious-mockery',
'bless', 'burning-hands', 'charm-person', 'command', 'cure-wounds', 'detect-magic', 'entangle', 'faerie-fire',
'guiding-bolt', 'healing-word', 'mage-armor', 'magic-missile', 'shield', 'sleep', 'thunderwave',
],
earned: [
'hold-person', 'invisibility', 'lesser-restoration', 'misty-step', 'moonbeam', 'scorching-ray', 'shatter',
'spiritual-weapon',
'counterspell', 'daylight', 'dispel-magic', 'fireball', 'remove-curse', 'revivify',
'banishment',
],
};
// ---------------------------------------------------------------------------
// NPCs. `description` is what the party sees once the NPC is in a scene;
// `dmNotes` never leaves the DM's screen. Combat stats only where a fight is
// plausible.
// ---------------------------------------------------------------------------
const npcs = [
{
name: 'King Aldric Veyrmont', role: 'King of Aldenfell', disposition: 'NEUTRAL',
description: 'An aging king with a soldier’s posture and tired eyes. Courteous, proud, and quick to steer talk away from anything that sounds like bad news.',
dmNotes: 'Tired, proud, in denial — cornered, not ill. Pays well for quiet problem-solving and reacts badly to blunt truth. An audience needs a sponsor (a knight, a noble, a Concord representative). The crown fractures publicly at Stage 3.',
},
{
name: 'Princess Isolde Veyrmont', role: 'Heir to the throne', disposition: 'FRIENDLY',
description: 'Sharp, pragmatic and well liked. She keeps a small private study at the palace and has a habit of asking capable outsiders exactly the right question.',
dmNotes: 'The best long-term ally, and she recruits outsiders on purpose. Already suspects something is deeply wrong, so she is the most likely to believe the party early. Reporting to her at E10B sets up Act 3’s politics. Rumour says she will move against her father — misleading.',
},
{
name: 'Ser Kavan Thorne', role: 'Knight-Commander of the Grey Dawn', disposition: 'FRIENDLY',
description: 'Rigid, honorable and loyal to a fault. He runs Grey Dawn Hall by the book and judges people by whether they do what they said they would.',
dmNotes: 'Recruits the party (E4A → Grey Dawn track). SECRET: the Order’s founding is tied to the original sacrifice, and Thorne does not know. He struggles when evidence points at the crown; whether and how the party tells him is theirs to choose. If Ressa Vane escorted them (E9C) he already suspects by E10B.',
ac: 18, maxHp: 65, attackBonus: 6, damageDice: '1d8+4',
},
{
name: 'Mireille Costa', role: 'Merchant Concord representative', disposition: 'NEUTRAL',
description: 'Charming and transactional, usually found at the Concord Exchange with a contract already drafted. She always has a job, and she always pays what she promised.',
dmNotes: 'Source of paying side work (E4B → The Smugglers’ Tithe). The Concord is neutral and pragmatic, and drops anyone who becomes bad for business — including the party.',
},
{
name: 'Ashwen', role: 'Warden of the Greywood', disposition: 'NEUTRAL',
description: 'Terse and watchful, with a longbow that never seems to be out of reach. She answers to the forest, not to the crown.',
dmNotes: 'Suspicious, not hostile — a slow-burn ally. The first to notice the old shrines acting strangely (E4C → The Warden’s Silence). After that quest she is convinced something is deeply wrong, whatever happened to her Warden.',
ac: 15, maxHp: 44, attackBonus: 6, damageDice: '1d8+3',
},
{
name: 'Threndir Ashclaw', role: 'Old copper dragon of the Spine', disposition: 'NEUTRAL',
description: 'Vast, scarred and weary, coiled in an eyrie high on the eastern peaks. He grants an audience to anyone who approaches openly, and he prefers words to fire.',
dmNotes: 'Blunt, and wants the kingdom to survive. He wants proof the crown can still be trusted — the intended “win” is a negotiation (Persuasion DC 15–18). Always offers to talk unless attacked first. If it does come to a fight, use the “Threndir Ashclaw (old copper dragon)” monster stat block.',
},
{
name: 'Korgath Stonejaw', role: 'Chieftain of the Stonejaw clan', disposition: 'NEUTRAL',
description: 'A hill giant chieftain with a voice like a rockslide and no patience for politics. Outsiders in his hold are tested, not attacked.',
dmNotes: 'Honor-bound: trials, contests and bargains, never ambushes. Offers the Trial of Stone instead of an agreement; success is a standing alliance (Spine Clans track), failure earns a second, harder trial. Nothing sinister here — it is a genuine test of character.',
ac: 13, maxHp: 105, attackBonus: 8, damageDice: '3d8+5',
},
{
name: 'Vessa Hollow', role: 'A voice in the dark', disposition: 'NEUTRAL',
description: 'A woman’s voice — calm, patient, almost kind. She does not blink. She speaks as though she has been waiting a very long time and can wait a little longer.',
dmNotes: 'SECRET: one of the people Highcrown’s founders sacrificed, and the Hollow King’s sympathetic “face”. She believes waking the rest is justice. Unsettling, never mustache-twirling; DC 16 Insight confirms she believes what she says. Use her in dreams and omens long before she appears. Negotiating with her is a legitimate outcome. She does not fight — any combat near her uses Hollowed guardians.',
},
{
name: 'Corporal Ressa Vane', role: 'Grey Dawn warden escort', disposition: 'FRIENDLY',
description: 'A steady, plain-spoken corporal of the Grey Dawn, assigned to see you safely through the tunnels. She writes things down.',
dmNotes: 'Only appears if the party took Grey Dawn recruitment (E4A → E9C). She lets them skip to Root Room 2, and she reports everything back to Grey Dawn Hall — which matters at E10B and E15.',
ac: 16, maxHp: 27, attackBonus: 4, damageDice: '1d8+2',
},
{
name: 'The Bell-Ringer', role: 'Bell-ringer of the Founding Chapel', disposition: 'FRIENDLY',
description: 'A stooped, soft-spoken man who has rung the chapel bell every hour for most of his life. Badly shaken, and very grateful to see a friendly face.',
dmNotes: 'Found alive in Vault Room 5 (E3). He saw “someone who looked like his neighbor, except wrong”. Returns in The Bell-Ringer’s Debt: he owes a Highcrown lender who operates under the corrupted house’s protection (not knowable yet). A loyal low-key ally either way.',
},
{
name: 'A Frightened Local', role: 'Witness outside the chapel', disposition: 'NEUTRAL',
description: 'A market porter who was passing the chapel when the bell stopped. Pale, talking too fast, and not keen to go back inside.',
dmNotes: 'DC 12 Persuasion or Insight gets the account of “a woman who wasn’t blinking”. If the party talks to the crowd first (E2C) they get three witness accounts but no script lead, and their E10 Insight check is at advantage.',
},
{
name: 'The Lender', role: 'Highcrown moneylender', disposition: 'HOSTILE',
description: 'A neat, unhurried man who keeps his ledgers tidier than his conscience. He never raises his voice; he has collectors for that.',
dmNotes: 'Holds the bell-ringer’s debt. Operates under the corrupted noble house’s protection — digging into who backs him is harder but plants a clue toward the house before Act 2. Can be paid off, intimidated, or exposed.',
ac: 12, maxHp: 22, attackBonus: 3, damageDice: '1d4+1',
},
{
name: 'Greyford’s Commander', role: 'Commander of Greyford Keep', disposition: 'NEUTRAL',
description: 'An exhausted officer holding a border fortress with half the troops and a third of the supplies it needs. Blunt, overworked, and out of patience.',
dmNotes: 'Wants the Sorrow Camp “moved along” — and is not wrong about the strategic liability. No clean villain in Refugees of the Marches. Whether Greyford is supplied (The Smugglers’ Tithe outcome) decides rescue vs recovery at E11 and whether the keep holds at E16.',
ac: 17, maxHp: 52, attackBonus: 5, damageDice: '1d8+3',
},
];
// ---------------------------------------------------------------------------
// Encounters — pre-built (PLANNING) scenes for the beats of the Campaign Book,
// in book order. BATTLE scenes run in initiative order; QUEST scenes are built
// around NPCs and an objective.
// location — a location's name: where the scene is. Its characters move
// there when it begins.
// description — the scene, shown to the players in it: no spoilers.
// dmNotes — DCs, branches and secrets, DM only.
// monsters — [monster name, count, optional display name]; a count above
// one gets "#n" suffixes.
// npcs — NPC names.
// rewards — lined up unawarded, so they stay the DM's secret until
// handed out. { gold } | { weapon } | { item }, with optional
// quantity, level (defaults to the entry's starting level) and
// note.
// ---------------------------------------------------------------------------
const encounters = [
{ name: 'E3 — Sunken Vaults: The Restless Ossuary', monsters: [['Skeleton', 2]] },
{ name: 'E6 — The Bell-Ringer’s Debt: Collectors', monsters: [['Highcrown Cutthroat', 3]] },
{ name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', monsters: [['Bandit Captain', 1], ['Highcrown Cutthroat', 4]] },
{ name: 'Side Quest — The Warden’s Silence: Direwolf Pack', monsters: [['Greywood Direwolf', 3]] },
{ name: 'Side Quest — The Warden’s Silence: The Shrine', monsters: [['Hollowed', 1]] },
{ name: 'Side Quest — What the Tide Brought In: The Reef', monsters: [['Reef Terror', 1]] },
{ name: 'E9 — The Root: Weeping Passage', monsters: [['Bellcrawler', 1]] },
{ name: 'E9 — The Root: Hollowed Nest', monsters: [['Hollowed', 2]] },
{ name: 'E9 — The Root: Root Stalker’s Warren', monsters: [['Root Stalker', 1]] },
{ name: 'E9 — The Root: Bellcrawler Gallery', monsters: [['Bellcrawler', 3]] },
{ name: 'E12 — A Line Thins', monsters: [['Root Stalker', 1]] },
{ name: 'E14 — The Break', monsters: [['Hollowed', 4]] },
{ name: 'Mid-campaign scare — The Hollow King’s Shadow', monsters: [['The Hollow King’s Shadow', 1]] },
{ name: 'E25 — The Last Approach', monsters: [['Root Stalker', 1], ['Bellcrawler', 2]] },
{
name: 'E1 — The Founding Fair', mode: 'QUEST', location: 'Highcrown — The Lantern Market',
description: 'Highcrown’s Lantern Market at the height of the Founding Fair: lantern-strung stalls, contests of strength and skill, minstrels, and strangers from every corner of the kingdom. This is where you meet.',
dmNotes: 'Character creation happens here (Roll-Your-Character System) — it can be the whole first half of session one. No combat. Let each PC win or lose something small at a stall, and introduce Mireille and Ser Kavan in passing so they are familiar faces by E4. Ends when the chapel bell fails to ring → E2.',
npcs: ['Mireille Costa', 'Ser Kavan Thorne'],
},
{
name: 'E2 — The Bell That Wasn’t Ringing', mode: 'QUEST', location: 'Highcrown — The Founding Chapel',
description: 'The Founding Chapel’s bell has rung every hour for as long as anyone remembers. It has stopped. A crowd is gathering outside, and someone is asking for volunteers to go in and look.',
dmNotes: 'No break-in. The bell rope is neatly cut and left coiled; the bell-ringer is missing, not dead; a line of unfamiliar script is scratched inside the bell (Hollow King iconography). DC 13 Investigation finds the rope and script; DC 12 Persuasion/Insight gets the “woman who wasn’t blinking”.\n• E2A investigate immediately: full clues; skip Vault Room 1.\n• E2B distracted first: the guard missed the script; DC 13 Investigation to find it; start at Room 1.\n• E2C talk to the crowd first: three witness accounts, no script lead; E10 Insight later at advantage.\nThe trail leads to the cellar → E3.',
npcs: ['Ser Kavan Thorne', 'A Frightened Local'],
},
{
name: 'E3 — Sunken Vaults: The Old Crypt Hall', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
description: 'A long hall of burial niches beneath the chapel, some sealed, some broken open. Something is growling in the dark between the pillars.',
dmNotes: 'Vault Room 2, the hub (→ Rooms 3, 4, stair to 6). The dog is starving, not vicious: DC 12 Animal Handling or a thrown ration ends it. DC 13 Investigation: the open niches are just old theft. DC 12 Perception finds the locket.',
monsters: [['Feral Guard Dog', 1]],
rewards: [{ item: 'Silver Locket', note: 'Room 2 — DC 12 Perception' }],
},
{
name: 'E3 — Sunken Vaults: The Restless Ossuary', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
description: 'Bones stacked floor to ceiling, and an offering box furred with dust. The air is still. Then it isn’t.',
dmNotes: 'Vault Room 4, a dead end. One or two skeletons rise halfway through the room — ordinary restless dead, not tied to the Hollow King. DC 12 Religion: proper deconsecration would settle them without a fight. DC 13 to open the offering box.',
monsters: [['Skeleton', 2]],
rewards: [{ gold: 25, note: 'Offering box — DC 13 to open' }],
},
{
name: 'E3 — Sunken Vaults: The Bell-Ringer’s Refuge', mode: 'QUEST', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
description: 'A side chamber with a bedroll, a cold lantern, and a frightened old man who very much hopes you are who you look like.',
dmNotes: 'Vault Room 5. The bell-ringer is alive but shaken: he saw “someone who looked like his neighbor, except wrong”. Getting him out is the win — the bell rings again, the fair goes on, nothing is solved.\nRoom 3 (Smugglers’ Cache) is on the way: DC 14 Investigation ties the crates’ sigil to a lender linked to the corrupted house.\nRoom 6 (The Descent) leads to the Root: wrong, cold air. Signal the tone shift; the party can always turn back. → E4.',
npcs: ['The Bell-Ringer'],
rewards: [
{ gold: 40, note: 'Room 3 — trade goods from the smugglers’ cache' },
{ item: 'Smugglers’ Ledger Page', note: 'Room 3 — DC 14 Investigation' },
],
},
{
name: 'E4 — Aftermath & Faction Hooks', mode: 'QUEST',
description: 'Word travels fast in Highcrown. By the next morning three different people have sent for you, and each of them has work.',
dmNotes: 'All open doors; they can run in parallel.\n• E4A Grey Dawn recruitment (Kavan) → Grey Dawn ending track; unlocks Ressa Vane at E9C.\n• E4B Concord job board (Mireille) → The Smugglers’ Tithe.\n• E4C Warden contact (Ashwen) → The Warden’s Silence.\n• E4D Caravan to the Spine → Trial of Stone → Spine Clans track.\n• E4E Investigate independently (DC 15 Investigation) → Independent track.\nAfter two or more threads and one E5 omen, advance the clock to Stage 2 (E8).',
npcs: ['Ser Kavan Thorne', 'Mireille Costa', 'Ashwen'],
rewards: [
{ item: 'Grey Dawn Signet', note: 'E4A — if they accept recruitment' },
{ item: 'Concord Writ of Passage', note: 'E4B — on taking Mireille’s contract' },
],
},
{
name: 'E6 — The Bell-Ringer’s Debt: Collectors', mode: 'BATTLE',
description: 'Three hard-faced men are waiting outside the bell-ringer’s door when you arrive. They are not here to hear him ring.',
dmNotes: 'Optional side quest, Lv 1–2. The collectors are ordinary desperate people: kill them, scare them off, or find out why they are desperate. Beats: hear the bell-ringer out → track the collectors → pay the debt, intimidate the lender, or dig into who backs him (harder, more rewarding — a clue toward the corrupted house before Act 2).',
monsters: [['Highcrown Cutthroat', 3]],
npcs: ['The Bell-Ringer', 'The Lender'],
rewards: [
{ gold: 30, note: 'The collectors’ take for the week' },
{ item: 'Bell-Ringer’s Keepsake', note: 'Given once the debt is dealt with' },
],
},
{
name: 'Side Quest — The Smugglers’ Tithe: Tidewatch', mode: 'QUEST', location: 'Salt Coast — Tidewatch',
description: 'Tidewatch, the Salt Coast’s largest port: proud, salt-stained, and suspicious of anyone asking questions. Someone is skimming the town’s quiet trade, and the Concord wants to know who.',
dmNotes: 'Lv 1–3. Mireille hires the party to learn why a cut of Tidewatch’s smuggled goods vanishes before reaching buyers. TRUTH: a Bandit Captain is extorting the smugglers and funnelling the “tithe” to arm one side of the Bramble Marches conflict. Smugglers distrust outsiders. Track a shipment to Smuggler’s Cove → next scene.\nOutcome decides whether Greyford is supplied at E11/E16.',
npcs: ['Mireille Costa'],
rewards: [{ gold: 100, note: 'Mireille’s fee — paid in full if it ends quietly' }],
},
{
name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', mode: 'BATTLE', location: 'Salt Coast — Smuggler’s Cove',
description: 'A hidden inlet south of Tidewatch. Lanterns hooded, a longboat drawn up on the shingle, and a crew that was not expecting company.',
dmNotes: 'Fight, negotiate, or both. The Captain leads from the front (multiattack) and bolts for the boat below half HP. End it quietly (Mireille pays well) or expose where the tithe was headed (pulls the Marches plot forward).',
monsters: [['Bandit Captain', 1], ['Highcrown Cutthroat', 4]],
rewards: [
{ gold: 60, note: 'The tithe chest' },
{ weapon: 'Smuggler’s Cutlass', note: 'The Captain’s own blade' },
],
},
{
name: 'Side Quest — The Warden’s Silence: Direwolf Pack', mode: 'BATTLE', location: 'Greywood — The Old Shrine Sites',
description: 'The trail to the shrine runs through old growth where the light barely reaches the ground. The forest has gone quiet, and there are eyes between the trees.',
dmNotes: 'Lv 1–2. The pack is agitated by the shrine, not evil — DC 13 Animal Handling or Nature to back away without a fight. Pack tactics make three wolves the clearest action-economy lesson for a new table.',
monsters: [['Greywood Direwolf', 3]],
},
{
name: 'Side Quest — The Warden’s Silence: The Shrine', mode: 'BATTLE', location: 'Greywood — The Old Shrine Sites',
description: 'A mossed-over shrine older than the kingdom, split by a crack you could lose an arm in. The missing Warden is standing in front of it. She turns and greets you by name.',
dmNotes: 'HORROR register — signal it. The Warden was Hollowed after investigating the crack alone: the party’s first, sympathetic Hollowed. DC 15 Insight notices the wrongness (no blink, a repeated phrase) before it acts. Fight, restrain and bring back to Ashwen (hard questions), or it escapes to report back. Either way Ashwen is convinced.',
monsters: [['Hollowed', 1, 'The Missing Warden']],
rewards: [
{ item: 'Warden’s Charm', note: 'From Ashwen, whatever the outcome' },
{ weapon: 'Warden’s Longbow', note: 'The missing Warden’s bow — Ashwen says it should be used' },
],
},
{
name: 'Side Quest — Trial of Stone', mode: 'QUEST', location: 'The Spine — Stonejaw Hold',
description: 'Stonejaw Hold, a giant clan’s valley in the Spine foothills. The chieftain has heard you out. He does not offer agreement. He offers a trial.',
dmNotes: 'Lv 2–4. A genuine test of character, nothing sinister. Trials (per PC or shared): non-lethal wrestling with a giant (the Hill Giant here — HP is a “pin” clock, nobody dies); a test of honesty; proving their word is good (retrieve something, deliver a message honestly). Beats: arrive → formal challenge → trial → feast.\nSuccess = standing alliance (Spine Clans track, unlocks E9D and E11C). Failure is not fatal: Korgath offers a second, harder trial.',
monsters: [['Stonejaw Hill Giant', 1, 'Stonejaw Champion']],
npcs: ['Korgath Stonejaw'],
rewards: [
{ item: 'Stonejaw Oath-Token', note: 'On passing the trial' },
{ weapon: 'Stonejaw Maul', note: 'Feast-gift for whoever wrestled the champion' },
],
},
{
name: 'Side Quest — Refugees of the Marches', mode: 'QUEST', location: 'Bramble Marches — The Sorrow Camp',
description: 'The Sorrow Camp: hundreds of people under canvas just inside the border, and a garrison at Greyford Keep that wants them gone. Food sent for the camp is not all arriving.',
dmNotes: 'Lv 2–4, the clearest moral-choice quest. No clean villain: the commander is not wrong about the strategic liability, and the refugees have nowhere else to go. Beats: tension between camp and garrison → find who is skimming aid (the Captain’s crew) → confront them → broker, or fail to broker, an arrangement.\nIntroduce a named refugee NPC here — they are at risk at E16B.',
monsters: [['Bandit Captain', 1, 'Aid-Skimmer Boss'], ['Highcrown Cutthroat', 2]],
npcs: ['Greyford’s Commander'],
rewards: [{ gold: 50, note: 'Recovered aid money — keeping it is a choice the camp will notice' }],
},
{
name: 'Side Quest — What the Tide Brought In: The Reef', mode: 'BATTLE', location: 'Salt Coast — Tidewatch',
description: 'A fishing boat washed up empty this morning: sails intact, catch in the hold, crew gone. The fishers will point out the reef. None of them will row you there.',
dmNotes: 'Lv 2–3, horror-lite. NOT connected to the Hollow King — a deliberate palate-cleanser. DC 13 Investigation on the boat finds claw marks, not violence. Confront it at sea (it swims, the party does not) or lure it to the shallows. Outcome: safe fishing reopens and real goodwill in Tidewatch.',
monsters: [['Reef Terror', 1]],
rewards: [
{ gold: 80, note: 'Tidewatch’s collection for whoever cleared the reef' },
{ item: 'Potion of Healing', quantity: 2, note: 'From the harbour apothecary' },
],
},
{
name: 'Audience — Threndir’s Eyrie', mode: 'QUEST', location: 'The Spine — Threndir’s Eyrie',
description: 'A hard climb above the snow line, and a cave mouth wide enough to sail a ship into. The dragon inside already knows you are here. He has not moved, and he has not told you to leave.',
dmNotes: 'A negotiation, not a fight: Threndir wants proof the crown can still be trusted (Persuasion DC 15–18; real evidence from E10 lowers it). He talks first unless attacked. If it goes wrong, add the “Threndir Ashclaw (old copper dragon)” monster to this scene — and remember he is far beyond a low-level party.',
npcs: ['Threndir Ashclaw'],
rewards: [{ weapon: 'Threndir’s Fang', note: 'A shed tooth, set in a hilt — given only if he comes to respect them' }],
},
{
name: 'E9 — The Root: Weeping Passage', mode: 'BATTLE', location: 'Hollow Below — The Weeping Passage',
description: 'A long tunnel beneath the Greywood where the walls weep, and the sound they make is almost a voice. Somewhere ahead, very faintly, a bell tolls.',
dmNotes: 'HORROR register — signal the tone shift. Root Room 2. DC 14 Survival: the dripping is a Hollowed’s footsteps. The Bellcrawler uses Stillness (DC 17 Perception to spot) and mimics the bell to lure the curious.\n• E9A retreat: keep what they learned, full rest before re-entry.\n• E9B push through: the marking here matches the bell script (DC 16 Arcana/Religion).\n• E9C (took E4A): Ressa Vane escorts and reports back — remove her from the scene otherwise.\n• E9D (won Trial of Stone): a Spine guide enters via Room 4.',
monsters: [['Bellcrawler', 1]],
npcs: ['Corporal Ressa Vane'],
},
{
name: 'E9 — The Root: Hollowed Nest', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
description: 'A cavern kept unnervingly tidy: bedrolls squared away, boots in a row. Two people sit facing the wall. They stand when you enter, and smile at exactly the same moment.',
dmNotes: 'Root Room 3. DC 15 Insight before combat. Consider making one a named missing person. Slam hits force DC 12 Wis or frightened for a round; each can whisper one sentence in Vessa’s voice. The letter (DC 14) hints at the corrupted house.',
monsters: [['Hollowed', 2]],
rewards: [
{ gold: 20, note: 'Personal effects' },
{ item: 'Half-Burned Letter', note: 'DC 14 Investigation' },
],
},
{
name: 'E9 — The Root: Root Stalker’s Warren', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
description: 'The roots in this chamber are as thick as a man’s waist, and they are not growing from anything. One of them shifts when your light touches it.',
dmNotes: 'Root Room 4, a dead end (optional secret passage to Room 6). DC 14 Perception or its first attack has advantage. Run it as a slow, dreadful stalk: tremorsense, burrow, grasping roots (restrained, DC 14 Str). E9D: DC 13 Survival or it wakes at the entrance.',
monsters: [['Root Stalker', 1]],
rewards: [
{ gold: 60, note: 'From what the roots grew around' },
{ weapon: 'Masterwork Dagger', note: 'Still in a dead hand' },
],
},
{
name: 'E9 — The Root: Bellcrawler Gallery', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
description: 'Narrow ledges over a drop your torch cannot find the bottom of. From three different directions at once, a bell begins to toll.',
dmNotes: 'Root Room 6. DC 15 Perception to place them; DC 13 Dex on the ledges or fall 2d6. They skitter (bonus-action disengage) and freeze when watched — stealth and dread, not a straight fight. → Room 7, the stair to the Hollow Court: let them see it and turn back.',
monsters: [['Bellcrawler', 3]],
rewards: [
{ gold: 90, note: 'Scattered along the ledges' },
{ item: 'Potion of Healing', note: 'In a fallen pack' },
],
},
{
name: 'E10 — The House Unmasked', mode: 'QUEST', location: 'Highcrown — The Palace of Aldenfell',
description: 'Back in Highcrown with a name, a ledger, and a question nobody at court wants asked out loud: what do you do with it?',
dmNotes: 'The ledger: every shrine “donation” falls three days after a livestock death.\n• E10A confront the house directly: DC 16 Persuasion/Intimidation with proof, DC 20 without.\n• E10B report to the crown, Isolde or Kavan: sets up Act 3 politics; if E9C, Kavan already suspected (trust bump).\n• E10C (E2C + the servant thread): private DC 13 check at advantage → confession and the sealed letter; the house never learns it is suspected. Best route into Act 3.',
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne'],
rewards: [{ item: 'Sealed Letter', note: 'E10C only' }],
},
{
name: 'E11 — Silence on the Border', mode: 'QUEST', location: 'Bramble Marches — Greyford Keep',
description: 'Greyford Keep. The gate stands open. No smoke from the chimneys, no one on the walls, no bodies. A kettle is still warm on a guardroom stove.',
dmNotes: 'DC 13 Perception: drag marks — the defenders left. The Smugglers’ Tithe outcome decides rescue vs recovery. E11C (Spine allied): the clans are already here and share Root Stalker migration intel. Remove the Commander from the scene if this is a recovery.',
npcs: ['Greyford’s Commander'],
rewards: [
{ gold: 70, note: 'Trade goods in the keep’s stores (2d6×10 gp)' },
{ weapon: 'Greyford Halberd', note: 'From the armoury' },
],
},
{
name: 'E12 — A Line Thins', mode: 'BATTLE',
description: 'The ground splits somewhere you thought was safe, and something comes up through it that should have stayed far below.',
dmNotes: 'A Root Stalker surfaces near a location the party did not secure — pick it from what they left undone. DC 15 Arcana/Religion: this is deliberate erosion from below. The root-fiber scrap is a key item (needed at E14/E18/E19). Then E13: the clock turns to Stage 3.',
monsters: [['Root Stalker', 1]],
rewards: [{ item: 'Root-Fiber Scrap', note: 'Key item — cut from the Stalker' }],
},
{
name: 'E14 — The Break', mode: 'BATTLE',
description: 'The crack has become a breach. People are coming out of it — neighbours, guards, people you have met — walking calmly, smiling, and not blinking.',
dmNotes: 'Two to four Hollowed (scale to the party). DC 15 Arcana: the breach is shaped for something to come through.\n• E14A seal it (DC 17 Arcana or the chapel ritual): closes, but a debt comes due at E17.\n• E14B evacuate instead: no deaths, but the breach stays open, worsening omens until E18.\nThe seal-stone fragment is a key item (needed at E19).',
monsters: [['Hollowed', 4]],
rewards: [{ item: 'Seal-Stone Fragment', note: 'Key item — from the breach’s edge' }],
},
{
name: 'Mid-campaign scare — The Hollow King’s Shadow', mode: 'BATTLE',
description: 'The light goes thin and the air goes cold. Something tall is standing where the wall should be, and the wall does not seem to mind.',
dmNotes: 'DO NOT run as a fair fight. At 0 HP it disperses for the scene and never dies; treat its HP as a ticking clock. Give the party a way out: close a ritual circle, reach sunlight or a warded threshold, or survive N rounds. Echoing dread: DC 15 Wis or frightened within 30 ft. Save its true, killable form for Act 4.',
monsters: [['The Hollow King’s Shadow', 1]],
},
{
name: 'E19 — Vessa’s Seat', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'A single worn stone seat at the heart of the Hollow Court, and a woman who has been expecting you. She asks you to sit. She says she would like to explain.',
dmNotes: 'Court Room 4. The party learns the Hollow King’s true nature from Vessa: the imprisoned will of everyone the founders sacrificed — a suppression of many, not a prison for one. DC 16 Insight: she believes what she says. A strong candidate for a negotiated outcome.\nRoot-fiber + seal-stone (DC 15 Arcana, automatic with a Religion-proficient caster) → advantage on the first check at E22. Then E20: the clock turns to Stage 4.',
npcs: ['Vessa Hollow'],
},
{
name: 'E21 — Rally Point', mode: 'QUEST',
description: 'One last gathering before the descent. Whoever is going to stand with you is here now — and they do not all agree about what happens next.',
dmNotes: 'Who shows up depends on the branch list — remove anyone the party lost or alienated.\n• E21A Grey Dawn track: Kavan and his wardens, by the book.\n• E21B Spine Clans track: Korgath’s war-band; advantage on the first Survival check at E22.\n• E21C Independent: no reinforcements; every choice stays theirs.\n• Several tracks: the groups arrive and disagree — the party mediates.',
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne', 'Korgath Stonejaw', 'Ashwen'],
rewards: [{ item: 'Potion of Greater Healing', quantity: 2, note: 'Going-away gift from whoever stands with them' }],
},
{
name: 'E22 — The Seal Chamber', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'A wound in the world, stitched shut long ago, and coming undone one thread at a time. Whatever you came here to decide, this is where you decide it.',
dmNotes: 'Court Room 5 — the key branch. DC 18 Arcana/Religion reveals the seal is a suppression of many. Optional DC 14 Wis saves vs disorientation.\n• E22A reinforce (DC 18 Arcana/Religion): the Sealed Ending — time bought.\n• E22B break it deliberately: E23 on the party’s terms — the only path to true victory.\n• E22C do nothing: the seal fails anyway; E24–E25 are harder.\nHollowbane is the campaign’s single most significant magic item: legendary growth, starts at level 7.',
npcs: ['Vessa Hollow'],
rewards: [{ weapon: 'Hollowbane', note: 'Drawn from the seal itself' }],
},
{
name: 'E25 — The Last Approach', mode: 'BATTLE', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'The final stretch of the Hollow Court, where gravity, light and memory have all stopped agreeing with one another. The things that live here know you are coming.',
dmNotes: 'A gauntlet of horror-register creatures; whichever faction the party rallied fights alongside them (add those NPCs). Grey Dawn = a disciplined line; Spine Clans = terrain and ritual (collapse passages); Independent = hardest, and entirely theirs. → E26, the Hollow King: build that stat block from DM Secrets, scaled to the party.',
monsters: [['Root Stalker', 1], ['Bellcrawler', 2]],
},
];
// ---------------------------------------------------------------------------
@@ -414,4 +803,4 @@ The Salt Coast: 1 smugglers wary of witnesses · 2 short-handed fishing crew ·
The Bramble Marches: 1 Bandit Captain’s crew · 2 refugees on the road · 3 tense border patrol · 4 signs of the neighboring power · 5 a skirmish in progress · 6 watchful quiet.`,
];
module.exports = { campaign, locations, monsters, encounters, sharedNotes, dmNotes };
module.exports = { campaign, locations, monsters, weapons, items, spells, npcs, encounters, sharedNotes, dmNotes };
File diff suppressed because one or more lines are too long
+59 -10
View File
@@ -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,
};
+114 -3
View File
@@ -207,17 +207,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,
};
}
@@ -247,4 +352,10 @@ module.exports = {
getClassSpellcastingAbility,
computeCharacterSheet,
computeWeaponStats,
MAX_GEAR_LEVEL,
MAX_DAMAGE_SCALING,
DAMAGE_LADDER,
diceAverage,
scaledDamageDice,
canUseGear,
};
+110
View File
@@ -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
});
});
+375
View File
@@ -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,
};
+41 -35
View File
@@ -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,15 +102,15 @@ 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'] },
@@ -112,36 +118,36 @@ const SPELL_LIBRARY = [
{ 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: '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'] },
File diff suppressed because it is too large Load Diff
+29
View File
@@ -18,6 +18,11 @@ const loginSchema = z.object({
password: z.string().min(1),
});
const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
});
function issueToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, displayName: user.displayName },
@@ -76,4 +81,28 @@ router.get('/me', requireAuth, async (req, res, next) => {
}
});
// Tokens already issued stay valid until they expire; this only changes what
// the next login needs.
router.post('/password', requireAuth, async (req, res, next) => {
try {
const parsed = changePasswordSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { currentPassword, newPassword } = parsed.data;
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (!user) return res.status(404).json({ error: 'User not found' });
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) return res.status(400).json({ error: 'Your current password is incorrect' });
if (newPassword === currentPassword) {
return res.status(400).json({ error: 'Your new password must be different from the current one' });
}
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: await bcrypt.hash(newPassword, 12) } });
return res.status(204).end();
} catch (err) {
return next(err);
}
});
module.exports = router;
+331 -35
View File
@@ -4,8 +4,9 @@ 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 { computeCharacterSheet, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
const { visitedLocationIds } = require('../utils/locations');
const router = express.Router();
@@ -16,12 +17,15 @@ 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,
ownerId: campaign.ownerId,
archivedAt: campaign.archivedAt,
role,
createdAt: campaign.createdAt,
};
@@ -44,10 +48,11 @@ 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(),
});
// POST /campaigns — create, creator becomes DM/owner
@@ -84,6 +89,7 @@ router.post('/join/:inviteCode', async (req, res, next) => {
try {
const campaign = await prisma.campaign.findUnique({ where: { inviteCode: req.params.inviteCode } });
if (!campaign) return res.status(404).json({ error: 'Invalid invite code' });
if (campaign.archivedAt) return res.status(403).json({ error: 'That campaign is archived and is not taking new members' });
const existing = await prisma.campaignMember.findUnique({
where: { campaignId_userId: { campaignId: campaign.id, userId: req.user.id } },
@@ -112,20 +118,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 +194,9 @@ function characterSummary(character) {
buildStage: character.buildStage,
currentHp: character.currentHp,
tempHp: character.tempHp,
gold: character.gold,
silver: character.silver,
copper: character.copper,
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
currentLocationId: character.currentLocationId,
@@ -181,7 +211,19 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
include: { skillProficiencies: true, saveProficiencies: true, currentLocation: true },
orderBy: { createdAt: 'asc' },
});
res.json({ characters: characters.map(characterSummary) });
const summaries = characters.map(characterSummary);
if (req.membership.role === 'DM') return res.json({ characters: summaries });
// A player sees the party's headline numbers, but another character's
// purse is their own business, and so is a place this player has not been.
const visited = await visitedLocationIds(prisma, req.user.id, req.campaign.id);
res.json({
characters: summaries.map((c) => {
if (c.userId === req.user.id) return c;
const knowsPlace = c.currentLocationId && visited.has(c.currentLocationId);
return { ...c, gold: null, silver: null, copper: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
}),
});
} catch (err) {
next(err);
}
@@ -210,6 +252,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 +260,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,12 +278,28 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
});
// ---------------------------------------------------------------------------
// Weapons (campaign-level pool; DM manages, anyone in campaign can read)
// Weapons (campaign-level pool; the DM manages it and sees all of it)
// ---------------------------------------------------------------------------
// What a player may see of a pool: whatever one of their own characters holds,
// plus the starting options while they still have a character to build. The
// rest of the pool — gear yet to be found, spells yet to be learned — is the
// DM's. `held` is the pool model's relation to the per-character rows.
async function poolScope(req, held) {
const where = { campaignId: req.campaign.id };
if (req.membership.role === 'DM') return where;
const mine = { userId: req.user.id, campaignId: req.campaign.id };
const building = await prisma.character.count({ where: { ...mine, buildStage: { not: 'COMPLETE' } } });
return {
...where,
OR: [{ [held]: { some: { character: mine } } }, ...(building > 0 ? [{ isStartingOption: true }] : [])],
};
}
router.get('/:id/weapons', requireCampaignMember(), async (req, res, next) => {
try {
const weapons = await prisma.weapon.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
const weapons = await prisma.weapon.findMany({ where: await poolScope(req, 'assigned'), orderBy: { name: 'asc' } });
res.json({ weapons });
} catch (err) {
next(err);
@@ -249,8 +314,35 @@ 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(),
});
// 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 +358,9 @@ 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);
res.json({ weapon });
} catch (err) {
next(err);
@@ -278,7 +369,9 @@ 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 } });
if (!(await deletePoolEntry('weapon', req.params.weaponId, req.campaign.id))) {
return res.status(404).json({ error: 'Weapon not found in this campaign' });
}
res.status(204).end();
} catch (err) {
next(err);
@@ -291,7 +384,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 +399,8 @@ 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(),
});
router.post('/:id/spells', requireCampaignMember(), requireDM(), async (req, res, next) => {
@@ -319,11 +414,92 @@ 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(),
});
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);
res.json({ item });
} catch (err) {
next(err);
}
});
router.delete('/:id/items/:itemId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
if (!(await deletePoolEntry('item', req.params.itemId, req.campaign.id))) {
return res.status(404).json({ error: 'Item not found in this campaign' });
}
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 +510,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,7 +519,7 @@ const monsterSchema = z.object({
intScore: z.number().int().min(1).max(30).optional(),
wisScore: z.number().int().min(1).max(30).optional(),
chaScore: z.number().int().min(1).max(30).optional(),
attackBonus: z.number().int().optional(),
attackBonus: z.number().int().min(-20).max(50).optional(),
damageDice: z.string().max(20).optional().nullable(),
challengeRating: z.string().max(10).optional().nullable(),
specialTrait: z.string().max(500).optional().nullable(),
@@ -365,7 +541,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 +551,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 +566,136 @@ 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 }, 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(),
});
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() });
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() });
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' });
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 +718,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 +755,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 +765,9 @@ 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);
@@ -484,7 +780,7 @@ router.delete('/:id/locations/:locationId', requireCampaignMember(), requireDM()
// backend/src/game/data/{weapons,spells,monsters}Library.js for the sets.
// ---------------------------------------------------------------------------
router.get('/:id/library', requireCampaignMember(), async (req, res, next) => {
router.get('/:id/library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
res.json({ weapons: WEAPON_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY });
} catch (err) {
File diff suppressed because it is too large Load Diff
+475 -81
View File
@@ -3,7 +3,10 @@ const { z } = require('zod');
const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { loadCampaignMembership } = require('../middleware/campaignAccess');
const { computeCharacterSheet, computeWeaponStats } = require('../game/calculations');
const { computeCharacterSheet, computeWeaponStats, canUseGear, abilityModifier, MAX_GEAR_LEVEL } = require('../game/calculations');
const { CURRENCIES, adjustGold, giveGear } = require('../utils/grants');
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
const { absorbDamage } = require('../utils/hp');
const router = express.Router();
@@ -17,42 +20,116 @@ async function requireEncounterAccess(req, res, next) {
const membership = await loadCampaignMembership(req.user.id, encounter.campaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
// A player only gets into a scene one of their own characters is in. To
// anyone else it doesn't exist — the name alone can be a spoiler.
const isDM = membership.role === 'DM';
if (!isDM) {
const present = await prisma.encounterParticipant.findFirst({
where: { encounterId: encounter.id, character: { userId: req.user.id } },
select: { id: true },
});
if (!present) return res.status(404).json({ error: 'Encounter not found' });
}
req.encounter = encounter;
req.membership = membership;
req.isDM = membership.role === 'DM';
req.isDM = isDM;
return next();
} catch (err) {
return next(err);
}
}
// Every campaign member looking at an encounter can see a character
// combatant's equipped weapons and known spells (attack/damage bonus, save
// DC) — not just HP/AC — so the party can plan around each other mid-fight,
// not only the DM. Uses the same calculation-engine functions the
// quick-stats panel already uses, never re-deriving the formulas.
function characterCombatInfo(character) {
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
const weapons = character.weapons.map((cw) => ({
characterWeaponId: cw.id,
name: cw.weapon.name,
equipped: cw.equipped,
quantity: cw.quantity,
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true),
}));
const spells = character.spells.map((cs) => ({
characterSpellId: cs.id,
name: cs.spell.name,
level: cs.spell.level,
prepared: cs.prepared,
}));
return { armorClass: sheet.armorClass, weapons, spells, spellcasting: sheet.spellcasting };
function requireDM(req, res, next) {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
return next();
}
async function serializeEncounter(encounterId) {
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
// Highest initiative first; ties keep the order combatants were added in.
// Anyone who has not rolled yet waits at the bottom.
function inTurnOrder(participants) {
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
}
// What is added to a combatant's d20 for initiative: the sheet's initiative
// bonus for a character, the Dexterity modifier for a monster, and nothing for
// an NPC (they have no ability scores). Takes a participant with its
// character (and proficiencies) and monster loaded.
function initiativeBonus({ character, monster }) {
if (character) return computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).initiative;
if (monster) return abilityModifier(monster.dexScore);
return 0;
}
const initiativeRollSchema = z.number().int().min(1, 'An initiative roll is a d20: 1 to 20').max(20, 'An initiative roll is a d20: 1 to 20');
// What every campaign member sees about a character in an encounter: the
// sheet's headline numbers plus the weapons and spells it can actually use —
// so the party can plan around each other mid-fight, not only the DM. Uses the
// same calculation-engine functions as the sheet, never re-deriving formulas.
function characterCombatInfo(character) {
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
// Gear and spells above the character's level are held but can't be used,
// so they stay out of the fight.
const weapons = character.weapons
.filter((cw) => canUseGear(character.level, cw.level))
.map((cw) => ({
characterWeaponId: cw.id,
name: cw.weapon.name,
level: cw.level,
equipped: cw.equipped,
quantity: cw.quantity,
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true, cw.level),
}));
const spells = character.spells
.filter((cs) => canUseGear(character.level, cs.spell.level))
.map((cs) => ({
characterSpellId: cs.id,
name: cs.spell.name,
level: cs.spell.level,
description: cs.spell.description,
damageDice: cs.spell.damageDice,
prepared: cs.prepared,
}));
return {
level: character.level,
species: character.species,
characterClass: character.characterClass,
armorClass: sheet.armorClass,
maxHp: sheet.hp.max,
tempHp: character.tempHp,
speed: sheet.speed,
initiativeBonus: sheet.initiative,
passivePerception: sheet.passivePerception,
proficiencyBonus: sheet.proficiencyBonus,
abilities: sheet.abilities,
saves: sheet.saves,
specialAbilities: character.specialAbilities,
weapons,
spells,
spellcasting: sheet.spellcasting,
};
}
// The DM gets everything. Players get the scene as their characters see it:
// no DM notes, no monster stat blocks, no NPC secrets or stats, and only the
// rewards that have actually been handed out.
//
// `viewerId` is the user the payload is for. A player is told where a scene
// is once one of their characters has been there, and — when the campaign
// hides monster hit points — gets a percentage for the health bar in place of
// the numbers for anyone who is not a character.
async function serializeEncounter(encounterId, isDM, viewerId) {
const encounter = await prisma.encounter.findUnique({
where: { id: encounterId },
include: {
campaign: { select: { showMonsterHp: true } },
location: { select: { id: true, name: true, description: true } },
participants: {
include: {
character: {
@@ -63,69 +140,184 @@ async function serializeEncounter(encounterId) {
spells: { include: { spell: true } },
},
},
monster: { select: { id: true, name: true } },
monster: true,
npc: true,
},
orderBy: { initiative: 'desc' },
},
rewards: { include: { weapon: true, item: true }, orderBy: { createdAt: 'asc' } },
},
});
if (!encounter) return encounter;
encounter.participants = encounter.participants.map((p) => {
if (!p.character) return p;
return {
...p,
character: { id: p.character.id, name: p.character.name },
characterCombatInfo: characterCombatInfo(p.character),
};
const hideHp = !isDM && !encounter.campaign.showMonsterHp;
const participants = inTurnOrder(encounter.participants).map((p) => {
const { character, monster, npc, ...row } = p;
// The bonus is part of a monster's stat block, so only the DM gets it for
// those; a character's is on their sheet for all to see.
const bonus = isDM || character ? { initiativeBonus: initiativeBonus(p) } : {};
const out = { ...row, ...bonus, kind: npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER' };
if (character) {
// A character combatant's HP is the character's own HP, not a
// per-encounter copy, so the tracker and the sheet can never disagree.
const { maxHp, ...combatInfo } = characterCombatInfo(character);
out.currentHp = character.currentHp;
out.maxHp = maxHp;
out.character = { id: character.id, name: character.name, userId: character.userId };
out.characterCombatInfo = combatInfo;
}
if (monster) out.monster = isDM ? monster : { id: monster.id, name: monster.name };
if (npc) {
const { dmNotes, ac, maxHp, attackBonus, damageDice, campaignId, createdAt, ...publicNpc } = npc;
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice } : publicNpc;
}
if (hideHp && !character) {
// Rounded up, so anything still standing shows a sliver of bar.
out.hpPercent = p.maxHp > 0 ? Math.ceil((clamp(p.currentHp, 0, p.maxHp) / p.maxHp) * 100) : null;
out.currentHp = null;
out.maxHp = null;
}
return out;
});
return encounter;
const { dmNotes, rewards, campaign, location, ...scene } = encounter;
const knowsPlace = isDM || (location && (await visitedLocationIds(prisma, viewerId, encounter.campaignId)).has(location.id));
return {
...scene,
locationId: knowsPlace ? scene.locationId : null,
location: knowsPlace ? location : null,
...(isDM ? { dmNotes } : {}),
participants,
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
};
}
// Every open tab refetches its own view of the encounter (the payload differs
// between the DM and players, so it is never pushed over the shared room).
function emitEncounterUpdated(req) {
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounterId: req.encounter.id });
}
function emitCharacterUpdated(req, characterId) {
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('character:updated', { characterId });
}
// While an encounter with a location is in progress, the characters in it
// are at that location: starting it, moving it, or adding someone to it puts
// them there, and that goes on their record of places visited.
async function syncCharacterLocations(req) {
const encounter = await prisma.encounter.findUnique({
where: { id: req.encounter.id },
include: { participants: { where: { characterId: { not: null } }, include: { character: { select: { id: true, currentLocationId: true } } } } },
});
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId) return;
const elsewhere = encounter.participants.map((p) => p.character).filter((c) => c.currentLocationId !== encounter.locationId);
await moveCharacters(prisma, elsewhere.map((c) => c.id), encounter.locationId);
for (const character of elsewhere) emitCharacterUpdated(req, character.id);
}
async function respondWithEncounter(req, res, status = 200, extra = {}) {
await syncCharacterLocations(req);
const encounter = await serializeEncounter(req.encounter.id, req.isDM, req.user.id);
emitEncounterUpdated(req);
res.status(status).json({ ...extra, encounter });
}
router.get('/:id', requireEncounterAccess, async (req, res, next) => {
try {
const encounter = await serializeEncounter(req.encounter.id);
res.json({ encounter });
res.json({ encounter: await serializeEncounter(req.encounter.id, req.isDM, req.user.id) });
} catch (err) {
next(err);
}
});
const patchEncounterSchema = z.object({
status: z.enum(['PLANNING', 'ACTIVE', 'COMPLETE']).optional(),
mode: z.enum(['BATTLE', 'QUEST']).optional(),
name: z.string().min(1).max(120).optional(),
description: z.string().max(4000).nullable().optional(),
dmNotes: z.string().max(4000).nullable().optional(),
locationId: z.string().nullable().optional(),
archived: z.boolean().optional(), // put away (true) or bring back (false)
});
router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const parsed = patchEncounterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { archived, ...data } = parsed.data;
if (archived === true && !req.encounter.archivedAt) data.archivedAt = new Date();
if (archived === false) data.archivedAt = null;
if (data.locationId) {
const location = await prisma.location.findFirst({ where: { id: data.locationId, campaignId: req.encounter.campaignId } });
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
}
// Starting a battle puts the first combatant in the initiative order on turn.
const mode = data.mode ?? req.encounter.mode;
if (data.status === 'ACTIVE' && mode === 'BATTLE' && !req.encounter.activeParticipantId) {
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? null;
}
await prisma.encounter.update({ where: { id: req.encounter.id }, data });
await respondWithEncounter(req, res);
} catch (err) {
next(err);
}
});
// Deleting removes the scene, its combatant rows and its reward list. What
// was already handed out stays with the characters, as does their HP.
router.delete('/:id', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
await prisma.encounter.delete({ where: { id: req.encounter.id } });
emitEncounterUpdated(req);
res.status(204).end();
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Combatants
// ---------------------------------------------------------------------------
const participantSchema = z
.object({
characterId: z.string().optional(),
monsterId: z.string().optional(),
npcId: z.string().optional(),
displayName: z.string().min(1).max(80).optional(),
initiative: z.number().int().default(0),
initiativeRoll: initiativeRollSchema.optional(),
})
.refine((data) => Boolean(data.characterId) !== Boolean(data.monsterId), {
message: 'Provide exactly one of characterId or monsterId',
.refine((data) => [data.characterId, data.monsterId, data.npcId].filter(Boolean).length === 1, {
message: 'Provide exactly one of characterId, monsterId or npcId',
});
router.post('/:id/participants', requireEncounterAccess, async (req, res, next) => {
router.post('/:id/participants', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
const parsed = participantSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { characterId, monsterId, displayName, initiative } = parsed.data;
const { characterId, monsterId, npcId, displayName, initiativeRoll } = parsed.data;
let currentHp, maxHp, resolvedName, isMonster;
let currentHp, maxHp, resolvedName;
let bonus = 0;
if (characterId) {
const character = await prisma.character.findUnique({ where: { id: characterId } });
if (!character || character.campaignId !== req.encounter.campaignId) {
return res.status(400).json({ error: 'That character is not part of this campaign.' });
}
const full = await prisma.character.findUnique({
const character = await prisma.character.findUnique({
where: { id: characterId },
include: { skillProficiencies: true, saveProficiencies: true },
});
const sheet = computeCharacterSheet(full, full.skillProficiencies, full.saveProficiencies);
if (!character || character.campaignId !== req.encounter.campaignId) {
return res.status(400).json({ error: 'That character is not part of this campaign.' });
}
currentHp = character.currentHp;
maxHp = sheet.hp.max;
maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
resolvedName = displayName || character.name;
isMonster = false;
} else {
bonus = initiativeBonus({ character });
} else if (monsterId) {
const monster = await prisma.monster.findUnique({ where: { id: monsterId } });
if (!monster || monster.campaignId !== req.encounter.campaignId) {
return res.status(400).json({ error: 'That monster is not part of this campaign.' });
@@ -133,7 +325,16 @@ router.post('/:id/participants', requireEncounterAccess, async (req, res, next)
currentHp = monster.maxHp;
maxHp = monster.maxHp;
resolvedName = displayName || monster.name;
isMonster = true;
bonus = initiativeBonus({ monster });
} else {
const npc = await prisma.npc.findUnique({ where: { id: npcId } });
if (!npc || npc.campaignId !== req.encounter.campaignId) {
return res.status(400).json({ error: 'That NPC is not part of this campaign.' });
}
// An NPC with no combat stats takes part with no hit points to track.
currentHp = npc.maxHp ?? 0;
maxHp = npc.maxHp ?? 0;
resolvedName = displayName || npc.name;
}
const participant = await prisma.encounterParticipant.create({
@@ -141,17 +342,17 @@ router.post('/:id/participants', requireEncounterAccess, async (req, res, next)
encounterId: req.encounter.id,
characterId: characterId || null,
monsterId: monsterId || null,
npcId: npcId || null,
displayName: resolvedName,
initiative,
initiativeRoll: initiativeRoll ?? null,
initiative: initiativeRoll == null ? 0 : initiativeRoll + bonus,
currentHp,
maxHp,
isMonster,
isMonster: Boolean(monsterId),
},
});
const encounter = await serializeEncounter(req.encounter.id);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounter });
res.status(201).json({ participant, encounter });
await respondWithEncounter(req, res, 201, { participant });
} catch (err) {
next(err);
}
@@ -159,67 +360,260 @@ router.post('/:id/participants', requireEncounterAccess, async (req, res, next)
const updateParticipantSchema = z.object({
currentHp: z.number().int().optional(),
maxHp: z.number().int().min(1).optional(),
initiative: z.number().int().optional(),
conditions: z.array(z.string()).optional(),
maxHp: z.number().int().min(1).max(100000).optional(),
initiativeRoll: initiativeRollSchema.nullable().optional(), // null clears the roll
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
displayName: z.string().min(1).max(80).optional(),
});
// The DM runs every combatant. A player may do one thing here: enter the
// initiative roll for their own character.
router.patch('/:id/participants/:participantId', requireEncounterAccess, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
const parsed = updateParticipantSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const participant = await prisma.encounterParticipant.update({
const existing = await prisma.encounterParticipant.findUnique({
where: { id: req.params.participantId },
data: parsed.data,
include: { character: { include: { skillProficiencies: true, saveProficiencies: true } }, monster: true },
});
if (!existing || existing.encounterId !== req.encounter.id) {
return res.status(404).json({ error: 'Combatant not found in this encounter' });
}
if (!req.isDM) {
const onlyTheRoll = Object.keys(parsed.data).every((key) => key === 'initiativeRoll');
const ownCharacter = existing.character && existing.character.userId === req.user.id;
if (!onlyTheRoll || !ownCharacter) {
return res.status(403).json({ error: 'You can only enter the initiative roll for your own character' });
}
}
const encounter = await serializeEncounter(req.encounter.id);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounter });
res.json({ participant, encounter });
const data = { ...parsed.data };
// The roll is what was on the die; the bonus is added here.
if (data.initiativeRoll !== undefined) {
data.initiative = data.initiativeRoll == null ? 0 : data.initiativeRoll + initiativeBonus(existing);
}
if (existing.character) {
// Max HP comes from the sheet, and damage/healing lands on the character.
const { character } = existing;
const maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
data.maxHp = maxHp;
if (data.currentHp !== undefined) {
// Temp HP soaks damage first; what is left lands on the character.
const after = absorbDamage(character.currentHp, character.tempHp, data.currentHp);
data.currentHp = clamp(after.currentHp, 0, maxHp);
await prisma.character.update({ where: { id: character.id }, data: { currentHp: data.currentHp, tempHp: after.tempHp } });
emitCharacterUpdated(req, character.id);
}
} else if (data.currentHp !== undefined) {
data.currentHp = clamp(data.currentHp, 0, data.maxHp ?? existing.maxHp);
} else if (data.maxHp !== undefined) {
data.currentHp = Math.min(existing.currentHp, data.maxHp);
}
const participant = await prisma.encounterParticipant.update({ where: { id: existing.id }, data });
await respondWithEncounter(req, res, 200, { participant });
} catch (err) {
next(err);
}
});
router.delete('/:id/participants/:participantId', requireEncounterAccess, async (req, res, next) => {
router.delete('/:id/participants/:participantId', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
const order = inTurnOrder(participants);
const index = order.findIndex((p) => p.id === req.params.participantId);
if (index === -1) return res.status(404).json({ error: 'Combatant not found in this encounter' });
// Removing whoever is on turn passes the turn to the next combatant.
if (req.encounter.activeParticipantId === req.params.participantId) {
const next = order.length > 1 ? order[(index + 1) % order.length] : null;
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { activeParticipantId: next ? next.id : null } });
}
await prisma.encounterParticipant.delete({ where: { id: req.params.participantId } });
const encounter = await serializeEncounter(req.encounter.id);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:participant-updated', { encounter });
res.json({ encounter });
await respondWithEncounter(req, res);
} catch (err) {
next(err);
}
});
router.post('/:id/advance-round', requireEncounterAccess, async (req, res, next) => {
// ---------------------------------------------------------------------------
// Turns and rounds
// ---------------------------------------------------------------------------
// Passes the turn down the initiative order; going past the last combatant
// starts the next round.
router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
const order = inTurnOrder(participants);
if (order.length === 0) return res.status(400).json({ error: 'Add combatants before taking turns.' });
const index = order.findIndex((p) => p.id === req.encounter.activeParticipantId);
const wrapped = index === order.length - 1;
await prisma.encounter.update({
where: { id: req.encounter.id },
data: {
status: 'ACTIVE',
activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id,
...(wrapped ? { currentRound: { increment: 1 } } : {}),
},
});
await respondWithEncounter(req, res);
} catch (err) {
next(err);
}
});
router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
const updated = await prisma.encounter.update({
where: { id: req.encounter.id },
data: { currentRound: { increment: 1 }, status: 'ACTIVE' },
data: {
currentRound: { increment: 1 },
status: 'ACTIVE',
// A new round starts back at the top of the initiative order.
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
},
});
await syncCharacterLocations(req);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
encounterId: updated.id,
currentRound: updated.currentRound,
});
res.json({ encounter: updated });
res.json({ encounter: await serializeEncounter(updated.id, req.isDM, req.user.id) });
} catch (err) {
next(err);
}
});
router.patch('/:id', requireEncounterAccess, async (req, res, next) => {
// ---------------------------------------------------------------------------
// Rewards — loot the DM lines up, then hands to a character
// ---------------------------------------------------------------------------
const rewardSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('GOLD'), gold: z.number().int().min(1).max(1000000), currency: z.enum(CURRENCIES).default('GOLD'), note: z.string().trim().max(200).optional().nullable() }),
z.object({
type: z.literal('WEAPON'),
weaponId: z.string(),
quantity: z.number().int().min(1).max(999).default(1),
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
note: z.string().trim().max(200).optional().nullable(),
}),
z.object({
type: z.literal('ITEM'),
itemId: z.string(),
quantity: z.number().int().min(1).max(999).default(1),
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
note: z.string().trim().max(200).optional().nullable(),
}),
]);
router.post('/:id/rewards', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
const parsed = z.object({ status: z.enum(['PLANNING', 'ACTIVE', 'COMPLETE']).optional(), name: z.string().min(1).max(120).optional() }).safeParse(req.body);
const parsed = rewardSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const updated = await prisma.encounter.update({ where: { id: req.encounter.id }, data: parsed.data });
res.json({ encounter: updated });
const data = { ...parsed.data, note: parsed.data.note || null };
if (data.type !== 'GOLD') {
const pool = data.type === 'WEAPON' ? 'weapon' : 'item';
const entry = await prisma[pool].findUnique({ where: { id: data[`${pool}Id`] } });
if (!entry || entry.campaignId !== req.encounter.campaignId) {
return res.status(400).json({ error: `That ${pool} is not part of this campaign.` });
}
data.level = data.level ?? entry.minLevel;
if (data.level < entry.minLevel) {
return res.status(400).json({ error: `${entry.name} starts at level ${entry.minLevel} and can't go below it.` });
}
}
const reward = await prisma.encounterReward.create({ data: { ...data, encounterId: req.encounter.id } });
await respondWithEncounter(req, res, 201, { reward });
} catch (err) {
next(err);
}
});
// Removing a reward only takes it off the list — anything already handed out
// stays with the character.
router.delete('/:id/rewards/:rewardId', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const { count } = await prisma.encounterReward.deleteMany({
where: { id: req.params.rewardId, encounterId: req.encounter.id },
});
if (count === 0) return res.status(404).json({ error: 'Reward not found in this encounter' });
await respondWithEncounter(req, res);
} catch (err) {
next(err);
}
});
const awardSchema = z.union([z.object({ characterId: z.string() }), z.object({ split: z.literal(true) })]);
// Hands a reward over, once. `characterId` gives all of it to one character;
// `split` (gold only) shares it evenly between the characters in the
// encounter, any remainder going one gold each down the initiative order.
router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const parsed = awardSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const reward = await prisma.encounterReward.findUnique({
where: { id: req.params.rewardId },
include: { weapon: true, item: true },
});
if (!reward || reward.encounterId !== req.encounter.id) {
return res.status(404).json({ error: 'Reward not found in this encounter' });
}
if (reward.awardedAt) return res.status(400).json({ error: `Already awarded to ${reward.awardedTo}.` });
let recipients;
if (parsed.data.split) {
if (reward.type !== 'GOLD') return res.status(400).json({ error: 'Only coins can be split.' });
const participants = await prisma.encounterParticipant.findMany({
where: { encounterId: req.encounter.id, characterId: { not: null } },
include: { character: true },
});
recipients = inTurnOrder(participants).map((p) => p.character);
if (recipients.length === 0) return res.status(400).json({ error: 'There are no characters in this encounter to split it between.' });
} else {
const character = await prisma.character.findUnique({ where: { id: parsed.data.characterId } });
if (!character || character.campaignId !== req.encounter.campaignId) {
return res.status(400).json({ error: 'That character is not part of this campaign.' });
}
recipients = [character];
}
const reason = `Reward: ${req.encounter.name}`;
await prisma.$transaction(async (tx) => {
// Claim the reward first so two clicks can't hand it out twice.
const claimed = await tx.encounterReward.updateMany({
where: { id: reward.id, awardedAt: null },
data: { awardedAt: new Date(), awardedTo: recipients.map((c) => c.name).join(', ') },
});
if (claimed.count === 0) return;
if (reward.type === 'GOLD') {
const share = Math.floor(reward.gold / recipients.length);
const remainder = reward.gold % recipients.length;
for (const [i, character] of recipients.entries()) {
const amount = share + (i < remainder ? 1 : 0);
if (amount > 0) await adjustGold(tx, { characterId: character.id, amount, currency: reward.currency, reason, userId: req.user.id });
}
} else {
const kind = reward.type === 'WEAPON' ? 'weapon' : 'item';
await giveGear(tx, kind, {
characterId: recipients[0].id,
entry: reward[kind],
quantity: reward.quantity,
level: reward.level,
});
}
});
for (const character of recipients) emitCharacterUpdated(req, character.id);
await respondWithEncounter(req, res);
} catch (err) {
next(err);
}
+8 -2
View File
@@ -1,8 +1,11 @@
const jwt = require('jsonwebtoken');
const { loadCampaignMembership } = require('../middleware/campaignAccess');
// Clients join a room per campaign (`campaign:<id>`) after authenticating with
// the same JWT issued by /auth/login. Server-side route handlers emit into
// these rooms (see character:updated, encounter:*-updated, character:created).
// Events only ever carry ids: each client refetches its own view, since what
// the DM, an owner and another player may see of the same thing differs.
function attachSockets(io) {
io.use((socket, next) => {
const token = socket.handshake.auth && socket.handshake.auth.token;
@@ -17,8 +20,11 @@ function attachSockets(io) {
});
io.on('connection', (socket) => {
socket.on('campaign:join', (campaignId) => {
if (typeof campaignId === 'string') socket.join(`campaign:${campaignId}`);
socket.on('campaign:join', async (campaignId) => {
if (typeof campaignId !== 'string') return;
// Members only: a room's events are not for anyone who knows the id.
const membership = await loadCampaignMembership(socket.user.id, campaignId).catch(() => null);
if (membership) socket.join(`campaign:${campaignId}`);
});
socket.on('campaign:leave', (campaignId) => {
if (typeof campaignId === 'string') socket.leave(`campaign:${campaignId}`);
+130
View File
@@ -0,0 +1,130 @@
// The two ways a character's belongings change: coins through the ledger, and
// gear from the campaign pools. Shared by the character sheet routes and by
// encounter rewards so both follow the same rules. Every function takes a
// Prisma client or transaction client as `db`.
// The coins, and what each is worth counted in copper: silver is 0.1 gold and
// copper is 0.01 gold, so 1 gold = 10 silver = 100 copper and 1 silver = 10
// copper. Whole numbers keep exchanges exact.
const CURRENCIES = ['GOLD', 'SILVER', 'COPPER'];
const COIN_VALUE = { GOLD: 100, SILVER: 10, COPPER: 1 };
// Each coin is its own column on the character.
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
class InsufficientGoldError extends Error {}
// amount is signed: positive = gained, negative = spent, 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 (CURRENCIES.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
const pay = (coin, count) => {
delta[coin] -= count;
owed -= count * worth(coin);
};
pay(currency, Math.min(purse[currency], amount));
const bigger = CURRENCIES.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
for (const coin of bigger) {
if (owed <= 0) break;
pay(coin, Math.min(left(coin), Math.ceil(owed / worth(coin))));
// Overpaid: hand the difference back, biggest coins first.
let change = -owed;
for (const small of CURRENCIES.filter((c) => worth(c) < worth(coin))) {
if (change <= 0) break;
const count = Math.floor(change / worth(small));
delta[small] += count;
change -= count * worth(small);
}
if (owed < 0) owed = 0;
}
for (const coin of CURRENCIES.filter((c) => worth(c) < worth(currency))) {
if (owed <= 0) break;
pay(coin, Math.min(left(coin), Math.floor(owed / worth(coin))));
}
return delta;
}
// Pays `amount` coins of `currency` out of a character's purse, making change
// as needed (see planPayment). Every coin that moved gets its own ledger row;
// the rows are returned, the one in `currency` first when there is one. Throws
// InsufficientGoldError if the purse as a whole can't cover it.
async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
const character = await db.character.findUnique({ where: { id: characterId }, select: { gold: true, silver: true, copper: true } });
const purse = { GOLD: character.gold, SILVER: character.silver, COPPER: character.copper };
const delta = planPayment(purse, currency, amount);
if (!delta) throw new InsufficientGoldError();
const moved = CURRENCIES.filter((coin) => delta[coin] !== 0).sort((a, b) => (b === currency) - (a === currency));
const madeChange = moved.some((coin) => coin !== currency);
const label = reason || 'Spent';
const rows = [];
for (const coin of moved) {
rows.push(
await adjustGold(db, {
characterId,
amount: delta[coin],
currency: coin,
// Says what the payment was when the coins that moved aren't the ones asked for.
reason: madeChange ? `${label} (paid ${amount} ${currency.toLowerCase()}, with change)` : reason,
userId,
include,
})
);
}
return rows;
}
// A purse's whole worth as one figure in gold, for messages: 265 copper-worth -> 2.65.
function worthInGold(character) {
const copper = CURRENCIES.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
return copper / COIN_VALUE.GOLD;
}
const HELD = {
weapon: { model: 'characterWeapon', key: 'weaponId' },
item: { model: 'inventoryItem', key: 'itemId' },
};
// 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, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
+12
View File
@@ -0,0 +1,12 @@
// 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 };
}
module.exports = { absorbDamage };
+30
View File
@@ -0,0 +1,30 @@
// Where characters are, and where they have been. A visit is recorded every
// time a character is put somewhere and is never removed by moving on: the
// set of visited locations is what a player is allowed to see. Every function
// takes a Prisma client or transaction client as `db`.
async function recordVisits(db, characterIds, locationId) {
if (characterIds.length === 0) return;
await db.characterLocationVisit.createMany({
data: characterIds.map((characterId) => ({ characterId, locationId })),
skipDuplicates: true,
});
}
async function moveCharacters(db, characterIds, locationId) {
if (characterIds.length === 0) return;
await db.character.updateMany({ where: { id: { in: characterIds } }, data: { currentLocationId: locationId } });
await recordVisits(db, characterIds, locationId);
}
// Ids of the locations any of this user's characters in the campaign has been to.
async function visitedLocationIds(db, userId, campaignId) {
const visits = await db.characterLocationVisit.findMany({
where: { character: { userId, campaignId } },
select: { locationId: true },
distinct: ['locationId'],
});
return new Set(visits.map((v) => v.locationId));
}
module.exports = { recordVisits, moveCharacters, visitedLocationIds };
Binary file not shown.
+979
View File
@@ -0,0 +1,979 @@
# Characters
How characters work in this app: how they are created, what is stored, how every
number on the sheet is calculated, who can do what, and what is not modelled.
This describes the code as it stands. The formulas live in
`backend/src/game/calculations.js`; the build flow lives in
`backend/src/game/buildFlow.js` and `backend/src/routes/characters.routes.js`;
the lists the story steps pick from are in
`backend/src/game/data/characterOptions.js`.
Related: [WEAPONS.md](WEAPONS.md), [SPELLS.md](SPELLS.md),
[ITEMS.md](ITEMS.md), [ENCOUNTERS.md](ENCOUNTERS.md),
[MONSTERS.md](MONSTERS.md) and [NPCS.md](NPCS.md) cover those subjects in
full. `GAME_RULES_REFERENCE.md` and `ROLL_YOUR_CHARACTER.md` are the original
specifications the app was first built from; where they differ from this
document (the story steps, gear levels, picking a motivation), this document
is the current behaviour.
## Contents
1. [The basics](#1-the-basics)
2. [Creating a character](#2-creating-a-character)
3. [Species](#3-species)
4. [Classes](#4-classes)
5. [Ability scores and modifiers](#5-ability-scores-and-modifiers)
6. [Level and XP](#6-level-and-xp)
7. [Hit points](#7-hit-points)
8. [Other derived stats](#8-other-derived-stats)
9. [Saving throws and skills](#9-saving-throws-and-skills)
10. [Gear pools, starting options and gear levels](#10-gear-pools-starting-options-and-gear-levels)
11. [Weapons and spells](#11-weapons-and-spells)
12. [Inventory and money](#12-inventory-and-money)
13. [Story fields, location and notes](#13-story-fields-location-and-notes)
14. [Where characters appear](#14-where-characters-appear)
15. [Characters in encounters](#15-characters-in-encounters)
16. [Copying to another campaign](#16-copying-to-another-campaign)
17. [Permissions](#17-permissions)
18. [Deleting](#18-deleting)
19. [What is not modelled](#19-what-is-not-modelled)
20. [Everything the builder offers](#20-everything-the-builder-offers)
---
## 1. The basics
- A character belongs to **exactly one campaign**. Its weapons, spells, items
and location all come from that campaign's own lists.
- A character has an **owner** (a user who is a member of the campaign) and a
**creator** (whoever started it — the owner, or the DM on their behalf).
- A user can have **any number of characters** in a campaign, and characters in
many campaigns. Nothing limits it to one each.
- A character is either **being built** or **complete**. Only a complete
character has a sheet, quick stats, can join an encounter, or can be copied.
## 2. Creating a character
Any campaign member can start a character for themselves from the campaign
page ("Roll a New Character"). The DM can also create one on behalf of another
member through the API (the page itself always creates for the logged-in user).
A new character starts with the campaign's **starting gold** (recorded as the
first entry in its money log) and goes straight into the builder.
### Dice mode
Chosen once, before the first roll, and then locked:
| Mode | How rolls happen |
|---|---|
| Digital (default) | The server rolls using a cryptographic random source. |
| Physical | The player rolls real dice and types the results in; the server checks each value is in range for the die. |
### The build steps
| # | Step | What happens |
|---|---|---|
| 1 | Species | Roll a d6. Each result offers a pair to choose from; a 6 is a wildcard (any species). |
| 2 | Class | Roll a d8. Each result offers a pair; a 7 means roll again; an 8 is a wildcard (any class). |
| 3 | Motivation | Pick a story hook from a list of six, or press "Surprise me". No dice: it changes no numbers. Skipped entirely if the campaign has "skip the motivation step" on. |
| 4 | Ability scores | Roll six scores (4d6, drop the lowest die, sum the other three), then assign each score to one ability. |
| 5 | Skills | Skills are picked at random from the class's list the moment scores are locked in. The DM can override them at this step. |
| 6 | Special abilities | See what the species and class grant automatically, then pick two talents from a list. |
| 7 | Background | Answer five questions (upbringing, home, turning point, personality, flaw) by picking from lists. |
| 8 | Appearance | Pick build, height, hair, eyes, skin, up to two distinguishing features, and usual clothing. |
| 9 | Equipment | Pick starting weapons, items and spells from the ones the DM has marked as starting options, within the campaign's limits. |
| — | Finalize | Everything is written to the character, saving throw proficiencies are set from the class, and current HP is set to max. |
**Species roll (d6)**
| Roll | Choose between |
|---|---|
| 1 | Human, Half-Elf |
| 2 | Elf, Gnome |
| 3 | Dwarf, Halfling |
| 4 | Dragonborn, Tiefling |
| 5 | Half-Orc, Orc |
| 6 | Wildcard — any species |
**Class roll (d8)**
| Roll | Choose between |
|---|---|
| 1 | Fighter, Barbarian |
| 2 | Rogue, Ranger |
| 3 | Wizard, Sorcerer |
| 4 | Cleric, Paladin |
| 5 | Bard, Warlock |
| 6 | Druid, Monk |
| 7 | Roll again |
| 8 | Wildcard — any class |
**Motivations to pick from**
- Revenge for a past wrong
- Seeking fortune or wealth
- Protecting someone they love
- Redemption for a past mistake
- Driven by curiosity or knowledge
- Duty or oath to a cause or order
### The story steps
Steps 6 to 8 never involve typing. The player picks from fixed lists, and the
app writes the text that appears on the sheet. Each has a "Surprise me" button
that picks at random, which the player can then adjust.
**Special abilities** are listed one per line as "Name — what it does", in
this order:
1. What the **species** grants automatically (for example Dwarf: Darkvision,
Dwarven Resilience).
2. What the **class** grants automatically at level 1 (for example Barbarian:
Rage, Unarmored Defense).
3. The **two talents** the player picked from a list of fourteen (Alert, Keen
Eye, Silver Tongue, Field Medic, Tough as Nails, …).
These are table rules written in plain words for the DM to apply. The app
does not calculate anything from them: Rage does not add damage by itself, and
Unarmored Defense does not change AC unless the DM sets the armor bonus.
**Background** is five lines, one per question:
| Question | Picks |
|---|---|
| Upbringing — what were you before? | 1 of 14 (Soldier, Acolyte, Criminal, Noble, Scholar, Farmer, …) |
| Home — where did you grow up? | 1 of 8 |
| Turning point — what set you on the road? | 1 of 9 |
| Personality — how do others describe you? | 1 or 2 of 14 |
| Flaw — what gets you into trouble? | 1 of 9 |
**Appearance** is built the same way from build, height, hair, eyes, skin,
distinguishing features (none, one or two) and usual clothing.
Once the character is finalized, **the player cannot change any of the three**.
The DM can edit all of them on the sheet, which is how new abilities are added
as a character levels up. Every option is listed in
[section 20](#20-everything-the-builder-offers).
### Choosing versus committing
- A **dice roll is final** the moment it is made.
- A **choice is not**: on every step, clicking an option only selects it. The
live preview beside the steps shows what it would do, and the player can
switch as often as they like. Nothing is saved until they press **Next**.
- **Back** returns to the previous step so its choice can be changed. The
step comes back with the same dice roll, the same options and the earlier
pick still selected; nothing is re-rolled. Starting equipment is the one
thing that has to be confirmed again on the way forward.
- Skills are drawn once per class. Going back and forward again, or switching
class and switching back, gives the same skills each time.
- Back stops working once the character is finalized.
The builder explains itself as it goes: each step opens with a line on what it
is for, every species and class option is a card describing what it is like to
play and what it changes, and the ⓘ icons explain each number. On the ability
score step, "Suggest for me" puts the best rolls where the chosen class needs
them, and the player can still rearrange them.
### Rules the builder enforces
- Every die is rolled once: species, class (unless it landed on "roll again")
and ability scores. Going back never gives a fresh roll.
- Special abilities, background and appearance accept only entries from their
lists, in the numbers each question allows.
- A **Half-Elf** must pick two different abilities (not Charisma) for its
flexible +1/+1.
- All six rolled scores must be assigned, one per ability.
- Only pool entries the DM has marked as a **starting option** can be picked
(section 10).
- **Starting weapons and items together** cannot exceed the campaign's
starting item count.
- **Starting spells** cannot exceed the campaign's starting spell count, only
a class with a spellcasting ability can pick any, and a new (level 1)
character can only start with cantrips and level 1 spells.
- Starting weapons and items arrive at **gear level 1**, so one that starts at
a higher level (section 10) cannot be picked even if it is ticked.
## 3. Species
Species sets ability bonuses and walking speed, which feed the calculations,
and grants special abilities, which are written on the sheet for the DM to
apply (section 2, "The story steps").
| Species | Ability bonuses | Speed | Special abilities granted |
|---|---|---|---|
| Human | +1 to all six | 30 | Versatile |
| Half-Elf | +2 CHA, and +1 to two others of the player's choice | 30 | Darkvision, Fey Ancestry |
| Elf | +2 DEX | 30 | Darkvision, Fey Ancestry, Trance |
| Gnome | +2 INT | 25 | Darkvision, Gnome Cunning |
| Dwarf | +2 CON | 25 | Darkvision, Dwarven Resilience |
| Halfling | +2 DEX | 25 | Lucky, Brave |
| Dragonborn | +2 STR, +1 CHA | 30 | Breath Weapon, Draconic Resistance |
| Tiefling | +2 CHA, +1 INT | 30 | Darkvision, Hellish Resistance |
| Half-Orc | +2 STR, +1 CON | 30 | Darkvision, Relentless Endurance |
| Orc | +2 STR, +1 CON | 30 | Darkvision, Aggressive |
## 4. Classes
Class sets the hit die, the two saving throw proficiencies, the spellcasting
ability (if any), and which skills can be picked. Each class also grants two
special abilities at level 1, written on the sheet for the DM to apply:
| Class | Special abilities granted |
|---|---|
| Barbarian | Rage, Unarmored Defense |
| Bard | Bardic Inspiration, Jack of Many Tales |
| Cleric | Divine Domain, Healing Hands |
| Druid | Druidic, Nature's Voice |
| Fighter | Second Wind, Fighting Style |
| Monk | Martial Arts, Unarmored Defense |
| Paladin | Divine Sense, Lay on Hands |
| Ranger | Favored Enemy, Natural Explorer |
| Rogue | Sneak Attack, Thieves' Cant |
| Sorcerer | Innate Magic, Wild Talent |
| Warlock | Otherworldly Patron, Pact Magic |
| Wizard | Spellbook, Arcane Recovery |
The numbers each class sets:
| Class | Hit die | Saves | Spellcasting | Skills |
|---|---|---|---|---|
| Barbarian | d12 | STR, CON | — | 2 from Animal Handling, Athletics, Intimidation, Nature, Perception, Survival |
| Bard | d8 | DEX, CHA | CHA | 3 from any skill |
| Cleric | d8 | WIS, CHA | WIS | 2 from History, Insight, Medicine, Persuasion, Religion |
| Druid | d8 | INT, WIS | WIS | 2 from Arcana, Animal Handling, Insight, Medicine, Nature, Perception, Religion, Survival |
| Fighter | d10 | STR, CON | — | 2 from Acrobatics, Animal Handling, Athletics, History, Insight, Intimidation, Perception, Survival |
| Monk | d8 | STR, DEX | — | 2 from Acrobatics, Athletics, History, Insight, Religion, Stealth |
| Paladin | d10 | WIS, CHA | CHA | 2 from Athletics, Insight, Intimidation, Medicine, Persuasion, Religion |
| Ranger | d10 | STR, DEX | WIS | 3 from Animal Handling, Athletics, Insight, Investigation, Nature, Perception, Stealth, Survival |
| Rogue | d8 | DEX, INT | — | 4 from Acrobatics, Athletics, Deception, Insight, Intimidation, Investigation, Perception, Performance, Persuasion, Sleight of Hand, Stealth |
| Sorcerer | d6 | CON, CHA | CHA | 2 from Arcana, Deception, Insight, Intimidation, Persuasion, Religion |
| Warlock | d8 | WIS, CHA | CHA | 2 from Arcana, Deception, History, Intimidation, Investigation, Nature, Religion |
| Wizard | d6 | INT, WIS | INT | 2 from Arcana, History, Insight, Investigation, Medicine, Religion |
## 5. Ability scores and modifiers
The six abilities are Strength, Dexterity, Constitution, Intelligence, Wisdom
and Charisma.
- The **rolled score** is what the dice gave (3–18). This is what is stored.
- The **final score** is the rolled score plus the species bonus. This is what
the sheet shows and what every calculation uses.
- The **modifier** is `(final score − 10) ÷ 2`, rounded down.
| Final score | Modifier |
|---|---|
| 8–9 | −1 |
| 10–11 | 0 |
| 12–13 | +1 |
| 14–15 | +2 |
| 16–17 | +3 |
| 18–19 | +4 |
| 20 | +5 |
Ability scores cannot be changed after the build is finalized.
## 6. Level and XP
- Every character starts at **level 1**. The range is 1 to 20.
- Levelling is **manual**: the DM changes the Level field on the sheet. Only
the DM can change level or XP.
- **XP** is stored but no screen shows or edits it, and it does not drive level.
Level feeds two things:
**Proficiency bonus** — `2 + (level − 1) ÷ 4`, rounded down.
| Level | Proficiency bonus |
|---|---|
| 1–4 | +2 |
| 5–8 | +3 |
| 9–12 | +4 |
| 13–16 | +5 |
| 17–20 | +6 |
**Max HP** — see the next section.
## 7. Hit points
**Max HP** is calculated, never stored:
- Level 1: the full hit die + CON modifier.
- Each level after: the die's fixed average (half the die, plus 1) + CON modifier.
| Hit die | Level 1 | Each later level |
|---|---|---|
| d6 | 6 + CON | 4 + CON |
| d8 | 8 + CON | 5 + CON |
| d10 | 10 + CON | 6 + CON |
| d12 | 12 + CON | 7 + CON |
Example: a Fighter (d10) with CON 14 (+2) has 12 at level 1 and 28 at level 3.
**Current HP** is a stored number, separate from max:
- Set to max when the build is finalized, and when a character is copied.
- Changed with the Damage and Heal buttons on the sheet, or by the DM in an
encounter (section 15). Both change the same number.
- Always kept between **0 and max**: healing stops at max and damage stops at 0.
- A level-up raises max but leaves current HP where it was; the DM clicks
"Recompute Max HP" to set current HP to the new max (a full heal). If a
level change lowers max below current HP, current HP drops to the new max.
**Temp HP** is a tracked number shown beside HP. Damage is not taken from it
automatically.
## 8. Other derived stats
| Stat | Formula |
|---|---|
| Armor Class | 10 + DEX modifier + armor bonus |
| Initiative | DEX modifier |
| Speed | Species speed, unless a speed override is set |
| Passive Perception | 10 + Perception skill bonus |
| Hit dice | One per level, of the class's die size (shown only; not spent or tracked) |
| Spell save DC | 8 + proficiency bonus + spellcasting ability modifier |
| Spell attack bonus | Proficiency bonus + spellcasting ability modifier |
**Armor bonus** is a single number typed in on the sheet to stand for armor and
shield. There are no armor items, and equipping an inventory item does not
change AC.
**Speed override** exists in the data and API (DM only) but has no field on
the sheet.
## 9. Saving throws and skills
**Saving throws:** ability modifier, plus proficiency bonus if the class is
proficient in that save. Fixed by class; not editable.
**Skills:** governing ability modifier, plus proficiency bonus if proficient,
or double the proficiency bonus with expertise.
| Ability | Skills |
|---|---|
| STR | Athletics |
| DEX | Acrobatics, Sleight of Hand, Stealth |
| INT | Arcana, History, Investigation, Nature, Religion |
| WIS | Animal Handling, Insight, Medicine, Perception, Survival |
| CHA | Deception, Intimidation, Performance, Persuasion |
Skill proficiencies are drawn at random during the build (step 5) and cannot
be changed from the sheet afterwards. Expertise is supported by the
calculations but is only ever set if the DM supplies it during step 5.
## 10. Gear pools, starting options and gear levels
Each campaign has three **pools**, managed by the DM on the campaign page:
| Pool | What an entry holds | How entries are added |
|---|---|---|
| Weapons | Name, base damage dice, damage type, attack ability, starting level, growth | By hand, or from the built-in library |
| Spells | Name, level, school, optional damage | By hand, or from the built-in library (which supplies a default damage) |
| Items | Name, optional description, optional base dice, starting level, growth | By hand |
Everything a character carries or knows is an entry from its campaign's pools.
[WEAPONS.md](WEAPONS.md), [SPELLS.md](SPELLS.md) and [ITEMS.md](ITEMS.md)
describe each pool in full; this section is the part that matters to a
character.
- **Only the DM** adds to a pool, edits or removes entries, gives entries to
characters from the pool, changes how many a character has, levels them up,
and takes them away.
- **The owning player** can tick and untick Equipped (weapons, items) and
Prepared (spells) on gear their character can use, and can **give** what
their character holds to another character (section 12).
- Removing an entry from a pool removes it from every character holding it.
**Starting options.** Every pool entry has a "Starting" tick box. Only ticked
entries are offered in the equipment step of the builder (step 9); everything
else has to be given out by the DM later. New entries are **not** starting options until the DM
ticks them.
### Gear levels
This is a house rule of this app, not standard D&D.
**Each weapon or item a character holds has its own level, 1 to 9.** The
level belongs to that character's copy, not to the pool entry, so two
characters can hold spears of different levels. Only the DM changes it, with
the level picker next to it on the sheet. A character holds one stack of each
weapon or item, and the whole stack shares one level.
**Starting level.** Each weapon and item in a pool has a level it starts at —
1 for most, higher for gear that should only exist later (a greatsword that
starts at level 3, say). A copy is given at that level unless the DM picks a
higher one, and can never go below it. If the DM later raises an entry's
starting level, copies below it are lifted to it and unequipped.
**Who can use what.** A character can use gear of their own level or lower:
| Character level | Can use gear of level |
|---|---|
| 1 | 1 |
| 2 | 1–2 |
| 3 | 1–3 |
| … | … |
Gear above the character's level can still be **held**, but it shows
"Requires level N", cannot be equipped, and is left out of quick stats and
encounters. If the DM levels a piece of gear past its holder, or lowers the
character's level, anything now out of reach is unequipped automatically.
**How damage grows.** The dice in the pool are the damage at the gear's
**starting level**. Each level above that moves the dice up a fixed ladder, in
the smallest steps the 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 |
| 14 | 2d8+1d10 | 14.5 |
| … | and so on, 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.
Each weapon and item has a **growth** setting that says how many rungs it
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 2d6 greatsword that starts at level 3 with Standard growth is 2d6 at level
3, 1d6+1d8 at level 4, 2d8 at level 5, and so on.
- At its starting level, gear always shows the dice exactly as the DM typed
them.
- Dice that are not on the ladder (for example `2d4+2` or `1d4+1`) start
climbing from the highest rung that is not stronger than them: `2d4+2`
(average 7) continues from 2d6, so it becomes 1d6+1d8 one level up.
- Dice the app cannot read (free text such as "special") never change.
- Level changes the **dice only**. The attack bonus and the flat damage bonus
still come from the character's ability scores and proficiency bonus.
**Spells** do not have a level of their own to raise. The classic spell level
is the requirement: a character must be at least that level to prepare or use
the spell, and cantrips are open to everyone. A spell can carry a damage
value, which is fixed.
## 11. Weapons and spells
A character holds weapons, each with a quantity, a level and an equipped flag.
| Number | Formula |
|---|---|
| Attack bonus | Ability modifier + proficiency bonus |
| Damage | The weapon's dice at the held copy's level (section 10) + ability modifier |
The ability depends on the weapon: Strength, Dexterity, or "finesse" (whichever
of the two is higher).
- Characters are treated as **proficient with every weapon**.
- Giving a weapon the character already has increases its quantity; the
stack keeps the level it has.
A character knows spells, each with a prepared flag.
- The sheet's "Spells Known" card only appears when the campaign has at least
one spell.
- After the build, the DM can grant spells to **any class**; the "must be a
spellcaster" rule applies only to starting spells.
- A spell above the character's level can be known but not prepared, and is
left out of encounters (section 10).
- A spell's damage, if it has one, is shown beside it. It is fixed.
- Spell save DC and attack bonus are shown only for classes with a
spellcasting ability.
- There are no spell slots.
## 12. Inventory and money
**Inventory** is the items a character holds from the campaign's item pool,
each with a quantity, a level and an equipped flag. Giving an item the
character already has adds to the stack. Items have no effect on any stat. An
item can carry optional dice (a potion's healing, a bomb's damage) that grow
with its level exactly like a weapon's (section 10).
**Money** is a purse of three coins, each counted on its own, with a log
behind it.
| Coin | Worth |
|---|---|
| Gold (gp) | 1 gold |
| Silver (sp) | 0.1 gold — 10 to the gold |
| Copper (cp) | 0.01 gold — 100 to the gold, 10 to the silver |
- The sheet's Money card shows the three balances, what the purse is worth in
gold altogether, and every change: the amount and coin, what it was for, who
recorded it, when, and that coin's balance afterwards.
- The **owning player and the DM** can both record a gain or a spend, choosing
the coin. Other players cannot see the purse or the log.
- **Spending makes change for you.** A spend (or a gift to another character)
is paid from the whole purse. Coins of the kind named go first; if there
aren't enough, the nearest bigger coin is broken and the change comes back
in as few coins as possible; if there are no bigger coins, smaller ones are
counted out. Spending 3 silver from a purse of 5 gold leaves 4 gold and 7
silver. Each coin that moved gets its own log entry, marked "with change".
- A spend is refused only when the **whole purse** isn't worth enough.
- **Exchange** turns coins of one kind into another at the worth above, and
logs both sides. It moves exactly the coins named and has to come to whole
coins: silver into gold goes in tens, copper into silver in tens, copper
into gold in hundreds.
- The log is the only way a balance changes. Entries cannot be edited or
deleted; to correct a mistake, record the opposite entry.
- Starting gold (a campaign setting) is gold only. Encounter rewards and gifts
between characters can be in any coin.
### Giving to another character
The "Give to Another Character" card on the sheet lets a character's owner
(or the DM) hand over something the character holds to another finished
character in the same campaign.
| What | How much | What happens |
|---|---|---|
| Gold | Any amount up to the balance | Both money logs get an entry: "Gave to Bo" and "Received from Aria" |
| A weapon or item | Some or all of the stack | It leaves the giver's stack and joins the recipient's |
| A spell | The spell | The giver no longer knows it; the recipient does |
- **Giving always moves.** Nothing is copied: whatever is given, the giver no
longer has. This keeps the DM in control of how much gear, gold and magic
exists while letting the party share it out.
- A weapon or item arrives at the **giver's level**, unequipped. If the
recipient already holds some, theirs simply grows and keeps its own level.
- The recipient may be too low a level to use it. They hold it anyway, marked
"Requires level N" (section 10).
- A spell cannot be given to someone who already knows it.
- The recipient does not have to accept; it just arrives.
## 13. Story fields, location and notes
| Field | Who can see it | Who can edit it |
|---|---|---|
| Motivation | Everyone in the campaign | Set during the build only |
| Background | Everyone in the campaign | Built from picks; afterwards the DM only |
| Appearance | Everyone in the campaign | Built from picks; afterwards the DM only |
| Special abilities | Everyone in the campaign (sheet and quick stats) | Built from picks; afterwards the DM only |
| Private notes | **The owner only — not even the DM** | Owner only |
| Current location | The owner and the DM. Another player sees it in the party list only if their own character has been to that place | DM picks it on the sheet; an encounter in progress with a location also moves its characters there |
**Character notes** are a separate, running log on the sheet:
- Any campaign member can post a note on any character.
- Notes posted from the sheet are shared with the whole campaign.
- The API also supports DM-only notes, visible to the DM and the author.
- Only the author can delete a note.
## 14. Where characters appear
| Screen | What it shows |
|---|---|
| Campaign page | Character list: species, class, level, HP, AC, location. Players see their own first, then the rest of the party. |
| Party overview | One table of every character with an HP bar, AC, gold and location. |
| Character sheet | Everything in this document. |
| Character builder | A character that is still being built (section 2). |
| Quick stats drawer | A combat summary: HP, AC, initiative, speed, proficiency bonus, spellcasting numbers, special abilities, and each weapon's attack and damage. |
| Encounter page | Characters taking part in a battle or quest (next section). |
Changes are pushed live to everyone who has the campaign open, so sheets and
lists update without a refresh.
## 15. Characters in encounters
[ENCOUNTERS.md](ENCOUNTERS.md) covers encounters in full; this is the part
that concerns characters.
An encounter is a scene the DM runs. It has a **mode**:
| Mode | What it is for | What the screen shows |
|---|---|---|
| Battle | A fight | Everyone in initiative order, a round counter, and whose turn it is |
| NPC quest | A scene built around people and an objective | The NPCs, the party and any creatures as separate groups; no rounds or turns |
The DM can switch an encounter's mode at any time. Every encounter also has a
description shown to everyone (the scene or the quest), and DM notes that
players never receive.
**Who can be in one**
- **Characters.** A character's HP in an encounter **is its sheet HP**: damage
or healing the DM applies there changes the sheet, and a change on the sheet
shows in the encounter. Max HP always comes from the sheet.
- **Monsters**, from the campaign's monster list ([MONSTERS.md](MONSTERS.md)).
Each keeps its own HP per encounter, held between 0 and its max.
- **NPCs**, from the campaign's NPC list ([NPCS.md](NPCS.md)). An NPC has a
name, role, attitude (friendly, neutral or hostile), a description for
players, and DM-only notes. Combat stats are optional; an NPC without hit
points simply has no health bar.
**What each side sees**
| | DM | Players |
|---|---|---|
| A character | Level, species, class, HP, AC, speed, initiative bonus, passive perception, proficiency bonus, ability scores, saves, usable weapons and spells, special abilities | The same |
| A monster | The full stat block: AC, speed, attack, damage, challenge, ability scores, trait, notes | Name, HP and conditions only |
| An NPC | Everything, including DM notes and stats | Name, role, attitude and description |
| Rewards | All of them | Only the ones that have been awarded |
| DM notes | Yes | Never |
**Running a battle**
- Only the DM adds or removes combatants and changes HP, initiative or
conditions. Initiative is typed in; the app does not roll it.
- "Begin Battle" puts the highest initiative on turn. "Next Turn" passes the
turn down the order, and going past the last combatant starts the next
round. "Next Round" jumps straight to the top of the next round.
- A combatant at 0 HP is marked Down but still takes its turn in the order.
- Conditions (Prone, Poisoned, …) are picked from a list and removed with a
click.
**Rewards**
The DM can line up rewards on any encounter: an amount of gold, or a weapon
or item from the campaign pools at a chosen level and quantity.
- A reward does nothing until the DM **awards** it, and it can only be
awarded once.
- Awarding gold adds a "Reward: <encounter name>" entry to the character's
money log. Gold can also be **split** evenly between the characters in the
encounter; any remainder goes one gold each down the initiative order.
- Awarding a weapon or item adds it to what the character holds, at the
reward's level. A character below that level holds it but cannot use it yet
(section 10).
- A reward can go to any character in the campaign, not only those in the
encounter.
- Removing a reward from the list does not take back anything already awarded.
## 16. Copying to another campaign
A complete character can be copied into another campaign from the "Copy to
Another Campaign" card on its sheet.
- **Who:** the owner, or the DM of the character's campaign. You must already
be a member of the target campaign.
- **Copied:** name, species, class, level, XP, ability scores, skill and save
proficiencies, armor bonus, speed override, special abilities, gold (as a
single opening entry in the copy's money log), background, appearance,
motivation, inventory, weapons and spells, with each weapon's and item's
level.
- **Not copied:** location, character notes and encounter entries.
- The copy arrives at **full HP** with no temp HP.
- The copy is **owned by whoever made it**. If a DM copies a player's
character, the DM owns the copy and the player's private notes are not
carried over.
- **Weapons, spells and items** are matched to the target campaign's pools by
name (ignoring case). Any that are missing are added to the target's pool,
never as starting options.
- The copy is independent: the original is untouched and the two never sync.
## 17. Permissions
| Action | Owner | DM of the campaign | Other members |
|---|---|---|---|
| View the sheet and quick stats | Yes | Yes | Yes |
| View private notes | Yes | No | No |
| Run the builder | Yes | Yes | No |
| Change HP, temp HP, armor bonus | Yes | Yes | No |
| Edit special abilities, background, appearance (after the build) | No | Yes | No |
| Tick Equipped / Prepared | Yes | Yes | No |
| Give, remove or re-count weapons, spells and items | No | Yes | No |
| Level a weapon or item up or down | No | Yes | No |
| Record a gold gain or spend | Yes | Yes | No |
| Give gold, weapons, items or spells to another character | Yes | Yes | No |
| View the money log, gold and inventory | Yes | Yes | No |
| Change level, XP, location or speed override | No | Yes | No |
| Post a character note | Yes | Yes | Yes |
| Copy to another campaign | Yes | Yes | No |
| Delete the character | No | Yes | No |
People outside the campaign cannot see a character at all.
## 18. Deleting
- Only the DM can delete a character (from the campaign page).
- Deleting removes its proficiencies, inventory, weapon and spell assignments,
money log and notes. Encounter entries that pointed at it stay in the
encounter, with the name and the last HP seen, but lose everything else.
- Deleting a **campaign** deletes every character in it.
## 19. What is not modelled
Use special abilities (via the DM), items or notes for these; the app does no
calculations for them.
- The effects of special abilities: species traits and class features are
written on the sheet for the DM to apply, but nothing is calculated from them
- Class features beyond level 1 (the DM adds them to the sheet by hand),
subclasses and multiclassing
- Spell slots, spell preparation limits and concentration
- Ability score increases and feats on level-up
- Armor and shields as items (only the single armor bonus number)
- Weapon proficiency (everyone is proficient with everything)
- Rolling attacks, damage, saves or checks for a character (the dice roller,
bottom left of every page, is a plain roller that knows nothing about the
sheet)
- Death saves, conditions on the sheet, and resting
- Automatic levelling from XP
- Alignment, languages, tool proficiencies and backgrounds-as-rules
- Encumbrance and item weight
- Currencies other than gold, and item prices
## 20. Everything the builder offers
Every motivation, special ability, talent, background answer and appearance
answer a player can end up 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: builder-lists -->
### Motivations
- Revenge for a past wrong
- Seeking fortune or wealth
- Protecting someone they love
- Redemption for a past mistake
- Driven by curiosity or knowledge
- Duty or oath to a cause or order
### Special abilities granted by species
| Species | Abilities |
|---|---|
| Human | **Versatile** — Once per day, reroll one skill check you just failed and keep the new result. |
| Half-Elf | **Darkvision** — See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.<br>**Fey Ancestry** — Advantage on saves against being charmed, and magic cannot put you to sleep. |
| Elf | **Darkvision** — See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.<br>**Fey Ancestry** — Advantage on saves against being charmed, and magic cannot put you to sleep.<br>**Trance** — You do not sleep. Four hours of meditation rests you as well as a full night. |
| Gnome | **Darkvision** — See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.<br>**Gnome Cunning** — Advantage on Intelligence, Wisdom and Charisma saves against magic. |
| Dwarf | **Darkvision** — See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.<br>**Dwarven Resilience** — Advantage on saves against poison, and poison damage against you is halved. |
| Halfling | **Lucky** — When you roll a 1 on a d20 for an attack, check or save, reroll it and use the new roll.<br>**Brave** — Advantage on saves against being frightened. |
| Dragonborn | **Breath Weapon** — Once per rest, exhale destructive energy: creatures in a 15-foot cone take 2d6 damage (Dexterity save for half).<br>**Draconic Resistance** — Damage of your breath weapon’s type against you is halved. |
| Tiefling | **Darkvision** — See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.<br>**Hellish Resistance** — Fire damage against you is halved. |
| Half-Orc | **Darkvision** — See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.<br>**Relentless Endurance** — Once per day, when you would drop to 0 hit points, drop to 1 instead. |
| Orc | **Darkvision** — See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.<br>**Aggressive** — As a bonus action, move up to your speed toward an enemy you can see. |
### Special abilities granted by class
| Class | Abilities |
|---|---|
| Barbarian | **Rage** — Twice per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved.<br>**Unarmored Defense** — Without armor, add your Constitution modifier to your AC (ask your DM to set your armor bonus). |
| Bard | **Bardic Inspiration** — A few times per day, as a bonus action, give an ally a d6 to add to one attack, check or save.<br>**Jack of Many Tales** — You have heard a story about nearly everything: ask your DM for one useful rumour per session. |
| Cleric | **Divine Domain** — Choose with your DM what your god stands for (life, light, war, knowledge…). It shapes the favours you can ask.<br>**Healing Hands** — Once per rest, touch a creature to restore 1d8 + your Wisdom modifier hit points. |
| Druid | **Druidic** — You know the secret language of druids and can leave hidden messages in it.<br>**Nature’s Voice** — You can calm and make simple requests of ordinary animals. |
| Fighter | **Second Wind** — Once per rest, as a bonus action, recover 1d10 + your level hit points.<br>**Fighting Style** — Choose one with your DM: +2 to ranged attacks, +1 AC in armor, or +2 damage with a one-handed weapon. |
| Monk | **Martial Arts** — Your unarmed strikes deal 1d4 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.<br>**Unarmored Defense** — Without armor, add your Wisdom modifier to your AC (ask your DM to set your armor bonus). |
| Paladin | **Divine Sense** — A few times per day, sense any celestial, fiend or undead within 60 feet.<br>**Lay on Hands** — A pool of healing equal to 5 × your level per day, spent by touch however you like. |
| Ranger | **Favored Enemy** — Choose a kind of creature with your DM. Advantage on checks to track them and to recall what you know about them.<br>**Natural Explorer** — In the wild you cannot get lost, you find twice as much food, and difficult terrain does not slow your group. |
| Rogue | **Sneak Attack** — 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.<br>**Thieves’ Cant** — You know the secret slang and signs of the criminal underworld. |
| Sorcerer | **Innate Magic** — Your magic comes from within: you need no spellbook and no prayers.<br>**Wild Talent** — Once per day, reroll the damage dice of one spell and keep the better result. |
| Warlock | **Otherworldly Patron** — Choose with your DM who granted your power, and what they want in return.<br>**Pact Magic** — Your magic returns after a short rest, not only a long one. |
| Wizard | **Spellbook** — Your spells are written in a book. You can copy new spells into it when you find them.<br>**Arcane Recovery** — Once per day, after a short rest, regain some of your spent magic. |
### Talents (pick 2)
| Talent | What it does |
|---|---|
| Alert | You cannot be surprised while you are awake. |
| Keen Eye | Advantage on checks to spot hidden doors, traps and things out of place. |
| Silver Tongue | Once per day, reroll a Persuasion or Deception check and keep the better result. |
| Light Sleeper | You wake at the slightest sound, and keeping watch does not tire you. |
| Iron Stomach | Advantage on saves against spoiled food, strong drink and swallowed poisons. |
| Animal Friend | Ordinary animals are calm around you unless provoked. |
| Quick Hands | Once per turn, draw, stow or pick up an object without using your action. |
| Sure-Footed | Advantage on checks to keep your balance or avoid being knocked down. |
| Linguist | You speak two extra languages of your choice, agreed with your DM. |
| Field Medic | Steadying a dying creature is automatic for you; no roll needed. |
| Strong Back | You can carry, lift and drag twice as much as your size suggests. |
| Good Memory | You recall exactly anything you have seen or heard in the last month. |
| Streetwise | In any town you can find a place to sleep, a rumour, and someone who buys things without asking questions. |
| Tough as Nails | Once per day, shrug off a blow: reduce one instance of damage by 1d6. |
### Background questions
**Upbringing** — What were you before you became an adventurer? (pick 1)
| Option | What it adds |
|---|---|
| Soldier | Served in an army or militia, and still thinks in terms of orders and watches. |
| Acolyte | Raised in a temple, tending shrines and copying scripture. |
| Criminal | Made a living outside the law, and knows who to ask for what. |
| Noble | Born to wealth and a family name that opens doors — and makes enemies. |
| Scholar | Spent years among books, chasing answers to old questions. |
| Farmer | Worked the land from dawn to dusk, and knows the value of a good harvest. |
| Sailor | Grew up on deck, at home with rope, salt and bad weather. |
| Guild artisan | Learned a craft in a workshop and takes pride in good work. |
| Entertainer | Sang, juggled or acted for coin in taverns and market squares. |
| Outlander | Raised far from towns, among hunters, herders or nomads. |
| Urchin | Grew up alone on city streets, surviving on wits and quick feet. |
| Hermit | Lived apart from the world for years, with only thoughts for company. |
| Merchant | Travelled the trade roads buying low and selling high. |
| Village healer | Set bones, brewed remedies and sat with the sick. |
**Home** — Where did you grow up? (pick 1)
| Option | What it adds |
|---|---|
| A crowded city | Noise, alleys and a thousand strangers. |
| A small village | Everyone knew everyone, and nothing stayed secret. |
| A harbour town | Ships, gulls and news from far away. |
| A mountain hold | Thin air, stone halls and long winters. |
| Deep in the forest | Under old trees, far from any road. |
| A frontier fort | The last wall before the wild. |
| On the road | Never in one place long enough to call it home. |
| A remote monastery | Bells, silence and routine. |
**Turning point** — What set you on the road? (pick 1)
| Option | What it adds |
|---|---|
| Lost someone dear | And have not been the same since. |
| Was betrayed | By someone they trusted completely. |
| Survived a disaster | Fire, flood, plague or war — and walked away when others did not. |
| Found a secret | Something they were never meant to see. |
| Owes a debt | One that cannot be repaid by staying home. |
| Was cast out | Rightly or wrongly, there is no going back. |
| Was saved by a stranger | And means to pass the favour on. |
| Heard a calling | A dream, a sign or a voice that would not let them rest. |
| Could not stand the quiet | An ordinary life was never going to be enough. |
**Personality** — How do others describe you? Pick one or two. (pick 1 to 2)
| Option | What it adds |
|---|---|
| Brave | — |
| Cautious | — |
| Curious | — |
| Kind | — |
| Hot-headed | — |
| Sarcastic | — |
| Loyal | — |
| Honest to a fault | — |
| Cheerful | — |
| Quiet | — |
| Proud | — |
| Superstitious | — |
| Generous | — |
| Stubborn | — |
**Flaw** — What gets you into trouble? (pick 1)
| Option | What it adds |
|---|---|
| Greedy | Cannot walk past treasure. |
| Reckless | Acts first and thinks later. |
| Holds grudges | Never forgets a slight. |
| Too trusting | Believes a good story. |
| Loses their nerve | When it matters most. |
| Boastful | Promises more than they can deliver. |
| Secretive | Keeps things even from friends. |
| Soft-hearted | Cannot refuse someone in need. |
| Short temper | Easily provoked. |
### Appearance questions
**Build** — What shape are you? (pick 1)
| Option | What it adds |
|---|---|
| Slight | — |
| Lean | — |
| Average | — |
| Stocky | — |
| Muscular | — |
| Heavyset | — |
**Height** — For your species, are you… (pick 1)
| Option | What it adds |
|---|---|
| Short | — |
| Average height | — |
| Tall | — |
| Towering | — |
**Hair** — Hair (or what you have instead). (pick 1)
| Option | What it adds |
|---|---|
| Black | — |
| Brown | — |
| Blonde | — |
| Red | — |
| Grey | — |
| White | — |
| An unusual colour | — |
| Bald | — |
| None — scales, horns or crest | — |
**Eyes** — Eye colour. (pick 1)
| Option | What it adds |
|---|---|
| Brown | — |
| Blue | — |
| Green | — |
| Grey | — |
| Amber | — |
| Black | — |
| Violet | — |
| Mismatched | — |
**Skin** — Skin or scales. (pick 1)
| Option | What it adds |
|---|---|
| Pale | — |
| Fair | — |
| Tanned | — |
| Brown | — |
| Dark | — |
| Weathered | — |
| Ashen grey | — |
| Green-tinged | — |
| Deep red | — |
| Scaled | — |
**Distinguishing features** — What would a stranger remember? Pick up to two, or none. (pick 0 to 2)
| Option | What it adds |
|---|---|
| A scar across the face | — |
| A tattoo | — |
| Freckles | — |
| A broken nose | — |
| An eyepatch | — |
| A braided beard | — |
| Piercings | — |
| A birthmark | — |
| An old burn | — |
| A missing finger | — |
| A slight limp | — |
| An easy grin | — |
**Usually wears** — What are you usually seen in? (pick 1)
| Option | What it adds |
|---|---|
| Travel-worn leathers | — |
| Well-kept armor | — |
| A hooded cloak | — |
| Fine clothes | — |
| Patched and mended clothes | — |
| Long robes | — |
| Furs and hides | — |
| Bright colours | — |
| Plain, practical clothes | — |
<!-- END GENERATED: builder-lists -->
+264
View File
@@ -0,0 +1,264 @@
# 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. [What each side sees](#10-what-each-side-sees)
11. [Permissions](#11-permissions)
12. [What is not modelled](#12-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 | 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 |
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 You've Been", and on the encounter itself. Archiving or deleting
the encounter does not take a visited place away again.
### Order, archive and delete
**Order.** The campaign page lists encounters in the **campaign order**, which
the DM arranges with the ▲ and ▼ buttons and everyone sees. A new encounter
goes to the top. The "Sort by" menu re-sorts the list by name, status, type or
date instead; that choice is remembered per browser and changes nothing for
anyone else. ▲ and ▼ only show while sorting by campaign order.
**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 | No |
| Monster | The campaign's monster list ([MONSTERS.md](MONSTERS.md)) | Yes |
| NPC | The campaign's NPC list ([NPCS.md](NPCS.md)) | No |
Each has a card. The card's left edge is coloured by kind, and it shows:
- **Characters:** level, species and class; HP, AC, speed, initiative bonus,
passive perception, proficiency bonus and spell save DC. Under "details":
ability scores, saves, the weapons and spells the character can use (with
attack and damage numbers), and special abilities. The name links to the
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.
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 combatant is marked **Down** and dimmed. It keeps its place in
the order; nothing is removed automatically.
- A character's temporary HP is shown beside its HP but is not used up
automatically.
## 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.
## 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 |
|---|---|
| Gold | An amount |
| 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 (gold only) | Shared evenly between the characters in the encounter. Any remainder goes one gold each, highest initiative first: 101 gold between two is 51 and 50. |
- **Gold** is 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. What each side sees
| | DM | Players |
|---|---|---|
| Scene description | Yes | Yes |
| DM notes | Yes | Never |
| Characters | Full card | Full card |
| Monsters | Full stat block | Name, HP, conditions |
| NPCs | Everything | Name, role, attitude, description, HP bar |
| Rewards | All | Only awarded ones |
| Controls (add, damage, turns, rewards) | Yes | None |
The server only sends a player what the right-hand column lists, so hidden
information cannot be dug out of the page.
## 11. Permissions
| 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 |
| Advance turns and rounds | Yes | No |
| Add, remove or award rewards | Yes | No |
People outside the campaign cannot open its encounters.
## 12. What is not modelled
- Rolling initiative, attacks, damage or saves (use the dice roller, bottom
left of every page)
- Applying an attack's damage automatically
- Death saves and stabilising
- Conditions with durations or mechanical effects
- Maps, positions, distances and areas of effect
- Experience points and encounter difficulty
- Spells as rewards
- Renaming an encounter from the screen
- A log of what happened during the encounter
+151
View File
@@ -0,0 +1,151 @@
# 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)
6. [Starting items](#6-starting-items)
7. [Items as rewards](#7-items-as-rewards)
8. [Permissions](#8-permissions)
9. [What is not modelled](#9-what-is-not-modelled)
---
## 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.
- Items are **descriptive**. Holding or equipping one never changes a number
on the sheet.
## 2. The item pool
The pool is the "Items Pool" card on the campaign page (DM only).
| 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) |
- **Adding:** the "+ Add Item" form. There is no built-in library of items.
- **Changing:** starting level, growth (when the item has dice) and the
starting-option tick box can be changed in the table. Name, description and
dice cannot be changed on screen once created (the API accepts it); remove
and re-add instead.
- **Removing:** the ✕ button. This takes the item out of **every inventory**
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 a label. It does not change Armor Class or anything else;
armor is represented by the single "armor bonus" number on the sheet.
**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.
## 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
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).
## 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 |
| Change quantity or level | Yes | No | No |
| Equip or unequip | Yes | Yes, if the character can use it | No |
| See a character's inventory | Yes | Yes | Yes |
## 9. What is not modelled
- Any effect of an item on stats (AC, speed, abilities)
- Armor and shields as items
- Using up items automatically, charges, and attunement
- Weight, carrying capacity and containers
- Prices, buying and selling (record the gold change in the money log)
- Swaps as a single step (each side gives separately), and asking the
recipient to accept
+170
View File
@@ -0,0 +1,170 @@
# 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 | In feet. 30 unless set otherwise |
| 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` |
| 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" card on the campaign page (DM only).
- **Adding by hand:** "+ Add Monster" asks for name, AC, max HP, attack bonus
and damage. Speed is set to 30 and every ability score to 10; challenge
rating, trait and notes are left empty.
- **Adding from the bestiary:** "📚 From Library" copies a complete stat block.
- **Changing:** there is no edit form (the API accepts changes, but no screen
offers them). Remove and re-add, or add from the library.
- **Removing:** the ✕ button, immediately, with no confirmation. Copies
already in encounters stay there with their name and HP, but lose their
stat block.
## 4. The built-in bestiary
38 monsters, from harmless to dangerous. Each library row shows challenge
rating, AC, HP, attack and damage, plus its trait and notes, before you add
it. Adding copies the monster into your campaign; the copy is yours, and later
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 do see a monster's exact HP numbers. If you would rather keep those
secret, that is not currently possible.
## 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)
- 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
+123
View File
@@ -0,0 +1,123 @@
# NPCs
How non-player characters work in this app: what the DM records about them,
how they are brought into a scene, and what players can and cannot see.
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. [What players see](#5-what-players-see)
6. [NPC or monster?](#6-npc-or-monster)
7. [Permissions](#7-permissions)
8. [What is not modelled](#8-what-is-not-modelled)
---
## 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.
## 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 5 |
| Attack bonus | Optional | DM only |
| Damage | Optional dice, e.g. `1d6` | DM only |
A new NPC is Neutral unless you choose otherwise.
## 3. Managing NPCs
The NPC list is the "NPCs" card on the campaign page (DM only).
- **Adding:** "+ Add NPC" opens a form with every field above. Only the name
is required.
- **Changing:** there is no edit form. To change an NPC, remove it and add it
again (the API accepts changes, but no screen offers them).
- **Removing:** the ✕ button, after a confirmation. This also **removes the
NPC from every encounter** it was in.
## 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. 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 |
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.
## 6. 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 |
A named villain the party will both talk to and fight works well as an NPC
with HP, AC, attack and damage filled in.
## 7. Permissions
| 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 |
## 8. What is not modelled
- Ability scores, saves, skills and spells for NPCs
- NPC inventory, gold and shops
- 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
+388
View File
@@ -0,0 +1,388 @@
# 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 "Spell List" card on the campaign page (DM only).
| 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) |
| Casting time, range, duration | Stored for library spells but not shown on any screen |
A cantrip is a spell of level 0. The app always calls it a **cantrip**, never
"level 0" or "level 1".
- **Adding:** by hand with the "+ Add Spell" form (name, level, school,
damage and what it does), or from the library.
- **Changing:** damage (the drop-down in the table) and the starting-option
tick box. Name, level, school and description cannot be changed on screen
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.
## 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 -->
+281
View File
@@ -0,0 +1,281 @@
# 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](#9-weapons-as-rewards)
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 Pool" card on the campaign page (DM only).
| 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…) but not shown or used anywhere |
- **Adding:** by hand with the "+ Add Weapon" form, or from the library.
- **Changing:** starting level, growth and the starting-option tick box can be
changed in the table. Name, damage, type and ability cannot be changed on
screen once created (the API accepts it); remove and re-add instead.
- **Removing:** the ✕ button. This takes the weapon away from **every
character holding it** and off every encounter's reward list. There is no
undo.
- The same weapon can be added twice; nothing stops duplicate names.
## 3. The built-in library
"📚 From Library" offers 37 standard weapons to copy into the pool:
| 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 |
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).
## 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 ticks **Equipped**. Equipped is a label only: unequipped
weapons still show their numbers, and nothing limits how many are equipped.
**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 is left out of quick stats and encounters. 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
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).
## 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 |
| 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
- Range and ammunition
- Critical hits, advantage, and rolling the attack itself (use the dice roller)
- Magic bonuses such as +1 weapons (use a higher level or growth instead)
- Price and weight
## 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.
This list is written from the app's own data by `npm run docs:lists` (in `/backend`). Re-run it after changing the data; do not edit the list by hand.
<!-- BEGIN GENERATED: weapon-library -->
### Simple Melee (10)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Club | 1d4 | bludgeoning | Strength | light | — |
| Dagger | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, light, thrown | 20/60 ft |
| Greatclub | 1d8 | bludgeoning | Strength | two-handed | — |
| Handaxe | 1d6 | slashing | Strength | light, thrown | 20/60 ft |
| Javelin | 1d6 | piercing | Strength | thrown | 30/120 ft |
| Light Hammer | 1d4 | bludgeoning | Strength | light, thrown | 20/60 ft |
| Mace | 1d6 | bludgeoning | Strength | — | — |
| Quarterstaff | 1d6 | bludgeoning | Strength | versatile (1d8) | — |
| Sickle | 1d4 | slashing | Strength | light | — |
| Spear | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft |
### Simple Ranged (4)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Light Crossbow | 1d8 | piercing | Dexterity | ammunition, loading, two-handed | 80/320 ft |
| Dart | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, thrown | 20/60 ft |
| Shortbow | 1d6 | piercing | Dexterity | ammunition, two-handed | 80/320 ft |
| Sling | 1d4 | bludgeoning | Dexterity | ammunition | 30/120 ft |
### Martial Melee (18)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Battleaxe | 1d8 | slashing | Strength | versatile (1d10) | — |
| Flail | 1d8 | bludgeoning | Strength | — | — |
| Glaive | 1d10 | slashing | Strength | heavy, reach, two-handed | — |
| Greataxe | 1d12 | slashing | Strength | heavy, two-handed | — |
| Greatsword | 2d6 | slashing | Strength | heavy, two-handed | — |
| Halberd | 1d10 | slashing | Strength | heavy, reach, two-handed | — |
| Lance | 1d12 | piercing | Strength | reach, special | — |
| Longsword | 1d8 | slashing | Strength | versatile (1d10) | — |
| Maul | 2d6 | bludgeoning | Strength | heavy, two-handed | — |
| Morningstar | 1d8 | piercing | Strength | — | — |
| Pike | 1d10 | piercing | Strength | heavy, reach, two-handed | — |
| Rapier | 1d8 | piercing | Strength or Dexterity (finesse) | finesse | — |
| Scimitar | 1d6 | slashing | Strength or Dexterity (finesse) | finesse, light | — |
| Shortsword | 1d6 | piercing | Strength or Dexterity (finesse) | finesse, light | — |
| Trident | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft |
| War Pick | 1d8 | piercing | Strength | — | — |
| Warhammer | 1d8 | bludgeoning | Strength | versatile (1d10) | — |
| Whip | 1d4 | slashing | Strength or Dexterity (finesse) | finesse, reach | — |
### Martial Ranged (5)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Blowgun | 1 | piercing | Dexterity | ammunition, loading | 25/100 ft |
| Hand Crossbow | 1d6 | piercing | Dexterity | ammunition, light, loading | 30/120 ft |
| Heavy Crossbow | 1d10 | piercing | Dexterity | ammunition, heavy, loading, two-handed | 100/400 ft |
| Longbow | 1d8 | piercing | Dexterity | ammunition, heavy, two-handed | 150/600 ft |
| Net | - | special | Dexterity | special, thrown | 5/15 ft |
<!-- END GENERATED: weapon-library -->
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"version": "1.4.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -9,6 +9,7 @@ import { PartyOverviewPage } from './pages/PartyOverviewPage';
import { CharacterSheetPage } from './pages/CharacterSheetPage';
import { CharacterBuilderPage } from './pages/CharacterBuilderPage';
import { EncounterPage } from './pages/EncounterPage';
import { AccountPage } from './pages/AccountPage';
function App() {
return (
@@ -24,6 +25,7 @@ function App() {
<Route path="/characters/:characterId" element={<CharacterSheetPage />} />
<Route path="/characters/:characterId/build" element={<CharacterBuilderPage />} />
<Route path="/encounters/:encounterId" element={<EncounterPage />} />
<Route path="/account" element={<AccountPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+335 -20
View File
@@ -1,18 +1,23 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiClient } from './client';
import type {
BuildOptions,
Campaign,
CampaignLibrary,
CampaignMemberSummary,
CharacterDetail,
CharacterSummary,
Currency,
DiceMode,
Encounter,
EncounterMode,
EncounterParticipant,
InventoryItem,
GoldTransaction,
Item,
Location,
Monster,
Note,
Npc,
QuickStats,
Spell,
Weapon,
@@ -58,7 +63,7 @@ export function useCreateCampaign() {
export function useUpdateCampaign(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Partial<Campaign>) =>
mutationFn: async (data: Partial<Campaign> & { archived?: boolean }) =>
(await apiClient.patch<{ campaign: Campaign }>(`/campaigns/${campaignId}`, data)).data.campaign,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
@@ -67,6 +72,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 +150,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);
@@ -140,6 +171,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 +212,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 +272,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 +299,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 +310,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 +340,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 +372,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 +426,58 @@ 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;
minLevel?: number;
name?: string;
description?: string | null;
}) =>
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, pool] }),
});
}
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 +495,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,
});
@@ -338,10 +526,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,
});
@@ -422,13 +610,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],
@@ -440,7 +672,7 @@ export function useEncounter(encounterId: string | undefined) {
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] }),
});
@@ -475,13 +707,96 @@ export function useAdvanceRound(encounterId: string) {
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) => {
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
// 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';
export function useNextTurn(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await apiClient.post(`/encounters/${encounterId}/next-turn`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
// --------------------------------------------------------------------------
// 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: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
export function useDeleteReward(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (rewardId: string) => apiClient.delete(`/encounters/${encounterId}/rewards/${rewardId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
});
}
// 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: () => {
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
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'] }),
});
}
// --------------------------------------------------------------------------
// Notes
// --------------------------------------------------------------------------
+173 -8
View File
@@ -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,14 @@ export interface Campaign {
id: string;
name: string;
description: string | null;
inviteCode: string;
inviteCode?: string; // DM only
startingGold: number;
startingItemCount: number;
startingSpellCount: number;
skipMotivationRoll: boolean;
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
ownerId: string;
archivedAt: string | null; // set while the campaign is archived
role: 'DM' | 'PLAYER';
createdAt: string;
}
@@ -64,6 +105,9 @@ export interface CampaignMemberSummary {
role: 'DM' | 'PLAYER';
}
// The coins a character carries. 1 gold = 10 silver = 100 copper.
export type Currency = 'GOLD' | 'SILVER' | 'COPPER';
export interface CharacterSummary {
id: string;
campaignId: string;
@@ -77,6 +121,10 @@ export interface CharacterSummary {
buildStage: BuildStage;
currentHp: number;
tempHp: number;
// The purse; all three are null for another player's character.
gold: number | null;
silver: number | null;
copper: number | null;
maxHp: number | null;
armorClass: number | null;
currentLocationId: string | null;
@@ -121,6 +169,12 @@ 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;
}
export interface CharacterWeapon {
@@ -129,6 +183,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 +199,8 @@ export interface Spell {
range: string | null;
duration: string | null;
description: string | null;
damageDice: string | null;
isStartingOption: boolean;
}
export interface CharacterSpell {
@@ -149,16 +208,43 @@ 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;
}
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 {
@@ -198,7 +284,12 @@ export interface CharacterDetail {
diceMode: DiceMode;
currentLocationId: string | null;
currentLocation: Location | null;
gold: number;
gold: number | null;
silver: number | null;
copper: number | null;
// True when this is another player's character: no gold, inventory or
// location is sent, only what the party can see across the table.
restricted: boolean;
buildStage: BuildStage;
buildState: Record<string, unknown> | null;
skillProficiencies: SkillProficiency[];
@@ -230,10 +321,48 @@ export interface Monster {
}
export type EncounterStatus = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
export type EncounterMode = 'BATTLE' | 'QUEST';
export type NpcDisposition = 'FRIENDLY' | 'NEUTRAL' | 'HOSTILE';
// What players see of an NPC in an encounter.
export interface NpcPublic {
id: string;
name: string;
role: string | null;
disposition: NpcDisposition;
description: string | null;
}
// 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;
@@ -246,11 +375,24 @@ 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;
tempHp: number;
speed: number;
initiativeBonus: number;
passivePerception: number;
proficiencyBonus: number;
abilities: Record<Ability, AbilityDetail>;
saves: Record<Ability, { proficient: boolean; bonus: number }>;
specialAbilities: string | null;
weapons: CombatWeapon[];
spells: CombatSpell[];
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
@@ -262,13 +404,24 @@ export interface EncounterParticipant {
characterId: string | null;
monsterId: string | null;
displayName: string;
// The d20 rolled for initiative (1–20), null until rolled. `initiative` is
// that roll plus the combatant's bonus, and is what the order sorts on.
initiativeRoll: number | null;
initiative: number;
currentHp: number;
maxHp: number;
initiativeBonus?: number; // a monster's or NPC's is sent to the DM only
// Null for a player when the campaign hides monster hit points; hpPercent
// (0–100) is sent in their place to draw the health bar.
currentHp: number | null;
maxHp: number | null;
hpPercent?: number | null;
conditions: string[];
isMonster: boolean;
character?: { id: string; name: string } | null;
monster?: { id: string; name: string } | null;
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
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 +429,20 @@ export interface Encounter {
id: string;
campaignId: string;
name: string;
mode: EncounterMode;
status: EncounterStatus;
currentRound: number;
activeParticipantId: string | null;
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[];
rewards?: EncounterReward[]; // players only receive the awarded ones
}
export interface Note {
@@ -314,6 +477,7 @@ export interface LibrarySpell {
range: string | null;
duration: string | null;
description: string | null;
damageDice?: string; // default damage; absent for spells that deal none
classes: CharacterClass[];
}
@@ -346,6 +510,7 @@ export interface QuickStatsWeapon {
characterWeaponId: string;
weaponId: string;
name: string;
level: number;
equipped: boolean;
quantity: number;
attackBonus: number;
+166
View File
@@ -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>
)}
</>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { hpBarClass } from '../lib/format';
// A health bar with temporary hit points on it. Temp HP sits on top of real
// HP, so it is drawn as its own blue segment after the HP fill, and the bar
// stretches to fit when the two together come to more than the maximum (full
// health plus a buffer). The fill's colour still reflects real HP only: temp
// HP doesn't make a bloodied character look healthy.
export function HpBar({ current, max, temp = 0, height, className = 'mt-sm' }: {
current: number;
max: number;
temp?: number;
height?: number;
className?: string;
}) {
const hp = Math.max(0, Math.min(current, max));
const buffer = Math.max(0, temp);
const scale = Math.max(max, hp + buffer, 1);
return (
<div className={`hp-bar-track ${className}`} style={height ? { height } : undefined}>
<div className={hpBarClass(hp, max)} style={{ width: `${(hp / scale) * 100}%` }} />
{buffer > 0 && <div className="hp-bar-fill hp-temp" style={{ width: `${(buffer / scale) * 100}%` }} title="Temporary HP" />}
</div>
);
}
+53 -8
View File
@@ -1,4 +1,5 @@
import { useId, useState } from 'react';
import { useEffect, useId, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
// Short explanations for the stat labels sprinkled across the sheet, quick
// stats, party table, and builder — "what is this, and how is it worked
@@ -22,12 +23,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 +78,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 +86,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>
);
}
+5 -1
View File
@@ -1,5 +1,6 @@
import { Link, Outlet } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { DiceRoller } from './DiceRoller';
export function Layout() {
const { user, logout } = useAuth();
@@ -16,7 +17,9 @@ export function Layout() {
<nav className="topbar-nav">
{user && (
<div className="topbar-user">
<span>{user.displayName}</span>
<Link to="/account" title="Account settings">
{user.displayName}
</Link>
<button className="btn btn-ghost" onClick={logout}>
Log out
</button>
@@ -27,6 +30,7 @@ export function Layout() {
<main className="page">
<Outlet />
</main>
<DiceRoller />
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
import type { ChangeEvent, FocusEvent, InputHTMLAttributes } from 'react';
type NumberInputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'min' | 'max' | 'step'> & {
min?: number;
max?: number;
// For a field that always holds a number (a level, a bonus): if it is left
// empty, it goes back to its starting value instead of being saved blank.
keepValue?: boolean;
};
// A whole-number field that cannot hold a value outside its limits. A plain
// <input type="number" max={20}> only limits the arrow buttons; typing 500
// into it works. This one corrects the text as it is typed: anything above
// `max` becomes `max`, anything below `min` becomes `min`, decimals are
// dropped. Works the same whether the field is controlled (`value`) or not
// (`defaultValue`): the handlers passed in see the corrected value.
export function NumberInput({ min, max, keepValue, onChange, onBlur, ...rest }: NumberInputProps) {
function limit(input: HTMLInputElement, leaving: boolean) {
const raw = input.value;
if (raw === '' || raw === '-') {
// Nothing typed yet, or the start of a negative number.
if (leaving) input.value = keepValue ? String(rest.defaultValue ?? rest.value ?? min ?? 0) : '';
return;
}
let value = Math.trunc(Number(raw));
if (Number.isNaN(value)) {
input.value = '';
return;
}
if (max !== undefined && value > max) value = max;
// While typing, a value below a minimum above 1 may just be unfinished
// ("1" on the way to "15"), so that case waits until the field is left.
if (min !== undefined && value < min && (leaving || min <= 1)) value = min;
if (String(value) !== raw) input.value = String(value);
}
function handleChange(e: ChangeEvent<HTMLInputElement>) {
limit(e.target, false);
onChange?.(e);
}
function handleBlur(e: FocusEvent<HTMLInputElement>) {
limit(e.target, true);
onBlur?.(e);
}
return <input {...rest} type="number" inputMode="numeric" step={1} min={min} max={max} onChange={handleChange} onBlur={handleBlur} />;
}
+4 -5
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useQuickStats } from '../api/queries';
import { Spinner, ErrorBanner } from './Spinner';
import { fmtMod, hpBarClass } from '../lib/format';
import { fmtMod } from '../lib/format';
import { HpBar } from './HpBar';
export function QuickStatsFab({ characterId }: { characterId: string }) {
const [open, setOpen] = useState(false);
@@ -50,9 +51,7 @@ export function QuickStatsDrawer({ characterId, onClose }: { characterId: string
<span className="stat-value">{fmtMod(data.initiative)}</span>
</div>
</div>
<div className="hp-bar-track mt-sm">
<div className={hpBarClass(data.currentHp, data.maxHp)} style={{ width: `${Math.max(0, Math.min(100, (data.currentHp / data.maxHp) * 100))}%` }} />
</div>
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
{data.spellcasting && (
<div className="card mt-md">
@@ -72,7 +71,7 @@ export function QuickStatsDrawer({ characterId, onClose }: { characterId: string
<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)}
Lv {w.level} · {w.damageDice} {w.damageType} {fmtMod(w.damageBonus)}
</p>
</div>
<span className="stat-value" style={{ fontSize: '1.1rem' }}>
+11
View File
@@ -28,21 +28,31 @@ export function useCampaignSocket(campaignId: string | undefined) {
const onCharacterUpdated = () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet HP
// A character arriving somewhere new adds it to the places their player can see.
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'locations'] });
};
const onCharacterCreated = () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
};
const onParticipantUpdated = () => {
qc.invalidateQueries({ queryKey: ['encounter'] });
// A player's list only holds the encounters their characters are in.
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
};
const onRoundAdvanced = () => {
qc.invalidateQueries({ queryKey: ['encounter'] });
};
const onCampaignDeleted = () => {
qc.invalidateQueries({ queryKey: ['campaigns'] });
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
};
socket.on('character:updated', onCharacterUpdated);
socket.on('character:created', onCharacterCreated);
socket.on('encounter:participant-updated', onParticipantUpdated);
socket.on('encounter:round-advanced', onRoundAdvanced);
socket.on('campaign:deleted', onCampaignDeleted);
return () => {
socket.emit('campaign:leave', campaignId);
@@ -50,6 +60,7 @@ export function useCampaignSocket(campaignId: string | undefined) {
socket.off('character:created', onCharacterCreated);
socket.off('encounter:participant-updated', onParticipantUpdated);
socket.off('encounter:round-advanced', onRoundAdvanced);
socket.off('campaign:deleted', onCampaignDeleted);
};
}, [campaignId, token, qc]);
}
+461 -4
View File
@@ -450,6 +450,7 @@ input[type='radio'] {
}
.hp-bar-track {
display: flex;
width: 100%;
height: 10px;
background: var(--bg-inset);
@@ -470,6 +471,10 @@ input[type='radio'] {
.hp-bar-fill.hp-critical {
background: var(--danger-strong);
}
/* Temporary HP: a separate segment after the real HP. */
.hp-bar-fill.hp-temp {
background: var(--arcane-strong);
}
.divider {
border: none;
@@ -626,10 +631,9 @@ input[type='radio'] {
color: var(--accent-strong);
outline: none;
}
/* Placed by InfoTip.tsx against the viewport, so no scrolling container can clip it. */
.info-tip-bubble {
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
position: fixed;
transform: translateX(-50%);
width: max-content;
max-width: 260px;
@@ -643,7 +647,7 @@ input[type='radio'] {
letter-spacing: normal;
color: var(--text);
box-shadow: var(--shadow-deep);
z-index: 100;
z-index: 200;
pointer-events: none;
}
@@ -733,3 +737,456 @@ table.data-table tbody tr:hover {
font-size: 2.2rem;
}
}
/* ---------------------------------------------------------------------- */
/* Encounter screen */
/* ---------------------------------------------------------------------- */
.badge-friendly {
border-color: var(--success);
color: var(--success-strong);
}
.badge-neutral {
border-color: var(--border);
color: var(--text-muted);
}
.badge-hostile {
border-color: var(--danger);
color: var(--danger-strong);
}
.badge-live {
border-color: var(--success);
color: var(--success-strong);
}
.encounter-layout {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
align-items: start;
}
@media (min-width: 960px) {
.encounter-layout {
grid-template-columns: minmax(0, 1fr) 330px;
}
}
.section-label {
font-family: var(--font-display);
color: var(--text-muted);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.1em;
margin: 0 0 0.5rem;
}
.card + .section-label,
.empty-state + .section-label {
margin-top: 1.5rem;
}
/* One combatant. The left edge is colour-coded by who it is. */
.combatant {
border-left: 4px solid var(--border);
padding: 1rem 1.1rem;
}
.combatant-character {
border-left-color: var(--arcane);
}
.combatant-monster {
border-left-color: var(--danger);
}
.combatant-npc {
border-left-color: var(--success);
}
.combatant.is-active {
border-color: var(--accent-strong);
box-shadow: 0 0 0 1px var(--accent-strong), 0 0 22px rgba(201, 162, 39, 0.25), var(--shadow-deep);
}
.combatant.is-down {
opacity: 0.6;
}
.combatant-name {
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--text);
}
.combatant-sub {
color: var(--text-muted);
font-size: 0.85rem;
}
.turn-marker {
color: var(--accent-strong);
font-family: var(--font-display);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stat-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.stat-chip {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
background: var(--bg-inset);
border: 1px solid var(--border-soft);
border-radius: 6px;
padding: 0.2rem 0.55rem;
font-size: 0.9rem;
}
.stat-chip span {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.stat-chip b {
color: var(--accent-strong);
font-weight: 600;
}
.condition-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
font-size: 0.8rem;
border: 1px solid var(--danger);
color: var(--danger-strong);
background: var(--danger-soft);
}
.condition-chip button {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0 0.1rem;
font-size: 0.85rem;
line-height: 1;
}
.combatant-details {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-soft);
font-size: 0.92rem;
}
.combatant-details p {
margin: 0.3rem 0;
}
.detail-row {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 0.2rem 0;
}
.reward-row {
padding: 0.55rem 0;
border-bottom: 1px solid var(--border-soft);
}
.reward-row:last-child {
border-bottom: none;
}
.reward-row.is-awarded {
color: var(--text-muted);
}
.textarea-block {
display: block;
width: 100%;
resize: vertical;
}
/* ---------------------------------------------------------------------- */
/* Character builder: pick-then-confirm choices */
/* ---------------------------------------------------------------------- */
.step-intro {
color: var(--text-muted);
margin: 0 0 1rem;
}
.build-steps {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.build-steps .badge.is-done {
border-color: var(--accent);
color: var(--accent-strong);
}
.build-steps .badge.is-current {
border-color: var(--accent-strong);
background: var(--accent-soft);
color: var(--accent-strong);
font-weight: 700;
}
.choice-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
gap: 0.75rem;
text-align: left;
}
.choice-card {
display: flex;
flex-direction: column;
gap: 0.4rem;
text-align: left;
font-family: var(--font-body);
font-size: 0.95rem;
color: var(--text);
background: var(--bg-inset);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.9rem 1rem;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.choice-card:hover {
border-color: var(--accent);
transform: translateY(-1px);
}
.choice-card:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--accent-soft);
}
.choice-card.is-selected {
border-color: var(--accent-strong);
box-shadow: 0 0 0 1px var(--accent-strong), 0 0 18px rgba(201, 162, 39, 0.25);
background: linear-gradient(160deg, var(--bg-card-hover) 0%, var(--bg-inset) 100%);
}
.choice-card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-family: var(--font-display);
font-size: 1.1rem;
color: var(--accent-strong);
}
.choice-card-blurb {
color: var(--text-muted);
font-style: italic;
}
.choice-card-facts {
color: var(--text-muted);
font-size: 0.85rem;
}
.step-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--border-soft);
text-align: left;
}
.stat-block.is-key {
border-color: var(--accent);
}
.key-marker {
color: var(--accent-strong);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* ---------------------------------------------------------------------- */
/* Dice roller — on every page, bottom-left (quick stats owns bottom-right) */
/* ---------------------------------------------------------------------- */
.fab-dice {
position: fixed;
bottom: 1.25rem;
left: 1.25rem;
z-index: 80;
border-radius: 999px;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(circle at 35% 30%, #3a2e18, #1c160c 70%);
border: 2px solid var(--accent);
color: var(--accent-strong);
font-size: 1.6rem;
cursor: pointer;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5), 0 0 18px rgba(201, 162, 39, 0.3);
transition: transform 0.15s ease;
}
.fab-dice:hover {
transform: translateY(-2px) rotate(-8deg);
}
.dice-panel {
position: fixed;
bottom: calc(1.25rem + 60px + 0.75rem);
left: 1.25rem;
z-index: 85;
width: min(340px, calc(100vw - 2.5rem));
max-height: calc(100vh - 8rem);
overflow-y: auto;
background: linear-gradient(180deg, #1c160d 0%, #120e08 100%);
border: 1px solid var(--accent);
border-radius: var(--radius);
box-shadow: var(--shadow-deep);
padding: 1rem;
}
.dice-choices {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.4rem;
}
.die-btn {
font-family: var(--font-display);
font-size: 0.95rem;
padding: 0.55rem 0;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-inset);
color: var(--text);
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
}
.die-btn:hover {
border-color: var(--accent);
color: var(--accent-strong);
}
.die-btn.is-selected {
border-color: var(--accent-strong);
background: var(--accent-soft);
color: var(--accent-strong);
font-weight: 700;
}
.dice-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.dice-result {
text-align: center;
min-height: 6.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 0.75rem 0;
padding: 0.5rem;
background: var(--bg-inset);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
}
.roll-result.is-crit {
color: var(--success-strong);
text-shadow: 0 0 20px rgba(122, 178, 102, 0.6);
}
.roll-result.is-fumble {
color: var(--danger-strong);
text-shadow: 0 0 20px rgba(216, 84, 84, 0.5);
}
.dice-callout {
font-family: var(--font-display);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--success-strong);
}
.dice-callout.is-fumble {
color: var(--danger-strong);
}
.dice-breakdown {
color: var(--text-muted);
font-size: 0.85rem;
word-break: break-word;
}
.dice-history-row {
padding: 0.25rem 0;
border-top: 1px solid var(--border-soft);
font-size: 0.9rem;
}
.dice-history-dice {
color: var(--text-dim);
font-size: 0.8rem;
}
/* ---------------------------------------------------------------------- */
/* Character builder: story steps (motivation, abilities, background, look) */
/* ---------------------------------------------------------------------- */
.pick-group {
padding: 0.75rem 0;
border-top: 1px solid var(--border-soft);
}
.pick-group:first-of-type {
border-top: none;
}
.pick-group-label {
font-family: var(--font-display);
color: var(--accent);
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.pick-chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
}
.pick-chip {
font-family: var(--font-body);
font-size: 0.95rem;
padding: 0.35rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-inset);
color: var(--text);
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
}
.pick-chip:hover:not(:disabled) {
border-color: var(--accent);
}
.pick-chip.is-selected {
border-color: var(--accent-strong);
background: var(--accent-soft);
color: var(--accent-strong);
font-weight: 600;
}
.pick-chip:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.pick-detail {
color: var(--text-muted);
font-style: italic;
margin: 0.4rem 0 0;
}
.granted-ability {
padding: 0.4rem 0;
border-bottom: 1px solid var(--border-soft);
}
.granted-ability:last-child {
border-bottom: none;
}
/* Text the builder composed, shown read-only on the sheet. */
.composed-text {
margin: 0;
white-space: pre-wrap;
}
+46
View File
@@ -1,3 +1,5 @@
import type { Currency } from '../api/types';
export const SPECIES_LABELS: Record<string, string> = {
HUMAN: 'Human',
HALF_ELF: 'Half-Elf',
@@ -70,6 +72,29 @@ export function hpBarClass(current: number, max: number): string {
return 'hp-bar-fill';
}
// Coins. Silver is worth 0.1 gold and copper 0.01 gold.
export const CURRENCIES: Currency[] = ['GOLD', 'SILVER', 'COPPER'];
export const COIN_LABELS: Record<Currency, string> = { GOLD: 'Gold', SILVER: 'Silver', COPPER: 'Copper' };
export const COIN_SHORT: Record<Currency, string> = { GOLD: 'gp', SILVER: 'sp', COPPER: 'cp' };
export const COIN_FIELD: Record<Currency, 'gold' | 'silver' | 'copper'> = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
// What one coin is worth, counted in copper.
export const COIN_VALUE: Record<Currency, number> = { GOLD: 100, SILVER: 10, COPPER: 1 };
type Purse = { gold: number | null; silver: number | null; copper: number | null };
// "12 gp · 5 sp · 3 cp". Gold always shows; silver and copper only when held.
export function formatPurse(purse: Purse): string {
return CURRENCIES.filter((c) => c === 'GOLD' || (purse[COIN_FIELD[c]] ?? 0) > 0)
.map((c) => `${purse[COIN_FIELD[c]] ?? 0} ${COIN_SHORT[c]}`)
.join(' · ');
}
// The whole purse as one figure in gold, e.g. 12.65.
export function purseWorthInGold(purse: Purse): number {
const copper = CURRENCIES.reduce((sum, c) => sum + (purse[COIN_FIELD[c]] ?? 0) * COIN_VALUE[c], 0);
return copper / COIN_VALUE.GOLD;
}
export function extractErrorMessage(err: unknown): string {
const anyErr = err as { response?: { data?: { error?: unknown } } };
const apiError = anyErr?.response?.data?.error;
@@ -86,3 +111,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'];
+229
View File
@@ -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;
+67
View File
@@ -62,3 +62,70 @@ export interface PreviewAbilities {
}
export const ABILITY_ORDER: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
// --------------------------------------------------------------------------
// Gear levels — mirrors scaledDamageDice in backend/src/game/calculations.js.
// Used only to label the DM's level pickers ("Lv 3 · 1d10"); the dice actually
// shown for held gear always come from the server.
// --------------------------------------------------------------------------
export const MAX_GEAR_LEVEL = 9;
export const DAMAGE_SCALING_LABELS: Record<number, string> = { 1: 'Standard', 2: 'Powerful', 3: 'Legendary' };
const DIE_SIZES = [4, 6, 8, 10, 12];
function formatDice(sizes: number[]): string {
const counts = new Map<number, number>();
for (const size of sizes) counts.set(size, (counts.get(size) || 0) + 1);
return [...counts].map(([size, count]) => `${count}d${size}`).join('+');
}
function buildDamageLadder(rungs: number): { dice: string; average: number }[] {
const ladder: { dice: string; average: number }[] = [];
let k = 1;
let steps = 0;
while (ladder.length < rungs) {
if (steps > k * (DIE_SIZES.length - 1)) {
const previousAverage = ladder[ladder.length - 1].average;
k += 1;
steps = Math.floor(previousAverage - k * 2.5) + 1;
continue;
}
const sizes: number[] = [];
for (let i = 0; i < k; i++) sizes.push(DIE_SIZES[Math.floor(steps / k) + (i >= k - (steps % k) ? 1 : 0)]);
ladder.push({ dice: formatDice(sizes), average: k * 2.5 + steps });
steps += 1;
}
return ladder;
}
const DAMAGE_LADDER = buildDamageLadder(120);
function diceAverage(expression: string | null): number | null {
if (!expression || !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: number): number {
return Math.min(Math.max(Math.round(level || 1), 1), MAX_GEAR_LEVEL);
}
// `minLevel` is the level the gear starts at; baseDice is its damage there.
export function scaledDamageDice(baseDice: string, level = 1, scaling = 1, minLevel = 1): string {
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(scaling || 1), 1), 3);
return DAMAGE_LADDER[Math.min(baseRung + levelsGained * rungsPerLevel, DAMAGE_LADDER.length - 1)].dice;
}
+96
View File
@@ -0,0 +1,96 @@
import { useState, type FormEvent } from 'react';
import { apiClient } from '../api/client';
import { useAuth } from '../auth/AuthContext';
import { extractErrorMessage } from '../lib/format';
import { ErrorBanner } from '../components/Spinner';
export function AccountPage() {
const { user } = useAuth();
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setSaved(false);
if (newPassword !== confirmPassword) {
setError('The two new passwords do not match.');
return;
}
setSubmitting(true);
try {
await apiClient.post('/auth/password', { currentPassword, newPassword });
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setSaved(true);
} catch (err) {
setError(extractErrorMessage(err));
} finally {
setSubmitting(false);
}
}
return (
<div style={{ maxWidth: 480 }}>
<div className="page-header">
<div>
<h1>Your Account</h1>
<p className="subtitle">
{user?.displayName} · {user?.email}
</p>
</div>
</div>
<div className="card">
<div className="card-title">Change Password</div>
<form onSubmit={handleSubmit}>
<div className="field">
<label htmlFor="currentPassword">Current password</label>
<input
id="currentPassword"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="newPassword">New password (at least 8 characters)</label>
<input
id="newPassword"
type="password"
autoComplete="new-password"
required
minLength={8}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="confirmPassword">New password again</label>
<input
id="confirmPassword"
type="password"
autoComplete="new-password"
required
minLength={8}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
</div>
{error && <ErrorBanner message={error} />}
{saved && <p className="subtitle">Password changed. Use the new one next time you log in.</p>}
<button type="submit" className="btn btn-primary mt-md" disabled={submitting}>
{submitting ? 'Saving…' : 'Change Password'}
</button>
</form>
</div>
</div>
);
}
+679 -32
View File
@@ -7,6 +7,9 @@ import {
useDeleteCharacter,
useCampaignEncounters,
useCreateEncounter,
useReorderEncounters,
useSetEncounterArchived,
useDeleteEncounter,
useCampaignMonsters,
useCreateMonster,
useDeleteMonster,
@@ -14,9 +17,17 @@ import {
useCreateWeapon,
useCampaignSpells,
useCreateSpell,
useCampaignItems,
useCreateItem,
usePatchPoolEntry,
useDeletePoolEntry,
useCampaignNpcs,
useCreateNpc,
useCampaignNotes,
useCreateNote,
useUpdateCampaign,
useSetCampaignArchived,
useDeleteCampaign,
useRegenerateInvite,
useCampaignLocations,
useCreateLocation,
@@ -29,10 +40,46 @@ import {
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { usePagination, Pagination } from '../components/Paginated';
import { extractErrorMessage } from '../lib/format';
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, spellLevelLabel, spellRequirementLabel } from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
import type { CharacterSummary, LibraryMonster, LibrarySpell, LibraryWeapon } from '../api/types';
import { DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type { CharacterSummary, Encounter, EncounterMode, LibraryMonster, LibrarySpell, LibraryWeapon, NpcDisposition } from '../api/types';
// How the encounter list can be ordered. "custom" is the campaign's own order,
// which the DM arranges; the rest only change what this browser shows.
type EncounterSort = 'custom' | 'name' | 'status' | 'type' | 'newest' | 'oldest';
const ENCOUNTER_SORT_LABELS: Record<EncounterSort, string> = {
custom: 'Campaign order',
name: 'Name (A–Z)',
status: 'Status (in progress first)',
type: 'Type (battles first)',
newest: 'Newest first',
oldest: 'Oldest first',
};
const ENCOUNTER_SORT_KEY = 'dnd_encounter_sort';
const STATUS_RANK = { ACTIVE: 0, PLANNING: 1, COMPLETE: 2 };
function storedEncounterSort(): EncounterSort {
const stored = localStorage.getItem(ENCOUNTER_SORT_KEY);
return stored && stored in ENCOUNTER_SORT_LABELS ? (stored as EncounterSort) : 'custom';
}
// `encounters` arrives in campaign order, and the sort is stable, so ties
// (same status, same type) stay in that order.
function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter[] {
const time = (e: Encounter) => new Date(e.createdAt).getTime();
const compare: Record<EncounterSort, (a: Encounter, b: Encounter) => number> = {
custom: () => 0,
name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
status: (a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status],
type: (a, b) => Number(a.mode === 'QUEST') - Number(b.mode === 'QUEST'),
newest: (a, b) => time(b) - time(a),
oldest: (a, b) => time(a) - time(b),
};
return [...encounters].sort(compare[sort]);
}
function CharacterRow({ character, isDM, onDelete }: { character: CharacterSummary; isDM: boolean; onDelete: () => void }) {
const inProgress = character.buildStage !== 'COMPLETE';
@@ -71,17 +118,137 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
);
}
// The two trailing cells every pool table row shares: the "offered at
// character creation" toggle and the remove button.
function PoolRowControls({
entry,
onToggleStarting,
onDelete,
}: {
entry: { name: string; isStartingOption: boolean };
onToggleStarting: (isStartingOption: boolean) => void;
onDelete: () => void;
}) {
return (
<>
<td>
<input
type="checkbox"
aria-label={`${entry.name} is a starting option`}
checked={entry.isStartingOption}
onChange={(e) => onToggleStarting(e.target.checked)}
/>
</td>
<td>
<button
className="btn btn-ghost"
aria-label={`Remove ${entry.name}`}
onClick={() => window.confirm(`Remove ${entry.name}? Any character holding it loses it.`) && onDelete()}
>
✕
</button>
</td>
</>
);
}
// How fast a weapon's or item's dice grow as the DM levels a held copy up.
function ScalingSelect({ name, value, onChange }: { name?: string; value?: number; onChange?: (scaling: number) => void }) {
return (
<select
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.
function StartLevelSelect({ name, value, onChange }: { name?: string; value?: number; onChange?: (minLevel: number) => void }) {
return (
<select
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>
);
}
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
// 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.
function SpellDamageSelect({
name,
spellName,
value,
onChange,
}: {
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
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>
);
}
function LibraryPicker<T extends { key: string; name: string }>({
items,
onAdd,
adding,
renderMeta,
renderDetail,
placeholder,
}: {
items: T[];
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('');
@@ -97,6 +264,7 @@ function LibraryPicker<T extends { key: string; name: string }>({
<div>
<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 btn-ghost" disabled={adding} onClick={() => onAdd(item.key)}>
+ Add
@@ -119,23 +287,39 @@ export function CampaignHomePage() {
const { data, isLoading, error } = useCampaign(campaignId);
const { data: characters } = useCampaignCharacters(campaignId);
const { data: encounters } = useCampaignEncounters(campaignId);
const { data: monsters } = useCampaignMonsters(campaignId);
const { data: monsters } = useCampaignMonsters(campaignId, data?.campaign.role === 'DM');
const { data: weapons } = useCampaignWeapons(campaignId);
const { data: spells } = useCampaignSpells(campaignId);
const { data: items } = useCampaignItems(campaignId);
const { data: npcs } = useCampaignNpcs(campaignId, data?.campaign.role === 'DM');
const { data: notes } = useCampaignNotes(campaignId);
const { data: locations } = useCampaignLocations(campaignId);
const { data: library } = useCampaignLibrary(campaignId);
const { data: library } = useCampaignLibrary(campaignId, data?.campaign.role === 'DM');
const createCharacter = useCreateCharacter(campaignId);
const deleteCharacter = useDeleteCharacter(campaignId);
const createEncounter = useCreateEncounter(campaignId);
const reorderEncounters = useReorderEncounters(campaignId);
const setEncounterArchived = useSetEncounterArchived(campaignId);
const deleteEncounter = useDeleteEncounter(campaignId);
const createMonster = useCreateMonster(campaignId);
const deleteMonster = useDeleteMonster(campaignId);
const createWeapon = useCreateWeapon(campaignId);
const createSpell = useCreateSpell(campaignId);
const createItem = useCreateItem(campaignId);
const createNpc = useCreateNpc(campaignId);
const deleteNpc = useDeletePoolEntry(campaignId, 'npcs');
const patchWeapon = usePatchPoolEntry(campaignId, 'weapons');
const deleteWeapon = useDeletePoolEntry(campaignId, 'weapons');
const patchSpell = usePatchPoolEntry(campaignId, 'spells');
const deleteSpell = useDeletePoolEntry(campaignId, 'spells');
const patchItem = usePatchPoolEntry(campaignId, 'items');
const deleteItem = useDeletePoolEntry(campaignId, 'items');
const createNote = useCreateNote();
const updateCampaign = useUpdateCampaign(campaignId);
const regenerateInvite = useRegenerateInvite(campaignId);
const setArchived = useSetCampaignArchived();
const deleteCampaign = useDeleteCampaign();
const createLocation = useCreateLocation(campaignId);
const deleteLocation = useDeleteLocation(campaignId);
const addWeaponFromLibrary = useAddWeaponFromLibrary(campaignId);
@@ -144,21 +328,33 @@ export function CampaignHomePage() {
const [newCharName, setNewCharName] = useState('');
const [newEncounterName, setNewEncounterName] = useState('');
const [newEncounterMode, setNewEncounterMode] = useState<EncounterMode>('BATTLE');
const [encounterSort, setEncounterSort] = useState<EncounterSort>(storedEncounterSort);
const [showArchivedEncounters, setShowArchivedEncounters] = useState(false);
const [showNpcForm, setShowNpcForm] = useState(false);
const [noteBody, setNoteBody] = useState('');
const [dmNoteBody, setDmNoteBody] = useState('');
const [newLocationName, setNewLocationName] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [showSettings, setShowSettings] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [deleteConfirmName, setDeleteConfirmName] = useState('');
const [showMonsterForm, setShowMonsterForm] = useState(false);
const [showWeaponForm, setShowWeaponForm] = useState(false);
const [showSpellForm, setShowSpellForm] = useState(false);
const [showItemForm, setShowItemForm] = useState(false);
const [showWeaponLibrary, setShowWeaponLibrary] = useState(false);
const [showSpellLibrary, setShowSpellLibrary] = useState(false);
const [showMonsterLibrary, setShowMonsterLibrary] = useState(false);
const weaponsPage = usePagination(weapons, 8);
const spellsPage = usePagination(spells, 8);
const itemsPage = usePagination(items, 8);
const npcsPage = usePagination(npcs, 8);
const monstersPage = usePagination(monsters, 8);
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
const dmNotes = notes?.filter((n) => n.visibility === 'DM_ONLY');
const sharedNotesPage = usePagination(sharedNotes, 6);
@@ -187,13 +383,27 @@ export function CampaignHomePage() {
async function handleCreateEncounter(e: FormEvent) {
e.preventDefault();
try {
const encounter = await createEncounter.mutateAsync(newEncounterName || 'New Encounter');
const encounter = await createEncounter.mutateAsync({
name: newEncounterName || (newEncounterMode === 'QUEST' ? 'New Quest' : 'New Encounter'),
mode: newEncounterMode,
});
navigate(`/encounters/${encounter.id}`);
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
// Swaps an encounter with its neighbour in the campaign's own order.
function moveEncounter(index: number, step: -1 | 1) {
const ids = liveEncounters.map((e) => e.id);
const target = index + step;
if (target < 0 || target >= ids.length) return;
[ids[index], ids[target]] = [ids[target], ids[index]];
reorderEncounters.mutate([...ids, ...archivedEncounters.map((e) => e.id)], {
onError: (err) => setFormError(extractErrorMessage(err)),
});
}
async function handleCreateNote(e: FormEvent) {
e.preventDefault();
if (!noteBody.trim()) return;
@@ -208,6 +418,26 @@ export function CampaignHomePage() {
setDmNoteBody('');
}
async function handleSetArchived(archived: boolean) {
setFormError(null);
try {
await setArchived.mutateAsync({ campaignId, archived });
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleDeleteCampaign(e: FormEvent) {
e.preventDefault();
setFormError(null);
try {
await deleteCampaign.mutateAsync(campaignId);
navigate('/campaigns');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleCreateLocation(e: FormEvent) {
e.preventDefault();
if (!newLocationName.trim()) return;
@@ -226,6 +456,7 @@ export function CampaignHomePage() {
<Link to={`/campaigns/${campaignId}/party`} className="btn">
📊 Party Overview
</Link>
{campaign.archivedAt && <span className="badge">Archived</span>}
<span className={`badge ${isDM ? 'badge-dm' : 'badge-player'}`}>{isDM ? 'Dungeon Master' : 'Player'}</span>
</div>
</div>
@@ -256,26 +487,31 @@ export function CampaignHomePage() {
startingItemCount: Number(fd.get('startingItemCount')),
startingSpellCount: Number(fd.get('startingSpellCount')),
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
showMonsterHp: fd.get('showMonsterHp') === 'on',
});
}}
>
<div className="grid grid-cols-3">
<div className="field">
<label>Starting gold</label>
<input name="startingGold" type="number" min={0} defaultValue={campaign.startingGold} />
<NumberInput name="startingGold" min={0} max={1000000} keepValue defaultValue={campaign.startingGold} />
</div>
<div className="field">
<label>Starting items</label>
<input name="startingItemCount" type="number" min={0} defaultValue={campaign.startingItemCount} />
<label>Starting items (weapons + items)</label>
<NumberInput name="startingItemCount" min={0} max={50} keepValue defaultValue={campaign.startingItemCount} />
</div>
<div className="field">
<label>Starting spells</label>
<input name="startingSpellCount" type="number" min={0} defaultValue={campaign.startingSpellCount} />
<NumberInput name="startingSpellCount" min={0} max={50} keepValue defaultValue={campaign.startingSpellCount} />
</div>
</div>
<label className="checkbox-row">
<input type="checkbox" name="skipMotivationRoll" defaultChecked={campaign.skipMotivationRoll} />
Skip the motivation roll for this campaign
Skip the motivation step when building a character
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
</label>
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
Save Settings
@@ -292,6 +528,53 @@ export function CampaignHomePage() {
))}
</div>
</div>
<div className="mt-md">
<div className="card-title" style={{ marginBottom: '0.4rem' }}>Archive or Delete</div>
<p className="subtitle" style={{ marginTop: 0 }}>
{campaign.archivedAt
? 'This campaign is archived: it sits under “Archived” on your campaign list and its invite code is switched off. Nothing in it has been removed.'
: 'Archiving moves the campaign off everyone’s main list and switches off its invite code. Nothing is removed, and you can restore it at any time.'}
</p>
<div className="flex-row flex-wrap">
<button className="btn" disabled={setArchived.isPending} onClick={() => handleSetArchived(!campaign.archivedAt)}>
{campaign.archivedAt ? 'Restore Campaign' : 'Archive Campaign'}
</button>
{campaign.ownerId === user?.id && (
<button
className="btn btn-danger"
onClick={() => {
setShowDelete((s) => !s);
setDeleteConfirmName('');
}}
>
{showDelete ? 'Cancel' : 'Delete Campaign…'}
</button>
)}
</div>
{showDelete && (
<form className="mt-md" onSubmit={handleDeleteCampaign}>
<div className="field">
<label htmlFor="deleteConfirmName">
This permanently deletes every character, encounter, note and pool entry in the campaign, for
every player. There is no undo. Type <strong>{campaign.name}</strong> to confirm.
</label>
<input
id="deleteConfirmName"
autoComplete="off"
value={deleteConfirmName}
onChange={(e) => setDeleteConfirmName(e.target.value)}
/>
</div>
<button
type="submit"
className="btn btn-danger"
disabled={deleteCampaign.isPending || deleteConfirmName.trim() !== campaign.name.trim()}
>
{deleteCampaign.isPending ? 'Deleting…' : 'Delete Forever'}
</button>
</form>
)}
</div>
</div>
)}
@@ -337,6 +620,25 @@ export function CampaignHomePage() {
<div className="mt-lg">
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
{liveEncounters.length > 1 && (
<label className="flex-row subtitle">
Sort by
<select
value={encounterSort}
onChange={(e) => {
const sort = e.target.value as EncounterSort;
setEncounterSort(sort);
localStorage.setItem(ENCOUNTER_SORT_KEY, sort);
}}
>
{(Object.keys(ENCOUNTER_SORT_LABELS) as EncounterSort[]).map((sort) => (
<option key={sort} value={sort}>
{ENCOUNTER_SORT_LABELS[sort]}
</option>
))}
</select>
</label>
)}
</div>
{isDM && (
<form onSubmit={handleCreateEncounter} className="flex-row flex-wrap" style={{ marginBottom: '0.75rem' }}>
@@ -346,26 +648,218 @@ export function CampaignHomePage() {
onChange={(e) => setNewEncounterName(e.target.value)}
style={{ flex: 1, minWidth: 160 }}
/>
<select aria-label="Encounter type" value={newEncounterMode} onChange={(e) => setNewEncounterMode(e.target.value as EncounterMode)}>
<option value="BATTLE">Battle</option>
<option value="QUEST">NPC quest</option>
</select>
<button type="submit" className="btn btn-primary" disabled={createEncounter.isPending}>
⚔ Start Encounter
{newEncounterMode === 'QUEST' ? '📜 Start Quest' : '⚔ Start Encounter'}
</button>
</form>
)}
{encounters?.length === 0 && <div className="empty-state">No encounters yet.</div>}
{encounters?.map((enc) => (
<Link key={enc.id} to={`/encounters/${enc.id}`} className="card flex-between" style={{ display: 'flex' }}>
<span>{enc.name}</span>
<span className="badge">{enc.status} · Round {enc.currentRound}</span>
</Link>
{isDM && encounterSort === 'custom' && liveEncounters.length > 1 && (
<p className="subtitle" style={{ marginTop: 0 }}>
This is the campaign's own order, which everyone sees by default. Use ▲ and ▼ to rearrange it.
</p>
)}
{encounters && liveEncounters.length === 0 && (
<div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div>
)}
{shownEncounters.map((enc, index) => (
<div key={enc.id} className="card flex-between flex-wrap">
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
{enc.name}
</Link>
<span className="flex-row">
<span className="badge">
{enc.status}
{enc.mode === 'BATTLE' && ` · Round ${enc.currentRound}`}
</span>
{isDM && encounterSort === 'custom' && (
<>
<button
className="btn btn-ghost"
aria-label={`Move ${enc.name} up`}
title="Move up"
disabled={index === 0 || reorderEncounters.isPending}
onClick={() => moveEncounter(index, -1)}
>
▲
</button>
<button
className="btn btn-ghost"
aria-label={`Move ${enc.name} down`}
title="Move down"
disabled={index === shownEncounters.length - 1 || reorderEncounters.isPending}
onClick={() => moveEncounter(index, 1)}
>
▼
</button>
</>
)}
{isDM && (
<button
className="btn btn-ghost"
title="Archive: take it off the list without deleting it"
disabled={setEncounterArchived.isPending}
onClick={() => setEncounterArchived.mutate({ encounterId: enc.id, archived: true })}
>
Archive
</button>
)}
</span>
</div>
))}
{isDM && archivedEncounters.length > 0 && (
<div className="mt-md">
<div className="flex-between">
<h3 className="section-label" style={{ margin: 0 }}>Archived ({archivedEncounters.length})</h3>
<button className="btn btn-ghost" onClick={() => setShowArchivedEncounters((s) => !s)}>
{showArchivedEncounters ? 'Hide' : 'Show'}
</button>
</div>
{showArchivedEncounters && (
<>
<p className="subtitle" style={{ marginTop: 0 }}>Players don't see these in their list.</p>
{archivedEncounters.map((enc) => (
<div key={enc.id} className="card flex-between flex-wrap">
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
{enc.name}
</Link>
<span className="flex-row">
<button
className="btn"
disabled={setEncounterArchived.isPending}
onClick={() => setEncounterArchived.mutate({ encounterId: enc.id, archived: false })}
>
Restore
</button>
<button
className="btn btn-danger"
disabled={deleteEncounter.isPending}
onClick={() =>
window.confirm(`Delete "${enc.name}" for good? Rewards already handed out stay with the characters.`) &&
deleteEncounter.mutate(enc.id)
}
>
Delete
</button>
</span>
</div>
))}
</>
)}
</div>
)}
</div>
{!isDM && locations && locations.length > 0 && (
<div className="mt-lg card">
<div className="card-title">Places You've Been</div>
{locations.map((loc) => (
<div key={loc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<strong>📍 {loc.name}</strong>
{loc.description && <p className="subtitle" style={{ margin: '0.2rem 0 0', whiteSpace: 'pre-wrap' }}>{loc.description}</p>}
</div>
))}
</div>
)}
{isDM && (
<div className="mt-lg card">
<div className="card-title">
<span>NPCs</span>
<button className="btn btn-ghost" onClick={() => setShowNpcForm((s) => !s)}>
{showNpcForm ? 'Close' : '+ Add NPC'}
</button>
</div>
<p className="subtitle" style={{ marginTop: 0 }}>
The people your party meets. Add them to an encounter to bring them into a scene. Players never see your notes or an
NPC's stats.
</p>
{showNpcForm && (
<form
onSubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const fd = new FormData(form);
const text = (key: string) => String(fd.get(key) || '').trim() || null;
const num = (key: string) => (fd.get(key) ? Number(fd.get(key)) : null);
try {
await createNpc.mutateAsync({
name: String(fd.get('name')),
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'),
});
form.reset();
} catch (err) {
setFormError(extractErrorMessage(err));
}
}}
>
<div className="grid grid-cols-3">
<input name="name" placeholder="Name" required maxLength={80} />
<input name="role" placeholder="Role (innkeeper, captain…)" maxLength={80} />
<select name="disposition" defaultValue="NEUTRAL" aria-label="Attitude toward the party">
<option value="FRIENDLY">Friendly</option>
<option value="NEUTRAL">Neutral</option>
<option value="HOSTILE">Hostile</option>
</select>
</div>
<textarea name="description" rows={2} className="textarea-block mt-sm" placeholder="What the party sees and knows (shown to players)" />
<textarea name="dmNotes" rows={2} className="textarea-block mt-sm" placeholder="Secrets and motives (only you see this)" />
<div className="grid grid-cols-3 mt-sm">
<NumberInput name="ac" min={0} max={50} placeholder="AC (optional)" />
<NumberInput name="maxHp" min={1} max={100000} placeholder="HP (optional)" />
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus (optional)" />
<input name="damageDice" placeholder="Damage (optional, e.g. 1d6)" maxLength={20} />
<button type="submit" className="btn btn-primary" disabled={createNpc.isPending}>Add NPC</button>
</div>
</form>
)}
<div className="mt-sm">
{npcs?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No NPCs yet.</p>}
{npcsPage.pageItems.map((npc) => (
<div key={npc.id} className="flex-between" style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{npc.name}</strong>
{npc.role && <span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>{npc.role}</span>}
<span className={`badge badge-${npc.disposition.toLowerCase()}`} style={{ marginLeft: 8 }}>{npc.disposition}</span>
{npc.description && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{npc.description}</div>}
{npc.dmNotes && <div style={{ color: 'var(--text-dim)', fontSize: '0.85rem' }}>🔒 {npc.dmNotes}</div>}
</div>
<button
className="btn btn-ghost"
aria-label={`Remove ${npc.name}`}
onClick={() => window.confirm(`Remove ${npc.name}?`) && deleteNpc.mutate(npc.id)}
>
✕
</button>
</div>
))}
</div>
<Pagination page={npcsPage.page} pageCount={npcsPage.pageCount} onPrev={npcsPage.prevPage} onNext={npcsPage.nextPage} />
</div>
)}
{isDM && (
<div className="mt-lg card">
<div className="card-title">
<span>Locations</span>
</div>
<p className="subtitle" style={{ marginTop: 0 }}>Track where each character currently is.</p>
<p className="subtitle" style={{ marginTop: 0 }}>
Track where each character currently is. Give an encounter a location and its characters move there when it
begins. Players only see the places their own characters have been.
</p>
<form onSubmit={handleCreateLocation} className="flex-row flex-wrap">
<input
placeholder="Location name (e.g. The Rusty Tankard)"
@@ -389,7 +883,20 @@ export function CampaignHomePage() {
{isDM && (
<>
<div className="mt-lg card">
<div className="mt-lg">
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Gear Pools</h2>
<p className="subtitle">
You hand out weapons, items and spells from these pools on each character's sheet. Tick "Starting" for the ones a
player may pick while building a new character.
</p>
<p className="subtitle">
Weapons and items have levels 1–{MAX_GEAR_LEVEL}. The damage you enter is the damage at the level it starts at
(level 1 unless you pick a higher one). When you level up a character's copy, its dice grow one small step per
level (1d4 → 1d6 → 1d8 → 1d10 → 1d12 → 2d6 → …) — two steps for Powerful gear, three for Legendary. A character
must be at least a weapon's or item's level to use it, and at least a spell's level to cast it.
</p>
</div>
<div className="mt-sm card">
<div className="card-title">
<span>Weapons Pool</span>
<div className="flex-row gap-sm">
@@ -407,7 +914,7 @@ export function CampaignHomePage() {
adding={addWeaponFromLibrary.isPending}
onAdd={(key) => addWeaponFromLibrary.mutate(key)}
placeholder="Search the weapon library…"
renderMeta={(w) => `${w.category} · ${w.damageDice} ${w.damageType}`}
renderMeta={(w) => `${w.category} · ${w.damageDice} ${w.damageType} · starts at level 1`}
/>
)}
{showWeaponForm && (
@@ -423,19 +930,28 @@ export function CampaignHomePage() {
properties: [],
rangeNormal: null,
rangeLong: null,
damageScaling: Number(fd.get('damageScaling')),
minLevel: Number(fd.get('minLevel')),
isStartingOption: fd.get('isStartingOption') === 'on',
});
(e.target as HTMLFormElement).reset();
}}
className="grid grid-cols-3"
>
<input name="name" placeholder="Name" required />
<input name="damageDice" placeholder="Damage dice (1d8)" required />
<input name="damageDice" placeholder="Damage at its starting level (1d8)" required />
<input name="damageType" placeholder="Damage type" required />
<select name="attackAbility" defaultValue="STR">
<option value="STR">STR</option>
<option value="DEX">DEX</option>
<option value="FINESSE">Finesse</option>
</select>
<ScalingSelect name="damageScaling" />
<StartLevelSelect name="minLevel" />
<label className="checkbox-row">
<input type="checkbox" name="isStartingOption" />
Starting option
</label>
<button type="submit" className="btn btn-primary">Add</button>
</form>
)}
@@ -444,7 +960,11 @@ export function CampaignHomePage() {
<thead>
<tr>
<th>Name</th>
<th>Damage</th>
<th>Base damage</th>
<th>Level</th>
<th>Growth</th>
<th>Starting</th>
<th />
</tr>
</thead>
<tbody>
@@ -452,6 +972,17 @@ export function CampaignHomePage() {
<tr key={w.id}>
<td>{w.name}</td>
<td>{w.damageDice} {w.damageType}</td>
<td>
<StartLevelSelect value={w.minLevel} onChange={(minLevel) => patchWeapon.mutate({ id: w.id, minLevel })} />
</td>
<td>
<ScalingSelect value={w.damageScaling} onChange={(damageScaling) => patchWeapon.mutate({ id: w.id, damageScaling })} />
</td>
<PoolRowControls
entry={w}
onToggleStarting={(isStartingOption) => patchWeapon.mutate({ id: w.id, isStartingOption })}
onDelete={() => deleteWeapon.mutate(w.id)}
/>
</tr>
))}
</tbody>
@@ -460,6 +991,88 @@ export function CampaignHomePage() {
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} />
</div>
<div className="mt-md card">
<div className="card-title">
<span>Items Pool</span>
<button className="btn btn-ghost" onClick={() => setShowItemForm((s) => !s)}>
{showItemForm ? 'Close' : '+ Add Item'}
</button>
</div>
{showItemForm && (
<form
onSubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const fd = new FormData(form);
await createItem.mutateAsync({
name: String(fd.get('name')),
description: String(fd.get('description') || '') || null,
damageDice: String(fd.get('damageDice') || '').trim() || null,
damageScaling: Number(fd.get('damageScaling')),
minLevel: Number(fd.get('minLevel')),
isStartingOption: fd.get('isStartingOption') === 'on',
});
form.reset();
}}
className="grid grid-cols-3"
>
<input name="name" placeholder="Name" required />
<input name="description" placeholder="Description (optional)" />
<input name="damageDice" placeholder="Dice (optional, e.g. 2d4+2)" maxLength={20} />
<ScalingSelect name="damageScaling" />
<StartLevelSelect name="minLevel" />
<label className="checkbox-row">
<input type="checkbox" name="isStartingOption" />
Starting option
</label>
<button type="submit" className="btn btn-primary">Add</button>
</form>
)}
{items?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No items yet. Add rope, potions, quest items…</p>}
{items && items.length > 0 && (
<div className="table-wrap mt-sm">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Base dice</th>
<th>Level</th>
<th>Growth</th>
<th>Starting</th>
<th />
</tr>
</thead>
<tbody>
{itemsPage.pageItems.map((item) => (
<tr key={item.id}>
<td>{item.name}</td>
<td>{item.description}</td>
<td>{item.damageDice || '—'}</td>
<td>
<StartLevelSelect value={item.minLevel} onChange={(minLevel) => patchItem.mutate({ id: item.id, minLevel })} />
</td>
<td>
{item.damageDice ? (
<ScalingSelect value={item.damageScaling} onChange={(damageScaling) => patchItem.mutate({ id: item.id, damageScaling })} />
) : (
'—'
)}
</td>
<PoolRowControls
entry={item}
onToggleStarting={(isStartingOption) => patchItem.mutate({ id: item.id, isStartingOption })}
onDelete={() => deleteItem.mutate(item.id)}
/>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} />
</div>
<div className="mt-md card">
<div className="card-title">
<span>Spell List</span>
@@ -478,7 +1091,12 @@ export function CampaignHomePage() {
adding={addSpellFromLibrary.isPending}
onAdd={(key) => addSpellFromLibrary.mutate(key)}
placeholder="Search the spell library…"
renderMeta={(s) => `${s.level === 0 ? 'Cantrip' : `Lv ${s.level}`} · ${s.school || ''}`}
renderMeta={(s) =>
[spellLevelLabel(s.level), spellRequirementLabel(s.level), s.damageDice ? `${s.damageDice} damage` : 'no damage', s.school]
.filter(Boolean)
.join(' · ')
}
renderDetail={(s) => s.description}
/>
)}
{showSpellForm && (
@@ -493,15 +1111,29 @@ export function CampaignHomePage() {
castingTime: null,
range: null,
duration: null,
description: null,
description: String(fd.get('description') || '').trim() || null,
damageDice: String(fd.get('damageDice') || '') || null,
isStartingOption: fd.get('isStartingOption') === 'on',
});
(e.target as HTMLFormElement).reset();
}}
className="grid grid-cols-3"
>
<input name="name" placeholder="Name" required />
<input name="level" type="number" min={0} max={9} placeholder="Level (0=cantrip)" required />
<select name="level" defaultValue={0} aria-label="Spell level">
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((level) => (
<option key={level} value={level}>
{spellLevelLabel(level)}
</option>
))}
</select>
<input name="school" placeholder="School" />
<SpellDamageSelect name="damageDice" />
<input name="description" placeholder="What it does (one sentence)" maxLength={4000} required style={{ gridColumn: '1 / -1' }} />
<label className="checkbox-row">
<input type="checkbox" name="isStartingOption" />
Starting option
</label>
<button type="submit" className="btn btn-primary">Add</button>
</form>
)}
@@ -509,15 +1141,29 @@ export function CampaignHomePage() {
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Spell</th>
<th>Level</th>
<th>Damage</th>
<th>Starting</th>
<th />
</tr>
</thead>
<tbody>
{spellsPage.pageItems.map((s) => (
<tr key={s.id}>
<td>{s.name}</td>
<td>{s.level === 0 ? 'Cantrip' : s.level}</td>
<td>
<strong>{s.name}</strong>
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{s.description || 'No description.'}</div>
</td>
<td style={{ whiteSpace: 'nowrap' }}>{spellLevelLabel(s.level)}</td>
<td>
<SpellDamageSelect spellName={s.name} value={s.damageDice} onChange={(damageDice) => patchSpell.mutate({ id: s.id, damageDice })} />
</td>
<PoolRowControls
entry={s}
onToggleStarting={(isStartingOption) => patchSpell.mutate({ id: s.id, isStartingOption })}
onDelete={() => deleteSpell.mutate(s.id)}
/>
</tr>
))}
</tbody>
@@ -544,7 +1190,8 @@ export function CampaignHomePage() {
adding={addMonsterFromLibrary.isPending}
onAdd={(key) => addMonsterFromLibrary.mutate(key)}
placeholder="Search the monster library…"
renderMeta={(m) => `CR ${m.challengeRating} · AC ${m.ac} · HP ${m.maxHp}`}
renderMeta={(m) => `CR ${m.challengeRating} · AC ${m.ac} · HP ${m.maxHp} · attack +${m.attackBonus}${m.damageDice ? ` for ${m.damageDice}` : ''}`}
renderDetail={(m) => [m.specialTrait, m.notes].filter(Boolean).join(' — ') || null}
/>
)}
{showMonsterForm && (
@@ -574,9 +1221,9 @@ export function CampaignHomePage() {
className="grid grid-cols-3"
>
<input name="name" placeholder="Name" required />
<input name="ac" type="number" placeholder="AC" required />
<input name="maxHp" type="number" placeholder="Max HP" required />
<input name="attackBonus" type="number" placeholder="Attack bonus" />
<NumberInput name="ac" min={0} max={50} placeholder="AC" required />
<NumberInput name="maxHp" min={1} max={100000} placeholder="Max HP" required />
<NumberInput name="attackBonus" min={-20} max={50} placeholder="Attack bonus" />
<input name="damageDice" placeholder="Damage dice" />
<button type="submit" className="btn btn-primary">Add</button>
</form>
+46 -4
View File
@@ -1,6 +1,6 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useCampaigns, useCreateCampaign, useJoinCampaign } from '../api/queries';
import { useCampaigns, useCreateCampaign, useJoinCampaign, useSetCampaignArchived } from '../api/queries';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { extractErrorMessage } from '../lib/format';
@@ -8,6 +8,7 @@ export function CampaignListPage() {
const { data: campaigns, isLoading, error } = useCampaigns();
const createCampaign = useCreateCampaign();
const joinCampaign = useJoinCampaign();
const setArchived = useSetCampaignArchived();
const navigate = useNavigate();
const [showCreate, setShowCreate] = useState(false);
@@ -15,6 +16,10 @@ export function CampaignListPage() {
const [description, setDescription] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const activeCampaigns = campaigns?.filter((c) => !c.archivedAt);
const archivedCampaigns = campaigns?.filter((c) => c.archivedAt) || [];
async function handleCreate(e: FormEvent) {
e.preventDefault();
@@ -89,11 +94,15 @@ export function CampaignListPage() {
<div className="mt-lg">
{isLoading && <Spinner label="Consulting the tavern board…" />}
{error && <ErrorBanner message="Could not load your campaigns." />}
{campaigns && campaigns.length === 0 && (
<div className="empty-state">No campaigns yet — found one, or join with an invite code above.</div>
{activeCampaigns && activeCampaigns.length === 0 && (
<div className="empty-state">
{archivedCampaigns.length > 0
? 'No active campaigns — found one, join with an invite code above, or restore one from the archive below.'
: 'No campaigns yet — found one, or join with an invite code above.'}
</div>
)}
<div className="grid grid-cols-2">
{campaigns?.map((c) => (
{activeCampaigns?.map((c) => (
<Link key={c.id} to={`/campaigns/${c.id}`} className="card" style={{ display: 'block' }}>
<div className="card-title">
<span>{c.name}</span>
@@ -104,6 +113,39 @@ export function CampaignListPage() {
))}
</div>
</div>
{archivedCampaigns.length > 0 && (
<div className="mt-lg">
<div className="flex-between">
<h2 className="section-label" style={{ margin: 0 }}>Archived ({archivedCampaigns.length})</h2>
<button className="btn btn-ghost" onClick={() => setShowArchived((s) => !s)}>
{showArchived ? 'Hide' : 'Show'}
</button>
</div>
{showArchived && (
<div className="grid grid-cols-2 mt-sm">
{archivedCampaigns.map((c) => (
<div key={c.id} className="card">
<div className="card-title">
<Link to={`/campaigns/${c.id}`}>{c.name}</Link>
<span className={`badge ${c.role === 'DM' ? 'badge-dm' : 'badge-player'}`}>{c.role}</span>
</div>
<p style={{ color: 'var(--text-muted)', marginTop: 0 }}>{c.description || 'No description yet.'}</p>
{c.role === 'DM' && (
<button
className="btn"
disabled={setArchived.isPending}
onClick={() => setArchived.mutate({ campaignId: c.id, archived: false })}
>
Restore
</button>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+570 -99
View File
@@ -1,33 +1,233 @@
import { useState, type FormEvent } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Link, useNavigate, useParams } from 'react-router-dom';
import {
useCharacter,
usePatchCharacter,
useCampaignWeapons,
useAssignWeapon,
usePatchCharacterWeapon,
useAddInventoryItem,
useRemoveCharacterWeapon,
useCampaignItems,
useAssignItem,
usePatchInventoryItem,
useDeleteInventoryItem,
useRemoveInventoryItem,
useCampaignSpells,
useAssignSpell,
usePatchCharacterSpell,
useRemoveCharacterSpell,
useGoldLedger,
useAdjustGold,
useExchangeCoins,
useCampaignCharacters,
useGive,
type Gift,
useCharacterNotes,
useCreateNote,
useCampaign,
useCampaigns,
useCopyCharacter,
useCampaignLocations,
} from '../api/queries';
import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
import { QuickStatsFab } from '../components/QuickStatsDrawer';
import { InfoTip } from '../components/InfoTip';
import { usePagination, Pagination } from '../components/Paginated';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, fmtMod, hpBarClass, extractErrorMessage } from '../lib/format';
import {
ABILITY_LABELS,
CLASS_LABELS,
COIN_FIELD,
COIN_LABELS,
COIN_SHORT,
COIN_VALUE,
CURRENCIES,
SKILL_LABELS,
SPECIES_LABELS,
fmtMod,
extractErrorMessage,
formatPurse,
purseWorthInGold,
spellLevelLabel,
} from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
import { hpMax as previewHpMax } from '../lib/previewCalc';
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import { CLASS_HIT_DIE } from '../lib/classData';
import type { Ability } from '../api/types';
import type { Ability, CharacterDetail, Currency } from '../api/types';
// The DM's level picker for one held weapon or item, from the level the gear
// starts at up to the maximum. Each option shows what the dice become at that
// level, so the step up is visible before choosing.
function GearLevelSelect({
name,
level,
entry,
onChange,
}: {
name: string;
level: number;
entry: { damageDice: string | null; damageScaling: number; minLevel: number };
onChange: (level: number) => void;
}) {
const levels = Array.from({ length: MAX_GEAR_LEVEL - entry.minLevel + 1 }, (_, i) => entry.minLevel + i);
return (
<select aria-label={`${name} level`} value={level} onChange={(e) => onChange(Number(e.target.value))}>
{levels.map((l) => (
<option key={l} value={l}>
Lv {l}
{entry.damageDice ? ` · ${scaledDamageDice(entry.damageDice, l, entry.damageScaling, entry.minLevel)}` : ''}
</option>
))}
</select>
);
}
function RequiresLevel({ level }: { level: number }) {
return (
<span className="badge" style={{ marginLeft: 6 }} title="Held, but can't be used until the character reaches this level">
Requires level {level}
</span>
);
}
// Lets the owner (or DM) hand something this character holds to another
// character in the campaign. Giving moves it: the giver no longer has it.
function GiveCard({ character, onError }: { character: CharacterDetail; onError: (err: unknown) => void }) {
const { data: characters } = useCampaignCharacters(character.campaignId);
const give = useGive(character.id);
const [what, setWhat] = useState('');
const [amount, setAmount] = useState('1');
const [toCharacterId, setToCharacterId] = useState('');
const [done, setDone] = useState<string | null>(null);
const others = characters?.filter((c) => c.id !== character.id && c.buildStage === 'COMPLETE') ?? [];
if (others.length === 0) return null;
// "kind:id" identifies the thing picked; for coins the id is which coin.
const [kind, id] = what.split(':');
const coin = (kind === 'gold' ? id : 'GOLD') as Currency;
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
const heldItem = character.inventoryItems.find((i) => i.itemId === id);
// A gift of coins is paid from the whole purse, with change, so any coin
// the purse is worth at least one of can be given.
const held = (c: Currency) => character[COIN_FIELD[c]] ?? 0;
const purseInCopper = CURRENCIES.reduce((sum, c) => sum + held(c) * COIN_VALUE[c], 0);
const affordable = (c: Currency) => Math.floor(purseInCopper / COIN_VALUE[c]);
const coinsHeld = CURRENCIES.filter((c) => affordable(c) > 0);
const max = kind === 'gold' ? affordable(coin) : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
const count = Math.floor(Number(amount));
const countOk = kind === 'spell' || (count >= 1 && count <= max);
const nothingToGive = coinsHeld.length === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
function handleGive() {
const gift: Gift | null =
kind === 'gold'
? { kind, amount: count, currency: coin }
: kind === 'weapon' || kind === 'item'
? { kind, id, quantity: count }
: kind === 'spell'
? { kind, id }
: null;
if (!gift || !toCharacterId) return;
setDone(null);
give.mutate(
{ ...gift, toCharacterId },
{
onError,
onSuccess: () => {
setDone(`Given to ${others.find((c) => c.id === toCharacterId)?.name}.`);
setWhat('');
setAmount('1');
},
}
);
}
return (
<div className="card mt-md">
<div className="card-title">Give to Another Character</div>
<p className="subtitle" style={{ marginTop: 0 }}>
Hand gold, a weapon, an item or a spell to someone else in the party. Giving moves it — {character.name} no longer has
it afterwards.
</p>
{nothingToGive ? (
<p style={{ color: 'var(--text-muted)', margin: 0 }}>{character.name} has nothing to give yet.</p>
) : (
<div className="flex-row flex-wrap">
<select
aria-label="What to give"
value={what}
onChange={(e) => {
setWhat(e.target.value);
setAmount('1');
setDone(null);
}}
style={{ flex: 2, minWidth: 160 }}
>
<option value="">What to give…</option>
{coinsHeld.map((c) => (
<option key={c} value={`gold:${c}`}>
{COIN_LABELS[c]} ({held(c)} {COIN_SHORT[c]} held)
</option>
))}
{character.weapons.length > 0 && (
<optgroup label="Weapons">
{character.weapons.map((w) => (
<option key={w.weaponId} value={`weapon:${w.weaponId}`}>
{w.weapon.name} (Lv {w.level}, x{w.quantity})
</option>
))}
</optgroup>
)}
{character.inventoryItems.length > 0 && (
<optgroup label="Items">
{character.inventoryItems.map((i) => (
<option key={i.itemId} value={`item:${i.itemId}`}>
{i.item.name} (Lv {i.level}, x{i.quantity})
</option>
))}
</optgroup>
)}
{character.spells.length > 0 && (
<optgroup label="Spells">
{character.spells.map((s) => (
<option key={s.spellId} value={`spell:${s.spellId}`}>
{s.spell.name} ({spellLevelLabel(s.spell.level)})
</option>
))}
</optgroup>
)}
</select>
{what && kind !== 'spell' && (
<NumberInput
min={1}
max={max}
aria-label={kind === 'gold' ? 'How many coins' : 'How many'}
title={`How many (up to ${max})`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
style={{ width: 90 }}
/>
)}
<select aria-label="Who to give it to" value={toCharacterId} onChange={(e) => setToCharacterId(e.target.value)} style={{ flex: 1, minWidth: 130 }}>
<option value="">To whom…</option>
{others.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<button className="btn" onClick={handleGive} disabled={!what || !toCharacterId || !countOk || give.isPending}>
Give
</button>
</div>
)}
{done && <p className="subtitle">✓ {done}</p>}
</div>
);
}
export function CharacterSheetPage() {
const { characterId = '' } = useParams();
@@ -39,29 +239,46 @@ export function CharacterSheetPage() {
const { data: campaignData } = useCampaign(character?.campaignId);
const { data: campaignWeapons } = useCampaignWeapons(character?.campaignId);
const { data: campaignSpells } = useCampaignSpells(character?.campaignId);
const { data: campaignItems } = useCampaignItems(character?.campaignId);
const { data: goldLedger } = useGoldLedger(characterId, Boolean(character) && !character?.restricted);
const { data: locations } = useCampaignLocations(character?.campaignId);
const { data: notes } = useCharacterNotes(characterId);
const assignWeapon = useAssignWeapon(characterId);
const patchWeapon = usePatchCharacterWeapon(characterId);
const addItem = useAddInventoryItem(characterId);
const removeWeapon = useRemoveCharacterWeapon(characterId);
const assignItem = useAssignItem(characterId);
const patchItem = usePatchInventoryItem(characterId);
const deleteItem = useDeleteInventoryItem(characterId);
const removeItem = useRemoveInventoryItem(characterId);
const assignSpell = useAssignSpell(characterId);
const patchSpell = usePatchCharacterSpell(characterId);
const removeSpell = useRemoveCharacterSpell(characterId);
const adjustGold = useAdjustGold(characterId);
const exchangeCoins = useExchangeCoins(characterId);
const createNote = useCreateNote();
const { data: campaigns } = useCampaigns();
const copyCharacter = useCopyCharacter(characterId);
const navigate = useNavigate();
const [hpDelta, setHpDelta] = useState('');
const [newItemName, setNewItemName] = useState('');
const [pickedItemId, setPickedItemId] = useState('');
const [goldAmount, setGoldAmount] = useState('');
const [goldReason, setGoldReason] = useState('');
const [goldCurrency, setGoldCurrency] = useState<Currency>('GOLD');
const [exchangeAmount, setExchangeAmount] = useState('');
const [exchangeFrom, setExchangeFrom] = useState<Currency>('GOLD');
const [exchangeTo, setExchangeTo] = useState<Currency>('SILVER');
const [noteBody, setNoteBody] = useState('');
const [pickedWeaponId, setPickedWeaponId] = useState('');
const [pickedSpellId, setPickedSpellId] = useState('');
const [copyTargetId, setCopyTargetId] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const weaponsPage = usePagination(character?.weapons, 8);
const spellsPage = usePagination(character?.spells, 8);
const itemsPage = usePagination(character?.inventoryItems, 8);
const notesPage = usePagination(notes, 6);
const ledgerPage = usePagination(goldLedger, 6);
if (isLoading) return <Spinner label="Unfurling the character sheet…" />;
if (error || !character) return <ErrorBanner message="Could not load this character." />;
@@ -99,11 +316,39 @@ export function CharacterSheetPage() {
patchCharacter.mutate({ currentHp: newMax });
}
async function handleAddItem(e: FormEvent) {
e.preventDefault();
if (!newItemName.trim()) return;
await addItem.mutateAsync({ name: newItemName });
setNewItemName('');
async function handleAssignItem() {
if (!pickedItemId) return;
try {
await assignItem.mutateAsync({ itemId: pickedItemId });
setPickedItemId('');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleGold(sign: 1 | -1) {
const amount = Math.floor(Number(goldAmount));
if (!(amount > 0)) return;
setFormError(null);
try {
await adjustGold.mutateAsync({ amount: sign * amount, currency: goldCurrency, reason: goldReason.trim() || undefined });
setGoldAmount('');
setGoldReason('');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleExchange() {
const amount = Math.floor(Number(exchangeAmount));
if (!(amount > 0) || exchangeFrom === exchangeTo) return;
setFormError(null);
try {
await exchangeCoins.mutateAsync({ from: exchangeFrom, to: exchangeTo, amount });
setExchangeAmount('');
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
async function handleAddNote(e: FormEvent) {
@@ -133,6 +378,19 @@ export function CharacterSheetPage() {
}
}
async function handleCopy() {
if (!copyTargetId) return;
try {
const copy = await copyCharacter.mutateAsync(copyTargetId);
setCopyTargetId('');
navigate(`/campaigns/${copy.campaignId}`);
} catch (err) {
setFormError(extractErrorMessage(err));
}
}
const otherCampaigns = campaigns?.filter((c) => c.id !== character.campaignId && !c.archivedAt) ?? [];
const assignedWeaponIds = new Set(character.weapons.map((w) => w.weaponId));
const assignedSpellIds = new Set(character.spells.map((s) => s.spellId));
@@ -199,15 +457,15 @@ export function CharacterSheetPage() {
</div>
</div>
<div className="hp-bar-track mt-sm">
<div className={hpBarClass(character.currentHp, sheet.hp.max)} style={{ width: `${Math.max(0, Math.min(100, (character.currentHp / sheet.hp.max) * 100))}%` }} />
</div>
<HpBar current={character.currentHp} max={sheet.hp.max} temp={character.tempHp} />
{canEdit && (
<div className="flex-row mt-sm flex-wrap">
<input
type="number"
<NumberInput
min={1}
max={9999}
placeholder="Amount"
aria-label="HP change"
value={hpDelta}
onChange={(e) => setHpDelta(e.target.value)}
style={{ width: 100 }}
@@ -220,9 +478,11 @@ export function CharacterSheetPage() {
</button>
<label className="flex-row gap-sm" style={{ marginLeft: '1rem' }}>
Temp HP <InfoTip term="TEMP_HP" />
<input
type="number"
<NumberInput
key={`temp:${character.tempHp}`}
min={0}
max={9999}
keepValue
defaultValue={character.tempHp}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ tempHp: Number(e.target.value) })}
@@ -230,9 +490,11 @@ export function CharacterSheetPage() {
</label>
<label className="flex-row gap-sm">
Armor Bonus <InfoTip term="ARMOR_BONUS" />
<input
type="number"
<NumberInput
key={`armor:${character.armorBonus}`}
min={0}
max={30}
keepValue
defaultValue={character.armorBonus}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ armorBonus: Number(e.target.value) })}
@@ -245,10 +507,11 @@ export function CharacterSheetPage() {
<div className="flex-row mt-sm flex-wrap">
<label className="flex-row gap-sm">
Level
<input
type="number"
<NumberInput
key={`level:${character.level}`}
min={1}
max={20}
keepValue
defaultValue={character.level}
style={{ width: 60 }}
onBlur={(e) => patchCharacter.mutate({ level: Number(e.target.value) })}
@@ -349,26 +612,44 @@ export function CharacterSheetPage() {
<div>
<strong>{cw.weapon.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
{cw.weapon.damageDice} {cw.weapon.damageType} · x{cw.quantity}
Lv {cw.level} · {cw.scaledDamageDice} {cw.weapon.damageType} · x{cw.quantity}
</span>
{!cw.usable && <RequiresLevel level={cw.level} />}
</div>
{canEdit && (
<label className="flex-row gap-sm">
Equipped
<input
type="checkbox"
checked={cw.equipped}
onChange={(e) => patchWeapon.mutate({ weaponId: cw.weaponId, equipped: e.target.checked })}
/>
</label>
<div className="flex-row gap-sm">
<label className="flex-row gap-sm">
Equipped
<input
type="checkbox"
checked={cw.equipped}
disabled={!cw.usable}
onChange={(e) => patchWeapon.mutate({ weaponId: cw.weaponId, equipped: e.target.checked })}
/>
</label>
{isDM && (
<>
<GearLevelSelect
name={cw.weapon.name}
level={cw.level}
entry={cw.weapon}
onChange={(level) => patchWeapon.mutate({ weaponId: cw.weaponId, level })}
/>
<button className="btn btn-ghost" aria-label={`Take away ${cw.weapon.name}`} onClick={() => removeWeapon.mutate(cw.weaponId)}>
✕
</button>
</>
)}
</div>
)}
</div>
))}
{character.weapons.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No weapons. The DM hands these out.</p>}
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} />
{canEdit && (
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedWeaponId} onChange={(e) => setPickedWeaponId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
<option value="">Add a weapon from the campaign pool…</option>
<option value="">Give a weapon from the campaign pool…</option>
{campaignWeapons
?.filter((w) => !assignedWeaponIds.has(w.id))
.map((w) => (
@@ -378,7 +659,7 @@ export function CharacterSheetPage() {
))}
</select>
<button className="btn" onClick={handleAssignWeapon} disabled={!pickedWeaponId}>
Add
Give
</button>
</div>
)}
@@ -391,48 +672,78 @@ export function CharacterSheetPage() {
<div key={cs.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{cs.spell.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>Lv {cs.spell.level}</span>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
{spellLevelLabel(cs.spell.level)}
{cs.spell.damageDice && ` · ${cs.spell.damageDice} damage`}
</span>
{!cs.usable && <RequiresLevel level={cs.spell.level} />}
{cs.spell.description && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{cs.spell.description}</div>}
</div>
{canEdit && (
<label className="flex-row gap-sm">
Prepared
<input
type="checkbox"
checked={cs.prepared}
onChange={(e) => patchSpell.mutate({ spellId: cs.spellId, prepared: e.target.checked })}
/>
</label>
<div className="flex-row gap-sm">
<label className="flex-row gap-sm">
Prepared
<input
type="checkbox"
checked={cs.prepared}
disabled={!cs.usable}
onChange={(e) => patchSpell.mutate({ spellId: cs.spellId, prepared: e.target.checked })}
/>
</label>
{isDM && (
<button className="btn btn-ghost" aria-label={`Take away ${cs.spell.name}`} onClick={() => removeSpell.mutate(cs.spellId)}>
✕
</button>
)}
</div>
)}
</div>
))}
{character.spells.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No spells known. The DM grants these.</p>}
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} />
{canEdit && (
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedSpellId} onChange={(e) => setPickedSpellId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
<option value="">Add a spell from the campaign list…</option>
<option value="">Grant a spell from the campaign list…</option>
{campaignSpells
.filter((s) => !assignedSpellIds.has(s.id))
.map((s) => (
<option key={s.id} value={s.id}>
{s.name} (Lv {s.level})
{s.name} ({spellLevelLabel(s.level)})
</option>
))}
</select>
<button className="btn" onClick={handleAssignSpell} disabled={!pickedSpellId}>
Add
Grant
</button>
</div>
)}
</div>
)}
{character.restricted && (
<p className="subtitle mt-md">
This is another player's character. You can see what the party sees across the table; what they carry and how
much gold they have is theirs to share.
</p>
)}
{!character.restricted && (
<>
<div className="card mt-md">
<div className="card-title">Inventory</div>
{itemsPage.pageItems.map((item) => (
<div key={item.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
{itemsPage.pageItems.map((held) => (
<div key={held.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong>{item.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>x{item.quantity}</span>
<strong>{held.item.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
Lv {held.level}
{held.scaledDamageDice && ` · ${held.scaledDamageDice}`} · x{held.quantity}
</span>
{!held.usable && <RequiresLevel level={held.level} />}
{held.item.description && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{held.item.description}</div>
)}
</div>
{canEdit && (
<div className="flex-row gap-sm">
@@ -440,66 +751,226 @@ export function CharacterSheetPage() {
Equipped
<input
type="checkbox"
checked={item.equipped}
onChange={(e) => patchItem.mutate({ itemId: item.id, equipped: e.target.checked })}
checked={held.equipped}
disabled={!held.usable}
onChange={(e) => patchItem.mutate({ itemId: held.itemId, equipped: e.target.checked })}
/>
</label>
<button className="btn btn-ghost" onClick={() => deleteItem.mutate(item.id)}>
✕
</button>
{isDM && (
<>
<GearLevelSelect
name={held.item.name}
level={held.level}
entry={held.item}
onChange={(level) => patchItem.mutate({ itemId: held.itemId, level })}
/>
<button
className="btn btn-ghost"
aria-label={`One fewer ${held.item.name}`}
disabled={held.quantity <= 1}
onClick={() => patchItem.mutate({ itemId: held.itemId, quantity: held.quantity - 1 })}
>
−
</button>
<button
className="btn btn-ghost"
aria-label={`One more ${held.item.name}`}
onClick={() => patchItem.mutate({ itemId: held.itemId, quantity: held.quantity + 1 })}
>
+
</button>
<button className="btn btn-ghost" aria-label={`Take away ${held.item.name}`} onClick={() => removeItem.mutate(held.itemId)}>
✕
</button>
</>
)}
</div>
)}
</div>
))}
{character.inventoryItems.length === 0 && <p style={{ color: 'var(--text-muted)' }}>Nothing carried. The DM hands out items.</p>}
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} />
{canEdit && (
<form onSubmit={handleAddItem} className="flex-row mt-sm flex-wrap">
<input
placeholder="New item name"
value={newItemName}
onChange={(e) => setNewItemName(e.target.value)}
style={{ flex: 1, minWidth: 160 }}
/>
<button type="submit" className="btn">
Add Item
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedItemId} onChange={(e) => setPickedItemId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
<option value="">Give an item from the campaign pool…</option>
{campaignItems?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<button className="btn" onClick={handleAssignItem} disabled={!pickedItemId}>
Give
</button>
</form>
</div>
)}
{isDM && campaignItems?.length === 0 && (
<p className="subtitle">The item pool is empty — add items on the campaign page first.</p>
)}
</div>
{canEdit && (
<div className="card mt-md">
<div className="card-title">Special Abilities</div>
<textarea
rows={3}
defaultValue={character.specialAbilities || ''}
placeholder="Darkvision, Second Wind, Sneak Attack, etc."
onBlur={(e) => patchCharacter.mutate({ specialAbilities: e.target.value })}
/>
<div className="card mt-md">
<div className="card-title">
<span>Money</span>
<span className="mono">{formatPurse(character)}</span>
</div>
<p className="subtitle" style={{ marginTop: 0 }}>
1 gold = 10 silver = 100 copper. Worth {purseWorthInGold(character)} gold in all. Spending and giving make
change for you: if you are short of a coin, a bigger one is broken.
</p>
{canEdit && (
<div className="flex-row flex-wrap">
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="Coin amount"
value={goldAmount}
onChange={(e) => setGoldAmount(e.target.value)}
style={{ width: 100 }}
/>
<select aria-label="Coin" value={goldCurrency} onChange={(e) => setGoldCurrency(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
<input
placeholder="What for? (optional)"
aria-label="Reason"
maxLength={200}
value={goldReason}
onChange={(e) => setGoldReason(e.target.value)}
style={{ flex: 1, minWidth: 160 }}
/>
<button className="btn" onClick={() => handleGold(1)} disabled={!goldAmount || adjustGold.isPending}>
+ Gain
</button>
<button className="btn btn-danger" onClick={() => handleGold(-1)} disabled={!goldAmount || adjustGold.isPending}>
− Spend
</button>
</div>
)}
{canEdit && (
<div className="flex-row flex-wrap mt-sm">
<span className="subtitle">Exchange</span>
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label="How many coins to exchange"
value={exchangeAmount}
onChange={(e) => setExchangeAmount(e.target.value)}
style={{ width: 100 }}
/>
<select aria-label="Exchange from" value={exchangeFrom} onChange={(e) => setExchangeFrom(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
<span className="subtitle">into</span>
<select aria-label="Exchange into" value={exchangeTo} onChange={(e) => setExchangeTo(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
<button className="btn" onClick={handleExchange} disabled={!exchangeAmount || exchangeFrom === exchangeTo || exchangeCoins.isPending}>
Exchange
</button>
</div>
)}
<div className="mt-sm">
{goldLedger?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No transactions yet.</p>}
{ledgerPage.pageItems.map((t) => (
<div key={t.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div>
<strong className="mono">
{t.amount > 0 ? `+${t.amount}` : `−${Math.abs(t.amount)}`} {COIN_SHORT[t.currency ?? 'GOLD']}
</strong>
<span style={{ marginLeft: 8 }}>{t.reason || (t.amount > 0 ? 'Gained' : 'Spent')}</span>
<div style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
{t.createdBy ? `${t.createdBy.displayName} · ` : ''}
{new Date(t.createdAt).toLocaleString()}
</div>
</div>
<span className="mono" style={{ color: 'var(--text-muted)' }}>
{t.balanceAfter} {COIN_SHORT[t.currency ?? 'GOLD']}
</span>
</div>
))}
</div>
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} />
</div>
</>
)}
<div className="card mt-md">
<div className="card-title">Background</div>
<textarea
rows={4}
defaultValue={character.background || ''}
placeholder={canEdit ? "Where this character came from, what drives them…" : 'No background written yet.'}
readOnly={!canEdit}
onBlur={(e) => canEdit && patchCharacter.mutate({ background: e.target.value })}
/>
</div>
{/* Composed by the character builder from the player's picks. Players
read them; only the DM can rewrite them (new abilities on level-up,
a scar earned in play). */}
{(
[
['specialAbilities', 'Special Abilities', 'One per line, e.g. “Reckless Attack — gained at level 2.”', 6],
['background', 'Background', 'Where this character came from, what drives them…', 5],
['appearance', 'Appearance', 'Height, build, notable features, typical outfit…', 5],
] as const
).map(([field, title, placeholder, rows]) => (
<div className="card mt-md" key={field}>
<div className="card-title">
<span>{title}</span>
{isDM && <span className="badge">DM can edit</span>}
</div>
{isDM ? (
<textarea
key={`${field}:${character[field] ?? ''}`}
className="textarea-block"
rows={rows}
defaultValue={character[field] ?? ''}
placeholder={placeholder}
aria-label={title}
onBlur={(e) => {
const value = e.target.value.trim() || null;
if (value !== (character[field] ?? null)) patchCharacter.mutate({ [field]: value });
}}
/>
) : character[field] ? (
<p className="composed-text">{character[field]}</p>
) : (
<p className="subtitle" style={{ margin: 0 }}>
Nothing here yet. Your DM can fill this in.
</p>
)}
</div>
))}
<div className="card mt-md">
<div className="card-title">Appearance</div>
<textarea
rows={3}
defaultValue={character.appearance || ''}
placeholder={canEdit ? 'Height, build, notable features, typical outfit…' : 'No appearance written yet.'}
readOnly={!canEdit}
onBlur={(e) => canEdit && patchCharacter.mutate({ appearance: e.target.value })}
/>
</div>
{canEdit && <GiveCard character={character} onError={(err) => setFormError(extractErrorMessage(err))} />}
{canEdit && otherCampaigns.length > 0 && (
<div className="card mt-md">
<div className="card-title">Copy to Another Campaign</div>
<p className="subtitle" style={{ marginTop: 0 }}>
Makes an independent copy at full HP with the same stats, gear and spells. This character stays here unchanged.
</p>
<div className="flex-row flex-wrap">
<select value={copyTargetId} onChange={(e) => setCopyTargetId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
<option value="">Choose a campaign…</option>
{otherCampaigns.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<button className="btn" onClick={handleCopy} disabled={!copyTargetId || copyCharacter.isPending}>
{copyCharacter.isPending ? 'Copying…' : 'Copy Character'}
</button>
</div>
</div>
)}
{isOwner && (
<div className="card mt-md">
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -5,7 +5,8 @@ import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { usePagination, Pagination } from '../components/Paginated';
import { InfoTip } from '../components/InfoTip';
import { CLASS_LABELS, SPECIES_LABELS, hpBarClass } from '../lib/format';
import { CLASS_LABELS, SPECIES_LABELS, formatPurse } from '../lib/format';
import { HpBar } from '../components/HpBar';
import { speciesIcon } from '../lib/speciesIcons';
// A DM's one-screen view of every character's stats at a glance; a player
@@ -55,6 +56,7 @@ export function PartyOverviewPage() {
<th>
AC <InfoTip term="AC" />
</th>
<th>Coins</th>
<th>Location</th>
</tr>
</thead>
@@ -76,19 +78,18 @@ export function PartyOverviewPage() {
<td>
{c.maxHp != null ? (
<div style={{ minWidth: 90 }}>
<span className="mono">{c.currentHp}/{c.maxHp}</span>
<div className="hp-bar-track mt-sm" style={{ height: 6 }}>
<div
className={hpBarClass(c.currentHp, c.maxHp)}
style={{ width: `${Math.max(0, Math.min(100, (c.currentHp / c.maxHp) * 100))}%` }}
/>
</div>
<span className="mono">
{c.currentHp}/{c.maxHp}
{c.tempHp > 0 && ` +${c.tempHp}`}
</span>
<HpBar current={c.currentHp} max={c.maxHp} temp={c.tempHp} height={6} />
</div>
) : (
'—'
)}
</td>
<td>{c.armorClass ?? '—'}</td>
<td className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
</tr>
);
+6
View File
@@ -4,4 +4,10 @@ import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
preview: {
host: true,
port: 5173,
allowedHosts: ['the-sunken-reach.crosscode.co.za'],
},
})
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.0.0",
"version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.0.0",
"version": "1.4.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.0.0",
"version": "1.4.0",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "0.0.0",
"version": "1.4.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.0.0",
"version": "1.4.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [
@@ -10,6 +10,7 @@
"scripts": {
"dev:backend": "npm run dev --workspace backend",
"dev:frontend": "npm run dev --workspace frontend",
"test:backend": "npm run test --workspace backend"
"test:backend": "npm run test --workspace backend",
"seed:hollowcrown": "npm run seed:hollowcrown --workspace backend"
}
}