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>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"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",
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
-- AlterTable: starting-option flags. Existing pool entries keep the previous
|
||||
-- behaviour (everything was offered at character creation); new ones opt in.
|
||||
ALTER TABLE "weapons" ADD COLUMN "isStartingOption" BOOLEAN NOT NULL DEFAULT false;
|
||||
ALTER TABLE "spells" ADD COLUMN "isStartingOption" BOOLEAN NOT NULL DEFAULT false;
|
||||
UPDATE "weapons" SET "isStartingOption" = true;
|
||||
UPDATE "spells" SET "isStartingOption" = true;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "items" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"isStartingOption" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Move free-text inventory into the campaign item pool: one pool item per
|
||||
-- distinct name (case-insensitive) per campaign, then point each row at it.
|
||||
INSERT INTO "items" ("id", "campaignId", "name", "description")
|
||||
SELECT DISTINCT ON (c."campaignId", lower(i."name"))
|
||||
'itm_' || md5(c."campaignId" || ':' || lower(i."name")),
|
||||
c."campaignId", i."name", i."description"
|
||||
FROM "inventory_items" i
|
||||
JOIN "characters" c ON c."id" = i."characterId"
|
||||
ORDER BY c."campaignId", lower(i."name"), i."id";
|
||||
|
||||
ALTER TABLE "inventory_items" ADD COLUMN "itemId" TEXT;
|
||||
|
||||
UPDATE "inventory_items" i
|
||||
SET "itemId" = 'itm_' || md5(c."campaignId" || ':' || lower(i."name"))
|
||||
FROM "characters" c
|
||||
WHERE c."id" = i."characterId";
|
||||
|
||||
-- A character that held the same item on several rows keeps one, with the
|
||||
-- quantities summed.
|
||||
UPDATE "inventory_items" keep
|
||||
SET "quantity" = agg.total, "equipped" = agg.any_equipped
|
||||
FROM (
|
||||
SELECT "characterId", "itemId", min("id") AS keep_id, sum("quantity")::int AS total, bool_or("equipped") AS any_equipped
|
||||
FROM "inventory_items"
|
||||
GROUP BY "characterId", "itemId"
|
||||
) agg
|
||||
WHERE keep."id" = agg.keep_id;
|
||||
|
||||
DELETE FROM "inventory_items" d
|
||||
USING (
|
||||
SELECT "characterId", "itemId", min("id") AS keep_id
|
||||
FROM "inventory_items"
|
||||
GROUP BY "characterId", "itemId"
|
||||
) agg
|
||||
WHERE d."characterId" = agg."characterId" AND d."itemId" = agg."itemId" AND d."id" <> agg.keep_id;
|
||||
|
||||
ALTER TABLE "inventory_items" ALTER COLUMN "itemId" SET NOT NULL;
|
||||
ALTER TABLE "inventory_items" DROP COLUMN "name";
|
||||
ALTER TABLE "inventory_items" DROP COLUMN "description";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "gold_transactions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"balanceAfter" INTEGER NOT NULL,
|
||||
"reason" TEXT,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "gold_transactions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Give characters that already hold gold an opening ledger entry.
|
||||
INSERT INTO "gold_transactions" ("id", "characterId", "amount", "balanceAfter", "reason")
|
||||
SELECT 'gtx_' || md5("id"), "id", "gold", "gold", 'Opening balance'
|
||||
FROM "characters"
|
||||
WHERE "gold" > 0;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "inventory_items_characterId_itemId_key" ON "inventory_items"("characterId", "itemId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "gold_transactions_characterId_createdAt_idx" ON "gold_transactions"("characterId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "items" ADD CONSTRAINT "items_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "inventory_items" ADD CONSTRAINT "inventory_items_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "gold_transactions" ADD CONSTRAINT "gold_transactions_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "gold_transactions" ADD CONSTRAINT "gold_transactions_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- AlterTable: how many ladder rungs a weapon climbs per level (1 standard, 2 powerful, 3 legendary)
|
||||
ALTER TABLE "weapons" ADD COLUMN "damageScaling" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- AlterTable: the level of the copy a character holds
|
||||
ALTER TABLE "character_weapons" ADD COLUMN "level" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- AlterTable: items can optionally carry dice (damage, healing, ...) that scale the same way
|
||||
ALTER TABLE "items" ADD COLUMN "damageDice" TEXT,
|
||||
ADD COLUMN "damageScaling" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "inventory_items" ADD COLUMN "level" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- AlterTable: spells get a fixed damage value; their classic level is the requirement
|
||||
ALTER TABLE "spells" ADD COLUMN "damageDice" TEXT;
|
||||
|
||||
-- A spell above its holder's level can no longer be prepared.
|
||||
UPDATE "character_spells" cs
|
||||
SET "prepared" = false
|
||||
FROM "spells" s, "characters" c
|
||||
WHERE s."id" = cs."spellId" AND c."id" = cs."characterId" AND s."level" > c."level";
|
||||
@@ -0,0 +1,7 @@
|
||||
-- AlterTable: the level a weapon or item starts at (its dice are the damage at this level)
|
||||
ALTER TABLE "weapons" ADD COLUMN "minLevel" INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE "items" ADD COLUMN "minLevel" INTEGER NOT NULL DEFAULT 1;
|
||||
|
||||
-- Gear levels now stop at 9.
|
||||
UPDATE "character_weapons" SET "level" = 9 WHERE "level" > 9;
|
||||
UPDATE "inventory_items" SET "level" = 9 WHERE "level" > 9;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "EncounterMode" AS ENUM ('BATTLE', 'QUEST');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NpcDisposition" AS ENUM ('FRIENDLY', 'NEUTRAL', 'HOSTILE');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "RewardType" AS ENUM ('GOLD', 'WEAPON', 'ITEM');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "encounter_participants" ADD COLUMN "npcId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "encounters" ADD COLUMN "activeParticipantId" TEXT,
|
||||
ADD COLUMN "description" TEXT,
|
||||
ADD COLUMN "dmNotes" TEXT,
|
||||
ADD COLUMN "mode" "EncounterMode" NOT NULL DEFAULT 'BATTLE';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "npcs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"role" TEXT,
|
||||
"disposition" "NpcDisposition" NOT NULL DEFAULT 'NEUTRAL',
|
||||
"description" TEXT,
|
||||
"dmNotes" TEXT,
|
||||
"ac" INTEGER,
|
||||
"maxHp" INTEGER,
|
||||
"attackBonus" INTEGER,
|
||||
"damageDice" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "npcs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_rewards" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounterId" TEXT NOT NULL,
|
||||
"type" "RewardType" NOT NULL,
|
||||
"gold" INTEGER,
|
||||
"weaponId" TEXT,
|
||||
"itemId" TEXT,
|
||||
"quantity" INTEGER NOT NULL DEFAULT 1,
|
||||
"level" INTEGER NOT NULL DEFAULT 1,
|
||||
"note" TEXT,
|
||||
"awardedTo" TEXT,
|
||||
"awardedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "encounter_rewards_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "npcs" ADD CONSTRAINT "npcs_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_rewards" ADD CONSTRAINT "encounter_rewards_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_rewards" ADD CONSTRAINT "encounter_rewards_weaponId_fkey" FOREIGN KEY ("weaponId") REFERENCES "weapons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_rewards" ADD CONSTRAINT "encounter_rewards_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_participants" ADD CONSTRAINT "encounter_participants_npcId_fkey" FOREIGN KEY ("npcId") REFERENCES "npcs"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterEnum: three story steps between skills and equipment
|
||||
ALTER TYPE "BuildStage" ADD VALUE 'CHOOSING_ABILITIES' BEFORE 'CHOOSING_EQUIPMENT';
|
||||
ALTER TYPE "BuildStage" ADD VALUE 'CHOOSING_BACKGROUND' BEFORE 'CHOOSING_EQUIPMENT';
|
||||
ALTER TYPE "BuildStage" ADD VALUE 'CHOOSING_APPEARANCE' BEFORE 'CHOOSING_EQUIPMENT';
|
||||
+145
-10
@@ -86,6 +86,9 @@ enum BuildStage {
|
||||
ROLLING_MOTIVATION
|
||||
ASSIGNING_SCORES
|
||||
ASSIGNING_PROFICIENCIES
|
||||
CHOOSING_ABILITIES
|
||||
CHOOSING_BACKGROUND
|
||||
CHOOSING_APPEARANCE
|
||||
CHOOSING_EQUIPMENT
|
||||
COMPLETE
|
||||
}
|
||||
@@ -96,6 +99,25 @@ 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
|
||||
}
|
||||
|
||||
enum NoteVisibility {
|
||||
DM_ONLY
|
||||
SHARED
|
||||
@@ -122,6 +144,7 @@ model User {
|
||||
charactersOwned Character[] @relation("PlayerCharacters")
|
||||
charactersCreated Character[] @relation("CreatedBy")
|
||||
notesAuthored Note[]
|
||||
goldTransactions GoldTransaction[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -143,7 +166,9 @@ model Campaign {
|
||||
characters Character[]
|
||||
weapons Weapon[]
|
||||
spells Spell[]
|
||||
items Item[]
|
||||
monsters Monster[]
|
||||
npcs Npc[]
|
||||
encounters Encounter[]
|
||||
notes Note[]
|
||||
locations Location[]
|
||||
@@ -211,11 +236,13 @@ 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
|
||||
gold 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,6 +263,7 @@ model Character {
|
||||
skillProficiencies CharacterSkillProficiency[]
|
||||
saveProficiencies CharacterSaveProficiency[]
|
||||
inventoryItems InventoryItem[]
|
||||
goldTransactions GoldTransaction[]
|
||||
weapons CharacterWeapon[]
|
||||
spells CharacterSpell[]
|
||||
notes Note[]
|
||||
@@ -283,10 +311,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 +335,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 +344,58 @@ 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 Character.gold: every change to the balance is one row here.
|
||||
model GoldTransaction {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
amount Int // signed: positive = gained, negative = spent
|
||||
balanceAfter Int
|
||||
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 +404,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,30 +461,84 @@ 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
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
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
|
||||
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"
|
||||
initiative Int @default(0)
|
||||
currentHp Int
|
||||
@@ -415,6 +549,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")
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.weapon.createMany({ data: weapons.map((w) => ({ ...w, campaignId: campaign.id })) });
|
||||
await prisma.weapon.createMany({ data: weapons.map((w) => ({ ...w, isStartingOption: true, campaignId: campaign.id })) });
|
||||
|
||||
await prisma.location.createMany({
|
||||
data: aldenfell.locations.map((l) => ({ ...l, campaignId: campaign.id })),
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,10 @@ const {
|
||||
weaponDamageBonus,
|
||||
computeCharacterSheet,
|
||||
computeWeaponStats,
|
||||
DAMAGE_LADDER,
|
||||
diceAverage,
|
||||
scaledDamageDice,
|
||||
canUseGear,
|
||||
} = require('./calculations');
|
||||
|
||||
describe('abilityModifier', () => {
|
||||
@@ -211,4 +215,110 @@ describe('computeWeaponStats', () => {
|
||||
expect(stats.damageBonus).toBe(sheet.abilities.STR.modifier);
|
||||
expect(stats.damageDice).toBe('1d8');
|
||||
});
|
||||
|
||||
test('uses the dice for the level of the copy the character holds', () => {
|
||||
const sheet = computeCharacterSheet({ level: 5, strScore: 16 });
|
||||
const spear = { attackAbility: 'STR', damageDice: '1d6', damageType: 'piercing', damageScaling: 1 };
|
||||
expect(computeWeaponStats(spear, sheet.abilities, sheet.proficiencyBonus, true, 3).damageDice).toBe('1d10');
|
||||
// The level changes the dice only, never the bonuses.
|
||||
expect(computeWeaponStats(spear, sheet.abilities, sheet.proficiencyBonus, true, 3).damageBonus).toBe(sheet.abilities.STR.modifier);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gear levels — the damage ladder', () => {
|
||||
test('starts with single dice, then pairs, in the smallest possible steps', () => {
|
||||
expect(DAMAGE_LADDER.slice(0, 13).map((r) => r.dice)).toEqual([
|
||||
'1d4', '1d6', '1d8', '1d10', '1d12',
|
||||
'2d6', '1d6+1d8', '2d8', '1d8+1d10', '2d10', '1d10+1d12', '2d12',
|
||||
'3d8',
|
||||
]);
|
||||
});
|
||||
|
||||
test('every rung is stronger than the last by 1 average damage, or 0.5 where a die is added', () => {
|
||||
for (let i = 1; i < DAMAGE_LADDER.length; i++) {
|
||||
const step = DAMAGE_LADDER[i].average - DAMAGE_LADDER[i - 1].average;
|
||||
expect([0.5, 1]).toContain(step);
|
||||
// The stated average really is the average of the dice shown.
|
||||
expect(diceAverage(DAMAGE_LADDER[i].dice)).toBe(DAMAGE_LADDER[i].average);
|
||||
}
|
||||
});
|
||||
|
||||
test('diceAverage reads the expressions DMs type, and rejects free text', () => {
|
||||
expect(diceAverage('1d8')).toBe(4.5);
|
||||
expect(diceAverage('d8')).toBe(4.5);
|
||||
expect(diceAverage('2d6')).toBe(7);
|
||||
expect(diceAverage('1d6 + 1d8')).toBe(8);
|
||||
expect(diceAverage('2d4+2')).toBe(7);
|
||||
expect(diceAverage('1d6-1')).toBe(2.5);
|
||||
expect(diceAverage('3')).toBe(3);
|
||||
expect(diceAverage('special')).toBeNull();
|
||||
expect(diceAverage('')).toBeNull();
|
||||
expect(diceAverage(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scaledDamageDice', () => {
|
||||
const byLevel = (base, scaling, levels) => levels.map((level) => scaledDamageDice(base, level, scaling));
|
||||
|
||||
test('standard gear climbs one rung per level', () => {
|
||||
expect(byLevel('1d4', 1, [1, 2, 3, 4, 5, 6])).toEqual(['1d4', '1d6', '1d8', '1d10', '1d12', '2d6']);
|
||||
expect(byLevel('1d8', 1, [1, 2, 3])).toEqual(['1d8', '1d10', '1d12']);
|
||||
});
|
||||
|
||||
test('powerful and legendary gear climb two and three rungs per level', () => {
|
||||
expect(byLevel('1d6', 2, [1, 2, 3, 4])).toEqual(['1d6', '1d10', '2d6', '2d8']);
|
||||
expect(byLevel('1d8', 3, [1, 2, 3])).toEqual(['1d8', '2d6', '1d8+1d10']);
|
||||
});
|
||||
|
||||
test('level 1 is always the base dice exactly as written', () => {
|
||||
expect(scaledDamageDice('2d4+2', 1, 1)).toBe('2d4+2');
|
||||
expect(scaledDamageDice('special', 1, 3)).toBe('special');
|
||||
});
|
||||
|
||||
test('dice that are not on the ladder start from the highest rung that does not beat them', () => {
|
||||
expect(scaledDamageDice('2d4+2', 2, 1)).toBe('1d6+1d8'); // avg 7 sits on 2d6
|
||||
expect(scaledDamageDice('1d4+1', 2, 1)).toBe('1d8'); // avg 3.5 sits on 1d6
|
||||
expect(scaledDamageDice('1', 2, 1)).toBe('1d4'); // below the ladder
|
||||
});
|
||||
|
||||
test('free text never scales, and levels and scaling are clamped to their ranges', () => {
|
||||
expect(scaledDamageDice('special', 9, 3)).toBe('special');
|
||||
expect(scaledDamageDice('1d4', 99, 1)).toBe(scaledDamageDice('1d4', 9, 1));
|
||||
expect(scaledDamageDice('1d4', 9, 1)).toBe('1d8+1d10');
|
||||
expect(scaledDamageDice('1d4', 0, 1)).toBe('1d4');
|
||||
expect(scaledDamageDice('1d4', 2, 99)).toBe(scaledDamageDice('1d4', 2, 3));
|
||||
expect(scaledDamageDice('1d4', 2, undefined)).toBe('1d6');
|
||||
});
|
||||
|
||||
test('gear with a starting level shows its base dice there and climbs from it', () => {
|
||||
// A greatsword that only exists from level 3: 2d6 at 3, then one rung per level.
|
||||
expect([3, 4, 5].map((level) => scaledDamageDice('2d6', level, 1, 3))).toEqual(['2d6', '1d6+1d8', '2d8']);
|
||||
expect(scaledDamageDice('2d6', 1, 1, 3)).toBe('2d6'); // never below its base
|
||||
expect(scaledDamageDice('2d4+2', 3, 2, 3)).toBe('2d4+2'); // as written at its starting level
|
||||
const sheet = computeCharacterSheet({ level: 5, strScore: 16 });
|
||||
const greatsword = { attackAbility: 'STR', damageDice: '2d6', damageType: 'slashing', damageScaling: 1, minLevel: 3 };
|
||||
expect(computeWeaponStats(greatsword, sheet.abilities, sheet.proficiencyBonus, true, 4).damageDice).toBe('1d6+1d8');
|
||||
});
|
||||
|
||||
test('a higher level is never weaker', () => {
|
||||
for (const base of ['1d4', '1d12', '2d6', '2d4+2']) {
|
||||
for (const scaling of [1, 2, 3]) {
|
||||
for (let level = 2; level <= 9; level++) {
|
||||
expect(diceAverage(scaledDamageDice(base, level, scaling))).toBeGreaterThan(
|
||||
diceAverage(scaledDamageDice(base, level - 1, scaling))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('canUseGear', () => {
|
||||
test('a character can use gear of their own level or lower', () => {
|
||||
expect(canUseGear(1, 1)).toBe(true);
|
||||
expect(canUseGear(3, 1)).toBe(true);
|
||||
expect(canUseGear(3, 3)).toBe(true);
|
||||
expect(canUseGear(2, 3)).toBe(false);
|
||||
expect(canUseGear(1, 0)).toBe(true); // cantrips
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
// What a player picks from in the story steps of the character builder:
|
||||
// motivation, special abilities, background and appearance. A character's
|
||||
// text for these is always composed from the entries below (see buildFlow.js),
|
||||
// never typed freely, so the builder page fetches this file's contents rather
|
||||
// than keeping its own copy.
|
||||
//
|
||||
// None of this feeds the calculation engine. Special abilities are written as
|
||||
// table rules for the DM to apply, in plain words.
|
||||
|
||||
const MOTIVATIONS = [
|
||||
{ key: 1, label: 'Revenge for a past wrong' },
|
||||
{ key: 2, label: 'Seeking fortune or wealth' },
|
||||
{ key: 3, label: 'Protecting someone they love' },
|
||||
{ key: 4, label: 'Redemption for a past mistake' },
|
||||
{ key: 5, label: 'Driven by curiosity or knowledge' },
|
||||
{ key: 6, label: 'Duty or oath to a cause or order' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Special abilities
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Granted automatically by species.
|
||||
const SPECIES_TRAITS = {
|
||||
HUMAN: [{ name: 'Versatile', text: 'Once per day, reroll one skill check you just failed and keep the new result.' }],
|
||||
HALF_ELF: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
|
||||
],
|
||||
ELF: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
|
||||
{ name: 'Trance', text: 'You do not sleep. Four hours of meditation rests you as well as a full night.' },
|
||||
],
|
||||
GNOME: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Gnome Cunning', text: 'Advantage on Intelligence, Wisdom and Charisma saves against magic.' },
|
||||
],
|
||||
DWARF: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Dwarven Resilience', text: 'Advantage on saves against poison, and poison damage against you is halved.' },
|
||||
],
|
||||
HALFLING: [
|
||||
{ name: 'Lucky', text: 'When you roll a 1 on a d20 for an attack, check or save, reroll it and use the new roll.' },
|
||||
{ name: 'Brave', text: 'Advantage on saves against being frightened.' },
|
||||
],
|
||||
DRAGONBORN: [
|
||||
{ name: 'Breath Weapon', text: 'Once per rest, exhale destructive energy: creatures in a 15-foot cone take 2d6 damage (Dexterity save for half).' },
|
||||
{ name: 'Draconic Resistance', text: 'Damage of your breath weapon’s type against you is halved.' },
|
||||
],
|
||||
TIEFLING: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Hellish Resistance', text: 'Fire damage against you is halved.' },
|
||||
],
|
||||
HALF_ORC: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Relentless Endurance', text: 'Once per day, when you would drop to 0 hit points, drop to 1 instead.' },
|
||||
],
|
||||
ORC: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Aggressive', text: 'As a bonus action, move up to your speed toward an enemy you can see.' },
|
||||
],
|
||||
};
|
||||
|
||||
// Granted automatically by class, at level 1.
|
||||
const CLASS_FEATURES = {
|
||||
FIGHTER: [
|
||||
{ name: 'Second Wind', text: 'Once per rest, as a bonus action, recover 1d10 + your level hit points.' },
|
||||
{ name: 'Fighting Style', text: 'Choose one with your DM: +2 to ranged attacks, +1 AC in armor, or +2 damage with a one-handed weapon.' },
|
||||
],
|
||||
BARBARIAN: [
|
||||
{ name: 'Rage', text: 'Twice per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved.' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor, add your Constitution modifier to your AC (ask your DM to set your armor bonus).' },
|
||||
],
|
||||
ROGUE: [
|
||||
{ name: 'Sneak Attack', text: 'Once per turn, deal an extra 1d6 damage to a target you have advantage against, or that has one of your allies next to it.' },
|
||||
{ name: 'Thieves’ Cant', text: 'You know the secret slang and signs of the criminal underworld.' },
|
||||
],
|
||||
RANGER: [
|
||||
{ name: 'Favored Enemy', text: 'Choose a kind of creature with your DM. Advantage on checks to track them and to recall what you know about them.' },
|
||||
{ name: 'Natural Explorer', text: 'In the wild you cannot get lost, you find twice as much food, and difficult terrain does not slow your group.' },
|
||||
],
|
||||
WIZARD: [
|
||||
{ name: 'Spellbook', text: 'Your spells are written in a book. You can copy new spells into it when you find them.' },
|
||||
{ name: 'Arcane Recovery', text: 'Once per day, after a short rest, regain some of your spent magic.' },
|
||||
],
|
||||
SORCERER: [
|
||||
{ name: 'Innate Magic', text: 'Your magic comes from within: you need no spellbook and no prayers.' },
|
||||
{ name: 'Wild Talent', text: 'Once per day, reroll the damage dice of one spell and keep the better result.' },
|
||||
],
|
||||
CLERIC: [
|
||||
{ name: 'Divine Domain', text: 'Choose with your DM what your god stands for (life, light, war, knowledge…). It shapes the favours you can ask.' },
|
||||
{ name: 'Healing Hands', text: 'Once per rest, touch a creature to restore 1d8 + your Wisdom modifier hit points.' },
|
||||
],
|
||||
PALADIN: [
|
||||
{ name: 'Divine Sense', text: 'A few times per day, sense any celestial, fiend or undead within 60 feet.' },
|
||||
{ name: 'Lay on Hands', text: 'A pool of healing equal to 5 × your level per day, spent by touch however you like.' },
|
||||
],
|
||||
BARD: [
|
||||
{ name: 'Bardic Inspiration', text: 'A few times per day, as a bonus action, give an ally a d6 to add to one attack, check or save.' },
|
||||
{ name: 'Jack of Many Tales', text: 'You have heard a story about nearly everything: ask your DM for one useful rumour per session.' },
|
||||
],
|
||||
WARLOCK: [
|
||||
{ name: 'Otherworldly Patron', text: 'Choose with your DM who granted your power, and what they want in return.' },
|
||||
{ name: 'Pact Magic', text: 'Your magic returns after a short rest, not only a long one.' },
|
||||
],
|
||||
DRUID: [
|
||||
{ name: 'Druidic', text: 'You know the secret language of druids and can leave hidden messages in it.' },
|
||||
{ name: 'Nature’s Voice', text: 'You can calm and make simple requests of ordinary animals.' },
|
||||
],
|
||||
MONK: [
|
||||
{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d4 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor, add your Wisdom modifier to your AC (ask your DM to set your armor bonus).' },
|
||||
],
|
||||
};
|
||||
|
||||
// The player picks TALENT_PICKS of these.
|
||||
const TALENT_PICKS = 2;
|
||||
const TALENTS = [
|
||||
{ key: 'ALERT', name: 'Alert', text: 'You cannot be surprised while you are awake.' },
|
||||
{ key: 'KEEN_EYE', name: 'Keen Eye', text: 'Advantage on checks to spot hidden doors, traps and things out of place.' },
|
||||
{ key: 'SILVER_TONGUE', name: 'Silver Tongue', text: 'Once per day, reroll a Persuasion or Deception check and keep the better result.' },
|
||||
{ key: 'LIGHT_SLEEPER', name: 'Light Sleeper', text: 'You wake at the slightest sound, and keeping watch does not tire you.' },
|
||||
{ key: 'IRON_STOMACH', name: 'Iron Stomach', text: 'Advantage on saves against spoiled food, strong drink and swallowed poisons.' },
|
||||
{ key: 'ANIMAL_FRIEND', name: 'Animal Friend', text: 'Ordinary animals are calm around you unless provoked.' },
|
||||
{ key: 'QUICK_HANDS', name: 'Quick Hands', text: 'Once per turn, draw, stow or pick up an object without using your action.' },
|
||||
{ key: 'SURE_FOOTED', name: 'Sure-Footed', text: 'Advantage on checks to keep your balance or avoid being knocked down.' },
|
||||
{ key: 'LINGUIST', name: 'Linguist', text: 'You speak two extra languages of your choice, agreed with your DM.' },
|
||||
{ key: 'FIELD_MEDIC', name: 'Field Medic', text: 'Steadying a dying creature is automatic for you; no roll needed.' },
|
||||
{ key: 'STRONG_BACK', name: 'Strong Back', text: 'You can carry, lift and drag twice as much as your size suggests.' },
|
||||
{ key: 'GOOD_MEMORY', name: 'Good Memory', text: 'You recall exactly anything you have seen or heard in the last month.' },
|
||||
{ key: 'STREETWISE', name: 'Streetwise', text: 'In any town you can find a place to sleep, a rumour, and someone who buys things without asking questions.' },
|
||||
{ key: 'TOUGH_AS_NAILS', name: 'Tough as Nails', text: 'Once per day, shrug off a blow: reduce one instance of damage by 1d6.' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background and appearance — groups of options. `min`/`max` say how many a
|
||||
// player picks in each group.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BACKGROUND_GROUPS = [
|
||||
{
|
||||
key: 'upbringing',
|
||||
label: 'Upbringing',
|
||||
prompt: 'What were you before you became an adventurer?',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'SOLDIER', label: 'Soldier', text: 'Served in an army or militia, and still thinks in terms of orders and watches.' },
|
||||
{ key: 'ACOLYTE', label: 'Acolyte', text: 'Raised in a temple, tending shrines and copying scripture.' },
|
||||
{ key: 'CRIMINAL', label: 'Criminal', text: 'Made a living outside the law, and knows who to ask for what.' },
|
||||
{ key: 'NOBLE', label: 'Noble', text: 'Born to wealth and a family name that opens doors — and makes enemies.' },
|
||||
{ key: 'SCHOLAR', label: 'Scholar', text: 'Spent years among books, chasing answers to old questions.' },
|
||||
{ key: 'FARMER', label: 'Farmer', text: 'Worked the land from dawn to dusk, and knows the value of a good harvest.' },
|
||||
{ key: 'SAILOR', label: 'Sailor', text: 'Grew up on deck, at home with rope, salt and bad weather.' },
|
||||
{ key: 'ARTISAN', label: 'Guild artisan', text: 'Learned a craft in a workshop and takes pride in good work.' },
|
||||
{ key: 'ENTERTAINER', label: 'Entertainer', text: 'Sang, juggled or acted for coin in taverns and market squares.' },
|
||||
{ key: 'OUTLANDER', label: 'Outlander', text: 'Raised far from towns, among hunters, herders or nomads.' },
|
||||
{ key: 'URCHIN', label: 'Urchin', text: 'Grew up alone on city streets, surviving on wits and quick feet.' },
|
||||
{ key: 'HERMIT', label: 'Hermit', text: 'Lived apart from the world for years, with only thoughts for company.' },
|
||||
{ key: 'MERCHANT', label: 'Merchant', text: 'Travelled the trade roads buying low and selling high.' },
|
||||
{ key: 'HEALER', label: 'Village healer', text: 'Set bones, brewed remedies and sat with the sick.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'home',
|
||||
label: 'Home',
|
||||
prompt: 'Where did you grow up?',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'CITY', label: 'A crowded city', text: 'Noise, alleys and a thousand strangers.' },
|
||||
{ key: 'VILLAGE', label: 'A small village', text: 'Everyone knew everyone, and nothing stayed secret.' },
|
||||
{ key: 'PORT', label: 'A harbour town', text: 'Ships, gulls and news from far away.' },
|
||||
{ key: 'MOUNTAIN', label: 'A mountain hold', text: 'Thin air, stone halls and long winters.' },
|
||||
{ key: 'FOREST', label: 'Deep in the forest', text: 'Under old trees, far from any road.' },
|
||||
{ key: 'FRONTIER', label: 'A frontier fort', text: 'The last wall before the wild.' },
|
||||
{ key: 'ROAD', label: 'On the road', text: 'Never in one place long enough to call it home.' },
|
||||
{ key: 'MONASTERY', label: 'A remote monastery', text: 'Bells, silence and routine.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'turningPoint',
|
||||
label: 'Turning point',
|
||||
prompt: 'What set you on the road?',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'LOSS', label: 'Lost someone dear', text: 'And have not been the same since.' },
|
||||
{ key: 'BETRAYED', label: 'Was betrayed', text: 'By someone they trusted completely.' },
|
||||
{ key: 'DISASTER', label: 'Survived a disaster', text: 'Fire, flood, plague or war — and walked away when others did not.' },
|
||||
{ key: 'SECRET', label: 'Found a secret', text: 'Something they were never meant to see.' },
|
||||
{ key: 'DEBT', label: 'Owes a debt', text: 'One that cannot be repaid by staying home.' },
|
||||
{ key: 'EXILED', label: 'Was cast out', text: 'Rightly or wrongly, there is no going back.' },
|
||||
{ key: 'RESCUED', label: 'Was saved by a stranger', text: 'And means to pass the favour on.' },
|
||||
{ key: 'CALLING', label: 'Heard a calling', text: 'A dream, a sign or a voice that would not let them rest.' },
|
||||
{ key: 'BOREDOM', label: 'Could not stand the quiet', text: 'An ordinary life was never going to be enough.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'personality',
|
||||
label: 'Personality',
|
||||
prompt: 'How do others describe you? Pick one or two.',
|
||||
min: 1,
|
||||
max: 2,
|
||||
options: [
|
||||
{ key: 'BRAVE', label: 'Brave' },
|
||||
{ key: 'CAUTIOUS', label: 'Cautious' },
|
||||
{ key: 'CURIOUS', label: 'Curious' },
|
||||
{ key: 'KIND', label: 'Kind' },
|
||||
{ key: 'HOT_HEADED', label: 'Hot-headed' },
|
||||
{ key: 'SARCASTIC', label: 'Sarcastic' },
|
||||
{ key: 'LOYAL', label: 'Loyal' },
|
||||
{ key: 'HONEST', label: 'Honest to a fault' },
|
||||
{ key: 'CHEERFUL', label: 'Cheerful' },
|
||||
{ key: 'QUIET', label: 'Quiet' },
|
||||
{ key: 'PROUD', label: 'Proud' },
|
||||
{ key: 'SUPERSTITIOUS', label: 'Superstitious' },
|
||||
{ key: 'GENEROUS', label: 'Generous' },
|
||||
{ key: 'STUBBORN', label: 'Stubborn' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'flaw',
|
||||
label: 'Flaw',
|
||||
prompt: 'What gets you into trouble?',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'GREEDY', label: 'Greedy', text: 'Cannot walk past treasure.' },
|
||||
{ key: 'RECKLESS', label: 'Reckless', text: 'Acts first and thinks later.' },
|
||||
{ key: 'VENGEFUL', label: 'Holds grudges', text: 'Never forgets a slight.' },
|
||||
{ key: 'GULLIBLE', label: 'Too trusting', text: 'Believes a good story.' },
|
||||
{ key: 'COWARDLY', label: 'Loses their nerve', text: 'When it matters most.' },
|
||||
{ key: 'BOASTFUL', label: 'Boastful', text: 'Promises more than they can deliver.' },
|
||||
{ key: 'SECRETIVE', label: 'Secretive', text: 'Keeps things even from friends.' },
|
||||
{ key: 'SOFT_HEARTED', label: 'Soft-hearted', text: 'Cannot refuse someone in need.' },
|
||||
{ key: 'TEMPER', label: 'Short temper', text: 'Easily provoked.' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const APPEARANCE_GROUPS = [
|
||||
{
|
||||
key: 'build',
|
||||
label: 'Build',
|
||||
prompt: 'What shape are you?',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'SLIGHT', label: 'Slight' },
|
||||
{ key: 'LEAN', label: 'Lean' },
|
||||
{ key: 'AVERAGE', label: 'Average' },
|
||||
{ key: 'STOCKY', label: 'Stocky' },
|
||||
{ key: 'MUSCULAR', label: 'Muscular' },
|
||||
{ key: 'HEAVYSET', label: 'Heavyset' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'height',
|
||||
label: 'Height',
|
||||
prompt: 'For your species, are you…',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'SHORT', label: 'Short' },
|
||||
{ key: 'AVERAGE', label: 'Average height' },
|
||||
{ key: 'TALL', label: 'Tall' },
|
||||
{ key: 'TOWERING', label: 'Towering' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'hair',
|
||||
label: 'Hair',
|
||||
prompt: 'Hair (or what you have instead).',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'BLACK', label: 'Black' },
|
||||
{ key: 'BROWN', label: 'Brown' },
|
||||
{ key: 'BLONDE', label: 'Blonde' },
|
||||
{ key: 'RED', label: 'Red' },
|
||||
{ key: 'GREY', label: 'Grey' },
|
||||
{ key: 'WHITE', label: 'White' },
|
||||
{ key: 'UNUSUAL', label: 'An unusual colour' },
|
||||
{ key: 'BALD', label: 'Bald' },
|
||||
{ key: 'NONE', label: 'None — scales, horns or crest' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'eyes',
|
||||
label: 'Eyes',
|
||||
prompt: 'Eye colour.',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'BROWN', label: 'Brown' },
|
||||
{ key: 'BLUE', label: 'Blue' },
|
||||
{ key: 'GREEN', label: 'Green' },
|
||||
{ key: 'GREY', label: 'Grey' },
|
||||
{ key: 'AMBER', label: 'Amber' },
|
||||
{ key: 'BLACK', label: 'Black' },
|
||||
{ key: 'VIOLET', label: 'Violet' },
|
||||
{ key: 'MISMATCHED', label: 'Mismatched' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'complexion',
|
||||
label: 'Skin',
|
||||
prompt: 'Skin or scales.',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'PALE', label: 'Pale' },
|
||||
{ key: 'FAIR', label: 'Fair' },
|
||||
{ key: 'TANNED', label: 'Tanned' },
|
||||
{ key: 'BROWN', label: 'Brown' },
|
||||
{ key: 'DARK', label: 'Dark' },
|
||||
{ key: 'WEATHERED', label: 'Weathered' },
|
||||
{ key: 'ASHEN', label: 'Ashen grey' },
|
||||
{ key: 'GREEN', label: 'Green-tinged' },
|
||||
{ key: 'RED', label: 'Deep red' },
|
||||
{ key: 'SCALED', label: 'Scaled' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'features',
|
||||
label: 'Distinguishing features',
|
||||
prompt: 'What would a stranger remember? Pick up to two, or none.',
|
||||
min: 0,
|
||||
max: 2,
|
||||
options: [
|
||||
{ key: 'SCAR', label: 'A scar across the face' },
|
||||
{ key: 'TATTOO', label: 'A tattoo' },
|
||||
{ key: 'FRECKLES', label: 'Freckles' },
|
||||
{ key: 'BROKEN_NOSE', label: 'A broken nose' },
|
||||
{ key: 'EYEPATCH', label: 'An eyepatch' },
|
||||
{ key: 'BEARD', label: 'A braided beard' },
|
||||
{ key: 'PIERCINGS', label: 'Piercings' },
|
||||
{ key: 'BIRTHMARK', label: 'A birthmark' },
|
||||
{ key: 'BURN', label: 'An old burn' },
|
||||
{ key: 'MISSING_FINGER', label: 'A missing finger' },
|
||||
{ key: 'LIMP', label: 'A slight limp' },
|
||||
{ key: 'BIG_GRIN', label: 'An easy grin' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'style',
|
||||
label: 'Usually wears',
|
||||
prompt: 'What are you usually seen in?',
|
||||
min: 1,
|
||||
max: 1,
|
||||
options: [
|
||||
{ key: 'LEATHERS', label: 'Travel-worn leathers' },
|
||||
{ key: 'ARMOR', label: 'Well-kept armor' },
|
||||
{ key: 'CLOAK', label: 'A hooded cloak' },
|
||||
{ key: 'FINE', label: 'Fine clothes' },
|
||||
{ key: 'PATCHED', label: 'Patched and mended clothes' },
|
||||
{ key: 'ROBES', label: 'Long robes' },
|
||||
{ key: 'FURS', label: 'Furs and hides' },
|
||||
{ key: 'BRIGHT', label: 'Bright colours' },
|
||||
{ key: 'PLAIN', label: 'Plain, practical clothes' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
MOTIVATIONS,
|
||||
SPECIES_TRAITS,
|
||||
CLASS_FEATURES,
|
||||
TALENT_PICKS,
|
||||
TALENTS,
|
||||
BACKGROUND_GROUPS,
|
||||
APPEARANCE_GROUPS,
|
||||
};
|
||||
@@ -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
@@ -4,7 +4,7 @@ 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 router = express.Router();
|
||||
@@ -167,6 +167,7 @@ function characterSummary(character) {
|
||||
buildStage: character.buildStage,
|
||||
currentHp: character.currentHp,
|
||||
tempHp: character.tempHp,
|
||||
gold: character.gold,
|
||||
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
|
||||
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
|
||||
currentLocationId: character.currentLocationId,
|
||||
@@ -210,6 +211,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,7 +219,13 @@ 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 }]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -249,8 +257,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 +301,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 +312,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);
|
||||
@@ -306,6 +342,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 +357,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: { campaignId: req.campaign.id }, 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 });
|
||||
@@ -365,7 +484,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 +494,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 +509,99 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(),
|
||||
|
||||
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const isDM = req.membership.role === 'DM';
|
||||
const encounters = await prisma.encounter.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
orderBy: { 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(),
|
||||
});
|
||||
|
||||
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 } });
|
||||
const encounter = await prisma.encounter.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ encounter });
|
||||
} 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).optional().nullable(),
|
||||
maxHp: z.number().int().min(1).optional().nullable(),
|
||||
attackBonus: z.number().int().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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -462,7 +656,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 +666,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);
|
||||
|
||||
@@ -3,23 +3,33 @@ const { z } = require('zod');
|
||||
const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { requireCharacterAccess } = require('../middleware/characterAccess');
|
||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
const {
|
||||
computeCharacterSheet,
|
||||
computeWeaponStats,
|
||||
getClassSaveProficiencies,
|
||||
getClassSpellcastingAbility,
|
||||
scaledDamageDice,
|
||||
canUseGear,
|
||||
MAX_GEAR_LEVEL,
|
||||
ABILITIES,
|
||||
} = require('../game/calculations');
|
||||
const {
|
||||
rollDie,
|
||||
rollAbilityScore,
|
||||
abilityScoreFromDice,
|
||||
speciesChoicesForRoll,
|
||||
classChoicesForRoll,
|
||||
autoSelectSkills,
|
||||
validateManualRoll,
|
||||
MOTIVATION_TABLE,
|
||||
validatePicks,
|
||||
composeFromPicks,
|
||||
validateTalents,
|
||||
composeSpecialAbilities,
|
||||
} = require('../game/buildFlow');
|
||||
const characterOptions = require('../game/data/characterOptions');
|
||||
const { SKILLS } = require('../game/data/skills');
|
||||
const { InsufficientGoldError, adjustGold, giveGear } = require('../utils/grants');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -31,7 +41,7 @@ async function loadFullCharacter(id) {
|
||||
include: {
|
||||
skillProficiencies: true,
|
||||
saveProficiencies: true,
|
||||
inventoryItems: true,
|
||||
inventoryItems: { include: { item: true } },
|
||||
weapons: { include: { weapon: true } },
|
||||
spells: { include: { spell: true } },
|
||||
currentLocation: true,
|
||||
@@ -79,13 +89,40 @@ function serializeCharacter(character, isOwner) {
|
||||
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
|
||||
skillProficiencies: character.skillProficiencies,
|
||||
saveProficiencies: character.saveProficiencies,
|
||||
inventoryItems: character.inventoryItems,
|
||||
weapons: character.weapons,
|
||||
spells: character.spells,
|
||||
// Each held entry carries what its level works out to: the dice at that
|
||||
// level, and whether this character is high enough level to use it.
|
||||
inventoryItems: character.inventoryItems.map((ii) => ({
|
||||
...ii,
|
||||
scaledDamageDice: ii.item.damageDice
|
||||
? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel)
|
||||
: null,
|
||||
usable: canUseGear(character.level, ii.level),
|
||||
})),
|
||||
weapons: character.weapons.map((cw) => ({
|
||||
...cw,
|
||||
scaledDamageDice: scaledDamageDice(cw.weapon.damageDice, cw.level, cw.weapon.damageScaling, cw.weapon.minLevel),
|
||||
usable: canUseGear(character.level, cw.level),
|
||||
})),
|
||||
spells: character.spells.map((cs) => ({ ...cs, usable: canUseGear(character.level, cs.spell.level) })),
|
||||
sheet: character.buildStage === 'COMPLETE' ? sheet : null,
|
||||
};
|
||||
}
|
||||
|
||||
// The lists the builder's story steps are made of. Static, and the same for
|
||||
// everyone; the page fetches them rather than keeping a second copy.
|
||||
router.get('/build/options', (req, res) => {
|
||||
const { MOTIVATIONS, SPECIES_TRAITS, CLASS_FEATURES, TALENTS, TALENT_PICKS, BACKGROUND_GROUPS, APPEARANCE_GROUPS } = characterOptions;
|
||||
res.json({
|
||||
motivations: MOTIVATIONS,
|
||||
speciesTraits: SPECIES_TRAITS,
|
||||
classFeatures: CLASS_FEATURES,
|
||||
talents: TALENTS,
|
||||
talentPicks: TALENT_PICKS,
|
||||
background: BACKGROUND_GROUPS,
|
||||
appearance: APPEARANCE_GROUPS,
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/:id', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
const character = await loadFullCharacter(req.character.id);
|
||||
@@ -106,16 +143,39 @@ const patchCharacterSchema = z.object({
|
||||
appearance: z.string().max(4000).nullable().optional(),
|
||||
privateNotes: z.string().max(4000).nullable().optional(),
|
||||
currentLocationId: z.string().nullable().optional(),
|
||||
gold: z.number().int().min(0).optional(),
|
||||
level: z.number().int().min(1).max(20).optional(),
|
||||
xp: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
// Progression and placement are the DM's call, and so is rewriting what the
|
||||
// builder composed (special abilities, background, appearance): a player
|
||||
// picks those from lists and cannot type their own. The owning player can
|
||||
// edit the rest of their own sheet. Gold is not patchable at all — it only
|
||||
// moves through the ledger (POST /:id/gold).
|
||||
const DM_ONLY_PATCH_FIELDS = ['level', 'xp', 'currentLocationId', 'speedOverride', 'specialAbilities', 'background', 'appearance'];
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
if (req.body && req.body.gold !== undefined) {
|
||||
return res.status(400).json({ error: 'Gold changes go through the money tracker.' });
|
||||
}
|
||||
const parsed = patchCharacterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
if (!req.isDM && DM_ONLY_PATCH_FIELDS.some((field) => parsed.data[field] !== undefined)) {
|
||||
return res.status(403).json({ error: 'Only the DM can change level, XP, speed, location, special abilities, background or appearance.' });
|
||||
}
|
||||
|
||||
// Current HP stays within 0..max, including when a level change moves max.
|
||||
if (req.character.buildStage === 'COMPLETE' && (parsed.data.currentHp !== undefined || parsed.data.level !== undefined)) {
|
||||
const maxHp = computeCharacterSheet({ ...req.character, ...parsed.data }).hp.max;
|
||||
parsed.data.currentHp = clamp(parsed.data.currentHp ?? req.character.currentHp, 0, maxHp);
|
||||
}
|
||||
|
||||
// privateNotes is for the owning player only — a DM editing someone
|
||||
// else's character (or another player, though write access already
|
||||
// blocks that) should never be able to set it through this route.
|
||||
@@ -131,6 +191,16 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
|
||||
}
|
||||
|
||||
const updated = await prisma.character.update({ where: { id: req.character.id }, data: parsed.data });
|
||||
if (updated.level < req.character.level) {
|
||||
// Dropping a level can put equipped gear and prepared spells out of reach.
|
||||
const tooHigh = { characterId: updated.id, level: { gt: updated.level } };
|
||||
await prisma.characterWeapon.updateMany({ where: tooHigh, data: { equipped: false } });
|
||||
await prisma.inventoryItem.updateMany({ where: tooHigh, data: { equipped: false } });
|
||||
await prisma.characterSpell.updateMany({
|
||||
where: { characterId: updated.id, spell: { level: { gt: updated.level } } },
|
||||
data: { prepared: false },
|
||||
});
|
||||
}
|
||||
const full = await loadFullCharacter(updated.id);
|
||||
|
||||
// Broadcast payload never includes privateNotes — a socket room has
|
||||
@@ -153,6 +223,154 @@ router.delete('/:id', requireCharacterAccess(), async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Copy to another campaign
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Weapons, spells and items are per-campaign rows, so a copied character's gear
|
||||
// is re-pointed at the target campaign's entry of the same name
|
||||
// (case-insensitive), creating that entry from the source row when the target
|
||||
// doesn't have one. A created entry is never a starting option — that stays
|
||||
// the target DM's decision.
|
||||
// Returns source id -> the target pool row.
|
||||
async function mapPoolByName(tx, model, sourceRows, targetCampaignId) {
|
||||
const existing = await tx[model].findMany({ where: { campaignId: targetCampaignId } });
|
||||
const byName = new Map(existing.map((row) => [row.name.toLowerCase(), row]));
|
||||
const idMap = new Map();
|
||||
for (const { id, campaignId, createdAt, ...fields } of sourceRows) {
|
||||
const key = fields.name.toLowerCase();
|
||||
if (!byName.has(key)) {
|
||||
const created = await tx[model].create({
|
||||
data: { ...fields, isStartingOption: false, campaignId: targetCampaignId },
|
||||
});
|
||||
byName.set(key, created);
|
||||
}
|
||||
idMap.set(id, byName.get(key));
|
||||
}
|
||||
return idMap;
|
||||
}
|
||||
|
||||
// The copy is an independent character owned by whoever made it: the original
|
||||
// is untouched and the two never sync. Campaign-bound state (location, notes,
|
||||
// encounter entries) stays behind, and the copy arrives at full HP.
|
||||
router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ targetCampaignId: z.string().min(1) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { targetCampaignId } = parsed.data;
|
||||
|
||||
if (!req.isDM && !req.isOwner) {
|
||||
return res.status(403).json({ error: 'You can only copy your own character' });
|
||||
}
|
||||
if (req.character.buildStage !== 'COMPLETE') {
|
||||
return res.status(400).json({ error: 'Finish building this character before copying it.' });
|
||||
}
|
||||
if (targetCampaignId === req.character.campaignId) {
|
||||
return res.status(400).json({ error: 'Pick a different campaign to copy this character into.' });
|
||||
}
|
||||
const targetMembership = await loadCampaignMembership(req.user.id, targetCampaignId);
|
||||
if (!targetMembership) return res.status(403).json({ error: 'Not a member of the target campaign' });
|
||||
|
||||
const source = await loadFullCharacter(req.character.id);
|
||||
const sheet = computeCharacterSheet(source, source.skillProficiencies, source.saveProficiencies);
|
||||
|
||||
const copyId = await prisma.$transaction(async (tx) => {
|
||||
const targetWeapons = await mapPoolByName(tx, 'weapon', source.weapons.map((cw) => cw.weapon), targetCampaignId);
|
||||
const targetSpells = await mapPoolByName(tx, 'spell', source.spells.map((cs) => cs.spell), targetCampaignId);
|
||||
const targetItems = await mapPoolByName(tx, 'item', source.inventoryItems.map((ii) => ii.item), targetCampaignId);
|
||||
|
||||
// Two source entries that differ only by case land on one target row.
|
||||
const items = new Map();
|
||||
for (const ii of source.inventoryItems) {
|
||||
const target = targetItems.get(ii.itemId);
|
||||
const itemId = target.id;
|
||||
const prev = items.get(itemId);
|
||||
items.set(itemId, {
|
||||
itemId,
|
||||
quantity: (prev ? prev.quantity : 0) + ii.quantity,
|
||||
equipped: Boolean(prev && prev.equipped) || ii.equipped,
|
||||
// The target campaign's entry may start at a higher level than the source's.
|
||||
level: Math.max(prev ? prev.level : 1, ii.level, target.minLevel),
|
||||
});
|
||||
}
|
||||
const weapons = new Map();
|
||||
for (const cw of source.weapons) {
|
||||
const target = targetWeapons.get(cw.weaponId);
|
||||
const weaponId = target.id;
|
||||
const prev = weapons.get(weaponId);
|
||||
weapons.set(weaponId, {
|
||||
weaponId,
|
||||
quantity: (prev ? prev.quantity : 0) + cw.quantity,
|
||||
equipped: Boolean(prev && prev.equipped) || cw.equipped,
|
||||
level: Math.max(prev ? prev.level : 1, cw.level, target.minLevel),
|
||||
});
|
||||
}
|
||||
const spells = new Map();
|
||||
for (const cs of source.spells) {
|
||||
const spellId = targetSpells.get(cs.spellId).id;
|
||||
const prev = spells.get(spellId);
|
||||
spells.set(spellId, { spellId, prepared: Boolean(prev && prev.prepared) || cs.prepared });
|
||||
}
|
||||
|
||||
const created = await tx.character.create({
|
||||
data: {
|
||||
campaignId: targetCampaignId,
|
||||
userId: req.user.id,
|
||||
createdById: req.user.id,
|
||||
name: source.name,
|
||||
species: source.species,
|
||||
characterClass: source.characterClass,
|
||||
level: source.level,
|
||||
xp: source.xp,
|
||||
motivation: source.motivation,
|
||||
strScore: source.strScore,
|
||||
dexScore: source.dexScore,
|
||||
conScore: source.conScore,
|
||||
intScore: source.intScore,
|
||||
wisScore: source.wisScore,
|
||||
chaScore: source.chaScore,
|
||||
halfElfBonusAbility1: source.halfElfBonusAbility1,
|
||||
halfElfBonusAbility2: source.halfElfBonusAbility2,
|
||||
currentHp: sheet.hp.max,
|
||||
armorBonus: source.armorBonus,
|
||||
speedOverride: source.speedOverride,
|
||||
specialAbilities: source.specialAbilities,
|
||||
gold: source.gold,
|
||||
background: source.background,
|
||||
appearance: source.appearance,
|
||||
// Same privacy boundary as serializeCharacter: a DM copying a
|
||||
// player's character never carries the player's private notes along.
|
||||
privateNotes: req.isOwner ? source.privateNotes : null,
|
||||
diceMode: source.diceMode,
|
||||
buildStage: 'COMPLETE',
|
||||
skillProficiencies: {
|
||||
create: source.skillProficiencies.map(({ skill, proficient, expertise }) => ({ skill, proficient, expertise })),
|
||||
},
|
||||
saveProficiencies: {
|
||||
create: source.saveProficiencies.map(({ ability, proficient }) => ({ ability, proficient })),
|
||||
},
|
||||
inventoryItems: { create: [...items.values()] },
|
||||
goldTransactions: {
|
||||
create:
|
||||
source.gold > 0
|
||||
? [{ amount: source.gold, balanceAfter: source.gold, reason: 'Carried over from another campaign', createdById: req.user.id }]
|
||||
: [],
|
||||
},
|
||||
weapons: { create: [...weapons.values()] },
|
||||
spells: { create: [...spells.values()] },
|
||||
},
|
||||
});
|
||||
return created.id;
|
||||
});
|
||||
|
||||
const full = await loadFullCharacter(copyId);
|
||||
req.app.get('io').to(`campaign:${targetCampaignId}`).emit('character:created', { character: serializeCharacter(full, false) });
|
||||
res.status(201).json({ character: serializeCharacter(full, true) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Roll-Your-Character build flow — docs/ROLL_YOUR_CHARACTER.md
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -212,12 +430,15 @@ router.post('/:id/build/roll', requireCharacterAccess({ write: true }), async (r
|
||||
const buildState = { ...(character.buildState || {}) };
|
||||
|
||||
try {
|
||||
// Every die is rolled once. Going back to a step shows the roll that was
|
||||
// already made; it never hands out a fresh one.
|
||||
if (stage === 'ROLLING_SPECIES') {
|
||||
if (buildState.speciesRoll) {
|
||||
return res.status(400).json({ error: 'Species die already rolled for this character — choose from what it offered.' });
|
||||
}
|
||||
const roll = resolveSingleRoll(character, req.body, 6);
|
||||
buildState.speciesRoll = roll;
|
||||
buildState.speciesChoices = speciesChoicesForRoll(roll);
|
||||
delete buildState.species;
|
||||
delete buildState.halfElfBonusAbilities;
|
||||
} else if (stage === 'ROLLING_CLASS') {
|
||||
const alreadyRolled = buildState.classChoices && buildState.classChoices.type !== 'reroll';
|
||||
if (alreadyRolled) {
|
||||
@@ -227,10 +448,6 @@ router.post('/:id/build/roll', requireCharacterAccess({ write: true }), async (r
|
||||
buildState.classRoll = roll;
|
||||
buildState.classChoices = classChoicesForRoll(roll);
|
||||
delete buildState.characterClass;
|
||||
} else if (stage === 'ROLLING_MOTIVATION') {
|
||||
const roll = resolveSingleRoll(character, req.body, 6);
|
||||
buildState.motivationRoll = roll;
|
||||
buildState.motivationText = MOTIVATION_TABLE[roll];
|
||||
} else if (stage === 'ASSIGNING_SCORES') {
|
||||
if (buildState.abilityRolls) {
|
||||
return res.status(400).json({ error: 'Ability scores were already rolled for this character.' });
|
||||
@@ -258,7 +475,9 @@ const chooseSchemas = {
|
||||
halfElfBonusAbilities: z.array(z.enum(HALF_ELF_ELIGIBLE_ABILITIES)).length(2).optional(),
|
||||
}),
|
||||
ROLLING_CLASS: z.object({ characterClass: z.string() }),
|
||||
ROLLING_MOTIVATION: z.object({ motivation: z.string().max(500).optional() }),
|
||||
// Motivation is story only, so it is simply picked from the list (the stage
|
||||
// keeps its old name from when it was a die roll).
|
||||
ROLLING_MOTIVATION: z.object({ motivation: z.number().int() }),
|
||||
ASSIGNING_SCORES: z.object({
|
||||
assignments: z.object({
|
||||
STR: z.number().int().min(0).max(5),
|
||||
@@ -283,18 +502,35 @@ const chooseSchemas = {
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
CHOOSING_ABILITIES: z.object({ talents: z.array(z.string()) }),
|
||||
CHOOSING_BACKGROUND: z.object({ picks: z.record(z.array(z.string())) }),
|
||||
CHOOSING_APPEARANCE: z.object({ picks: z.record(z.array(z.string())) }),
|
||||
CHOOSING_EQUIPMENT: z.object({
|
||||
weaponIds: z.array(z.string()).default([]),
|
||||
itemIds: z.array(z.string()).default([]),
|
||||
spellIds: z.array(z.string()).default([]),
|
||||
}),
|
||||
};
|
||||
|
||||
// Every id must be a distinct entry in this campaign's pool that the DM has
|
||||
// marked as a starting option. `reach` narrows that to entries a character of
|
||||
// this level can already use (gear by its starting level, spells by their level).
|
||||
async function allStartingOptions(model, ids, campaignId, reach) {
|
||||
if (new Set(ids).size !== ids.length) return false;
|
||||
if (ids.length === 0) return true;
|
||||
const count = await prisma[model].count({ where: { id: { in: ids }, campaignId, isStartingOption: true, ...reach } });
|
||||
return count === ids.length;
|
||||
}
|
||||
|
||||
const STAGE_ORDER = [
|
||||
'ROLLING_SPECIES',
|
||||
'ROLLING_CLASS',
|
||||
'ROLLING_MOTIVATION',
|
||||
'ASSIGNING_SCORES',
|
||||
'ASSIGNING_PROFICIENCIES',
|
||||
'CHOOSING_ABILITIES',
|
||||
'CHOOSING_BACKGROUND',
|
||||
'CHOOSING_APPEARANCE',
|
||||
'CHOOSING_EQUIPMENT',
|
||||
'COMPLETE',
|
||||
];
|
||||
@@ -351,8 +587,10 @@ router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async
|
||||
buildState.motivationText = null;
|
||||
}
|
||||
} else if (stage === 'ROLLING_MOTIVATION') {
|
||||
if (!buildState.motivationRoll) return res.status(400).json({ error: 'Roll for motivation first.' });
|
||||
if (parsed.data.motivation) buildState.motivationText = parsed.data.motivation;
|
||||
const motivation = characterOptions.MOTIVATIONS.find((m) => m.key === parsed.data.motivation);
|
||||
if (!motivation) return res.status(400).json({ error: 'Pick a motivation from the list.' });
|
||||
buildState.motivationChoice = motivation.key;
|
||||
buildState.motivationText = motivation.label;
|
||||
} else if (stage === 'ASSIGNING_SCORES') {
|
||||
if (!buildState.abilityRolls) return res.status(400).json({ error: 'Roll ability scores first.' });
|
||||
const { assignments } = parsed.data;
|
||||
@@ -364,20 +602,41 @@ router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async
|
||||
buildState.abilityAssignments = assignments;
|
||||
// Skill proficiencies are auto-generated per the class's SRD "choose N"
|
||||
// rule the moment ability scores are locked in, so the next step has
|
||||
// something to show immediately rather than an empty picker.
|
||||
buildState.skillProficiencies = autoSelectSkills(buildState.characterClass);
|
||||
// something to show immediately rather than an empty picker. They are
|
||||
// generated once per class and remembered: stepping back and forward
|
||||
// again (or switching class and switching back) never re-rolls them.
|
||||
const cls = buildState.characterClass;
|
||||
if (buildState.skillsForClass !== cls) {
|
||||
buildState.autoSkills = { ...(buildState.autoSkills || {}) };
|
||||
buildState.autoSkills[cls] = buildState.autoSkills[cls] || autoSelectSkills(cls);
|
||||
buildState.skillProficiencies = buildState.autoSkills[cls];
|
||||
buildState.skillsForClass = cls;
|
||||
}
|
||||
} else if (stage === 'ASSIGNING_PROFICIENCIES') {
|
||||
// Optional DM override of the auto-generated picks; if omitted, the
|
||||
// auto-generated set from the previous step stands as-is.
|
||||
if (parsed.data.skillProficiencies) {
|
||||
buildState.skillProficiencies = parsed.data.skillProficiencies;
|
||||
}
|
||||
} else if (stage === 'CHOOSING_ABILITIES') {
|
||||
const problem = validateTalents(parsed.data.talents);
|
||||
if (problem) return res.status(400).json({ error: problem });
|
||||
buildState.talents = parsed.data.talents;
|
||||
} else if (stage === 'CHOOSING_BACKGROUND' || stage === 'CHOOSING_APPEARANCE') {
|
||||
const isBackground = stage === 'CHOOSING_BACKGROUND';
|
||||
const groups = isBackground ? characterOptions.BACKGROUND_GROUPS : characterOptions.APPEARANCE_GROUPS;
|
||||
const problem = validatePicks(groups, parsed.data.picks);
|
||||
if (problem) return res.status(400).json({ error: problem });
|
||||
// Keep only the groups that exist, so nothing else rides along in the build state.
|
||||
const picks = Object.fromEntries(groups.map((g) => [g.key, parsed.data.picks[g.key] || []]));
|
||||
buildState[isBackground ? 'backgroundPicks' : 'appearancePicks'] = picks;
|
||||
} else if (stage === 'CHOOSING_EQUIPMENT') {
|
||||
const { weaponIds, spellIds } = parsed.data;
|
||||
const { weaponIds, itemIds, spellIds } = parsed.data;
|
||||
const maxItems = req.campaign.startingItemCount;
|
||||
const maxSpells = req.campaign.startingSpellCount;
|
||||
if (weaponIds.length > maxItems) {
|
||||
return res.status(400).json({ error: `This campaign allows at most ${maxItems} starting item(s).` });
|
||||
// Weapons and items share the one "starting items" allowance.
|
||||
if (weaponIds.length + itemIds.length > maxItems) {
|
||||
return res.status(400).json({ error: `This campaign allows at most ${maxItems} starting item(s), weapons included.` });
|
||||
}
|
||||
const hasSpellcasting = Boolean(getClassSpellcastingAbility(buildState.characterClass));
|
||||
if (spellIds.length > 0 && !hasSpellcasting) {
|
||||
@@ -386,7 +645,18 @@ router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async
|
||||
if (spellIds.length > maxSpells) {
|
||||
return res.status(400).json({ error: `This campaign allows at most ${maxSpells} starting spell(s).` });
|
||||
}
|
||||
buildState.equipment = { weaponIds, spellIds };
|
||||
// A new character can only start with what it can already use.
|
||||
const gearInReach = { minLevel: { lte: character.level } };
|
||||
const allOffered =
|
||||
(await allStartingOptions('weapon', weaponIds, req.campaign.id, gearInReach)) &&
|
||||
(await allStartingOptions('item', itemIds, req.campaign.id, gearInReach)) &&
|
||||
(await allStartingOptions('spell', spellIds, req.campaign.id, { level: { lte: character.level } }));
|
||||
if (!allOffered) {
|
||||
return res.status(400).json({
|
||||
error: `Pick only from the starting options the DM has made available for a level ${character.level} character.`,
|
||||
});
|
||||
}
|
||||
buildState.equipment = { weaponIds, itemIds, spellIds };
|
||||
advanceTo = 'CHOOSING_EQUIPMENT'; // stay here; POST /build/finalize completes the character
|
||||
}
|
||||
|
||||
@@ -400,6 +670,40 @@ router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async
|
||||
}
|
||||
});
|
||||
|
||||
// Steps back to the previous step so its choice can be changed. Dice are
|
||||
// never re-rolled: the earlier step comes back with the same roll, the same
|
||||
// options and the previous pick still in place. The only thing dropped is the
|
||||
// equipment selection, which is re-confirmed on the way forward (a change of
|
||||
// class can change which starting spells are allowed).
|
||||
router.post('/:id/build/back', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const character = req.character;
|
||||
const stage = character.buildStage;
|
||||
const buildState = { ...(character.buildState || {}) };
|
||||
|
||||
let backTo;
|
||||
if (stage === 'CHOOSING_EQUIPMENT' && buildState.equipment) {
|
||||
backTo = 'CHOOSING_EQUIPMENT'; // from the finalize prompt back to picking equipment
|
||||
} else {
|
||||
const idx = STAGE_ORDER.indexOf(stage);
|
||||
if (idx <= 0 || stage === 'COMPLETE') {
|
||||
return res.status(400).json({ error: 'There is no earlier step to go back to.' });
|
||||
}
|
||||
backTo = STAGE_ORDER[idx - 1];
|
||||
if (backTo === 'ROLLING_MOTIVATION' && req.campaign.skipMotivationRoll) backTo = 'ROLLING_CLASS';
|
||||
}
|
||||
delete buildState.equipment;
|
||||
|
||||
const updated = await prisma.character.update({
|
||||
where: { id: character.id },
|
||||
data: { buildState, buildStage: backTo },
|
||||
});
|
||||
res.json({ buildStage: updated.buildStage, buildState: updated.buildState });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const character = req.character;
|
||||
@@ -430,6 +734,11 @@ router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), asyn
|
||||
species: bs.species,
|
||||
characterClass: bs.characterClass,
|
||||
motivation: bs.motivationText || null,
|
||||
// Composed here, from the picks, so the text can only ever say what
|
||||
// the lists allow.
|
||||
specialAbilities: composeSpecialAbilities(bs.species, bs.characterClass, bs.talents || []),
|
||||
background: bs.backgroundPicks ? composeFromPicks(characterOptions.BACKGROUND_GROUPS, bs.backgroundPicks) : null,
|
||||
appearance: bs.appearancePicks ? composeFromPicks(characterOptions.APPEARANCE_GROUPS, bs.appearancePicks) : null,
|
||||
strScore: rolledScores.STR,
|
||||
dexScore: rolledScores.DEX,
|
||||
conScore: rolledScores.CON,
|
||||
@@ -453,6 +762,9 @@ router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), asyn
|
||||
weapons: {
|
||||
create: (bs.equipment.weaponIds || []).map((weaponId) => ({ weaponId, quantity: 1, equipped: false })),
|
||||
},
|
||||
inventoryItems: {
|
||||
create: (bs.equipment.itemIds || []).map((itemId) => ({ itemId, quantity: 1, equipped: false })),
|
||||
},
|
||||
spells: {
|
||||
create: (bs.equipment.spellIds || []).map((spellId) => ({ spellId, prepared: false })),
|
||||
},
|
||||
@@ -488,14 +800,17 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
|
||||
}
|
||||
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
|
||||
|
||||
const weapons = character.weapons.map((cw) => ({
|
||||
characterWeaponId: cw.id,
|
||||
weaponId: cw.weaponId,
|
||||
name: cw.weapon.name,
|
||||
equipped: cw.equipped,
|
||||
quantity: cw.quantity,
|
||||
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true),
|
||||
}));
|
||||
const weapons = character.weapons
|
||||
.filter((cw) => canUseGear(character.level, cw.level))
|
||||
.map((cw) => ({
|
||||
characterWeaponId: cw.id,
|
||||
weaponId: cw.weaponId,
|
||||
name: cw.weapon.name,
|
||||
level: cw.level,
|
||||
equipped: cw.equipped,
|
||||
quantity: cw.quantity,
|
||||
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true, cw.level),
|
||||
}));
|
||||
|
||||
res.json({
|
||||
quickStats: {
|
||||
@@ -518,137 +833,267 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weapons assigned to this character
|
||||
// Money tracker — the gold balance only ever changes through this ledger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post('/:id/weapons', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
const GOLD_TRANSACTION_INCLUDE = { createdBy: { select: { displayName: true } } };
|
||||
|
||||
router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ weaponId: z.string(), quantity: z.number().int().min(1).default(1) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const weapon = await prisma.weapon.findUnique({ where: { id: parsed.data.weaponId } });
|
||||
if (!weapon || weapon.campaignId !== req.character.campaignId) {
|
||||
return res.status(400).json({ error: 'That weapon is not part of this campaign.' });
|
||||
}
|
||||
|
||||
const characterWeapon = await prisma.characterWeapon.upsert({
|
||||
where: { characterId_weaponId: { characterId: req.character.id, weaponId: parsed.data.weaponId } },
|
||||
create: { characterId: req.character.id, weaponId: parsed.data.weaponId, quantity: parsed.data.quantity },
|
||||
update: { quantity: { increment: parsed.data.quantity } },
|
||||
include: { weapon: true },
|
||||
const transactions = await prisma.goldTransaction.findMany({
|
||||
where: { characterId: req.character.id },
|
||||
include: GOLD_TRANSACTION_INCLUDE,
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
res.status(201).json({ characterWeapon });
|
||||
res.json({ gold: req.character.gold, transactions });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/weapons/:weaponId', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z
|
||||
.object({ quantity: z.number().int().min(0).optional(), equipped: z.boolean().optional() })
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const characterWeapon = await prisma.characterWeapon.update({
|
||||
where: { characterId_weaponId: { characterId: req.character.id, weaponId: req.params.weaponId } },
|
||||
data: parsed.data,
|
||||
include: { weapon: true },
|
||||
});
|
||||
res.json({ characterWeapon });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post('/:id/inventory', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
// amount is signed: positive = gained, negative = spent. The owning player
|
||||
// and the DM can both record entries; each one is stamped with who made it.
|
||||
router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(2000).optional().nullable(),
|
||||
quantity: z.number().int().min(1).default(1),
|
||||
equipped: z.boolean().default(false),
|
||||
amount: z.number().int().refine((n) => n !== 0, 'Amount cannot be zero'),
|
||||
reason: z.string().trim().max(200).optional().nullable(),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { amount, reason } = parsed.data;
|
||||
|
||||
const item = await prisma.inventoryItem.create({ data: { ...parsed.data, characterId: req.character.id } });
|
||||
res.status(201).json({ item });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/inventory/:itemId', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z
|
||||
.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
quantity: z.number().int().min(0).optional(),
|
||||
equipped: z.boolean().optional(),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const item = await prisma.inventoryItem.update({ where: { id: req.params.itemId }, data: parsed.data });
|
||||
res.json({ item });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id/inventory/:itemId', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
await prisma.inventoryItem.delete({ where: { id: req.params.itemId } });
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spells known by this character
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
router.post('/:id/spells', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ spellId: z.string(), prepared: z.boolean().default(false) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const spell = await prisma.spell.findUnique({ where: { id: parsed.data.spellId } });
|
||||
if (!spell || spell.campaignId !== req.character.campaignId) {
|
||||
return res.status(400).json({ error: 'That spell is not part of this campaign.' });
|
||||
let transaction;
|
||||
try {
|
||||
// Throwing inside the transaction rolls the balance change back.
|
||||
transaction = await prisma.$transaction((tx) =>
|
||||
adjustGold(tx, { characterId: req.character.id, amount, reason, userId: req.user.id, include: GOLD_TRANSACTION_INCLUDE })
|
||||
);
|
||||
} catch (err) {
|
||||
if (!(err instanceof InsufficientGoldError)) throw err;
|
||||
return res.status(400).json({ error: `Not enough gold — the balance is ${req.character.gold}.` });
|
||||
}
|
||||
|
||||
const characterSpell = await prisma.characterSpell.upsert({
|
||||
where: { characterId_spellId: { characterId: req.character.id, spellId: parsed.data.spellId } },
|
||||
create: { characterId: req.character.id, spellId: parsed.data.spellId, prepared: parsed.data.prepared },
|
||||
update: { prepared: parsed.data.prepared },
|
||||
include: { spell: true },
|
||||
});
|
||||
res.status(201).json({ characterSpell });
|
||||
emitCharacterUpdated(req);
|
||||
res.status(201).json({ gold: transaction.balanceAfter, transaction });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/spells/:spellId', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ prepared: z.boolean() }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gear held by this character — weapons, items and spells all come from the
|
||||
// campaign's DM-managed pools. Only the DM hands them out, changes quantities
|
||||
// or levels, or takes them away; the owning player can toggle equipped /
|
||||
// prepared — but only on gear the character is high enough level to use.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const characterSpell = await prisma.characterSpell.update({
|
||||
where: { characterId_spellId: { characterId: req.character.id, spellId: req.params.spellId } },
|
||||
data: parsed.data,
|
||||
include: { spell: true },
|
||||
});
|
||||
res.json({ characterSpell });
|
||||
function requireDmForGear(req, res) {
|
||||
if (req.isDM) return true;
|
||||
res.status(403).json({ error: 'Only the DM can give, remove, re-count or level up gear.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
function emitCharacterUpdated(req) {
|
||||
req.app.get('io').to(`campaign:${req.character.campaignId}`).emit('character:updated', { characterId: req.character.id });
|
||||
}
|
||||
|
||||
// One set of routes per kind of gear. `key` is the pool-entry id column on the
|
||||
// join row (also the route param), `flag` the boolean the owner may toggle.
|
||||
// A held weapon or item (`ownLevel`) has a quantity and its own level; a spell
|
||||
// has neither, and the level a character needs is the spell's classic level.
|
||||
const GEAR = [
|
||||
{ path: 'weapons', pool: 'weapon', held: 'characterWeapon', key: 'weaponId', flag: 'equipped', ownLevel: true, label: 'weapon' },
|
||||
{ path: 'inventory', pool: 'item', held: 'inventoryItem', key: 'itemId', flag: 'equipped', ownLevel: true, label: 'item' },
|
||||
{ path: 'spells', pool: 'spell', held: 'characterSpell', key: 'spellId', flag: 'prepared', ownLevel: false, label: 'spell' },
|
||||
];
|
||||
|
||||
const gearLevelSchema = z.number().int().min(1).max(MAX_GEAR_LEVEL);
|
||||
|
||||
for (const gear of GEAR) {
|
||||
const uniqueKey = `characterId_${gear.key}`;
|
||||
const whereHeld = (req) => ({ [uniqueKey]: { characterId: req.character.id, [gear.key]: req.params.poolId } });
|
||||
const include = { [gear.pool]: true };
|
||||
|
||||
const requiredLevel = (row, poolEntry) => (gear.ownLevel ? row.level : poolEntry.level);
|
||||
const tooHighMessage = (level) =>
|
||||
`Requires character level ${level} to ${gear.flag === 'prepared' ? 'prepare' : 'equip'} this ${gear.label}.`;
|
||||
|
||||
const belowStartMessage = (entry) => `${entry.name} starts at level ${entry.minLevel} and can't go below it.`;
|
||||
|
||||
const assignSchema = z.object({
|
||||
[gear.key]: z.string(),
|
||||
...(gear.ownLevel ? { quantity: z.number().int().min(1).default(1), level: gearLevelSchema.optional() } : {}),
|
||||
[gear.flag]: z.boolean().default(false),
|
||||
});
|
||||
|
||||
router.post(`/:id/${gear.path}`, requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
if (!requireDmForGear(req, res)) return;
|
||||
const parsed = assignSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const poolId = parsed.data[gear.key];
|
||||
|
||||
const entry = await prisma[gear.pool].findUnique({ where: { id: poolId } });
|
||||
if (!entry || entry.campaignId !== req.character.campaignId) {
|
||||
return res.status(400).json({ error: `That ${gear.label} is not part of this campaign.` });
|
||||
}
|
||||
|
||||
if (gear.ownLevel) {
|
||||
// Given at the entry's starting level unless the DM picks a higher one.
|
||||
parsed.data.level = parsed.data.level ?? entry.minLevel;
|
||||
if (parsed.data.level < entry.minLevel) return res.status(400).json({ error: belowStartMessage(entry) });
|
||||
}
|
||||
if (parsed.data[gear.flag] && !canUseGear(req.character.level, requiredLevel(parsed.data, entry))) {
|
||||
return res.status(400).json({ error: tooHighMessage(requiredLevel(parsed.data, entry)) });
|
||||
}
|
||||
|
||||
// Giving something the character already holds adds to the stack (the
|
||||
// stack keeps the level it has).
|
||||
const row = await prisma[gear.held].upsert({
|
||||
where: { [uniqueKey]: { characterId: req.character.id, [gear.key]: poolId } },
|
||||
create: { characterId: req.character.id, ...parsed.data },
|
||||
update: gear.ownLevel ? { quantity: { increment: parsed.data.quantity } } : { [gear.flag]: parsed.data[gear.flag] },
|
||||
include,
|
||||
});
|
||||
emitCharacterUpdated(req);
|
||||
res.status(201).json({ [gear.held]: row });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const patchSchema = z.object({
|
||||
...(gear.ownLevel ? { quantity: z.number().int().min(1).optional(), level: gearLevelSchema.optional() } : {}),
|
||||
[gear.flag]: z.boolean().optional(),
|
||||
});
|
||||
|
||||
router.patch(`/:id/${gear.path}/:poolId`, requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = patchSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const dmOnlyChange = parsed.data.quantity !== undefined || parsed.data.level !== undefined;
|
||||
if (dmOnlyChange && !requireDmForGear(req, res)) return;
|
||||
|
||||
const existing = await prisma[gear.held].findUnique({ where: whereHeld(req), include });
|
||||
if (!existing) return res.status(404).json({ error: `This character doesn't have that ${gear.label}.` });
|
||||
|
||||
const data = { ...parsed.data };
|
||||
if (data.level !== undefined && data.level < existing[gear.pool].minLevel) {
|
||||
return res.status(400).json({ error: belowStartMessage(existing[gear.pool]) });
|
||||
}
|
||||
const needed = requiredLevel({ ...existing, ...data }, existing[gear.pool]);
|
||||
if (!canUseGear(req.character.level, needed)) {
|
||||
if (data[gear.flag]) return res.status(400).json({ error: tooHighMessage(needed) });
|
||||
data[gear.flag] = false; // levelled past its holder: it comes off
|
||||
}
|
||||
|
||||
const row = await prisma[gear.held].update({ where: whereHeld(req), data, include });
|
||||
emitCharacterUpdated(req);
|
||||
res.json({ [gear.held]: row });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete(`/:id/${gear.path}/:poolId`, requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
if (!requireDmForGear(req, res)) return;
|
||||
const { count } = await prisma[gear.held].deleteMany({
|
||||
where: { characterId: req.character.id, [gear.key]: req.params.poolId },
|
||||
});
|
||||
if (count === 0) return res.status(404).json({ error: `This character doesn't have that ${gear.label}.` });
|
||||
emitCharacterUpdated(req);
|
||||
res.status(204).end();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Giving to another character — a player (or the DM) hands something this
|
||||
// character holds to another character in the same campaign. It always moves:
|
||||
// whatever is given leaves the giver. That keeps the DM in control of how much
|
||||
// gear, gold and magic exists, while letting the party share it out.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const giveSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('gold'), toCharacterId: z.string(), amount: z.number().int().min(1) }),
|
||||
z.object({ kind: z.literal('weapon'), toCharacterId: z.string(), id: z.string(), quantity: z.number().int().min(1).default(1) }),
|
||||
z.object({ kind: z.literal('item'), toCharacterId: z.string(), id: z.string(), quantity: z.number().int().min(1).default(1) }),
|
||||
z.object({ kind: z.literal('spell'), toCharacterId: z.string(), id: z.string() }),
|
||||
]);
|
||||
|
||||
class GiveError extends Error {}
|
||||
|
||||
router.post('/:id/give', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = giveSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const give = parsed.data;
|
||||
const giver = req.character;
|
||||
|
||||
const recipient = await prisma.character.findUnique({ where: { id: give.toCharacterId } });
|
||||
if (!recipient || recipient.campaignId !== giver.campaignId) {
|
||||
return res.status(400).json({ error: 'That character is not part of this campaign.' });
|
||||
}
|
||||
if (recipient.id === giver.id) return res.status(400).json({ error: 'Pick a different character to give it to.' });
|
||||
if (giver.buildStage !== 'COMPLETE' || recipient.buildStage !== 'COMPLETE') {
|
||||
return res.status(400).json({ error: 'Both characters must be finished before they can trade.' });
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (give.kind === 'gold') {
|
||||
await adjustGold(tx, { characterId: giver.id, amount: -give.amount, reason: `Gave to ${recipient.name}`, userId: req.user.id });
|
||||
await adjustGold(tx, { characterId: recipient.id, amount: give.amount, reason: `Received from ${giver.name}`, userId: req.user.id });
|
||||
return;
|
||||
}
|
||||
|
||||
if (give.kind === 'spell') {
|
||||
const key = { characterId_spellId: { characterId: giver.id, spellId: give.id } };
|
||||
const known = await tx.characterSpell.findUnique({ where: key, include: { spell: true } });
|
||||
if (!known) throw new GiveError(`${giver.name} doesn't know that spell.`);
|
||||
const already = await tx.characterSpell.findUnique({
|
||||
where: { characterId_spellId: { characterId: recipient.id, spellId: give.id } },
|
||||
});
|
||||
if (already) throw new GiveError(`${recipient.name} already knows ${known.spell.name}.`);
|
||||
await tx.characterSpell.delete({ where: key });
|
||||
await tx.characterSpell.create({ data: { characterId: recipient.id, spellId: give.id } });
|
||||
return;
|
||||
}
|
||||
|
||||
// Weapons and items: take from the giver's stack, add to the
|
||||
// recipient's. A new stack arrives at the giver's level, unequipped; an
|
||||
// existing one just grows and keeps its own level.
|
||||
const model = give.kind === 'weapon' ? 'characterWeapon' : 'inventoryItem';
|
||||
const poolKey = `${give.kind}Id`;
|
||||
const key = { [`characterId_${poolKey}`]: { characterId: giver.id, [poolKey]: give.id } };
|
||||
const held = await tx[model].findUnique({ where: key, include: { [give.kind]: true } });
|
||||
if (!held) throw new GiveError(`${giver.name} doesn't have that ${give.kind}.`);
|
||||
if (give.quantity > held.quantity) {
|
||||
throw new GiveError(`${giver.name} only has ${held.quantity} of ${held[give.kind].name}.`);
|
||||
}
|
||||
if (give.quantity === held.quantity) await tx[model].delete({ where: key });
|
||||
else await tx[model].update({ where: key, data: { quantity: { decrement: give.quantity } } });
|
||||
await giveGear(tx, give.kind, { characterId: recipient.id, entry: held[give.kind], quantity: give.quantity, level: held.level });
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof InsufficientGoldError) {
|
||||
return res.status(400).json({ error: `Not enough gold — ${giver.name} has ${giver.gold}.` });
|
||||
}
|
||||
if (err instanceof GiveError) return res.status(400).json({ error: err.message });
|
||||
throw err;
|
||||
}
|
||||
|
||||
const io = req.app.get('io');
|
||||
for (const characterId of [giver.id, recipient.id]) {
|
||||
io.to(`campaign:${giver.campaignId}`).emit('character:updated', { characterId });
|
||||
}
|
||||
const full = await loadFullCharacter(giver.id);
|
||||
res.json({ character: serializeCharacter(full, req.isOwner) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ 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, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
const { adjustGold, giveGear } = require('../utils/grants');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -26,30 +27,72 @@ async function requireEncounterAccess(req, res, next) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
function inTurnOrder(participants) {
|
||||
return [...participants].sort((a, b) => b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
|
||||
}
|
||||
|
||||
// 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.
|
||||
async function serializeEncounter(encounterId, isDM) {
|
||||
const encounter = await prisma.encounter.findUnique({
|
||||
where: { id: encounterId },
|
||||
include: {
|
||||
@@ -63,69 +106,132 @@ 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 participants = inTurnOrder(encounter.participants).map((p) => {
|
||||
const { character, monster, npc, ...row } = p;
|
||||
const out = { ...row, 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 };
|
||||
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;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
return encounter;
|
||||
|
||||
const { dmNotes, rewards, ...scene } = encounter;
|
||||
return {
|
||||
...scene,
|
||||
...(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 });
|
||||
}
|
||||
|
||||
async function respondWithEncounter(req, res, status = 200, extra = {}) {
|
||||
const encounter = await serializeEncounter(req.encounter.id, req.isDM);
|
||||
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) });
|
||||
} 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(),
|
||||
});
|
||||
|
||||
router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = patchEncounterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const data = { ...parsed.data };
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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),
|
||||
})
|
||||
.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, initiative } = parsed.data;
|
||||
|
||||
let currentHp, maxHp, resolvedName, isMonster;
|
||||
let currentHp, maxHp, resolvedName;
|
||||
|
||||
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 {
|
||||
} 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 +239,15 @@ router.post('/:id/participants', requireEncounterAccess, async (req, res, next)
|
||||
currentHp = monster.maxHp;
|
||||
maxHp = monster.maxHp;
|
||||
resolvedName = displayName || monster.name;
|
||||
isMonster = true;
|
||||
} 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 +255,16 @@ 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,
|
||||
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);
|
||||
}
|
||||
@@ -161,65 +274,242 @@ 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(),
|
||||
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
|
||||
displayName: z.string().min(1).max(80).optional(),
|
||||
});
|
||||
|
||||
router.patch('/:id/participants/:participantId', requireEncounterAccess, async (req, res, next) => {
|
||||
router.patch('/:id/participants/:participantId', requireEncounterAccess, requireDM, 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 } } },
|
||||
});
|
||||
if (!existing || existing.encounterId !== req.encounter.id) {
|
||||
return res.status(404).json({ error: 'Combatant not found in this encounter' });
|
||||
}
|
||||
|
||||
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 };
|
||||
if (existing.character) {
|
||||
// Max HP comes from the sheet, and damage/healing lands on the character.
|
||||
const { character } = existing;
|
||||
const maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
|
||||
data.maxHp = maxHp;
|
||||
if (data.currentHp !== undefined) {
|
||||
data.currentHp = clamp(data.currentHp, 0, maxHp);
|
||||
await prisma.character.update({ where: { id: character.id }, data: { currentHp: data.currentHp } });
|
||||
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 } : {}),
|
||||
},
|
||||
});
|
||||
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) });
|
||||
} 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), note: z.string().trim().max(200).optional().nullable() }),
|
||||
z.object({
|
||||
type: z.literal('WEAPON'),
|
||||
weaponId: z.string(),
|
||||
quantity: z.number().int().min(1).default(1),
|
||||
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||
note: z.string().trim().max(200).optional().nullable(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('ITEM'),
|
||||
itemId: z.string(),
|
||||
quantity: z.number().int().min(1).default(1),
|
||||
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 gold 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, 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// The two ways a character's belongings change: gold 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`.
|
||||
|
||||
class InsufficientGoldError extends Error {}
|
||||
|
||||
// amount is signed: positive = gained, negative = spent. Writes the ledger row
|
||||
// and returns it; throws InsufficientGoldError if the balance would go below 0
|
||||
// (inside a transaction, that rolls the whole thing back).
|
||||
async function adjustGold(db, { characterId, amount, reason, userId, include }) {
|
||||
// The atomic increment is the balance check: two spends racing each other
|
||||
// can't both pass a stale read.
|
||||
const updated = await db.character.update({ where: { id: characterId }, data: { gold: { increment: amount } } });
|
||||
if (updated.gold < 0) throw new InsufficientGoldError();
|
||||
return db.goldTransaction.create({
|
||||
data: { characterId, amount, balanceAfter: updated.gold, reason: reason || null, createdById: userId },
|
||||
include,
|
||||
});
|
||||
}
|
||||
|
||||
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 = { InsufficientGoldError, adjustGold, giveGear };
|
||||
Binary file not shown.
@@ -0,0 +1,961 @@
|
||||
# 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 single gold balance with a log behind it.
|
||||
|
||||
- The sheet's Money card shows the balance and every change to it: the amount,
|
||||
what it was for, who recorded it, when, and the balance afterwards.
|
||||
- The **owning player and the DM** can both record a gain or a spend. Other
|
||||
members can see the log but not add to it.
|
||||
- The balance **cannot go below zero**; a spend larger than the balance is
|
||||
refused.
|
||||
- The log is the only way the balance changes. Entries cannot be edited or
|
||||
deleted; to correct a mistake, record the opposite entry.
|
||||
- There is one currency (gold). Silver, copper and so on are not tracked.
|
||||
|
||||
### 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 | Everyone in the campaign | DM picks it on the sheet, from the campaign's locations |
|
||||
|
||||
**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 | Yes | Yes | Yes |
|
||||
| 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 -->
|
||||
@@ -0,0 +1,215 @@
|
||||
# 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; every member can open them.
|
||||
- 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 | Typed in per combatant | Not shown |
|
||||
| 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
|
||||
deleted or renamed from the screen.
|
||||
|
||||
## 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.
|
||||
- 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
|
||||
|
||||
Battles only.
|
||||
|
||||
- The DM types each combatant's **initiative** when adding them, and can
|
||||
change it on the card. The app does not roll it. The list is ordered
|
||||
highest first; ties keep the order they were added in.
|
||||
- **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 |
|
||||
| Open an encounter | Yes | Yes |
|
||||
| Change mode, status, scene or DM notes | Yes | No |
|
||||
| Add or remove anyone | Yes | No |
|
||||
| Change HP, initiative or conditions | Yes | No |
|
||||
| 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
|
||||
- Deleting or renaming an encounter from the screen
|
||||
- A log of what happened during the encounter
|
||||
+151
@@ -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 | Yes | Yes |
|
||||
| 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
|
||||
@@ -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
@@ -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
|
||||
+387
@@ -0,0 +1,387 @@
|
||||
# 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 and the library | Yes | Yes | Yes |
|
||||
| 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 -->
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
# 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 and the library | Yes | Yes | Yes |
|
||||
| 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 -->
|
||||
+245
-17
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
BuildOptions,
|
||||
Campaign,
|
||||
CampaignLibrary,
|
||||
CampaignMemberSummary,
|
||||
@@ -8,11 +9,14 @@ import type {
|
||||
CharacterSummary,
|
||||
DiceMode,
|
||||
Encounter,
|
||||
EncounterMode,
|
||||
EncounterParticipant,
|
||||
InventoryItem,
|
||||
GoldTransaction,
|
||||
Item,
|
||||
Location,
|
||||
Monster,
|
||||
Note,
|
||||
Npc,
|
||||
QuickStats,
|
||||
Spell,
|
||||
Weapon,
|
||||
@@ -120,10 +124,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 +145,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 }
|
||||
| { 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'],
|
||||
@@ -153,6 +190,14 @@ export function useQuickStats(characterId: string | undefined, enabled: boolean)
|
||||
// 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 +236,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 +263,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 +274,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 +304,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 +336,38 @@ 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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export function useGoldLedger(characterId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['character', characterId, 'gold'],
|
||||
enabled: Boolean(characterId),
|
||||
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; reason?: string }) =>
|
||||
(await apiClient.post<{ gold: number; transaction: GoldTransaction }>(`/characters/${characterId}/gold`, data)).data,
|
||||
onSuccess: () => invalidateCharacter(qc, characterId), // prefix match also refreshes the ledger
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Campaign weapons / spells / monsters pools
|
||||
// --------------------------------------------------------------------------
|
||||
@@ -291,6 +389,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 +458,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,
|
||||
});
|
||||
@@ -422,9 +573,8 @@ 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'] }),
|
||||
});
|
||||
}
|
||||
@@ -440,7 +590,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; initiative?: number }) =>
|
||||
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
|
||||
});
|
||||
@@ -475,13 +625,91 @@ 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;
|
||||
}) => (await apiClient.patch(`/encounters/${encounterId}`, data)).data,
|
||||
onSuccess: (_data, variables) => {
|
||||
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
|
||||
// The campaign page lists encounters by name, mode and status.
|
||||
if (variables.status || variables.mode || variables.name) qc.invalidateQueries({ queryKey: ['campaign'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
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; 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
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
+141
-3
@@ -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;
|
||||
@@ -77,6 +116,7 @@ export interface CharacterSummary {
|
||||
buildStage: BuildStage;
|
||||
currentHp: number;
|
||||
tempHp: number;
|
||||
gold: number;
|
||||
maxHp: number | null;
|
||||
armorClass: number | null;
|
||||
currentLocationId: string | null;
|
||||
@@ -121,6 +161,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 +175,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 +191,8 @@ export interface Spell {
|
||||
range: string | null;
|
||||
duration: string | null;
|
||||
description: string | null;
|
||||
damageDice: string | null;
|
||||
isStartingOption: boolean;
|
||||
}
|
||||
|
||||
export interface CharacterSpell {
|
||||
@@ -149,16 +200,42 @@ 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;
|
||||
amount: number; // signed: positive = gained, negative = spent
|
||||
balanceAfter: number;
|
||||
reason: string | null;
|
||||
createdAt: string;
|
||||
createdBy: { displayName: string } | null;
|
||||
}
|
||||
|
||||
export interface SkillProficiency {
|
||||
@@ -230,10 +307,47 @@ 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;
|
||||
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 +360,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;
|
||||
@@ -267,8 +394,12 @@ export interface EncounterParticipant {
|
||||
maxHp: number;
|
||||
conditions: string[];
|
||||
isMonster: boolean;
|
||||
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
|
||||
npcId: string | null;
|
||||
character?: { id: string; name: string } | null;
|
||||
monster?: { id: string; name: string } | 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 +407,15 @@ 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
|
||||
createdAt: string;
|
||||
participants?: EncounterParticipant[];
|
||||
rewards?: EncounterReward[]; // players only receive the awarded ones
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
@@ -314,6 +450,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 +483,7 @@ export interface QuickStatsWeapon {
|
||||
characterWeaponId: string;
|
||||
weaponId: string;
|
||||
name: string;
|
||||
level: number;
|
||||
equipped: boolean;
|
||||
quantity: number;
|
||||
attackBonus: number;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,12 +22,24 @@ 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 id = useId();
|
||||
const text = STAT_HELP[term];
|
||||
const text = custom ?? (term ? STAT_HELP[term] : undefined);
|
||||
if (!text) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -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();
|
||||
@@ -27,6 +28,7 @@ export function Layout() {
|
||||
<main className="page">
|
||||
<Outlet />
|
||||
</main>
|
||||
<DiceRoller />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,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' }}>
|
||||
|
||||
@@ -28,6 +28,7 @@ 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
|
||||
};
|
||||
const onCharacterCreated = () => {
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||
|
||||
@@ -733,3 +733,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;
|
||||
}
|
||||
|
||||
@@ -86,3 +86,24 @@ export function extractErrorMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
return 'Something went wrong.';
|
||||
}
|
||||
|
||||
// A cantrip is spell level 0. It is always called a cantrip, never "level 0"
|
||||
// or "level 1".
|
||||
export function spellLevelLabel(level: number): string {
|
||||
return level === 0 ? 'Cantrip' : `Level ${level}`;
|
||||
}
|
||||
|
||||
// Who can use a spell: a character of at least its level; cantrips, anyone.
|
||||
export function spellRequirementLabel(level: number): string {
|
||||
return level === 0 ? 'any character can use it' : `needs character level ${level}`;
|
||||
}
|
||||
|
||||
// The damage values offered for a spell, grouped by how many dice are rolled.
|
||||
const SPELL_DIE_SIZES = [4, 6, 8, 10, 12];
|
||||
const SPELL_DIE_COUNTS = [1, 2, 3, 4, 5, 6, 8, 10];
|
||||
export const SPELL_DAMAGE_GROUPS: { label: string; options: string[] }[] = SPELL_DIE_COUNTS.map((count) => ({
|
||||
label: count === 1 ? '1 die' : `${count} dice`,
|
||||
options: SPELL_DIE_SIZES.map((size) => `${count}d${size}`),
|
||||
}));
|
||||
// Values the spell library uses that don't fit the pattern above.
|
||||
export const SPELL_DAMAGE_SPECIALS = ['3d4+3', '2d8+4d6', '20'];
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ import {
|
||||
useCreateWeapon,
|
||||
useCampaignSpells,
|
||||
useCreateSpell,
|
||||
useCampaignItems,
|
||||
useCreateItem,
|
||||
usePatchPoolEntry,
|
||||
useDeletePoolEntry,
|
||||
useCampaignNpcs,
|
||||
useCreateNpc,
|
||||
useCampaignNotes,
|
||||
useCreateNote,
|
||||
useUpdateCampaign,
|
||||
@@ -30,9 +36,10 @@ import { useAuth } from '../auth/AuthContext';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
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, EncounterMode, LibraryMonster, LibrarySpell, LibraryWeapon, NpcDisposition } from '../api/types';
|
||||
|
||||
function CharacterRow({ character, isDM, onDelete }: { character: CharacterSummary; isDM: boolean; onDelete: () => void }) {
|
||||
const inProgress = character.buildStage !== 'COMPLETE';
|
||||
@@ -71,17 +78,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 +224,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,9 +247,11 @@ 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);
|
||||
@@ -133,6 +263,15 @@ export function CampaignHomePage() {
|
||||
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);
|
||||
@@ -144,6 +283,8 @@ export function CampaignHomePage() {
|
||||
|
||||
const [newCharName, setNewCharName] = useState('');
|
||||
const [newEncounterName, setNewEncounterName] = useState('');
|
||||
const [newEncounterMode, setNewEncounterMode] = useState<EncounterMode>('BATTLE');
|
||||
const [showNpcForm, setShowNpcForm] = useState(false);
|
||||
const [noteBody, setNoteBody] = useState('');
|
||||
const [dmNoteBody, setDmNoteBody] = useState('');
|
||||
const [newLocationName, setNewLocationName] = useState('');
|
||||
@@ -152,12 +293,15 @@ export function CampaignHomePage() {
|
||||
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 sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
|
||||
const dmNotes = notes?.filter((n) => n.visibility === 'DM_ONLY');
|
||||
@@ -187,7 +331,10 @@ 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));
|
||||
@@ -265,7 +412,7 @@ export function CampaignHomePage() {
|
||||
<input name="startingGold" type="number" min={0} defaultValue={campaign.startingGold} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label>Starting items</label>
|
||||
<label>Starting items (weapons + items)</label>
|
||||
<input name="startingItemCount" type="number" min={0} defaultValue={campaign.startingItemCount} />
|
||||
</div>
|
||||
<div className="field">
|
||||
@@ -275,7 +422,7 @@ export function CampaignHomePage() {
|
||||
</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>
|
||||
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
|
||||
Save Settings
|
||||
@@ -346,20 +493,113 @@ 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>
|
||||
<span>
|
||||
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
|
||||
{enc.name}
|
||||
</span>
|
||||
<span className="badge">
|
||||
{enc.status}
|
||||
{enc.mode === 'BATTLE' && ` · Round ${enc.currentRound}`}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</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">
|
||||
<input name="ac" type="number" min={0} placeholder="AC (optional)" />
|
||||
<input name="maxHp" type="number" min={1} placeholder="HP (optional)" />
|
||||
<input name="attackBonus" type="number" placeholder="Attack bonus (optional)" />
|
||||
<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">
|
||||
@@ -389,7 +629,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 +660,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 +676,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 +706,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 +718,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 +737,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 +837,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 +857,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 +887,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 +936,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 && (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,30 @@
|
||||
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,
|
||||
useCampaignCharacters,
|
||||
useGive,
|
||||
type Gift,
|
||||
useCharacterNotes,
|
||||
useCreateNote,
|
||||
useCampaign,
|
||||
useCampaigns,
|
||||
useCopyCharacter,
|
||||
useCampaignLocations,
|
||||
} from '../api/queries';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
@@ -23,11 +33,173 @@ import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
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, SKILL_LABELS, SPECIES_LABELS, fmtMod, hpBarClass, extractErrorMessage, 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 } 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; gold has no id.
|
||||
const [kind, id] = what.split(':');
|
||||
const heldWeapon = character.weapons.find((w) => w.weaponId === id);
|
||||
const heldItem = character.inventoryItems.find((i) => i.itemId === id);
|
||||
const max = kind === 'gold' ? character.gold : kind === 'weapon' ? (heldWeapon?.quantity ?? 0) : kind === 'item' ? (heldItem?.quantity ?? 0) : 1;
|
||||
const count = Math.floor(Number(amount));
|
||||
const countOk = kind === 'spell' || (count >= 1 && count <= max);
|
||||
const nothingToGive = character.gold === 0 && character.weapons.length + character.inventoryItems.length + character.spells.length === 0;
|
||||
|
||||
function handleGive() {
|
||||
const gift: Gift | null =
|
||||
kind === 'gold'
|
||||
? { kind, amount: count }
|
||||
: 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>
|
||||
{character.gold > 0 && <option value="gold:">Gold ({character.gold} gp)</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' && (
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={max}
|
||||
aria-label={kind === 'gold' ? 'How much gold' : 'How many'}
|
||||
title={kind === 'gold' ? 'How much gold' : `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 +211,41 @@ 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);
|
||||
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 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 [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 +283,27 @@ 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, reason: goldReason.trim() || undefined });
|
||||
setGoldAmount('');
|
||||
setGoldReason('');
|
||||
} catch (err) {
|
||||
setFormError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddNote(e: FormEvent) {
|
||||
@@ -133,6 +333,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) ?? [];
|
||||
|
||||
const assignedWeaponIds = new Set(character.weapons.map((w) => w.weaponId));
|
||||
const assignedSpellIds = new Set(character.spells.map((s) => s.spellId));
|
||||
|
||||
@@ -349,26 +562,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 +609,7 @@ export function CharacterSheetPage() {
|
||||
))}
|
||||
</select>
|
||||
<button className="btn" onClick={handleAssignWeapon} disabled={!pickedWeaponId}>
|
||||
Add
|
||||
Give
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -391,35 +622,49 @@ 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>
|
||||
)}
|
||||
@@ -428,11 +673,18 @@ export function CharacterSheetPage() {
|
||||
|
||||
<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,67 +692,178 @@ 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">
|
||||
<span>Money</span>
|
||||
<span className="mono">{character.gold} gp</span>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<div className="flex-row flex-wrap">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Amount"
|
||||
aria-label="Gold amount"
|
||||
value={goldAmount}
|
||||
onChange={(e) => setGoldAmount(e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
<div className="mt-sm">
|
||||
{goldLedger?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No transactions yet.</p>}
|
||||
{ledgerPage.pageItems.map((t) => (
|
||||
<div key={t.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
|
||||
<div>
|
||||
<strong className="mono">{t.amount > 0 ? `+${t.amount}` : `−${Math.abs(t.amount)}`}</strong>
|
||||
<span style={{ marginLeft: 8 }}>{t.reason || (t.amount > 0 ? 'Gained' : 'Spent')}</span>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-dim)' }}>
|
||||
{t.createdBy ? `${t.createdBy.displayName} · ` : ''}
|
||||
{new Date(t.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<span className="mono" style={{ color: 'var(--text-muted)' }}>{t.balanceAfter} gp</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} />
|
||||
</div>
|
||||
|
||||
{/* Composed by the character builder from the player's picks. Players
|
||||
read them; only the DM can rewrite them (new abilities on level-up,
|
||||
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>
|
||||
))}
|
||||
|
||||
{canEdit && <GiveCard character={character} onError={(err) => setFormError(extractErrorMessage(err))} />}
|
||||
|
||||
{canEdit && otherCampaigns.length > 0 && (
|
||||
<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-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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{isOwner && (
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">Private Notes</div>
|
||||
|
||||
@@ -1,150 +1,572 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import {
|
||||
useEncounter,
|
||||
useCampaign,
|
||||
useCampaignCharacters,
|
||||
useCampaignMonsters,
|
||||
useCampaignNpcs,
|
||||
useCampaignWeapons,
|
||||
useCampaignItems,
|
||||
useAddParticipant,
|
||||
usePatchParticipant,
|
||||
useRemoveParticipant,
|
||||
useAdvanceRound,
|
||||
useNextTurn,
|
||||
usePatchEncounter,
|
||||
useAddReward,
|
||||
useDeleteReward,
|
||||
useAwardReward,
|
||||
type NewReward,
|
||||
} from '../api/queries';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { extractErrorMessage, fmtMod, hpBarClass } from '../lib/format';
|
||||
import type { EncounterParticipant } from '../api/types';
|
||||
import { ABILITY_LABELS, CLASS_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, hpBarClass, spellLevelLabel } from '../lib/format';
|
||||
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
import type {
|
||||
Ability,
|
||||
CharacterSummary,
|
||||
Encounter,
|
||||
EncounterMode,
|
||||
EncounterParticipant,
|
||||
EncounterReward,
|
||||
Item,
|
||||
Weapon,
|
||||
} from '../api/types';
|
||||
|
||||
function ParticipantRow({
|
||||
participant,
|
||||
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
|
||||
const CONDITIONS = [
|
||||
'Blinded',
|
||||
'Charmed',
|
||||
'Deafened',
|
||||
'Frightened',
|
||||
'Grappled',
|
||||
'Incapacitated',
|
||||
'Invisible',
|
||||
'Paralyzed',
|
||||
'Petrified',
|
||||
'Poisoned',
|
||||
'Prone',
|
||||
'Restrained',
|
||||
'Stunned',
|
||||
'Unconscious',
|
||||
'Concentrating',
|
||||
];
|
||||
|
||||
const KIND_LABELS = { CHARACTER: 'PC', MONSTER: 'Monster', NPC: 'NPC' } as const;
|
||||
const KIND_BADGES = { CHARACTER: 'badge-player', MONSTER: 'badge-hostile', NPC: 'badge-friendly' } as const;
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
|
||||
const STATUS_LABELS = { PLANNING: 'Planning', ACTIVE: 'In progress', COMPLETE: 'Complete' } as const;
|
||||
|
||||
function StatChip({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className="stat-chip">
|
||||
<span>{label}</span>
|
||||
<b>{children}</b>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Who this combatant is, in one muted line under the name.
|
||||
function subline(p: EncounterParticipant): string {
|
||||
const info = p.characterCombatInfo;
|
||||
if (info) {
|
||||
return [
|
||||
`Level ${info.level}`,
|
||||
info.species && SPECIES_LABELS[info.species],
|
||||
info.characterClass && CLASS_LABELS[info.characterClass],
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
if (p.kind === 'NPC') return p.npc?.role || 'Non-player character';
|
||||
if (p.monster?.challengeRating) return `Challenge ${p.monster.challengeRating}`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function CombatantCard({
|
||||
participant: p,
|
||||
isDM,
|
||||
mode,
|
||||
isActive,
|
||||
onPatch,
|
||||
onRemove,
|
||||
}: {
|
||||
participant: EncounterParticipant;
|
||||
isDM: boolean;
|
||||
mode: EncounterMode;
|
||||
isActive: boolean;
|
||||
onPatch: (data: Partial<EncounterParticipant>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const [hpDelta, setHpDelta] = useState('');
|
||||
const [showCombatInfo, setShowCombatInfo] = useState(false);
|
||||
const pct = Math.max(0, Math.min(100, (participant.currentHp / participant.maxHp) * 100));
|
||||
const combatInfo = participant.characterCombatInfo;
|
||||
// Details start open for the DM (who runs the scene from this screen) and
|
||||
// closed for players; null = "not toggled yet", so it follows isDM once the
|
||||
// campaign role has loaded.
|
||||
const [detailsToggled, setDetailsToggled] = useState<boolean | null>(null);
|
||||
const showDetails = detailsToggled ?? isDM;
|
||||
|
||||
const info = p.characterCombatInfo;
|
||||
const monster = p.monster;
|
||||
const npc = p.npc;
|
||||
const tracksHp = p.maxHp > 0;
|
||||
const isDown = tracksHp && p.currentHp <= 0;
|
||||
const pct = tracksHp ? Math.max(0, Math.min(100, (p.currentHp / p.maxHp) * 100)) : 0;
|
||||
// Players get a monster's name and health bar only; the stat block is the DM's.
|
||||
const monsterStats = monster && monster.ac !== undefined ? monster : null;
|
||||
const hasDetails = Boolean(info || monsterStats || npc?.dmNotes);
|
||||
|
||||
function applyDelta(sign: 1 | -1) {
|
||||
const amount = Number(hpDelta);
|
||||
if (!amount) return;
|
||||
onPatch({ currentHp: participant.currentHp + sign * amount });
|
||||
onPatch({ currentHp: p.currentHp + sign * amount });
|
||||
setHpDelta('');
|
||||
}
|
||||
|
||||
function addCondition(condition: string) {
|
||||
const name = condition.trim();
|
||||
if (!name || p.conditions.includes(name)) return;
|
||||
onPatch({ conditions: [...p.conditions, name] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown ? ' is-down' : ''}`}>
|
||||
<div className="flex-between flex-wrap">
|
||||
<div className="flex-row">
|
||||
<span className={`badge ${participant.isMonster ? 'badge-dm' : 'badge-player'}`}>
|
||||
{participant.isMonster ? 'Monster' : 'PC'}
|
||||
</span>
|
||||
<strong>{participant.displayName}</strong>
|
||||
</div>
|
||||
{isDM && (
|
||||
<label className="flex-row gap-sm">
|
||||
Init
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={participant.initiative}
|
||||
style={{ width: 60 }}
|
||||
onBlur={(e) => onPatch({ initiative: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="hp-bar-track mt-sm">
|
||||
<div className={hpBarClass(participant.currentHp, participant.maxHp)} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="flex-between mt-sm flex-wrap">
|
||||
<span>
|
||||
HP {participant.currentHp}/{participant.maxHp}
|
||||
</span>
|
||||
{isDM && (
|
||||
<div className="flex-row gap-sm">
|
||||
<input type="number" placeholder="Amt" value={hpDelta} onChange={(e) => setHpDelta(e.target.value)} style={{ width: 70 }} />
|
||||
<button className="btn btn-danger" onClick={() => applyDelta(-1)}>
|
||||
−
|
||||
</button>
|
||||
<button className="btn" onClick={() => applyDelta(1)}>
|
||||
+
|
||||
</button>
|
||||
<div>
|
||||
{isActive && <div className="turn-marker">▶ On turn</div>}
|
||||
<div className="flex-row flex-wrap">
|
||||
<span className={`badge ${KIND_BADGES[p.kind]}`}>{KIND_LABELS[p.kind]}</span>
|
||||
{p.character ? (
|
||||
<Link to={`/characters/${p.character.id}`} className="combatant-name">
|
||||
{p.displayName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="combatant-name">{p.displayName}</span>
|
||||
)}
|
||||
{npc && <span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</span>}
|
||||
{isDown && <span className="badge badge-hostile">Down</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isDM && (
|
||||
<div className="flex-row mt-sm flex-wrap">
|
||||
<input
|
||||
placeholder="Conditions, comma separated"
|
||||
defaultValue={participant.conditions.join(', ')}
|
||||
style={{ flex: 1, minWidth: 160 }}
|
||||
onBlur={(e) =>
|
||||
onPatch({
|
||||
conditions: e.target.value
|
||||
.split(',')
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button className="btn btn-ghost" onClick={onRemove}>
|
||||
Remove
|
||||
</button>
|
||||
<div className="combatant-sub">{subline(p)}</div>
|
||||
</div>
|
||||
{mode === 'BATTLE' &&
|
||||
(isDM ? (
|
||||
<label className="flex-row gap-sm">
|
||||
Initiative
|
||||
<input
|
||||
key={`${p.id}:${p.initiative}`}
|
||||
type="number"
|
||||
defaultValue={p.initiative}
|
||||
style={{ width: 64 }}
|
||||
onBlur={(e) => Number(e.target.value) !== p.initiative && onPatch({ initiative: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<StatChip label="Initiative">{p.initiative}</StatChip>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="stat-chips mt-sm">
|
||||
{tracksHp && (
|
||||
<StatChip label="HP">
|
||||
{p.currentHp}/{p.maxHp}
|
||||
{info && info.tempHp > 0 && ` +${info.tempHp}`}
|
||||
</StatChip>
|
||||
)}
|
||||
{info && (
|
||||
<>
|
||||
<StatChip label="AC">{info.armorClass}</StatChip>
|
||||
<StatChip label="Speed">{info.speed}</StatChip>
|
||||
<StatChip label="Init">{fmtMod(info.initiativeBonus)}</StatChip>
|
||||
<StatChip label="Passive Perc.">{info.passivePerception}</StatChip>
|
||||
<StatChip label="Prof.">{fmtMod(info.proficiencyBonus)}</StatChip>
|
||||
{info.spellcasting && <StatChip label="Spell DC">{info.spellcasting.saveDC}</StatChip>}
|
||||
</>
|
||||
)}
|
||||
{monsterStats && (
|
||||
<>
|
||||
<StatChip label="AC">{monsterStats.ac}</StatChip>
|
||||
<StatChip label="Speed">{monsterStats.speed}</StatChip>
|
||||
<StatChip label="Attack">{fmtMod(monsterStats.attackBonus ?? 0)}</StatChip>
|
||||
{monsterStats.damageDice && <StatChip label="Damage">{monsterStats.damageDice}</StatChip>}
|
||||
</>
|
||||
)}
|
||||
{npc && npc.ac != null && <StatChip label="AC">{npc.ac}</StatChip>}
|
||||
{npc && npc.attackBonus != null && <StatChip label="Attack">{fmtMod(npc.attackBonus)}</StatChip>}
|
||||
{npc && npc.damageDice && <StatChip label="Damage">{npc.damageDice}</StatChip>}
|
||||
</div>
|
||||
|
||||
{/* An NPC's description is what players are here for, so it is never tucked away. */}
|
||||
{npc?.description && <p style={{ margin: '0.5rem 0 0', whiteSpace: 'pre-wrap' }}>{npc.description}</p>}
|
||||
|
||||
{tracksHp && (
|
||||
<>
|
||||
<div className="hp-bar-track mt-sm">
|
||||
<div className={hpBarClass(p.currentHp, p.maxHp)} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{isDM && (
|
||||
<div className="flex-row gap-sm mt-sm">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Amount"
|
||||
aria-label={`HP change for ${p.displayName}`}
|
||||
value={hpDelta}
|
||||
onChange={(e) => setHpDelta(e.target.value)}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<button className="btn btn-danger" onClick={() => applyDelta(-1)} disabled={!hpDelta}>
|
||||
− Damage
|
||||
</button>
|
||||
<button className="btn" onClick={() => applyDelta(1)} disabled={!hpDelta}>
|
||||
+ Heal
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isDM && participant.conditions.length > 0 && (
|
||||
<div className="flex-row flex-wrap mt-sm">
|
||||
{participant.conditions.map((c) => (
|
||||
<span key={c} className="badge">
|
||||
|
||||
{(p.conditions.length > 0 || isDM) && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{p.conditions.map((c) => (
|
||||
<span key={c} className="condition-chip">
|
||||
{c}
|
||||
{isDM && (
|
||||
<button aria-label={`Remove ${c}`} onClick={() => onPatch({ conditions: p.conditions.filter((x) => x !== c) })}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{isDM && (
|
||||
<select
|
||||
aria-label={`Add a condition to ${p.displayName}`}
|
||||
value=""
|
||||
onChange={(e) => addCondition(e.target.value)}
|
||||
style={{ width: 'auto' }}
|
||||
>
|
||||
<option value="">+ Condition</option>
|
||||
{CONDITIONS.filter((c) => !p.conditions.includes(c)).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{combatInfo && (
|
||||
<div className="mt-sm">
|
||||
<button className="btn btn-ghost" onClick={() => setShowCombatInfo((s) => !s)}>
|
||||
{showCombatInfo ? 'Hide' : '⚔ Show'} Weapons & Spells
|
||||
</button>
|
||||
{showCombatInfo && (
|
||||
<div className="mt-sm">
|
||||
{combatInfo.weapons.length === 0 && combatInfo.spells.length === 0 && (
|
||||
<p style={{ color: 'var(--text-muted)', margin: 0 }}>Nothing equipped or prepared.</p>
|
||||
)}
|
||||
{combatInfo.weapons.map((w) => (
|
||||
<div key={w.characterWeaponId} className="flex-between" style={{ padding: '0.2rem 0' }}>
|
||||
|
||||
{(hasDetails || isDM) && (
|
||||
<div className="flex-between mt-sm">
|
||||
{hasDetails ? (
|
||||
<button className="btn btn-ghost" onClick={() => setDetailsToggled(!showDetails)}>
|
||||
{showDetails ? '▾ Hide details' : '▸ Show details'}
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{isDM && (
|
||||
<button className="btn btn-ghost" onClick={onRemove}>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasDetails && showDetails && (
|
||||
<div className="combatant-details">
|
||||
{info && (
|
||||
<>
|
||||
<div className="stat-chips">
|
||||
{ABILITIES.map((a) => (
|
||||
<StatChip key={a} label={a}>
|
||||
{info.abilities[a].final} ({fmtMod(info.abilities[a].modifier)})
|
||||
</StatChip>
|
||||
))}
|
||||
</div>
|
||||
<p className="combatant-sub">
|
||||
Saves:{' '}
|
||||
{ABILITIES.map((a) => `${a} ${fmtMod(info.saves[a].bonus)}${info.saves[a].proficient ? '*' : ''}`).join(' · ')}{' '}
|
||||
<span title="Proficient">(* proficient)</span>
|
||||
</p>
|
||||
{info.weapons.length === 0 && info.spells.length === 0 && <p className="combatant-sub">No usable weapons or spells.</p>}
|
||||
{info.weapons.map((w) => (
|
||||
<div key={w.characterWeaponId} className="detail-row">
|
||||
<span>
|
||||
{w.name} {w.equipped && <span className="badge">equipped</span>}
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 6, fontSize: '0.85rem' }}>
|
||||
{w.damageDice} {w.damageType} {fmtMod(w.damageBonus)}
|
||||
<span className="combatant-sub" style={{ marginLeft: 6 }}>
|
||||
Lv {w.level} · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
|
||||
</span>
|
||||
</span>
|
||||
<span className="mono">{fmtMod(w.attackBonus)}</span>
|
||||
<span className="mono" title="Attack bonus">
|
||||
{fmtMod(w.attackBonus)} to hit
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{combatInfo.spells.length > 0 && (
|
||||
<div className="flex-row flex-wrap mt-sm">
|
||||
{combatInfo.spells.map((s) => (
|
||||
<span key={s.characterSpellId} className={`badge ${s.prepared ? 'badge-dm' : ''}`}>
|
||||
{s.name} (Lv {s.level})
|
||||
{info.spells.length > 0 && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{info.spells.map((s) => (
|
||||
<span
|
||||
key={s.characterSpellId}
|
||||
className={`badge ${s.prepared ? 'badge-dm' : ''}`}
|
||||
title={[s.prepared ? 'Prepared' : 'Known, not prepared', s.description].filter(Boolean).join(' — ')}
|
||||
>
|
||||
{s.name} ({spellLevelLabel(s.level)}
|
||||
{s.damageDice ? ` · ${s.damageDice}` : ''})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{combatInfo.spellcasting && (
|
||||
<p style={{ margin: '0.4rem 0 0', fontSize: '0.85rem', color: 'var(--text-muted)' }}>
|
||||
Spell Save DC {combatInfo.spellcasting.saveDC} · Attack {fmtMod(combatInfo.spellcasting.attackBonus)}
|
||||
{info.spellcasting && (
|
||||
<p className="combatant-sub">
|
||||
Spellcasting ({ABILITY_LABELS[info.spellcasting.ability]}): save DC {info.spellcasting.saveDC} ·{' '}
|
||||
{fmtMod(info.spellcasting.attackBonus)} to hit
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{info.specialAbilities && <p style={{ whiteSpace: 'pre-wrap' }}>{info.specialAbilities}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{monsterStats && (
|
||||
<>
|
||||
<div className="stat-chips">
|
||||
{ABILITIES.map((a) => {
|
||||
const score = monsterStats[`${a.toLowerCase()}Score` as 'strScore'] ?? 10;
|
||||
return (
|
||||
<StatChip key={a} label={a}>
|
||||
{score} ({fmtMod(abilityModifier(score))})
|
||||
</StatChip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{monsterStats.specialTrait && (
|
||||
<p>
|
||||
<strong>Trait:</strong> {monsterStats.specialTrait}
|
||||
</p>
|
||||
)}
|
||||
{monsterStats.notes && <p style={{ whiteSpace: 'pre-wrap' }}>{monsterStats.notes}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{npc?.dmNotes && (
|
||||
<p style={{ whiteSpace: 'pre-wrap', color: 'var(--text-muted)' }} title="Only you can see this">
|
||||
🔒 {npc.dmNotes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function rewardLabel(r: EncounterReward): string {
|
||||
if (r.type === 'GOLD') return `${r.gold} gold`;
|
||||
const entry = r.type === 'WEAPON' ? r.weapon : r.item;
|
||||
return `${entry?.name ?? 'Removed from the pool'}${r.quantity > 1 ? ` ×${r.quantity}` : ''} · Lv ${r.level}`;
|
||||
}
|
||||
|
||||
function RewardsPanel({
|
||||
encounter,
|
||||
isDM,
|
||||
characters,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
isDM: boolean;
|
||||
characters: CharacterSummary[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const addReward = useAddReward(encounter.id);
|
||||
const deleteReward = useDeleteReward(encounter.id);
|
||||
const awardReward = useAwardReward(encounter.id);
|
||||
|
||||
const [type, setType] = useState<NewReward['type']>('GOLD');
|
||||
const [gold, setGold] = useState('');
|
||||
const [poolId, setPoolId] = useState('');
|
||||
const [quantity, setQuantity] = useState('1');
|
||||
const [level, setLevel] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [recipients, setRecipients] = useState<Record<string, string>>({});
|
||||
|
||||
const rewards = encounter.rewards ?? [];
|
||||
const pool: (Weapon | Item)[] = type === 'WEAPON' ? weapons : items;
|
||||
const picked = pool.find((entry) => entry.id === poolId);
|
||||
const partySize = (encounter.participants ?? []).filter((p) => p.kind === 'CHARACTER' && p.characterId).length;
|
||||
|
||||
if (!isDM && rewards.length === 0) return null;
|
||||
|
||||
function handleAdd() {
|
||||
const shared = { note: note.trim() || undefined };
|
||||
let reward: NewReward;
|
||||
if (type === 'GOLD') {
|
||||
const amount = Math.floor(Number(gold));
|
||||
if (!(amount > 0)) return;
|
||||
reward = { type, gold: amount, ...shared };
|
||||
} else {
|
||||
if (!picked) return;
|
||||
const gear = { quantity: Math.max(1, Math.floor(Number(quantity)) || 1), level: level ? Number(level) : undefined, ...shared };
|
||||
reward = type === 'WEAPON' ? { type, weaponId: picked.id, ...gear } : { type, itemId: picked.id, ...gear };
|
||||
}
|
||||
addReward.mutate(reward, {
|
||||
onError,
|
||||
onSuccess: () => {
|
||||
setGold('');
|
||||
setPoolId('');
|
||||
setQuantity('1');
|
||||
setLevel('');
|
||||
setNote('');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-title">Rewards</div>
|
||||
{isDM && rewards.length === 0 && (
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Line up gold, weapons and items here. Players only see a reward once you award it.
|
||||
</p>
|
||||
)}
|
||||
{rewards.map((r) => (
|
||||
<div key={r.id} className={`reward-row${r.awardedAt ? ' is-awarded' : ''}`}>
|
||||
<div className="flex-between">
|
||||
<strong>
|
||||
{r.type === 'GOLD' ? '🪙' : r.type === 'WEAPON' ? '🗡' : '🎒'} {rewardLabel(r)}
|
||||
</strong>
|
||||
{isDM && (
|
||||
<button className="btn btn-ghost" aria-label={`Remove reward ${rewardLabel(r)}`} onClick={() => deleteReward.mutate(r.id, { onError })}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{r.note && <div className="combatant-sub">{r.note}</div>}
|
||||
{r.awardedAt ? (
|
||||
<div className="combatant-sub">✓ Awarded to {r.awardedTo}</div>
|
||||
) : (
|
||||
isDM && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<select
|
||||
aria-label={`Who receives ${rewardLabel(r)}`}
|
||||
value={recipients[r.id] ?? ''}
|
||||
onChange={(e) => setRecipients({ ...recipients, [r.id]: e.target.value })}
|
||||
style={{ flex: 1, minWidth: 110 }}
|
||||
>
|
||||
<option value="">Award to…</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!recipients[r.id] || awardReward.isPending}
|
||||
onClick={() => awardReward.mutate({ rewardId: r.id, characterId: recipients[r.id] }, { onError })}
|
||||
>
|
||||
Award
|
||||
</button>
|
||||
{r.type === 'GOLD' && (
|
||||
<button
|
||||
className="btn"
|
||||
disabled={partySize === 0 || awardReward.isPending}
|
||||
title="Share evenly between the characters in this encounter"
|
||||
onClick={() => awardReward.mutate({ rewardId: r.id, split: true }, { onError })}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isDM && (
|
||||
<div className="mt-md">
|
||||
<div className="section-label">Add a reward</div>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<select
|
||||
aria-label="Reward type"
|
||||
value={type}
|
||||
onChange={(e) => {
|
||||
setType(e.target.value as NewReward['type']);
|
||||
setPoolId('');
|
||||
setLevel('');
|
||||
}}
|
||||
>
|
||||
<option value="GOLD">Gold</option>
|
||||
<option value="WEAPON">Weapon</option>
|
||||
<option value="ITEM">Item</option>
|
||||
</select>
|
||||
{type === 'GOLD' ? (
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Amount"
|
||||
aria-label="Gold amount"
|
||||
value={gold}
|
||||
onChange={(e) => setGold(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 90 }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<select
|
||||
aria-label={type === 'WEAPON' ? 'Weapon' : 'Item'}
|
||||
value={poolId}
|
||||
onChange={(e) => {
|
||||
setPoolId(e.target.value);
|
||||
setLevel('');
|
||||
}}
|
||||
style={{ flex: 1, minWidth: 110 }}
|
||||
>
|
||||
<option value="">{pool.length ? 'Choose…' : 'The pool is empty'}</option>
|
||||
{pool.map((entry) => (
|
||||
<option key={entry.id} value={entry.id}>
|
||||
{entry.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{picked && (
|
||||
<>
|
||||
<select aria-label="Level" value={level || picked.minLevel} onChange={(e) => setLevel(e.target.value)}>
|
||||
{Array.from({ length: MAX_GEAR_LEVEL - picked.minLevel + 1 }, (_, i) => picked.minLevel + i).map((l) => (
|
||||
<option key={l} value={l}>
|
||||
Lv {l}
|
||||
{picked.damageDice ? ` · ${scaledDamageDice(picked.damageDice, l, picked.damageScaling, picked.minLevel)}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
aria-label="Quantity"
|
||||
title="Quantity"
|
||||
value={quantity}
|
||||
onChange={(e) => setQuantity(e.target.value)}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-row gap-sm mt-sm">
|
||||
<input
|
||||
placeholder="Note (optional)"
|
||||
aria-label="Reward note"
|
||||
maxLength={200}
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleAdd} disabled={addReward.isPending || (type === 'GOLD' ? !gold : !picked)}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -157,72 +579,125 @@ export function EncounterPage() {
|
||||
useCampaignSocket(encounter?.campaignId);
|
||||
|
||||
const { data: campaignData } = useCampaign(encounter?.campaignId);
|
||||
const isDM = campaignData?.campaign.role === 'DM';
|
||||
const { data: characters } = useCampaignCharacters(encounter?.campaignId);
|
||||
const { data: monsters } = useCampaignMonsters(encounter?.campaignId);
|
||||
const { data: monsters } = useCampaignMonsters(encounter?.campaignId, isDM);
|
||||
const { data: npcs } = useCampaignNpcs(encounter?.campaignId, isDM);
|
||||
const { data: weapons } = useCampaignWeapons(encounter?.campaignId);
|
||||
const { data: items } = useCampaignItems(encounter?.campaignId);
|
||||
|
||||
const addParticipant = useAddParticipant(encounterId);
|
||||
const patchParticipant = usePatchParticipant(encounterId);
|
||||
const removeParticipant = useRemoveParticipant(encounterId);
|
||||
const advanceRound = useAdvanceRound(encounterId);
|
||||
const nextTurn = useNextTurn(encounterId);
|
||||
const patchEncounter = usePatchEncounter(encounterId);
|
||||
|
||||
const [pickType, setPickType] = useState<'character' | 'monster'>('character');
|
||||
const [pickType, setPickType] = useState<'character' | 'monster' | 'npc'>('character');
|
||||
const [pickId, setPickId] = useState('');
|
||||
const [initiative, setInitiative] = useState('0');
|
||||
const [initiative, setInitiative] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
if (isLoading) return <Spinner label="Setting the battlefield…" />;
|
||||
if (isLoading) return <Spinner label="Setting the scene…" />;
|
||||
if (error || !encounter) return <ErrorBanner message="Could not load this encounter." />;
|
||||
|
||||
const isDM = campaignData?.campaign.role === 'DM';
|
||||
const sorted = [...(encounter.participants || [])].sort((a, b) => b.initiative - a.initiative);
|
||||
const availableCharacters = characters?.filter((c) => !encounter.participants?.some((p) => p.characterId === c.id) && c.buildStage === 'COMPLETE');
|
||||
const availableMonsters = monsters;
|
||||
const isBattle = encounter.mode === 'BATTLE';
|
||||
const participants = encounter.participants ?? []; // already in initiative order
|
||||
const active = isBattle && encounter.status === 'ACTIVE' ? participants.find((p) => p.id === encounter.activeParticipantId) : undefined;
|
||||
const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? [];
|
||||
const onError = (err: unknown) => setFormError(extractErrorMessage(err));
|
||||
const fail = { onError };
|
||||
|
||||
async function handleAdd() {
|
||||
const pickOptions: { id: string; name: string }[] =
|
||||
pickType === 'character'
|
||||
? builtCharacters.filter((c) => !participants.some((p) => p.characterId === c.id))
|
||||
: pickType === 'monster'
|
||||
? (monsters ?? [])
|
||||
: (npcs ?? []).filter((n) => !participants.some((p) => p.npcId === n.id));
|
||||
|
||||
function handleAdd() {
|
||||
if (!pickId) return;
|
||||
setFormError(null);
|
||||
try {
|
||||
await addParticipant.mutateAsync({
|
||||
[pickType === 'character' ? 'characterId' : 'monsterId']: pickId,
|
||||
initiative: Number(initiative) || 0,
|
||||
});
|
||||
setPickId('');
|
||||
} catch (err) {
|
||||
setFormError(extractErrorMessage(err));
|
||||
}
|
||||
addParticipant.mutate(
|
||||
{ [`${pickType}Id`]: pickId, initiative: Number(initiative) || 0 },
|
||||
{ onError, onSuccess: () => setPickId('') }
|
||||
);
|
||||
}
|
||||
|
||||
const card = (p: EncounterParticipant) => (
|
||||
<CombatantCard
|
||||
key={p.id}
|
||||
participant={p}
|
||||
isDM={isDM}
|
||||
mode={encounter.mode}
|
||||
isActive={p.id === active?.id}
|
||||
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
|
||||
onRemove={() => removeParticipant.mutate(p.id, fail)}
|
||||
/>
|
||||
);
|
||||
|
||||
// A quest scene is grouped by who is there rather than by initiative.
|
||||
const groups: [string, EncounterParticipant[]][] = isBattle
|
||||
? [['Initiative order', participants]]
|
||||
: [
|
||||
['People you meet', participants.filter((p) => p.kind === 'NPC')],
|
||||
['The party', participants.filter((p) => p.kind === 'CHARACTER')],
|
||||
['Creatures', participants.filter((p) => p.kind === 'MONSTER')],
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>⚔ {encounter.name}</h1>
|
||||
<p className="subtitle">
|
||||
{encounter.status} · Round {encounter.currentRound}
|
||||
</p>
|
||||
<h1>
|
||||
{isBattle ? '⚔' : '📜'} {encounter.name}
|
||||
</h1>
|
||||
<div className="flex-row flex-wrap">
|
||||
<span className="badge badge-dm">{MODE_LABELS[encounter.mode]}</span>
|
||||
<span className={`badge ${encounter.status === 'ACTIVE' ? 'badge-live' : ''}`}>{STATUS_LABELS[encounter.status]}</span>
|
||||
{isBattle && <span className="badge">Round {encounter.currentRound}</span>}
|
||||
{active && <span className="turn-marker">▶ {active.displayName}'s turn</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-row">
|
||||
<div className="flex-row flex-wrap">
|
||||
<Link to={`/campaigns/${encounter.campaignId}`} className="btn btn-ghost">
|
||||
← Campaign
|
||||
</Link>
|
||||
{isDM && (
|
||||
<>
|
||||
<select
|
||||
aria-label="Encounter type"
|
||||
value={encounter.mode}
|
||||
onChange={(e) => patchEncounter.mutate({ mode: e.target.value as EncounterMode }, fail)}
|
||||
>
|
||||
<option value="BATTLE">Battle</option>
|
||||
<option value="QUEST">NPC quest</option>
|
||||
</select>
|
||||
{encounter.status === 'PLANNING' && (
|
||||
<button className="btn btn-primary" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' })}>
|
||||
Begin Combat
|
||||
<button className="btn btn-primary" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' }, fail)}>
|
||||
{isBattle ? 'Begin Battle' : 'Begin Quest'}
|
||||
</button>
|
||||
)}
|
||||
{encounter.status === 'ACTIVE' && (
|
||||
{encounter.status === 'ACTIVE' && isBattle && (
|
||||
<>
|
||||
<button className="btn btn-primary" onClick={() => advanceRound.mutate()}>
|
||||
Advance Round
|
||||
<button className="btn btn-primary" onClick={() => nextTurn.mutate(undefined, fail)} disabled={nextTurn.isPending}>
|
||||
Next Turn ▶
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={() => patchEncounter.mutate({ status: 'COMPLETE' })}>
|
||||
End Encounter
|
||||
<button className="btn" onClick={() => advanceRound.mutate(undefined, fail)} title="Skip straight to the top of the next round">
|
||||
Next Round
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{encounter.status === 'ACTIVE' && (
|
||||
<button className="btn btn-danger" onClick={() => patchEncounter.mutate({ status: 'COMPLETE' }, fail)}>
|
||||
{isBattle ? 'End Battle' : 'Complete Quest'}
|
||||
</button>
|
||||
)}
|
||||
{encounter.status === 'COMPLETE' && (
|
||||
<button className="btn" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' }, fail)}>
|
||||
Reopen
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -230,47 +705,129 @@ export function EncounterPage() {
|
||||
|
||||
{formError && <ErrorBanner message={formError} />}
|
||||
|
||||
{isDM && (
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">Add Combatant</div>
|
||||
<div className="flex-row flex-wrap">
|
||||
<select value={pickType} onChange={(e) => setPickType(e.target.value as 'character' | 'monster')}>
|
||||
<option value="character">Character</option>
|
||||
<option value="monster">Monster</option>
|
||||
</select>
|
||||
<select value={pickId} onChange={(e) => setPickId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
|
||||
<option value="">Select…</option>
|
||||
{(pickType === 'character' ? availableCharacters : availableMonsters)?.map((entity) => (
|
||||
<option key={entity.id} value={entity.id}>
|
||||
{entity.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Initiative"
|
||||
value={initiative}
|
||||
onChange={(e) => setInitiative(e.target.value)}
|
||||
style={{ width: 100 }}
|
||||
{(isDM || encounter.description) && (
|
||||
<div className="card">
|
||||
<div className="card-title">{isBattle ? 'The Scene' : 'Quest'}</div>
|
||||
{isDM ? (
|
||||
<textarea
|
||||
key={`description:${encounter.description ?? ''}`}
|
||||
rows={3}
|
||||
className="textarea-block"
|
||||
defaultValue={encounter.description ?? ''}
|
||||
aria-label="Scene description, shown to players"
|
||||
placeholder={
|
||||
isBattle
|
||||
? 'Describe the battlefield: terrain, cover, hazards… (shown to players)'
|
||||
: 'What is being asked of the party, and by whom? (shown to players)'
|
||||
}
|
||||
onBlur={(e) => {
|
||||
const description = e.target.value.trim() || null;
|
||||
if (description !== encounter.description) patchEncounter.mutate({ description }, fail);
|
||||
}}
|
||||
/>
|
||||
<button className="btn btn-primary" onClick={handleAdd} disabled={!pickId}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{encounter.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-lg">
|
||||
{sorted.length === 0 && <div className="empty-state">No combatants yet.</div>}
|
||||
{sorted.map((p) => (
|
||||
<ParticipantRow
|
||||
key={p.id}
|
||||
participant={p}
|
||||
isDM={Boolean(isDM)}
|
||||
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data })}
|
||||
onRemove={() => removeParticipant.mutate(p.id)}
|
||||
<div className="encounter-layout mt-md">
|
||||
<div>
|
||||
{participants.length === 0 && (
|
||||
<div className="empty-state card">
|
||||
{isDM
|
||||
? 'Nobody here yet. Add characters, monsters or NPCs from the panel alongside.'
|
||||
: 'The DM is still setting the scene.'}
|
||||
</div>
|
||||
)}
|
||||
{groups
|
||||
.filter(([, members]) => members.length > 0)
|
||||
.map(([label, members]) => (
|
||||
<div key={label} className="mt-md">
|
||||
<div className="section-label">
|
||||
{label} · {members.length}
|
||||
</div>
|
||||
{members.map(card)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isDM && (
|
||||
<div className="card">
|
||||
<div className="card-title">Add to the scene</div>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<select
|
||||
aria-label="What to add"
|
||||
value={pickType}
|
||||
onChange={(e) => {
|
||||
setPickType(e.target.value as typeof pickType);
|
||||
setPickId('');
|
||||
}}
|
||||
>
|
||||
<option value="character">Character</option>
|
||||
<option value="monster">Monster</option>
|
||||
<option value="npc">NPC</option>
|
||||
</select>
|
||||
<select aria-label="Who to add" value={pickId} onChange={(e) => setPickId(e.target.value)} style={{ flex: 1, minWidth: 110 }}>
|
||||
<option value="">{pickOptions.length ? 'Choose…' : 'None left to add'}</option>
|
||||
{pickOptions.map((entity) => (
|
||||
<option key={entity.id} value={entity.id}>
|
||||
{entity.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-row gap-sm mt-sm">
|
||||
{isBattle && (
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Initiative"
|
||||
aria-label="Initiative"
|
||||
value={initiative}
|
||||
onChange={(e) => setInitiative(e.target.value)}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={handleAdd} disabled={!pickId || addParticipant.isPending}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{pickType !== 'character' && pickOptions.length === 0 && (
|
||||
<p className="combatant-sub">
|
||||
Create {pickType === 'npc' ? 'NPCs' : 'monsters'} on the <Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link> first.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RewardsPanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
characters={builtCharacters}
|
||||
weapons={weapons ?? []}
|
||||
items={items ?? []}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isDM && (
|
||||
<div className="card">
|
||||
<div className="card-title">DM Notes</div>
|
||||
<textarea
|
||||
key={`dmNotes:${encounter.dmNotes ?? ''}`}
|
||||
rows={5}
|
||||
className="textarea-block"
|
||||
defaultValue={encounter.dmNotes ?? ''}
|
||||
aria-label="DM notes, never shown to players"
|
||||
placeholder="Tactics, secrets, what happens if they fail… (only you see this)"
|
||||
onBlur={(e) => {
|
||||
const dmNotes = e.target.value.trim() || null;
|
||||
if (dmNotes !== (encounter.dmNotes ?? null)) patchEncounter.mutate({ dmNotes }, fail);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -55,6 +55,7 @@ export function PartyOverviewPage() {
|
||||
<th>
|
||||
AC <InfoTip term="AC" />
|
||||
</th>
|
||||
<th>Gold</th>
|
||||
<th>Location</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -89,6 +90,7 @@ export function PartyOverviewPage() {
|
||||
)}
|
||||
</td>
|
||||
<td>{c.armorClass ?? '—'}</td>
|
||||
<td className="mono">{c.gold}</td>
|
||||
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user