Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16b5ac05ca | ||
|
|
9d0a4a2252 | ||
|
|
555765916f | ||
|
|
9b3637576d | ||
|
|
8811f14732 | ||
|
|
c3f2b0dfa7 |
@@ -99,6 +99,35 @@ including a character sheet or an encounter) and **Account**.
|
||||
checklist at level 2 or 3, and shown beside the class everywhere; the DM can
|
||||
change it from the sheet, and switch a 2014 path to its 2024 update. Every
|
||||
sheet shows **the road ahead**: what each level from 1 to 20 gives.
|
||||
- **Class features that change a number are worked out**: Unarmored Defense
|
||||
(barbarian, monk), Draconic Resilience, Fast Movement, Unarmored Movement,
|
||||
Aura of Protection, Diamond Soul, Slippery Mind and Jack of All Trades are
|
||||
counted on the sheet, by what is equipped (armor and shields are marked
|
||||
items) and by level in the class that gives them.
|
||||
- **Stricter battles.** A spell with no slot of its level or higher left
|
||||
cannot be cast (cantrips never need one), and a player changes the weapon
|
||||
in hand only as the battle begins or on their own turn after attacking.
|
||||
- **Free roam**, a third kind of encounter beside battles and quests: the DM
|
||||
ticks the nearby locations and adds the characters who are there, and
|
||||
each player sends their own character to a place to meet who is found there
|
||||
and shop (a place's own shop, or an NPC's wares) where they stand.
|
||||
- **Completed encounters are hidden** from the campaign's list until "Show
|
||||
completed" is ticked.
|
||||
- **Faster battles.** A colour-coded **turn-order track** (PCs blue, NPCs
|
||||
green, monsters red) shows this round, a 🏁 flag where it ends, and the
|
||||
next. Click a name (or press 1–9) to **target**. Whoever is on turn picks a
|
||||
**weapon or spell**; the d20 is typed in or rolled and the app says **hit,
|
||||
miss or critical**, or, for a spell, asks each target for its **saving
|
||||
throw** against the caster's DC. The DM overrules anything and applies it
|
||||
with one click. With **auto-roll** on, a monster's or NPC's turn opens with
|
||||
its attack already aimed and rolled, to accept, edit or discard. A "What
|
||||
now" line always says what the scene is waiting on.
|
||||
- **Dice that roll.** Each die is drawn as its own shape, tumbles in and
|
||||
lands on its result; a natural 20 glows. In a campaign the server makes
|
||||
every roll and the **DM sees each one** as it lands, in the roller's
|
||||
"Everyone's rolls" tab, beside the dice button, and on the encounter page.
|
||||
A campaign can play with digital dice, real dice typed in (and checked), or
|
||||
both, and can keep each player's rolls between them and the DM.
|
||||
- **Multiclassing** (a campaign setting): the level-up checklist asks which
|
||||
class gains the level, checks both classes' ability requirements, and works
|
||||
out HP, Hit Dice, spell slots, counters and features by each class's own
|
||||
@@ -114,9 +143,13 @@ including a character sheet or an encounter) and **Account**.
|
||||
who sees a roll. **Combat**: auto-rolled monster and NPC attacks, the
|
||||
critical hit rule, monster HP, the area penalty. **Progression**: XP
|
||||
tracking (off for milestone levelling) and HP on a level up.
|
||||
- **⬆ Level Up…** on a sheet (DM) takes the character up a level with the
|
||||
whole checklist: HP, the class's new special abilities, a Boon, spells and
|
||||
the free weapon upgrade.
|
||||
- **⬆ Level up…** (DM, beside Rest) approves a level for any of the party at
|
||||
once. Each player gets a notice, sees what the level gives and makes their
|
||||
own picks in the checklist: HP (the average, or a roll where the campaign
|
||||
rolls), the class's new special abilities, a subclass, a Boon, spells and
|
||||
the free weapon upgrade. A level waiting shows on the sheet and the
|
||||
campaign page until it is taken; the DM sees who still has picks to make,
|
||||
and can finish any of them.
|
||||
- The character sheet is long, so it starts with a row of **section links**
|
||||
that jump straight to that part of the page.
|
||||
- On a small screen every table (weapons, items, spells, monsters, the party
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.24.0",
|
||||
"version": "1.30.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Level-ups the DM approves for players to take, and rolled HP on a level up.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "characters" ADD COLUMN "hpRollAdjust" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "levelUpsPending" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Rolls made at the table's dice roller, kept so the DM can see each one.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dice_rolls" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"rollerName" TEXT NOT NULL,
|
||||
"label" TEXT,
|
||||
"notation" TEXT NOT NULL,
|
||||
"dice" JSONB NOT NULL,
|
||||
"modifier" INTEGER NOT NULL DEFAULT 0,
|
||||
"total" INTEGER NOT NULL,
|
||||
"natural" INTEGER,
|
||||
"mode" TEXT NOT NULL DEFAULT 'NORMAL',
|
||||
"physical" BOOLEAN NOT NULL DEFAULT false,
|
||||
"hidden" BOOLEAN NOT NULL DEFAULT false,
|
||||
"encounterId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "dice_rolls_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dice_rolls_campaignId_createdAt_idx" ON "dice_rolls"("campaignId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dice_rolls" ADD CONSTRAINT "dice_rolls_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Battle actions: an attack or spell from being declared to its damage
|
||||
-- landing; named attacks for monsters; and how each spell lands (a spell
|
||||
-- attack, or a saving throw). Spells already in campaigns that came from
|
||||
-- the library are marked by name.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "monsters" ADD COLUMN "attacks" JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "spells" ADD COLUMN "attackRoll" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "halfOnSave" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "saveAbility" "Ability";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_actions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounterId" TEXT NOT NULL,
|
||||
"round" INTEGER NOT NULL,
|
||||
"actorParticipantId" TEXT,
|
||||
"actorName" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"detail" JSONB NOT NULL,
|
||||
"targets" JSONB NOT NULL,
|
||||
"damage" JSONB,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"auto" BOOLEAN NOT NULL DEFAULT false,
|
||||
"hidden" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"appliedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "encounter_actions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "encounter_actions_encounterId_status_idx" ON "encounter_actions"("encounterId", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_actions" ADD CONSTRAINT "encounter_actions_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- How the library's spells land, for copies already in campaigns.
|
||||
UPDATE "spells" SET "attackRoll" = true WHERE lower("name") IN ('chill touch', 'eldritch blast', 'fire bolt', 'produce flame', 'ray of frost', 'shocking grasp', 'thorn whip', 'guiding bolt', 'witch bolt', 'scorching ray', 'spiritual weapon', 'contagion');
|
||||
UPDATE "spells" SET "saveAbility" = 'STR', "halfOnSave" = false WHERE lower("name") IN ('entangle', 'gust of wind');
|
||||
UPDATE "spells" SET "saveAbility" = 'DEX', "halfOnSave" = true WHERE lower("name") IN ('burning hands', 'flaming sphere', 'call lightning', 'fireball', 'lightning bolt', 'guardian of faith', 'ice storm', 'wall of fire', 'flame strike');
|
||||
UPDATE "spells" SET "saveAbility" = 'DEX', "halfOnSave" = false WHERE lower("name") IN ('acid splash', 'sacred flame', 'faerie fire', 'web', 'sleet storm');
|
||||
UPDATE "spells" SET "saveAbility" = 'CON', "halfOnSave" = true WHERE lower("name") IN ('thunderwave', 'moonbeam', 'shatter', 'blight', 'cloudkill', 'cone of cold', 'insect plague');
|
||||
UPDATE "spells" SET "saveAbility" = 'CON', "halfOnSave" = false WHERE lower("name") IN ('poison spray', 'blindness/deafness', 'levitate', 'stinking cloud');
|
||||
UPDATE "spells" SET "saveAbility" = 'WIS', "halfOnSave" = true WHERE lower("name") IN ('spirit guardians');
|
||||
UPDATE "spells" SET "saveAbility" = 'WIS', "halfOnSave" = false WHERE lower("name") IN ('vicious mockery', 'animal friendship', 'charm person', 'command', 'hold person', 'suggestion', 'fear', 'slow', 'confusion', 'polymorph', 'dominate person', 'hold monster');
|
||||
UPDATE "spells" SET "saveAbility" = 'CHA', "halfOnSave" = false WHERE lower("name") IN ('bane', 'zone of truth', 'banishment');
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Free roam: a kind of encounter where the party wanders between places the
|
||||
-- DM has laid out. A location can have NPCs found there and things for sale.
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "EncounterMode" ADD VALUE 'FREE_ROAM';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "npcs" ADD COLUMN "locationId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "sale_offers" ADD COLUMN "locationId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_locations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounterId" TEXT NOT NULL,
|
||||
"locationId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "encounter_locations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "encounter_locations_encounterId_locationId_key" ON "encounter_locations"("encounterId", "locationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sale_offers_locationId_idx" ON "sale_offers"("locationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "npcs" ADD CONSTRAINT "npcs_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Armor and shields say what kind they are, so the sheet can tell when a
|
||||
-- character is unarmored (Unarmored Defense, a monk's speed) or in heavy
|
||||
-- armor (a barbarian's Fast Movement). Copies of the library's armor already
|
||||
-- in a campaign are marked by name; anything else the DM marks on the item.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ArmorType" AS ENUM ('LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "items" ADD COLUMN "armorType" "ArmorType";
|
||||
|
||||
UPDATE "items" SET "armorType" = 'LIGHT' WHERE "name" IN ('Padded Armor', 'Leather Armor', 'Studded Leather');
|
||||
UPDATE "items" SET "armorType" = 'MEDIUM' WHERE "name" IN ('Hide Armor', 'Chain Shirt', 'Scale Mail', 'Breastplate', 'Half Plate');
|
||||
UPDATE "items" SET "armorType" = 'HEAVY' WHERE "name" IN ('Ring Mail', 'Chain Mail', 'Splint Armor', 'Plate Armor');
|
||||
UPDATE "items" SET "armorType" = 'SHIELD' WHERE "name" = 'Shield';
|
||||
@@ -134,11 +134,13 @@ 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.
|
||||
// What kind of scene an encounter is: a fight run in initiative order, a
|
||||
// quest scene built around NPCs and an objective, or free roam, where the
|
||||
// party wanders between places the DM has laid out (to shop, to meet people).
|
||||
enum EncounterMode {
|
||||
BATTLE
|
||||
QUEST
|
||||
FREE_ROAM
|
||||
}
|
||||
|
||||
enum NpcDisposition {
|
||||
@@ -290,10 +292,40 @@ model Campaign {
|
||||
saleOffers SaleOffer[]
|
||||
notes Note[]
|
||||
locations Location[]
|
||||
diceRolls DiceRoll[]
|
||||
|
||||
@@map("campaigns")
|
||||
}
|
||||
|
||||
// A roll made at the table's dice roller, kept so the table (and above all
|
||||
// the DM) can see it. Digital rolls are made by the server; physical ones are
|
||||
// typed in and checked against their dice. Who sees a roll follows the
|
||||
// campaign's rollVisibility; a `hidden` one is the DM's alone.
|
||||
model DiceRoll {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
userId String
|
||||
rollerName String // who rolled, as shown: kept so a roll outlives a renamed account
|
||||
label String? // what it was for: "Aria: Stealth"
|
||||
notation String // "1d20+3", "2d6+1d8+2"
|
||||
// Every die, in order: [{ sides, value, kept, sign }]. `kept` is false for
|
||||
// the d20 that advantage or disadvantage dropped.
|
||||
dice Json
|
||||
modifier Int @default(0)
|
||||
total Int
|
||||
natural Int? // the d20 that counted, for a roll of a single d20
|
||||
mode String @default("NORMAL") // NORMAL | ADVANTAGE | DISADVANTAGE
|
||||
physical Boolean @default(false) // rolled at the table and typed in
|
||||
hidden Boolean @default(false) // the DM's alone
|
||||
encounterId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([campaignId, createdAt])
|
||||
@@map("dice_rolls")
|
||||
}
|
||||
|
||||
model Location {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
@@ -305,6 +337,10 @@ model Location {
|
||||
characters Character[]
|
||||
encounters Encounter[]
|
||||
visits CharacterLocationVisit[]
|
||||
// Who is found here, what is for sale here, and the free-roam scenes it is a stop in.
|
||||
npcs Npc[]
|
||||
wares SaleOffer[]
|
||||
freeRoams EncounterLocation[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("locations")
|
||||
@@ -357,6 +393,13 @@ model Character {
|
||||
// until the class reaches the level it chooses at.
|
||||
subclass String?
|
||||
level Int @default(1)
|
||||
// Levels the DM has approved that the character has not yet taken: each is
|
||||
// one level-up checklist waiting for its picks. The player resolves them
|
||||
// (or the DM does), one at a time.
|
||||
levelUpsPending Int @default(0)
|
||||
// Where the campaign rolls HP on a level up: how far the rolls so far are
|
||||
// from the average they replace, in all (a 2 on a d8 is -3). Added to max HP.
|
||||
hpRollAdjust Int @default(0)
|
||||
xp Int @default(0)
|
||||
motivation String?
|
||||
|
||||
@@ -554,6 +597,10 @@ model Item {
|
||||
// item with an AC effect.
|
||||
effects Json @default("[]")
|
||||
effectNote String?
|
||||
// Set on armor and shields. Its AC is still an effect; this says what kind
|
||||
// it is, so the sheet knows when a character is unarmored (Unarmored
|
||||
// Defense, a monk's speed) or in heavy armor (a barbarian's Fast Movement).
|
||||
armorType ArmorType?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
@@ -565,6 +612,13 @@ model Item {
|
||||
@@map("items")
|
||||
}
|
||||
|
||||
enum ArmorType {
|
||||
LIGHT
|
||||
MEDIUM
|
||||
HEAVY
|
||||
SHIELD
|
||||
}
|
||||
|
||||
// An Item from the campaign pool held by a character.
|
||||
model InventoryItem {
|
||||
id String @id @default(cuid())
|
||||
@@ -619,6 +673,13 @@ model Spell {
|
||||
// Brings back the dead: a character who can cast it may revive a dead ally
|
||||
// on their turn in a battle (encounters.routes.js).
|
||||
revives Boolean @default(false)
|
||||
// How it lands in a battle (game/combat.js). attackRoll: the caster rolls a
|
||||
// spell attack against AC. saveAbility: each target saves against the
|
||||
// caster's spell DC with this ability, taking half on a success when
|
||||
// halfOnSave. Neither: it simply takes effect.
|
||||
attackRoll Boolean @default(false)
|
||||
saveAbility Ability?
|
||||
halfOnSave Boolean @default(false)
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
known CharacterSpell[]
|
||||
@@ -662,6 +723,11 @@ model Monster {
|
||||
challengeRating String?
|
||||
specialTrait String?
|
||||
notes String?
|
||||
// Named attacks, for a monster with more than the one above: a list of
|
||||
// { name, attackBonus, damageDice, damageType?, count? }. `count` is how
|
||||
// many times it is made each turn (a multiattack). Empty: the single
|
||||
// attackBonus/damageDice above is its attack.
|
||||
attacks Json @default("[]")
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
encounterAppearances EncounterParticipant[]
|
||||
@@ -688,9 +754,13 @@ model Npc {
|
||||
// for exchangeCash cash, either way. Both null: this NPC does not exchange.
|
||||
exchangeGold Int?
|
||||
exchangeCash Int?
|
||||
// Where this NPC is usually found. In a free-roam scene they are met (and
|
||||
// traded with) by whoever goes there.
|
||||
locationId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
|
||||
encounterAppearances EncounterParticipant[]
|
||||
wares SaleOffer[]
|
||||
|
||||
@@ -732,11 +802,66 @@ model Encounter {
|
||||
participants EncounterParticipant[]
|
||||
rewards EncounterReward[]
|
||||
wares SaleOffer[]
|
||||
actions EncounterAction[]
|
||||
// Free roam: the places the party can go to in this scene.
|
||||
places EncounterLocation[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@map("encounters")
|
||||
}
|
||||
|
||||
// A place that is a stop in a free-roam scene: one of the campaign's
|
||||
// locations, picked by the DM as somewhere the party can go right now.
|
||||
model EncounterLocation {
|
||||
id String @id @default(cuid())
|
||||
encounterId String
|
||||
locationId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([encounterId, locationId])
|
||||
@@map("encounter_locations")
|
||||
}
|
||||
|
||||
// An attack or a spell in a battle, from the moment it is declared to the
|
||||
// moment its damage lands (game/combat.js). A player declares their own on
|
||||
// their turn; the DM declares anyone's; and with the campaign's auto-roll
|
||||
// settings the server declares a monster's or NPC's, already rolled. While
|
||||
// PENDING it gathers what it needs (a d20 against each target's AC, or each
|
||||
// target's saving throw, and the damage); the DM then applies it, changed as
|
||||
// they see fit, or discards it. `hidden` keeps it from the players until it
|
||||
// is applied.
|
||||
model EncounterAction {
|
||||
id String @id @default(cuid())
|
||||
encounterId String
|
||||
round Int
|
||||
actorParticipantId String?
|
||||
actorName String
|
||||
kind String // ATTACK (a d20 against AC) | SAVE (targets save against a DC) | AUTO (it just lands)
|
||||
name String // "Longsword", "Fireball", "Bite"
|
||||
// What it rolls with: { attackBonus?, saveAbility?, saveDC?, halfOnSave?,
|
||||
// damageDice?, damageBonus, damageType?, characterId?, slotLevel? }.
|
||||
detail Json
|
||||
// One entry per target: { participantId, name, kind, ac, penalty, saveBonus,
|
||||
// d20, outcome (set by the DM to overrule the dice), damage (likewise) }.
|
||||
targets Json
|
||||
// The damage once rolled or entered: { total, dice?, modifier?, crit? }.
|
||||
damage Json?
|
||||
status String @default("PENDING") // PENDING | APPLIED | DISCARDED
|
||||
auto Boolean @default(false) // declared and rolled by the server
|
||||
hidden Boolean @default(false)
|
||||
createdById String?
|
||||
createdAt DateTime @default(now())
|
||||
appliedAt DateTime?
|
||||
|
||||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([encounterId, status])
|
||||
@@map("encounter_actions")
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -764,15 +889,17 @@ model EncounterReward {
|
||||
}
|
||||
|
||||
// Something for sale: a weapon or item from the campaign pools, at a price.
|
||||
// It belongs to an NPC (and is on offer wherever that NPC is met) or to an
|
||||
// encounter itself (a stall, a vending machine). Only the DM sets these up;
|
||||
// players see them, and buy, in a quest encounter in progress
|
||||
// (encounters.routes.js).
|
||||
// It belongs to an NPC (and is on offer wherever that NPC is met), to an
|
||||
// encounter itself (a stall, a vending machine), or to a location (a shop
|
||||
// that is always there, open to whoever goes to it in a free-roam scene).
|
||||
// Only the DM sets these up; players see them, and buy, in a quest or
|
||||
// free-roam encounter in progress (encounters.routes.js).
|
||||
model SaleOffer {
|
||||
id String @id @default(cuid())
|
||||
campaignId String
|
||||
npcId String? // exactly one of npcId / encounterId is set
|
||||
npcId String? // exactly one of npcId / encounterId / locationId is set
|
||||
encounterId String?
|
||||
locationId String?
|
||||
weaponId String? // exactly one of weaponId / itemId is set
|
||||
itemId String?
|
||||
price Int // for one, in `currency`
|
||||
@@ -784,12 +911,14 @@ model SaleOffer {
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
npc Npc? @relation(fields: [npcId], references: [id], onDelete: Cascade)
|
||||
encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
|
||||
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
|
||||
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([npcId])
|
||||
@@index([encounterId])
|
||||
@@index([locationId])
|
||||
@@map("sale_offers")
|
||||
}
|
||||
|
||||
|
||||
@@ -187,8 +187,8 @@ const items = [
|
||||
{ name: 'Torches (bundle of 5)', description: 'Each burns for an hour: bright light for 20 feet, dim for 20 more.', isStartingOption: true },
|
||||
{ name: 'Healer’s Kit', description: 'Ten uses. Spend one to stabilise a creature at 0 hit points without a Medicine check.', isStartingOption: true },
|
||||
{ name: 'Thieves’ Tools', description: 'Picks, a small file and a mirror on a handle. Needed to pick a lock or disarm a trap.', isStartingOption: true },
|
||||
{ name: 'Shield', description: 'A wooden or steel shield carried on one arm.', isStartingOption: true, effects: [{ stat: 'AC', amount: 2 }], effectNote: 'Takes a hand: no two-handed weapon while it is up.' },
|
||||
{ name: 'Leather Armor', description: 'Boiled leather, light enough to sleep and sneak in.', isStartingOption: true, effects: [{ stat: 'AC', amount: 1 }] },
|
||||
{ name: 'Shield', armorType: 'SHIELD', description: 'A wooden or steel shield carried on one arm.', isStartingOption: true, effects: [{ stat: 'AC', amount: 2 }], effectNote: 'Takes a hand: no two-handed weapon while it is up.' },
|
||||
{ name: 'Leather Armor', armorType: 'LIGHT', description: 'Boiled leather, light enough to sleep and sneak in.', isStartingOption: true, effects: [{ stat: 'AC', amount: 1 }] },
|
||||
{ name: 'Spellcasting Focus', description: 'A wand, holy symbol, crystal or carved staff that your spells are cast through.', isStartingOption: true },
|
||||
{ name: 'Founding Fair Token', description: 'A stamped brass token from the Lantern Market. Good for one drink at any stall, or a small favour from whoever ran the game you won it at.', isStartingOption: true },
|
||||
|
||||
@@ -212,7 +212,7 @@ const items = [
|
||||
{ name: 'Seal-Stone Fragment', description: 'A shard of worked stone from the edge of the breach. Script runs along one face. It is warm, and holding it makes the bell script easier to look at.' },
|
||||
{ name: 'Seized House Documents', description: 'Ledgers and letters taken from the noble house after it was confronted. No value in coin; a great deal of value to anyone building a case.' },
|
||||
{ name: 'House Records', description: 'The fallen house’s private records: names, payments and meeting places for its contacts across Aldenfell.' },
|
||||
{ name: 'Masterwork Chain Mail', description: 'Close-linked mail from the Grey Dawn’s own armoury, fitted rather than issued.', minLevel: 5, effects: [{ stat: 'AC', amount: 7 }, { stat: 'SPEED', amount: -10 }], effectNote: 'Disadvantage on Stealth checks.' },
|
||||
{ name: 'Masterwork Chain Mail', armorType: 'HEAVY', description: 'Close-linked mail from the Grey Dawn’s own armoury, fitted rather than issued.', minLevel: 5, effects: [{ stat: 'AC', amount: 7 }, { stat: 'SPEED', amount: -10 }], effectNote: 'Disadvantage on Stealth checks.' },
|
||||
{ name: 'The Seal-Stone', description: 'The stone at the heart of the seal, whole again and quiet. It is heavier than it looks, and it is never quite cold.', minLevel: 7, effects: [{ stat: 'SAVE_WIS', amount: 2 }], effectNote: 'Grows warm near anything of the Hollow Below. Its other properties are the DM’s to decide: something tied to binding, warding or detecting.' },
|
||||
];
|
||||
|
||||
|
||||
@@ -878,6 +878,7 @@ const book = {
|
||||
},
|
||||
{
|
||||
"name": "Shield Ward",
|
||||
"armorType": "SHIELD",
|
||||
"description": "A silver shield charm worn on the forearm; it unfolds into a disc of pale light. Counts as a shield (+2 AC). Once per long rest, as a reaction when hit, gain +5 AC against that attack.",
|
||||
"damageDice": null,
|
||||
"minLevel": 7,
|
||||
@@ -1087,6 +1088,7 @@ const book = {
|
||||
},
|
||||
{
|
||||
"name": "Riot Shield",
|
||||
"armorType": "SHIELD",
|
||||
"description": "A tall clear plastic shield from Port Meridian.",
|
||||
"damageDice": null,
|
||||
"minLevel": 1,
|
||||
@@ -1102,6 +1104,7 @@ const book = {
|
||||
},
|
||||
{
|
||||
"name": "Motorcycle Leathers",
|
||||
"armorType": "LIGHT",
|
||||
"description": "Jacket, trousers, gloves and boots, all scuffed. Light armor for heroes who still want to look cool.",
|
||||
"damageDice": null,
|
||||
"minLevel": 1,
|
||||
@@ -1132,6 +1135,7 @@ const book = {
|
||||
},
|
||||
{
|
||||
"name": "Silverleaf Mail",
|
||||
"armorType": "MEDIUM",
|
||||
"description": "A chain shirt woven from leaves of living silver, cool and quiet, light as a summer shirt. A gift of the Silverbough Court.",
|
||||
"damageDice": null,
|
||||
"minLevel": 5,
|
||||
@@ -1147,6 +1151,7 @@ const book = {
|
||||
},
|
||||
{
|
||||
"name": "Cinderscale Armor",
|
||||
"armorType": "MEDIUM",
|
||||
"description": "Scale mail made from red dragon scales that Cindermaw shed as a wyrmling, still warm.",
|
||||
"damageDice": null,
|
||||
"minLevel": 7,
|
||||
@@ -1162,6 +1167,7 @@ const book = {
|
||||
},
|
||||
{
|
||||
"name": "Gauntlet Plate",
|
||||
"armorType": "HEAVY",
|
||||
"description": "The silver plate armor of a fallen knight of the Silver Gauntlet, polished in the chapel for a hundred years.",
|
||||
"damageDice": null,
|
||||
"minLevel": 8,
|
||||
|
||||
@@ -287,6 +287,10 @@ function activeGear(character) {
|
||||
|
||||
const effects = [];
|
||||
const notes = [];
|
||||
// The armor on their back (the heaviest, should there be two) and whether
|
||||
// a shield is on their arm: class features turn on what is worn.
|
||||
const kinds = held.map((h) => h.entry.armorType).filter(Boolean);
|
||||
const armor = { worn: ['HEAVY', 'MEDIUM', 'LIGHT'].find((kind) => kinds.includes(kind)) || null, shield: kinds.includes('SHIELD') };
|
||||
for (const { entry } of held) {
|
||||
for (const effect of Array.isArray(entry.effects) ? entry.effects : []) {
|
||||
if (GEAR_STATS.includes(effect.stat) && Number.isInteger(effect.amount) && effect.amount !== 0) {
|
||||
@@ -295,7 +299,7 @@ function activeGear(character) {
|
||||
}
|
||||
if (entry.effectNote) notes.push({ source: entry.name, note: entry.effectNote });
|
||||
}
|
||||
return { effects, notes };
|
||||
return { effects, notes, armor };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,6 +329,7 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
speedOverride = null,
|
||||
spellSlotsUsed = [],
|
||||
hitDiceUsed = 0,
|
||||
hpRollAdjust = 0,
|
||||
} = character;
|
||||
|
||||
const rolled = { STR: strScore, DEX: dexScore, CON: conScore, INT: intScore, WIS: wisScore, CHA: chaScore };
|
||||
@@ -346,11 +351,22 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
|
||||
const profBonus = proficiencyBonus(level);
|
||||
|
||||
// Every class the character has levels in, the first first. One entry for
|
||||
// most characters; a multiclassed one adds up per class (multiclass.js).
|
||||
const classes = multiclass.classLevels(character);
|
||||
const multi = classes.length > 1;
|
||||
// What their class features do to the numbers below (classFeatures.js).
|
||||
const modifiers = Object.fromEntries(ABILITIES.map((ability) => [ability, abilities[ability].modifier]));
|
||||
const features = classFeatureEffects(classes, modifiers, profBonus, gear.armor);
|
||||
|
||||
const saves = {};
|
||||
for (const ability of ABILITIES) {
|
||||
const entry = saveProficiencies.find((s) => s.ability === ability);
|
||||
const proficient = entry ? entry.proficient : false;
|
||||
saves[ability] = { proficient, bonus: saveBonus(abilities[ability].modifier, proficient, profBonus) + fromGear('SAVES', `SAVE_${ability}`) };
|
||||
const proficient = (entry ? entry.proficient : false) || features.saveProficiencies.includes(ability);
|
||||
saves[ability] = {
|
||||
proficient,
|
||||
bonus: saveBonus(abilities[ability].modifier, proficient, profBonus) + features.saveBonus + fromGear('SAVES', `SAVE_${ability}`),
|
||||
};
|
||||
}
|
||||
|
||||
const skills = {};
|
||||
@@ -363,14 +379,10 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
ability,
|
||||
proficient,
|
||||
expertise,
|
||||
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise) + fromGear('SKILLS', `SKILL_${skillKey}`),
|
||||
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise) + (proficient ? 0 : features.halfProficiency) + fromGear('SKILLS', `SKILL_${skillKey}`),
|
||||
};
|
||||
}
|
||||
|
||||
// Every class the character has levels in, the first first. One entry for
|
||||
// most characters; a multiclassed one adds up per class (multiclass.js).
|
||||
const classes = multiclass.classLevels(character);
|
||||
const multi = classes.length > 1;
|
||||
const hitDieSize = getClassHitDieSize(characterClass);
|
||||
const casters = classes
|
||||
.map((entry) => ({ ...entry, ability: getClassSpellcastingAbility(entry.characterClass, entry.subclass) }))
|
||||
@@ -385,11 +397,18 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
return {
|
||||
abilities,
|
||||
proficiencyBonus: profBonus,
|
||||
armorClass: armorClass(abilities.DEX.modifier, armorBonus) + fromGear('AC'),
|
||||
initiative: initiative(abilities.DEX.modifier) + fromGear('INITIATIVE'),
|
||||
speed: Math.max(0, effectiveSpeed(species, speedOverride) + fromGear('SPEED')),
|
||||
// Unarmored Defense and the like replace the 10 + DEX that AC starts from.
|
||||
armorClass: (features.acBase !== null ? features.acBase + armorBonus : armorClass(abilities.DEX.modifier, armorBonus)) + fromGear('AC'),
|
||||
initiative: initiative(abilities.DEX.modifier) + features.halfProficiency + fromGear('INITIATIVE'),
|
||||
// A speed the DM has set is their last word: a class's extra speed is
|
||||
// added to the species' own speed only.
|
||||
speed: Math.max(0, effectiveSpeed(species, speedOverride) + (speedOverride != null ? 0 : features.speed) + fromGear('SPEED')),
|
||||
hp: {
|
||||
max: Math.max(1, (multi ? multiclass.multiclassHpMax(classes, abilities.CON.modifier) : hpMax(level, hitDieSize, abilities.CON.modifier)) + fromGear('MAX_HP')),
|
||||
// hpRollAdjust: how far HP rolled on level-ups is from the average counted here.
|
||||
max: Math.max(
|
||||
1,
|
||||
(multi ? multiclass.multiclassHpMax(classes, abilities.CON.modifier) : hpMax(level, hitDieSize, abilities.CON.modifier)) + (Number(hpRollAdjust) || 0) + features.hp + fromGear('MAX_HP')
|
||||
),
|
||||
},
|
||||
hitDice: multi ? multiclass.multiclassHitDice(classes, hitDiceUsed) : hitDiceState(characterClass, level, hitDiceUsed),
|
||||
saves,
|
||||
@@ -399,6 +418,8 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
passiveInsight: passiveScore(skills.INSIGHT.bonus),
|
||||
// What equipped gear is doing to the numbers above, and what else it does.
|
||||
gear,
|
||||
// The class features counted in the numbers above: [{ name, source, text }].
|
||||
features: speedOverride != null ? features.inForce.filter((f) => !['Fast Movement', 'Unarmored Movement'].includes(f.name)) : features.inForce,
|
||||
spellcasting: spellcastingAbility ? casting(spellcastingAbility) : null,
|
||||
// A multiclassed caster's DC and attack for each class that casts.
|
||||
spellcastingByClass: casters.map((entry) => ({ characterClass: entry.characterClass, ...casting(entry.ability) })),
|
||||
@@ -581,3 +602,4 @@ module.exports = {
|
||||
// multiclass.js and this file use each other, so it is required here, at
|
||||
// the end, and its functions are looked up when called.
|
||||
const multiclass = require('./multiclass');
|
||||
const { classFeatureEffects } = require('./classFeatures');
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// The class features that change a number on the sheet, worked out so nobody
|
||||
// has to add them by hand: Unarmored Defense, Draconic Resilience, Fast
|
||||
// Movement, Unarmored Movement, Aura of Protection, Diamond Soul, Slippery
|
||||
// Mind and Jack of All Trades. Everything else a class gives is a line of
|
||||
// text on the sheet (game/data/levelFeatures.js). Pure: no database.
|
||||
//
|
||||
// Each goes by the level in the class that gives it, so a multiclassed
|
||||
// character gets what each of their classes has earned (game/multiclass.js,
|
||||
// classLevels). Several depend on what is worn: armor and shields are items
|
||||
// marked with what kind they are (Item.armorType), and count while equipped.
|
||||
|
||||
const { SUBCLASS_LEVEL } = require('./data/subclasses');
|
||||
|
||||
// A monk's extra speed without armor or a shield, by monk level.
|
||||
function unarmoredMovement(monkLevel) {
|
||||
if (monkLevel < 2) return 0;
|
||||
if (monkLevel < 6) return 10;
|
||||
if (monkLevel < 10) return 15;
|
||||
if (monkLevel < 14) return 20;
|
||||
if (monkLevel < 18) return 25;
|
||||
return 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a character's class features do to the sheet's numbers.
|
||||
*
|
||||
* @param {Array<{characterClass: string, level: number, subclass: string|null}>} classes
|
||||
* @param {object} modifiers - ability modifiers by ability: { STR, DEX, CON, INT, WIS, CHA }
|
||||
* @param {number} profBonus
|
||||
* @param {{worn: 'LIGHT'|'MEDIUM'|'HEAVY'|null, shield: boolean}} armor - what is equipped
|
||||
* @returns {{
|
||||
* acBase: number|null, // what AC starts from in place of 10 + DEX, when that is better
|
||||
* speed: number, // added to walking speed
|
||||
* hp: number, // added to maximum HP
|
||||
* saveBonus: number, // added to every saving throw
|
||||
* saveProficiencies: string[],// saves the character is proficient in on top of their class's two
|
||||
* halfProficiency: number, // added to initiative and to skills they are not proficient in
|
||||
* inForce: Array<{name: string, source: string, text: string}>
|
||||
* }}
|
||||
*/
|
||||
function classFeatureEffects(classes, modifiers, profBonus, armor = { worn: null, shield: false }) {
|
||||
const levelIn = (characterClass) => {
|
||||
const entry = classes.find((c) => c.characterClass === characterClass);
|
||||
return entry ? entry.level : 0;
|
||||
};
|
||||
const unarmored = !armor.worn;
|
||||
const plain = 10 + modifiers.DEX;
|
||||
const inForce = [];
|
||||
const signed = (n) => (n >= 0 ? `+${n}` : `${n}`);
|
||||
|
||||
// Armor Class without armor: the best of the ways the character has of
|
||||
// working it out, and only when it beats 10 + DEX.
|
||||
const acWays = [];
|
||||
if (levelIn('BARBARIAN') >= 1 && unarmored) {
|
||||
acWays.push({ ac: plain + modifiers.CON, name: 'Unarmored Defense', source: 'Barbarian', text: `AC is 10 + Dexterity + Constitution (${signed(modifiers.CON)}) without armor; a shield still adds.` });
|
||||
}
|
||||
if (levelIn('MONK') >= 1 && unarmored && !armor.shield) {
|
||||
acWays.push({ ac: plain + modifiers.WIS, name: 'Unarmored Defense', source: 'Monk', text: `AC is 10 + Dexterity + Wisdom (${signed(modifiers.WIS)}) without armor or a shield.` });
|
||||
}
|
||||
const sorcerer = classes.find((c) => c.characterClass === 'SORCERER');
|
||||
const draconic = sorcerer && sorcerer.subclass === 'DRACONIC' && sorcerer.level >= SUBCLASS_LEVEL.SORCERER ? sorcerer : null;
|
||||
if (draconic && unarmored) {
|
||||
acWays.push({ ac: 13 + modifiers.DEX, name: 'Draconic Resilience', source: 'Draconic Bloodline', text: 'AC is 13 + Dexterity without armor.' });
|
||||
}
|
||||
const bestAc = acWays.sort((a, b) => b.ac - a.ac)[0];
|
||||
const acBase = bestAc && bestAc.ac > plain ? bestAc.ac : null;
|
||||
if (acBase !== null) inForce.push({ name: bestAc.name, source: bestAc.source, text: bestAc.text });
|
||||
|
||||
let hp = 0;
|
||||
if (draconic) {
|
||||
hp += draconic.level;
|
||||
inForce.push({ name: 'Draconic Resilience', source: 'Draconic Bloodline', text: `+1 maximum HP for each sorcerer level (+${draconic.level}).` });
|
||||
}
|
||||
|
||||
let speed = 0;
|
||||
if (levelIn('BARBARIAN') >= 5 && armor.worn !== 'HEAVY') {
|
||||
speed += 10;
|
||||
inForce.push({ name: 'Fast Movement', source: 'Barbarian', text: '+10 ft speed when not in heavy armor.' });
|
||||
}
|
||||
const monkSpeed = unarmored && !armor.shield ? unarmoredMovement(levelIn('MONK')) : 0;
|
||||
if (monkSpeed > 0) {
|
||||
speed += monkSpeed;
|
||||
inForce.push({ name: 'Unarmored Movement', source: 'Monk', text: `+${monkSpeed} ft speed without armor or a shield.` });
|
||||
}
|
||||
|
||||
let saveBonus = 0;
|
||||
if (levelIn('PALADIN') >= 6) {
|
||||
saveBonus = Math.max(1, modifiers.CHA);
|
||||
inForce.push({ name: 'Aura of Protection', source: 'Paladin', text: `+${saveBonus} (Charisma) to every saving throw while conscious. Allies in the aura add it by hand.` });
|
||||
}
|
||||
|
||||
const saveProficiencies = [];
|
||||
if (levelIn('MONK') >= 14) {
|
||||
saveProficiencies.push('STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA');
|
||||
inForce.push({ name: 'Diamond Soul', source: 'Monk', text: 'Proficient in every saving throw.' });
|
||||
}
|
||||
if (levelIn('ROGUE') >= 15) {
|
||||
saveProficiencies.push('WIS');
|
||||
inForce.push({ name: 'Slippery Mind', source: 'Rogue', text: 'Proficient in Wisdom saving throws.' });
|
||||
}
|
||||
|
||||
let halfProficiency = 0;
|
||||
if (levelIn('BARD') >= 2) {
|
||||
halfProficiency = Math.floor(profBonus / 2);
|
||||
inForce.push({ name: 'Jack of All Trades', source: 'Bard', text: `+${halfProficiency} (half proficiency) to initiative and to every skill you are not proficient in.` });
|
||||
}
|
||||
|
||||
return { acBase, speed, hp, saveBonus, saveProficiencies: [...new Set(saveProficiencies)], halfProficiency, inForce };
|
||||
}
|
||||
|
||||
module.exports = { classFeatureEffects, unarmoredMovement };
|
||||
@@ -0,0 +1,134 @@
|
||||
const { computeCharacterSheet } = require('./calculations');
|
||||
const { classFeatureEffects, unarmoredMovement } = require('./classFeatures');
|
||||
|
||||
// A human (+1 to everything) with STR 16, DEX 14 (+2), CON 14 (+2), INT 10,
|
||||
// WIS 16 (+3), CHA 16 (+3) once the species bonus is in.
|
||||
const scores = { species: 'HUMAN', strScore: 15, dexScore: 13, conScore: 13, intScore: 9, wisScore: 15, chaScore: 15, speciesBonusChoices: {} };
|
||||
const sheet = (character, saves = []) => computeCharacterSheet({ ...scores, level: 1, ...character }, [], saves);
|
||||
const wearing = (name, armorType, ac) => ({ equipped: true, level: 1, item: { name, armorType, effects: [{ stat: 'AC', amount: ac }] } });
|
||||
const names = (s) => s.features.map((f) => f.name);
|
||||
|
||||
describe('Armor Class without armor', () => {
|
||||
test('a barbarian adds Constitution, and keeps it behind a shield', () => {
|
||||
expect(sheet({ characterClass: 'BARBARIAN' }).armorClass).toBe(14); // 10 + 2 + 2
|
||||
const shielded = sheet({ characterClass: 'BARBARIAN', inventoryItems: [wearing('Shield', 'SHIELD', 2)] });
|
||||
expect(shielded.armorClass).toBe(16);
|
||||
expect(names(shielded)).toContain('Unarmored Defense');
|
||||
});
|
||||
|
||||
test('a monk adds Wisdom, but not with a shield', () => {
|
||||
expect(sheet({ characterClass: 'MONK' }).armorClass).toBe(15); // 10 + 2 + 3
|
||||
const shielded = sheet({ characterClass: 'MONK', inventoryItems: [wearing('Shield', 'SHIELD', 2)] });
|
||||
expect(shielded.armorClass).toBe(14); // 10 + 2, and the shield
|
||||
expect(names(shielded)).not.toContain('Unarmored Defense');
|
||||
});
|
||||
|
||||
test('armor on, it is the armor that counts', () => {
|
||||
const armored = sheet({ characterClass: 'BARBARIAN', inventoryItems: [wearing('Hide Armor', 'MEDIUM', 2)] });
|
||||
expect(armored.armorClass).toBe(14); // 10 + 2 + the hide's 2
|
||||
expect(names(armored)).toEqual([]);
|
||||
// Armor that is carried, not worn, changes nothing.
|
||||
const carried = sheet({ characterClass: 'BARBARIAN', inventoryItems: [{ ...wearing('Hide Armor', 'MEDIUM', 2), equipped: false }] });
|
||||
expect(carried.armorClass).toBe(14);
|
||||
expect(names(carried)).toContain('Unarmored Defense');
|
||||
});
|
||||
|
||||
test('it is left out when it would not help, and the DM’s extra AC still adds', () => {
|
||||
const frail = sheet({ characterClass: 'BARBARIAN', conScore: 7 }); // CON 8: -1
|
||||
expect(frail.armorClass).toBe(12);
|
||||
expect(names(frail)).toEqual([]);
|
||||
expect(sheet({ characterClass: 'BARBARIAN', armorBonus: 1 }).armorClass).toBe(15);
|
||||
});
|
||||
|
||||
test('a draconic sorcerer has 13 + Dexterity and a hit point a level, from the level the bloodline starts', () => {
|
||||
const before = sheet({ characterClass: 'SORCERER', subclass: 'DRACONIC', level: 2 });
|
||||
expect(before.armorClass).toBe(12);
|
||||
expect(before.hp.max).toBe(14); // 6 + 2, then 4 + 2
|
||||
const dragon = sheet({ characterClass: 'SORCERER', subclass: 'DRACONIC', level: 3 });
|
||||
expect(dragon.armorClass).toBe(15);
|
||||
expect(dragon.hp.max).toBe(23); // 20, and 3 more
|
||||
expect(sheet({ characterClass: 'SORCERER', subclass: 'WILD_MAGIC', level: 3 }).hp.max).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('speed', () => {
|
||||
test('a barbarian is 10 ft faster from level 5, out of heavy armor', () => {
|
||||
expect(sheet({ characterClass: 'BARBARIAN', level: 4 }).speed).toBe(30);
|
||||
expect(sheet({ characterClass: 'BARBARIAN', level: 5 }).speed).toBe(40);
|
||||
expect(sheet({ characterClass: 'BARBARIAN', level: 5, inventoryItems: [wearing('Hide Armor', 'MEDIUM', 2)] }).speed).toBe(40);
|
||||
expect(sheet({ characterClass: 'BARBARIAN', level: 5, inventoryItems: [wearing('Plate Armor', 'HEAVY', 8)] }).speed).toBe(30);
|
||||
});
|
||||
|
||||
test('a monk speeds up as they level, without armor or a shield', () => {
|
||||
expect([1, 2, 5, 6, 10, 14, 18, 20].map(unarmoredMovement)).toEqual([0, 10, 10, 15, 20, 25, 30, 30]);
|
||||
expect(sheet({ characterClass: 'MONK', level: 6 }).speed).toBe(45);
|
||||
expect(sheet({ characterClass: 'MONK', level: 6, inventoryItems: [wearing('Leather Armor', 'LIGHT', 1)] }).speed).toBe(30);
|
||||
expect(sheet({ characterClass: 'MONK', level: 6, inventoryItems: [wearing('Shield', 'SHIELD', 2)] }).speed).toBe(30);
|
||||
});
|
||||
|
||||
test('a speed the DM has set is the last word', () => {
|
||||
const set = sheet({ characterClass: 'BARBARIAN', level: 5, speedOverride: 40 });
|
||||
expect(set.speed).toBe(40);
|
||||
expect(names(set)).not.toContain('Fast Movement');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saving throws', () => {
|
||||
const classSaves = (...abilities) => abilities.map((ability) => ({ ability, proficient: true }));
|
||||
const bonuses = (s) => Object.fromEntries(Object.entries(s.saves).map(([ability, save]) => [ability, save.bonus]));
|
||||
|
||||
test('a paladin’s aura adds Charisma to every save from level 6, and at least 1', () => {
|
||||
const young = sheet({ characterClass: 'PALADIN', level: 5 }, classSaves('WIS', 'CHA'));
|
||||
expect(bonuses(young)).toEqual({ STR: 3, DEX: 2, CON: 2, INT: 0, WIS: 6, CHA: 6 });
|
||||
const aura = sheet({ characterClass: 'PALADIN', level: 6 }, classSaves('WIS', 'CHA'));
|
||||
expect(bonuses(aura)).toEqual({ STR: 6, DEX: 5, CON: 5, INT: 3, WIS: 9, CHA: 9 });
|
||||
const dour = sheet({ characterClass: 'PALADIN', level: 6, chaScore: 7 }, classSaves('WIS', 'CHA')); // CHA 8: -1
|
||||
expect(bonuses(dour).STR).toBe(4);
|
||||
});
|
||||
|
||||
test('a monk is proficient in every save from level 14', () => {
|
||||
const before = sheet({ characterClass: 'MONK', level: 13 }, classSaves('STR', 'DEX'));
|
||||
expect(before.saves.WIS).toEqual({ proficient: false, bonus: 3 });
|
||||
const soul = sheet({ characterClass: 'MONK', level: 14 }, classSaves('STR', 'DEX'));
|
||||
expect(Object.values(soul.saves).every((save) => save.proficient)).toBe(true);
|
||||
expect(bonuses(soul)).toEqual({ STR: 8, DEX: 7, CON: 7, INT: 5, WIS: 8, CHA: 8 }); // +5 proficiency
|
||||
});
|
||||
|
||||
test('a rogue is proficient in Wisdom saves from level 15', () => {
|
||||
expect(sheet({ characterClass: 'ROGUE', level: 14 }, classSaves('DEX', 'INT')).saves.WIS).toEqual({ proficient: false, bonus: 3 });
|
||||
expect(sheet({ characterClass: 'ROGUE', level: 15 }, classSaves('DEX', 'INT')).saves.WIS).toEqual({ proficient: true, bonus: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Jack of All Trades', () => {
|
||||
test('a bard adds half proficiency to initiative and to skills they are not proficient in', () => {
|
||||
const skills = [{ skill: 'PERFORMANCE', proficient: true, expertise: false }];
|
||||
const novice = computeCharacterSheet({ ...scores, characterClass: 'BARD', level: 1 }, skills, []);
|
||||
expect(novice.initiative).toBe(2);
|
||||
const bard = computeCharacterSheet({ ...scores, characterClass: 'BARD', level: 2 }, skills, []);
|
||||
expect(bard.initiative).toBe(3); // +2 Dexterity, +1
|
||||
expect(bard.skills.STEALTH.bonus).toBe(3);
|
||||
expect(bard.skills.PERFORMANCE.bonus).toBe(5); // +3 Charisma, +2: proficient, so no half
|
||||
expect(bard.passivePerception).toBe(14); // 10 + 3 Wisdom + 1
|
||||
expect(computeCharacterSheet({ ...scores, characterClass: 'BARD', level: 9 }, skills, []).initiative).toBe(4); // half of +4
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiclassed', () => {
|
||||
test('each class counts by its own level', () => {
|
||||
// Paladin 6 / Barbarian 1: the aura, and Unarmored Defense, but no Fast Movement.
|
||||
const mixed = sheet({ characterClass: 'PALADIN', level: 7, classLevels: [{ characterClass: 'BARBARIAN', level: 1 }] });
|
||||
expect(names(mixed).sort()).toEqual(['Aura of Protection', 'Unarmored Defense']);
|
||||
expect(mixed.armorClass).toBe(14);
|
||||
// Barbarian 1 / Monk 1: the better of the two ways, not both.
|
||||
const both = sheet({ characterClass: 'BARBARIAN', level: 2, classLevels: [{ characterClass: 'MONK', level: 1 }] });
|
||||
expect(both.armorClass).toBe(15);
|
||||
expect(both.features).toHaveLength(1);
|
||||
expect(both.features[0].source).toBe('Monk');
|
||||
});
|
||||
|
||||
test('nothing for a class with none of these', () => {
|
||||
const effects = classFeatureEffects([{ characterClass: 'FIGHTER', level: 20, subclass: null }], { STR: 3, DEX: 2, CON: 2, INT: 0, WIS: 3, CHA: 3 }, 6);
|
||||
expect(effects).toEqual({ acBase: null, speed: 0, hp: 0, saveBonus: 0, saveProficiencies: [], halfProficiency: 0, inForce: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
// Combat: how an attack or a spell is resolved in a battle. An "action" is
|
||||
// one attack or spell from being declared to its damage landing
|
||||
// (EncounterAction): who it is aimed at, the d20 rolled against each target
|
||||
// (or each target's saving throw), and the damage. Pure: no DB calls. The
|
||||
// random source can be passed in for tests.
|
||||
|
||||
const { parseDice, leadingDice, rollExpression, rollCritical, rollDie } = require('./dice');
|
||||
|
||||
// What an attack roll comes to against a target. A natural 20 is a critical
|
||||
// hit and a natural 1 a miss, whatever the numbers; otherwise the d20 plus the
|
||||
// bonus has to reach the AC (with any area-attack penalty added).
|
||||
function attackOutcome(d20, attackBonus, ac, penalty = 0) {
|
||||
if (d20 === 20) return 'CRIT';
|
||||
if (d20 === 1) return 'FUMBLE';
|
||||
return d20 + attackBonus >= ac + penalty ? 'HIT' : 'MISS';
|
||||
}
|
||||
|
||||
// Whether a saving throw succeeds: the d20 plus the bonus reaches the DC.
|
||||
const saveSucceeds = (d20, saveBonus, dc) => d20 + saveBonus >= dc;
|
||||
|
||||
const lands = (outcome) => outcome === 'HIT' || outcome === 'CRIT';
|
||||
|
||||
// A target as the dice have it (or as the DM has overruled them), with the
|
||||
// damage it takes. `outcome` and `damage` on the target are the DM's
|
||||
// overrides; null leaves it to the dice.
|
||||
// ATTACK: total, outcome HIT | MISS | CRIT | FUMBLE (null until a d20 is in).
|
||||
// SAVE: total, outcome SAVED | FAILED (null until the save is in).
|
||||
// AUTO: outcome HIT.
|
||||
// `finalDamage` is null until it can be known.
|
||||
function settleTarget(action, target) {
|
||||
const { kind, detail } = action;
|
||||
const base = action.damage ? action.damage.total : null;
|
||||
const crit = action.damage && action.damage.crit ? action.damage.crit.total : null;
|
||||
const noDamage = !detail.damageDice && base == null;
|
||||
let total = null;
|
||||
let rolled = null; // what the dice say, before any override
|
||||
if (kind === 'ATTACK') {
|
||||
if (target.d20 != null) {
|
||||
total = target.d20 + (detail.attackBonus || 0);
|
||||
rolled = attackOutcome(target.d20, detail.attackBonus || 0, target.ac ?? 10, target.penalty || 0);
|
||||
}
|
||||
} else if (kind === 'SAVE') {
|
||||
if (target.d20 != null) {
|
||||
total = target.d20 + (target.saveBonus || 0);
|
||||
rolled = saveSucceeds(target.d20, target.saveBonus || 0, detail.saveDC || 10) ? 'SAVED' : 'FAILED';
|
||||
}
|
||||
} else {
|
||||
rolled = 'HIT';
|
||||
}
|
||||
const outcome = target.outcome || rolled;
|
||||
|
||||
let finalDamage = null;
|
||||
if (target.damage != null) finalDamage = target.damage;
|
||||
else if (outcome === 'MISS' || outcome === 'FUMBLE') finalDamage = 0;
|
||||
else if (noDamage) finalDamage = outcome ? 0 : null;
|
||||
else if (outcome && base != null) {
|
||||
if (outcome === 'CRIT') finalDamage = crit != null ? crit : base;
|
||||
else if (outcome === 'SAVED') finalDamage = detail.halfOnSave ? Math.floor(base / 2) : 0;
|
||||
else finalDamage = base;
|
||||
}
|
||||
return { ...target, total, rolled, outcome, overruled: Boolean(target.outcome) && target.outcome !== rolled, finalDamage };
|
||||
}
|
||||
|
||||
// An action with every target settled, and what it is still waiting for:
|
||||
// `needsRolls` (targets with no outcome yet), `needsDamage` (someone is hit
|
||||
// and the damage is not known), `needsCrit` (someone is critically hit and the
|
||||
// critical damage has not been rolled), and `ready` when none of those.
|
||||
function settle(action) {
|
||||
const targets = (action.targets || []).map((target) => settleTarget(action, target));
|
||||
const needsRolls = targets.filter((t) => !t.outcome).length;
|
||||
const hurts = targets.some((t) => t.damage == null && (lands(t.outcome) || t.outcome === 'FAILED' || (t.outcome === 'SAVED' && action.detail.halfOnSave)));
|
||||
const hasDice = Boolean(action.detail.damageDice);
|
||||
const needsDamage = hasDice && hurts && !action.damage;
|
||||
const needsCrit = hasDice && Boolean(action.damage) && !action.damage.crit && !action.damage.entered && targets.some((t) => t.outcome === 'CRIT' && t.damage == null);
|
||||
return { ...action, targets, needsRolls, needsDamage, needsCrit, ready: needsRolls === 0 && !needsDamage && targets.length > 0 };
|
||||
}
|
||||
|
||||
// Rolls an action's damage: its dice plus its bonus, and, when a target is
|
||||
// critically hit, the critical damage by the campaign's rule. Null for an
|
||||
// action with no dice (or dice that can't be read).
|
||||
function rollDamage(detail, { critical = false, critRule = 'DOUBLE_DICE', roll = rollDie } = {}) {
|
||||
const dice = leadingDice(detail.damageDice || '');
|
||||
const parsed = dice ? parseDice(dice) : null;
|
||||
if (!parsed) return null;
|
||||
const withBonus = { dice: parsed.dice, flat: parsed.flat + (detail.damageBonus || 0) };
|
||||
const normal = rollExpression(withBonus, { roll });
|
||||
return {
|
||||
total: Math.max(0, normal.total),
|
||||
dice: normal.dice,
|
||||
modifier: normal.modifier,
|
||||
crit: critical ? critOf(withBonus, critRule, roll) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function critOf(parsed, critRule, roll) {
|
||||
const crit = rollCritical(parsed, critRule, { roll });
|
||||
return { total: Math.max(0, crit.total), dice: crit.dice, modifier: crit.modifier };
|
||||
}
|
||||
|
||||
// The critical damage alone, for an action whose normal damage is already rolled.
|
||||
function rollCritDamage(detail, { critRule = 'DOUBLE_DICE', roll = rollDie } = {}) {
|
||||
const dice = leadingDice(detail.damageDice || '');
|
||||
const parsed = dice ? parseDice(dice) : null;
|
||||
if (!parsed) return null;
|
||||
return critOf({ dice: parsed.dice, flat: parsed.flat + (detail.damageBonus || 0) }, critRule, roll);
|
||||
}
|
||||
|
||||
// Which side a combatant fights on: the party (characters and friendly NPCs),
|
||||
// its enemies (monsters and hostile NPCs), or neither (a neutral NPC).
|
||||
// Takes { kind: 'CHARACTER' | 'MONSTER' | 'NPC', disposition? }.
|
||||
function sideOf(combatant) {
|
||||
if (combatant.kind === 'CHARACTER') return 'PARTY';
|
||||
if (combatant.kind === 'MONSTER') return 'ENEMY';
|
||||
if (combatant.disposition === 'FRIENDLY') return 'PARTY';
|
||||
if (combatant.disposition === 'HOSTILE') return 'ENEMY';
|
||||
return 'NEUTRAL';
|
||||
}
|
||||
|
||||
// Who a combatant acting by itself could go for: everyone standing on the
|
||||
// other side. A neutral has no enemies.
|
||||
function opponentsOf(actor, combatants) {
|
||||
const side = sideOf(actor);
|
||||
if (side === 'NEUTRAL') return [];
|
||||
return combatants.filter((c) => c.id !== actor.id && !c.fallen && sideOf(c) !== 'NEUTRAL' && sideOf(c) !== side);
|
||||
}
|
||||
|
||||
// How likely each opponent is to be picked, relative to the others. Everyone
|
||||
// starts equal; whoever the actor went for last, and whoever has hurt it,
|
||||
// count for more (a creature keeps at what it is fighting), and so, a little,
|
||||
// does someone easier to hit.
|
||||
function targetWeights(candidates, { lastTargetId = null, hurtBy = [] } = {}) {
|
||||
return candidates.map((c) => {
|
||||
let weight = 2;
|
||||
if (c.id === lastTargetId) weight += 3;
|
||||
if (hurtBy.includes(c.id)) weight += 3;
|
||||
weight += Math.max(0, Math.min(2, (16 - (c.ac ?? 12)) / 3));
|
||||
return weight;
|
||||
});
|
||||
}
|
||||
|
||||
// Picks one opponent at random by those weights; null when there is nobody.
|
||||
// `random` gives a number from 0 (inclusive) to 1 (exclusive).
|
||||
function pickTarget(candidates, context = {}, random = Math.random) {
|
||||
if (candidates.length === 0) return null;
|
||||
const weights = targetWeights(candidates, context);
|
||||
let at = random() * weights.reduce((sum, w) => sum + w, 0);
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
at -= weights[i];
|
||||
if (at < 0) return candidates[i];
|
||||
}
|
||||
return candidates[candidates.length - 1];
|
||||
}
|
||||
|
||||
// The attacks a monster or NPC makes on its turn, one entry per attack made
|
||||
// (a multiattack's `count` is spread out): [{ name, attackBonus, damageDice,
|
||||
// damageType }]. A monster's named `attacks` when it has any, otherwise its
|
||||
// single attack; an NPC's single attack. Empty for one with nothing to hit with.
|
||||
function attacksOf({ monster = null, npc = null }) {
|
||||
const named = monster && Array.isArray(monster.attacks) ? monster.attacks.filter((a) => a && a.name) : [];
|
||||
if (named.length > 0) {
|
||||
return named.flatMap((a) =>
|
||||
Array.from({ length: Math.max(1, Math.min(10, Number(a.count) || 1)) }, () => ({
|
||||
name: a.name,
|
||||
attackBonus: Number(a.attackBonus) || 0,
|
||||
damageDice: a.damageDice || null,
|
||||
damageType: a.damageType || null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
const stats = monster || npc;
|
||||
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
|
||||
return [{ name: 'Attack', attackBonus: stats.attackBonus || 0, damageDice: stats.damageDice || null, damageType: null }];
|
||||
}
|
||||
|
||||
// Highest initiative first; ties keep the order combatants were added in.
|
||||
// Anyone who has not rolled yet waits at the bottom.
|
||||
function inTurnOrder(participants) {
|
||||
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
|
||||
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
|
||||
}
|
||||
|
||||
// Whether a combatant may change the weapon in their hand in a battle that
|
||||
// is under way. There are two moments for it: as the battle begins, before
|
||||
// anyone has made a move (round 1, still the first combatant's turn, nothing
|
||||
// landed yet), and on their own turn once their attack has landed, before the
|
||||
// turn passes. `applied` is the battle's landed actions: [{ round,
|
||||
// actorParticipantId }]. `firstId` is whoever went first. `declared` is true
|
||||
// while somebody's attack or spell is declared and still being resolved: that
|
||||
// is a move being made.
|
||||
function weaponSwapOpen({ round, activeId, firstId, participantId, applied = [], declared = false }) {
|
||||
if (activeId == null) return true; // no turn has started
|
||||
const opening = round === 1 && activeId === firstId && applied.length === 0 && !declared;
|
||||
const afterOwnAttack = activeId === participantId && applied.some((a) => a.round === round && a.actorParticipantId === participantId);
|
||||
return opening || afterOwnAttack;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attackOutcome,
|
||||
saveSucceeds,
|
||||
lands,
|
||||
settleTarget,
|
||||
settle,
|
||||
rollDamage,
|
||||
rollCritDamage,
|
||||
sideOf,
|
||||
opponentsOf,
|
||||
targetWeights,
|
||||
pickTarget,
|
||||
attacksOf,
|
||||
inTurnOrder,
|
||||
weaponSwapOpen,
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
const { attackOutcome, saveSucceeds, settle, rollDamage, rollCritDamage, sideOf, opponentsOf, targetWeights, pickTarget, attacksOf } = require('./combat');
|
||||
|
||||
const fixed = (...faces) => {
|
||||
let i = 0;
|
||||
return () => faces[i++ % faces.length];
|
||||
};
|
||||
|
||||
describe('an attack roll', () => {
|
||||
test('hits when the d20 plus the bonus reaches the AC', () => {
|
||||
expect(attackOutcome(10, 5, 15)).toBe('HIT');
|
||||
expect(attackOutcome(9, 5, 15)).toBe('MISS');
|
||||
});
|
||||
|
||||
test('a natural 20 always hits, critically, and a natural 1 always misses', () => {
|
||||
expect(attackOutcome(20, -5, 30)).toBe('CRIT');
|
||||
expect(attackOutcome(1, 20, 5)).toBe('FUMBLE');
|
||||
});
|
||||
|
||||
test('an area attack counts each AC higher', () => {
|
||||
expect(attackOutcome(10, 5, 15, 2)).toBe('MISS');
|
||||
expect(attackOutcome(12, 5, 15, 2)).toBe('HIT');
|
||||
});
|
||||
});
|
||||
|
||||
test('a saving throw succeeds on the DC or more', () => {
|
||||
expect(saveSucceeds(12, 3, 15)).toBe(true);
|
||||
expect(saveSucceeds(11, 3, 15)).toBe(false);
|
||||
});
|
||||
|
||||
describe('settling an action', () => {
|
||||
const attack = (targets, damage = null) => ({ kind: 'ATTACK', detail: { attackBonus: 5, damageDice: '1d8', damageBonus: 3 }, targets, damage });
|
||||
|
||||
test('waits for a d20 for each target, then for the damage', () => {
|
||||
const waiting = settle(attack([{ participantId: 'a', ac: 15, d20: null }]));
|
||||
expect(waiting).toMatchObject({ needsRolls: 1, ready: false });
|
||||
const hit = settle(attack([{ participantId: 'a', ac: 15, d20: 12 }]));
|
||||
expect(hit.targets[0]).toMatchObject({ total: 17, outcome: 'HIT', finalDamage: null });
|
||||
expect(hit).toMatchObject({ needsRolls: 0, needsDamage: true, ready: false });
|
||||
const done = settle(attack([{ participantId: 'a', ac: 15, d20: 12 }], { total: 7 }));
|
||||
expect(done.targets[0].finalDamage).toBe(7);
|
||||
expect(done.ready).toBe(true);
|
||||
});
|
||||
|
||||
test('a miss needs no damage at all', () => {
|
||||
const miss = settle(attack([{ participantId: 'a', ac: 15, d20: 4 }]));
|
||||
expect(miss.targets[0]).toMatchObject({ outcome: 'MISS', finalDamage: 0 });
|
||||
expect(miss).toMatchObject({ needsDamage: false, ready: true });
|
||||
});
|
||||
|
||||
test('a critical hit takes the critical damage, once it is rolled', () => {
|
||||
const crit = settle(attack([{ participantId: 'a', ac: 15, d20: 20 }], { total: 7 }));
|
||||
expect(crit.needsCrit).toBe(true);
|
||||
expect(settle(attack([{ participantId: 'a', ac: 15, d20: 20 }], { total: 7, crit: { total: 13 } })).targets[0].finalDamage).toBe(13);
|
||||
});
|
||||
|
||||
test('the DM can overrule the dice, and set a target’s damage by hand', () => {
|
||||
const overruled = settle(attack([{ participantId: 'a', ac: 15, d20: 4, outcome: 'HIT' }, { participantId: 'b', ac: 12, d20: 15, damage: 2 }], { total: 9 }));
|
||||
expect(overruled.targets[0]).toMatchObject({ rolled: 'MISS', outcome: 'HIT', overruled: true, finalDamage: 9 });
|
||||
expect(overruled.targets[1]).toMatchObject({ outcome: 'HIT', overruled: false, finalDamage: 2 });
|
||||
});
|
||||
|
||||
test('a save spell deals full damage on a failure, half or none on a success', () => {
|
||||
const spell = (halfOnSave) =>
|
||||
settle({
|
||||
kind: 'SAVE',
|
||||
detail: { saveAbility: 'DEX', saveDC: 15, halfOnSave, damageDice: '8d6' },
|
||||
targets: [{ participantId: 'a', saveBonus: 2, d20: 13 }, { participantId: 'b', saveBonus: 2, d20: 12 }, { participantId: 'c', saveBonus: 0, d20: null }],
|
||||
damage: { total: 27 },
|
||||
});
|
||||
const fireball = spell(true);
|
||||
expect(fireball.targets.map((t) => [t.outcome, t.finalDamage])).toEqual([['SAVED', 13], ['FAILED', 27], [null, null]]);
|
||||
expect(fireball).toMatchObject({ needsRolls: 1, ready: false });
|
||||
expect(spell(false).targets[0].finalDamage).toBe(0);
|
||||
});
|
||||
|
||||
test('something that just lands hits everyone it is aimed at', () => {
|
||||
const missile = settle({ kind: 'AUTO', detail: { damageDice: '3d4+3' }, targets: [{ participantId: 'a' }], damage: { total: 10 } });
|
||||
expect(missile.targets[0]).toMatchObject({ outcome: 'HIT', finalDamage: 10 });
|
||||
expect(missile.ready).toBe(true);
|
||||
// With no dice at all (a utility spell) it is ready as it stands.
|
||||
expect(settle({ kind: 'AUTO', detail: {}, targets: [{ participantId: 'a' }], damage: null })).toMatchObject({ ready: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('damage', () => {
|
||||
test('is the dice plus the bonus', () => {
|
||||
expect(rollDamage({ damageDice: '1d8', damageBonus: 3 }, { roll: fixed(5) })).toMatchObject({ total: 8, crit: null });
|
||||
expect(rollDamage({ damageDice: '2d6+1 fire', damageBonus: 0 }, { roll: fixed(2, 3) }).total).toBe(6);
|
||||
expect(rollDamage({ damageDice: 'a nasty bite' })).toBeNull();
|
||||
});
|
||||
|
||||
test('a critical hit doubles the dice, never the bonus', () => {
|
||||
const rolled = rollDamage({ damageDice: '1d8', damageBonus: 3 }, { critical: true, roll: fixed(5, 4, 6) });
|
||||
expect(rolled.total).toBe(8);
|
||||
expect(rolled.crit.total).toBe(4 + 6 + 3);
|
||||
expect(rollCritDamage({ damageDice: '1d8', damageBonus: 3 }, { critRule: 'MAX_PLUS_ROLL', roll: fixed(2) }).total).toBe(8 + 2 + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sides', () => {
|
||||
const aria = { id: 'aria', kind: 'CHARACTER', ac: 16 };
|
||||
const bo = { id: 'bo', kind: 'CHARACTER', ac: 12 };
|
||||
const goblin = { id: 'goblin', kind: 'MONSTER', ac: 13 };
|
||||
const guard = { id: 'guard', kind: 'NPC', disposition: 'FRIENDLY', ac: 14 };
|
||||
const thug = { id: 'thug', kind: 'NPC', disposition: 'HOSTILE', ac: 11 };
|
||||
const baker = { id: 'baker', kind: 'NPC', disposition: 'NEUTRAL', ac: 10 };
|
||||
const everyone = [aria, bo, goblin, guard, thug, baker];
|
||||
|
||||
test('characters and friendly NPCs against monsters and hostile NPCs', () => {
|
||||
expect(everyone.map(sideOf)).toEqual(['PARTY', 'PARTY', 'ENEMY', 'PARTY', 'ENEMY', 'NEUTRAL']);
|
||||
expect(opponentsOf(goblin, everyone).map((c) => c.id)).toEqual(['aria', 'bo', 'guard']);
|
||||
expect(opponentsOf(guard, everyone).map((c) => c.id)).toEqual(['goblin', 'thug']);
|
||||
expect(opponentsOf(baker, everyone)).toEqual([]);
|
||||
});
|
||||
|
||||
test('the fallen are nobody’s target', () => {
|
||||
expect(opponentsOf(goblin, [aria, { ...bo, fallen: true }]).map((c) => c.id)).toEqual(['aria']);
|
||||
});
|
||||
|
||||
test('a creature favours who it fought last, who hurt it, and the easier mark', () => {
|
||||
const [ariaWeight, boWeight] = targetWeights([aria, bo]);
|
||||
expect(boWeight).toBeGreaterThan(ariaWeight); // lower AC
|
||||
const [again] = targetWeights([aria, bo], { lastTargetId: 'aria', hurtBy: ['aria'] });
|
||||
expect(again).toBeGreaterThan(boWeight);
|
||||
});
|
||||
|
||||
test('picks by weight, and nobody when there is nobody', () => {
|
||||
expect(pickTarget([aria, bo], {}, () => 0).id).toBe('aria');
|
||||
expect(pickTarget([aria, bo], {}, () => 0.999).id).toBe('bo');
|
||||
expect(pickTarget([], {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a creature attacks with', () => {
|
||||
test('its single attack, or each of its named attacks as often as it makes it', () => {
|
||||
expect(attacksOf({ monster: { attackBonus: 4, damageDice: '1d6+2', attacks: [] } })).toEqual([{ name: 'Attack', attackBonus: 4, damageDice: '1d6+2', damageType: null }]);
|
||||
const owlbear = attacksOf({ monster: { attackBonus: 7, damageDice: '2d8+5', attacks: [{ name: 'Claw', attackBonus: 7, damageDice: '2d8+5', damageType: 'slashing', count: 2 }, { name: 'Beak', attackBonus: 7, damageDice: '1d10+5' }] } });
|
||||
expect(owlbear.map((a) => a.name)).toEqual(['Claw', 'Claw', 'Beak']);
|
||||
expect(attacksOf({ npc: { attackBonus: 3, damageDice: '1d6' } })).toHaveLength(1);
|
||||
expect(attacksOf({ npc: { attackBonus: null, damageDice: null } })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing weapons in a battle', () => {
|
||||
const { inTurnOrder, weaponSwapOpen } = require('./combat');
|
||||
const base = { round: 1, activeId: 'a', firstId: 'a', participantId: 'b', applied: [] };
|
||||
|
||||
test('the turn order is by initiative, the unrolled last', () => {
|
||||
const order = inTurnOrder([
|
||||
{ id: '1', initiativeRoll: null, initiative: 0 },
|
||||
{ id: '2', initiativeRoll: 5, initiative: 7 },
|
||||
{ id: '3', initiativeRoll: 12, initiative: 12 },
|
||||
]);
|
||||
expect(order.map((p) => p.id)).toEqual(['3', '2', '1']);
|
||||
});
|
||||
|
||||
test('anyone may as the battle begins, before a move is made', () => {
|
||||
expect(weaponSwapOpen(base)).toBe(true);
|
||||
expect(weaponSwapOpen({ ...base, activeId: null })).toBe(true);
|
||||
// The first turn has passed, or something has landed: the moment is gone.
|
||||
expect(weaponSwapOpen({ ...base, activeId: 'b', participantId: 'c' })).toBe(false);
|
||||
expect(weaponSwapOpen({ ...base, applied: [{ round: 1, actorParticipantId: 'a' }] })).toBe(false);
|
||||
expect(weaponSwapOpen({ ...base, round: 2 })).toBe(false);
|
||||
expect(weaponSwapOpen({ ...base, declared: true })).toBe(false); // an attack is being resolved
|
||||
});
|
||||
|
||||
test('after that, only on their own turn once their attack has landed', () => {
|
||||
const later = { round: 2, activeId: 'b', firstId: 'a', participantId: 'b' };
|
||||
expect(weaponSwapOpen({ ...later, applied: [] })).toBe(false);
|
||||
expect(weaponSwapOpen({ ...later, applied: [{ round: 1, actorParticipantId: 'b' }] })).toBe(false); // last round's
|
||||
expect(weaponSwapOpen({ ...later, applied: [{ round: 2, actorParticipantId: 'b' }] })).toBe(true);
|
||||
// Somebody else's turn, even though they attacked earlier this round.
|
||||
expect(weaponSwapOpen({ ...later, activeId: 'c', applied: [{ round: 2, actorParticipantId: 'b' }] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -266,7 +266,7 @@ const CLASS_FEATURES = {
|
||||
],
|
||||
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).' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor, add your Constitution modifier to your AC (counted on your sheet). A shield still adds.' },
|
||||
],
|
||||
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.' },
|
||||
@@ -306,7 +306,7 @@ const CLASS_FEATURES = {
|
||||
],
|
||||
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).' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor or a shield, add your Wisdom modifier to your AC (counted on your sheet).' },
|
||||
],
|
||||
ARTIFICER: [
|
||||
{ name: 'Magical Tinkering', text: 'Touch a tiny object to give it a small magic: it shines, plays a recorded message, gives off a smell or sound, or shows a picture. You can keep as many going as your Intelligence modifier.' },
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// is in the note. A DM who wants it stricter edits the copy in their campaign.
|
||||
|
||||
const STEALTH = 'Disadvantage on Stealth checks.';
|
||||
const ARMOR_TYPE = { 'Light Armor': 'LIGHT', 'Medium Armor': 'MEDIUM', 'Heavy Armor': 'HEAVY', Shields: 'SHIELD' };
|
||||
const armor = (key, name, category, ac, description, note = null, more = []) => ({
|
||||
key,
|
||||
name,
|
||||
@@ -18,6 +19,8 @@ const armor = (key, name, category, ac, description, note = null, more = []) =>
|
||||
damageDice: null,
|
||||
effects: [{ stat: 'AC', amount: ac }, ...more],
|
||||
effectNote: note,
|
||||
// What kind it is: the sheet goes by this for Unarmored Defense and the like.
|
||||
armorType: ARMOR_TYPE[category],
|
||||
});
|
||||
const gear = (key, name, category, description, effects = [], effectNote = null, damageDice = null) => ({
|
||||
key,
|
||||
|
||||
@@ -57,7 +57,7 @@ const LEVEL_FEATURES = {
|
||||
],
|
||||
3: [rage('Three times', 2)],
|
||||
4: [BOON],
|
||||
5: [EXTRA_ATTACK, { name: 'Fast Movement', text: '+10 ft speed when not in heavy armor (ask your DM to raise your speed).' }],
|
||||
5: [EXTRA_ATTACK, { name: 'Fast Movement', text: '+10 ft speed when not in heavy armor (counted on your sheet).' }],
|
||||
6: [rage('Four times', 2)],
|
||||
7: [{ name: 'Feral Instinct', text: 'Advantage on initiative; you can act normally in a surprise round if you rage first.' }],
|
||||
8: [BOON],
|
||||
@@ -77,7 +77,7 @@ const LEVEL_FEATURES = {
|
||||
},
|
||||
BARD: {
|
||||
2: [
|
||||
{ name: 'Jack of All Trades', text: 'Add half your proficiency bonus (round down) to any check you are not proficient in.' },
|
||||
{ name: 'Jack of All Trades', text: 'Add half your proficiency bonus (round down) to any check you are not proficient in (counted on your sheet for skills and initiative).' },
|
||||
{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d6.' },
|
||||
],
|
||||
3: [
|
||||
@@ -169,7 +169,7 @@ const LEVEL_FEATURES = {
|
||||
{ name: 'Flurry of Blows', text: '1 ki: two unarmed strikes as a bonus action.' },
|
||||
{ name: 'Patient Defense', text: '1 ki: Dodge as a bonus action.' },
|
||||
{ name: 'Step of the Wind', text: '1 ki: Dash or Disengage as a bonus action, jump distance doubled.' },
|
||||
{ name: 'Unarmored Movement', text: '+10 ft speed without armor (ask your DM to raise your speed).' },
|
||||
{ name: 'Unarmored Movement', text: '+10 ft speed without armor (counted on your sheet).' },
|
||||
],
|
||||
3: [
|
||||
{ name: 'Deflect Missiles', text: 'Reaction: reduce ranged weapon damage by 1d10 + Dexterity modifier + your level.' },
|
||||
@@ -182,28 +182,28 @@ const LEVEL_FEATURES = {
|
||||
],
|
||||
6: [
|
||||
{ name: 'Ki-Empowered Strikes', text: 'Your unarmed strikes count as magical.' },
|
||||
{ name: 'Unarmored Movement', text: '+15 ft speed without armor (ask your DM to raise your speed).', upgrade: true },
|
||||
{ name: 'Unarmored Movement', text: '+15 ft speed without armor (counted on your sheet).', upgrade: true },
|
||||
],
|
||||
7: [EVASION, { name: 'Stillness of Mind', text: 'Action: end charm or fear on yourself.' }],
|
||||
8: [BOON],
|
||||
9: [{ name: 'Unarmored Movement', text: '+15 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true }],
|
||||
9: [{ name: 'Unarmored Movement', text: '+15 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true }],
|
||||
10: [
|
||||
{ name: 'Purity of Body', text: 'Immune to poison and disease.' },
|
||||
{ name: 'Unarmored Movement', text: '+20 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
|
||||
{ name: 'Unarmored Movement', text: '+20 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true },
|
||||
],
|
||||
11: [{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d8 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.', upgrade: true }],
|
||||
12: [BOON],
|
||||
13: [{ name: 'Tongue of the Sun and Moon', text: 'You understand every spoken language, and anyone who speaks one understands you.' }],
|
||||
14: [
|
||||
{ name: 'Diamond Soul', text: 'You are proficient in every saving throw (add the bonus by hand); 1 ki rerolls a failed save.' },
|
||||
{ name: 'Unarmored Movement', text: '+25 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
|
||||
{ name: 'Diamond Soul', text: 'You are proficient in every saving throw (counted on your sheet); 1 ki rerolls a failed save.' },
|
||||
{ name: 'Unarmored Movement', text: '+25 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true },
|
||||
],
|
||||
15: [{ name: 'Timeless Body', text: 'You need no food or water and suffer none of the frailty of old age.' }],
|
||||
16: [BOON],
|
||||
17: [{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d10 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.', upgrade: true }],
|
||||
18: [
|
||||
{ name: 'Empty Body', text: '4 ki: invisible for one minute, and all damage but force against you is halved. 8 ki: cast Astral Projection on yourself.' },
|
||||
{ name: 'Unarmored Movement', text: '+30 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
|
||||
{ name: 'Unarmored Movement', text: '+30 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true },
|
||||
],
|
||||
19: [BOON],
|
||||
20: [{ name: 'Perfect Self', text: 'When you roll initiative with no ki left, regain 4 ki.' }],
|
||||
@@ -219,7 +219,7 @@ const LEVEL_FEATURES = {
|
||||
],
|
||||
4: [BOON],
|
||||
5: [EXTRA_ATTACK],
|
||||
6: [{ name: 'Aura of Protection', text: 'You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious.' }],
|
||||
6: [{ name: 'Aura of Protection', text: 'You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet).' }],
|
||||
8: [BOON],
|
||||
10: [{ name: 'Aura of Courage', text: 'You and allies within 10 ft can’t be frightened.' }],
|
||||
11: [{ name: 'Improved Divine Smite', text: 'Every melee weapon hit deals an extra 1d8 radiant damage.' }],
|
||||
@@ -227,7 +227,7 @@ const LEVEL_FEATURES = {
|
||||
14: [{ name: 'Cleansing Touch', text: 'A few times per day (your Charisma modifier, at least once), action: end one spell on yourself or a willing creature you touch.' }],
|
||||
16: [BOON],
|
||||
18: [
|
||||
{ name: 'Aura of Protection', text: 'You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious.', upgrade: true },
|
||||
{ name: 'Aura of Protection', text: 'You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet).', upgrade: true },
|
||||
{ name: 'Aura of Courage', text: 'You and allies within 30 ft can’t be frightened.', upgrade: true },
|
||||
],
|
||||
19: [BOON],
|
||||
@@ -275,7 +275,7 @@ const LEVEL_FEATURES = {
|
||||
12: [BOON],
|
||||
13: [sneakAttack('7d6')],
|
||||
14: [{ name: 'Blindsense', text: 'If you can hear, you know where any hidden or invisible creature within 10 ft is.' }],
|
||||
15: [sneakAttack('8d6'), { name: 'Slippery Mind', text: 'You are proficient in Wisdom saves (add the bonus by hand).' }],
|
||||
15: [sneakAttack('8d6'), { name: 'Slippery Mind', text: 'You are proficient in Wisdom saves (counted on your sheet).' }],
|
||||
16: [BOON],
|
||||
17: [sneakAttack('9d6')],
|
||||
18: [{ name: 'Elusive', text: 'No attack against you has advantage while you are not incapacitated.' }],
|
||||
|
||||
@@ -155,4 +155,61 @@ const SPELL_LIBRARY = [
|
||||
{ key: 'wall-of-stone', name: 'Wall of Stone', level: 5, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A nonmagical wall of solid stone springs into existence.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
|
||||
];
|
||||
|
||||
// How each spell lands in a battle (game/combat.js): a spell attack against
|
||||
// AC, or a saving throw by each target, [ability, half damage on a success].
|
||||
// A spell in neither list simply takes effect. Written onto the entries
|
||||
// below, as `attackRoll`, `saveAbility` and `halfOnSave`.
|
||||
const SPELL_ATTACKS = ['chill-touch', 'eldritch-blast', 'fire-bolt', 'produce-flame', 'ray-of-frost', 'shocking-grasp', 'thorn-whip', 'guiding-bolt', 'witch-bolt', 'scorching-ray', 'spiritual-weapon', 'contagion'];
|
||||
const SPELL_SAVES = {
|
||||
'acid-splash': ['DEX', false],
|
||||
'poison-spray': ['CON', false],
|
||||
'sacred-flame': ['DEX', false],
|
||||
'vicious-mockery': ['WIS', false],
|
||||
'burning-hands': ['DEX', true],
|
||||
'thunderwave': ['CON', true],
|
||||
'bane': ['CHA', false],
|
||||
'charm-person': ['WIS', false],
|
||||
'command': ['WIS', false],
|
||||
'entangle': ['STR', false],
|
||||
'faerie-fire': ['DEX', false],
|
||||
'animal-friendship': ['WIS', false],
|
||||
'blindness-deafness': ['CON', false],
|
||||
'hold-person': ['WIS', false],
|
||||
'flaming-sphere': ['DEX', true],
|
||||
'moonbeam': ['CON', true],
|
||||
'shatter': ['CON', true],
|
||||
'suggestion': ['WIS', false],
|
||||
'web': ['DEX', false],
|
||||
'zone-of-truth': ['CHA', false],
|
||||
'levitate': ['CON', false],
|
||||
'gust-of-wind': ['STR', false],
|
||||
'call-lightning': ['DEX', true],
|
||||
'fear': ['WIS', false],
|
||||
'fireball': ['DEX', true],
|
||||
'lightning-bolt': ['DEX', true],
|
||||
'sleet-storm': ['DEX', false],
|
||||
'slow': ['WIS', false],
|
||||
'spirit-guardians': ['WIS', true],
|
||||
'stinking-cloud': ['CON', false],
|
||||
'banishment': ['CHA', false],
|
||||
'blight': ['CON', true],
|
||||
'confusion': ['WIS', false],
|
||||
'ice-storm': ['DEX', true],
|
||||
'polymorph': ['WIS', false],
|
||||
'wall-of-fire': ['DEX', true],
|
||||
'guardian-of-faith': ['DEX', true],
|
||||
'cloudkill': ['CON', true],
|
||||
'cone-of-cold': ['CON', true],
|
||||
'dominate-person': ['WIS', false],
|
||||
'flame-strike': ['DEX', true],
|
||||
'hold-monster': ['WIS', false],
|
||||
'insect-plague': ['CON', true],
|
||||
};
|
||||
for (const spell of SPELL_LIBRARY) {
|
||||
spell.attackRoll = SPELL_ATTACKS.includes(spell.key);
|
||||
const save = SPELL_SAVES[spell.key];
|
||||
spell.saveAbility = save ? save[0] : null;
|
||||
spell.halfOnSave = save ? save[1] : false;
|
||||
}
|
||||
|
||||
module.exports = { SPELL_LIBRARY };
|
||||
|
||||
@@ -9,7 +9,7 @@ const SORCERER = [
|
||||
edition: '2014',
|
||||
summary: 'Dragon blood: tougher skin and one element at your call.',
|
||||
features: {
|
||||
3: [{ name: 'Dragon Ancestor', text: 'Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier (ask your DM about Extra AC).' }],
|
||||
3: [{ name: 'Dragon Ancestor', text: 'Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier: without armor your AC is 13 + Dexterity, and you have 1 more maximum HP for each sorcerer level (both counted on your sheet).' }],
|
||||
6: [{ name: 'Elemental Affinity', text: 'Draconic Bloodline. Add your Charisma modifier to one damage roll of your dragon’s element; spend 1 sorcery point to resist that element for 1 hour.' }],
|
||||
14: [{ name: 'Dragon Wings', text: 'Bonus action: sprout dragon wings and fly at your speed until you dismiss them.' }],
|
||||
18: [{ name: 'Draconic Presence', text: 'Action, 5 sorcery points: for a minute, creatures within 60 ft make a Wisdom save or are charmed (awe) or frightened (fear) of you.' }],
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Dice: reading an expression ("2d6+3"), rolling it, and checking dice that
|
||||
// were rolled at the table and typed in. Used by the table's dice roller
|
||||
// (POST /campaigns/:id/rolls) and by attacks in an encounter (game/combat.js).
|
||||
// Pure but for the random source, which can be passed in for tests.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const rollDie = (sides) => crypto.randomInt(1, sides + 1);
|
||||
|
||||
const MAX_DICE = 100;
|
||||
const MAX_SIDES = 1000;
|
||||
|
||||
// Reads the dice expressions the app writes: "1d8", "2d6", "1d6+1d8",
|
||||
// "2d4+2", "1d8 - 1". Returns { dice: [{ count, sides, sign }], flat }, or null
|
||||
// for anything else (free text is allowed where a DM types dice) and for
|
||||
// something too big to be a real roll. Mirrors parseDice in the frontend's
|
||||
// lib/diceRoller.ts.
|
||||
function parseDice(expression) {
|
||||
const terms = String(expression || '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[−–]/g, '-')
|
||||
.toLowerCase()
|
||||
.replace(/-/g, '+-')
|
||||
.split('+')
|
||||
.filter(Boolean);
|
||||
if (terms.length === 0) return null;
|
||||
const parsed = { dice: [], flat: 0 };
|
||||
let total = 0;
|
||||
for (const term of terms) {
|
||||
const die = /^(-?)(\d*)d(\d+)$/.exec(term);
|
||||
if (die) {
|
||||
const count = Number(die[2]) || 1;
|
||||
const sides = Number(die[3]);
|
||||
total += count;
|
||||
if (total > MAX_DICE || sides < 2 || sides > MAX_SIDES) return null;
|
||||
parsed.dice.push({ count, sides, sign: die[1] ? -1 : 1 });
|
||||
} else if (/^-?\d+$/.test(term)) {
|
||||
parsed.flat += Number(term);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parsed.dice.length > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
// The dice at the start of a line of text that says more: "2d8+3" from
|
||||
// "2d8+3 fire". Null when it does not start with dice.
|
||||
function leadingDice(text) {
|
||||
const match = /^\s*((?:\d*d\d+|\d+)(?:\s*[+\-−–]\s*(?:\d*d\d+|\d+))*)/i.exec(String(text || ''));
|
||||
return match && parseDice(match[1]) ? match[1].replace(/\s+/g, '') : null;
|
||||
}
|
||||
|
||||
// Every die of a parsed expression, one entry each, in order: [{ sides, sign }].
|
||||
function diceOf(parsed) {
|
||||
return parsed.dice.flatMap((term) => Array.from({ length: term.count }, () => ({ sides: term.sides, sign: term.sign })));
|
||||
}
|
||||
|
||||
// Whether `values` could be what these dice showed: one whole number per die,
|
||||
// each on its die.
|
||||
function validValues(dice, values) {
|
||||
return Array.isArray(values) && values.length === dice.length && dice.every((die, i) => Number.isInteger(values[i]) && values[i] >= 1 && values[i] <= die.sides);
|
||||
}
|
||||
|
||||
// Rolls a parsed expression, or takes `values` (dice rolled at the table,
|
||||
// already checked with validValues) in place of rolling. Returns
|
||||
// { dice: [{ sides, value, kept, sign }], modifier, total }.
|
||||
function rollExpression(parsed, { values = null, roll = rollDie } = {}) {
|
||||
const dice = diceOf(parsed).map((die, i) => ({ sides: die.sides, value: values ? values[i] : roll(die.sides), kept: true, sign: die.sign }));
|
||||
return { dice, modifier: parsed.flat, total: dice.reduce((sum, die) => sum + die.sign * die.value, 0) + parsed.flat };
|
||||
}
|
||||
|
||||
const D20_MODES = ['NORMAL', 'ADVANTAGE', 'DISADVANTAGE'];
|
||||
|
||||
// How many d20s a mode rolls: two with advantage or disadvantage.
|
||||
const d20Count = (mode) => (mode === 'NORMAL' ? 1 : 2);
|
||||
|
||||
// A d20 plus a modifier, as the mode has it: one die, or two with the higher
|
||||
// (advantage) or lower (disadvantage) kept. `values` as in rollExpression.
|
||||
// Returns { dice, modifier, total, natural }: `natural` is the d20 that counted.
|
||||
function rollD20(modifier = 0, mode = 'NORMAL', { values = null, roll = rollDie } = {}) {
|
||||
const rolled = Array.from({ length: d20Count(mode) }, (_, i) => (values ? values[i] : roll(20)));
|
||||
const natural = mode === 'ADVANTAGE' ? Math.max(...rolled) : mode === 'DISADVANTAGE' ? Math.min(...rolled) : rolled[0];
|
||||
// Of two equal dice only the first is marked as the one kept.
|
||||
const keptIndex = rolled.indexOf(natural);
|
||||
return {
|
||||
dice: rolled.map((value, i) => ({ sides: 20, value, kept: i === keptIndex, sign: 1 })),
|
||||
modifier,
|
||||
total: natural + modifier,
|
||||
natural,
|
||||
};
|
||||
}
|
||||
|
||||
// What a critical hit does to damage dice (Campaign.critRule). DOUBLE_DICE:
|
||||
// every die is rolled twice. MAX_PLUS_ROLL: every die counts at its maximum,
|
||||
// and is rolled once more on top. Takes and returns a parsed expression's
|
||||
// roll; the flat modifier is never doubled.
|
||||
function rollCritical(parsed, critRule = 'DOUBLE_DICE', { roll = rollDie } = {}) {
|
||||
const base = diceOf(parsed);
|
||||
const first = base.map((die) => ({ sides: die.sides, value: critRule === 'MAX_PLUS_ROLL' ? die.sides : roll(die.sides), kept: true, sign: die.sign, maxed: critRule === 'MAX_PLUS_ROLL' }));
|
||||
const second = base.map((die) => ({ sides: die.sides, value: roll(die.sides), kept: true, sign: die.sign }));
|
||||
const dice = [...first, ...second];
|
||||
return { dice, modifier: parsed.flat, total: dice.reduce((sum, die) => sum + die.sign * die.value, 0) + parsed.flat };
|
||||
}
|
||||
|
||||
module.exports = { rollDie, parseDice, leadingDice, diceOf, validValues, rollExpression, rollD20, d20Count, rollCritical, D20_MODES, MAX_DICE };
|
||||
@@ -0,0 +1,77 @@
|
||||
const { parseDice, leadingDice, diceOf, validValues, rollExpression, rollD20, rollCritical } = require('./dice');
|
||||
|
||||
// A die that always shows the same face, or the faces given in turn.
|
||||
const fixed = (...faces) => {
|
||||
let i = 0;
|
||||
return () => faces[i++ % faces.length];
|
||||
};
|
||||
|
||||
describe('parseDice', () => {
|
||||
test('reads the expressions the app writes', () => {
|
||||
expect(parseDice('2d6+3')).toEqual({ dice: [{ count: 2, sides: 6, sign: 1 }], flat: 3 });
|
||||
expect(parseDice('1d6 + 1d8 − 1')).toEqual({ dice: [{ count: 1, sides: 6, sign: 1 }, { count: 1, sides: 8, sign: 1 }], flat: -1 });
|
||||
expect(parseDice('d20')).toEqual({ dice: [{ count: 1, sides: 20, sign: 1 }], flat: 0 });
|
||||
});
|
||||
|
||||
test('refuses free text, bare numbers and absurd rolls', () => {
|
||||
expect(parseDice('a big bite')).toBeNull();
|
||||
expect(parseDice('7')).toBeNull();
|
||||
expect(parseDice('101d6')).toBeNull();
|
||||
expect(parseDice('60d6+60d6')).toBeNull();
|
||||
expect(parseDice('1d1')).toBeNull();
|
||||
});
|
||||
|
||||
test('leadingDice takes the dice off the front of a line', () => {
|
||||
expect(leadingDice('2d8+3 fire')).toBe('2d8+3');
|
||||
expect(leadingDice('fire, 2d8')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rolling', () => {
|
||||
test('rolls every die and adds the flat part', () => {
|
||||
const roll = rollExpression(parseDice('2d6+1d4+2'), { roll: fixed(3, 5, 4) });
|
||||
expect(roll.dice.map((d) => [d.sides, d.value])).toEqual([[6, 3], [6, 5], [4, 4]]);
|
||||
expect(roll).toMatchObject({ modifier: 2, total: 14 });
|
||||
});
|
||||
|
||||
test('a subtracted die takes away', () => {
|
||||
expect(rollExpression(parseDice('1d8-1d4'), { roll: fixed(6, 2) }).total).toBe(4);
|
||||
});
|
||||
|
||||
test('dice rolled at the table are checked against each die, then used as they are', () => {
|
||||
const dice = diceOf(parseDice('1d8+1d6'));
|
||||
expect(validValues(dice, [8, 6])).toBe(true);
|
||||
expect(validValues(dice, [9, 1])).toBe(false);
|
||||
expect(validValues(dice, [3])).toBe(false);
|
||||
expect(validValues(dice, [3, 2.5])).toBe(false);
|
||||
expect(rollExpression(parseDice('1d8+1d6+1'), { values: [8, 6] }).total).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a d20', () => {
|
||||
test('rolls one die and adds the modifier', () => {
|
||||
expect(rollD20(5, 'NORMAL', { roll: fixed(12) })).toMatchObject({ total: 17, natural: 12, dice: [{ sides: 20, value: 12, kept: true }] });
|
||||
});
|
||||
|
||||
test('keeps the higher with advantage and the lower with disadvantage', () => {
|
||||
const adv = rollD20(2, 'ADVANTAGE', { roll: fixed(7, 15) });
|
||||
expect(adv).toMatchObject({ natural: 15, total: 17 });
|
||||
expect(adv.dice.map((d) => d.kept)).toEqual([false, true]);
|
||||
expect(rollD20(0, 'DISADVANTAGE', { roll: fixed(7, 15) }).natural).toBe(7);
|
||||
expect(rollD20(0, 'ADVANTAGE', { values: [20, 20] }).dice.map((d) => d.kept)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a critical hit', () => {
|
||||
test('rolls every damage die twice, and the flat part once', () => {
|
||||
const crit = rollCritical(parseDice('1d8+3'), 'DOUBLE_DICE', { roll: fixed(5, 7) });
|
||||
expect(crit.dice.map((d) => d.value)).toEqual([5, 7]);
|
||||
expect(crit.total).toBe(15);
|
||||
});
|
||||
|
||||
test('or takes the dice at their most and rolls them once more', () => {
|
||||
const crit = rollCritical(parseDice('2d6+1'), 'MAX_PLUS_ROLL', { roll: fixed(2, 3) });
|
||||
expect(crit.dice.map((d) => d.value)).toEqual([6, 6, 2, 3]);
|
||||
expect(crit.total).toBe(18);
|
||||
});
|
||||
});
|
||||
@@ -2638,7 +2638,7 @@ describe('gear effects', () => {
|
||||
const quick = async (auth = playerAuth) => (await request(app).get(`${ch}/quick-stats`).set(auth)).body.quickStats;
|
||||
|
||||
const before = (await sheet()).sheet;
|
||||
expect(before.gear).toEqual({ effects: [], notes: [] });
|
||||
expect(before.gear).toEqual({ effects: [], notes: [], armor: { worn: null, shield: false } });
|
||||
|
||||
// Only the DM says what gear does, and only with numbers the sheet has.
|
||||
const mail = {
|
||||
@@ -3694,3 +3694,556 @@ describe('multiclassing', () => {
|
||||
expect(res.body.paths.map((p) => p.key)).toContain('SORLOCK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('approved level-ups', () => {
|
||||
itIfDb('the DM approves for many at once; each player then takes their own level, once', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('approve');
|
||||
const ariaId = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
|
||||
const boId = await buildAs(campaign.id, dmAuth, 'Bo', 'WIZARD', 1);
|
||||
const approve = (auth, body) => request(app).post(`/campaigns/${campaign.id}/level-up`).set(auth).send(body);
|
||||
const levelUp = (id, auth, body = {}) => request(app).post(`/characters/${id}/level-up`).set(auth).send(body);
|
||||
|
||||
// Not the player's to take until it is approved, and not theirs to approve.
|
||||
expect((await request(app).get(`/characters/${ariaId}/level-up`).set(playerAuth)).status).toBe(403);
|
||||
expect((await levelUp(ariaId, playerAuth)).status).toBe(403);
|
||||
expect((await approve(playerAuth, { characterIds: [ariaId] })).status).toBe(403);
|
||||
|
||||
const approved = await approve(dmAuth, { characterIds: [ariaId, boId] });
|
||||
expect(approved.status).toBe(200);
|
||||
expect(approved.body.results.map((r) => [r.name, r.levelUpsPending, r.skipped])).toEqual([['Aria', 1, null], ['Bo', 1, null]]);
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters;
|
||||
expect(listed.find((c) => c.id === ariaId).levelUpsPending).toBe(1);
|
||||
|
||||
// The player sees the plan and takes the level; a second try has nothing approved.
|
||||
const plan = await request(app).get(`/characters/${ariaId}/level-up`).set(playerAuth);
|
||||
expect(plan.status).toBe(200);
|
||||
expect(plan.body.plan).toMatchObject({ toLevel: 2, hp: { method: 'AVERAGE', die: 10, average: 6 } });
|
||||
const taken = await levelUp(ariaId, playerAuth);
|
||||
expect(taken.status).toBe(200);
|
||||
expect(taken.body.character).toMatchObject({ level: 2, levelUpsPending: 0 });
|
||||
expect(taken.body.hpRolled).toBeNull();
|
||||
expect((await levelUp(ariaId, playerAuth)).status).toBe(403);
|
||||
// Bo is the DM's own character; another player cannot take that level.
|
||||
expect((await levelUp(boId, playerAuth)).status).toBe(403);
|
||||
|
||||
// An approval can be taken back, and stops at the top level.
|
||||
const revoked = await approve(dmAuth, { characterIds: [boId, ariaId], revoke: true });
|
||||
expect(revoked.body.results.find((r) => r.name === 'Bo')).toMatchObject({ levelUpsPending: 0, skipped: null });
|
||||
expect(revoked.body.results.find((r) => r.name === 'Aria').skipped).toMatch(/no level up waiting/);
|
||||
await prisma.character.update({ where: { id: boId }, data: { level: 20 } });
|
||||
expect((await approve(dmAuth, { characterIds: [boId] })).body.results[0].skipped).toMatch(/level 20/);
|
||||
});
|
||||
|
||||
itIfDb('an approved level is no longer shown as due', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('approvedue');
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
|
||||
await request(app).patch(`/characters/${id}`).set(dmAuth).send({ xp: 300 });
|
||||
const read = async () => (await request(app).get(`/characters/${id}`).set(playerAuth)).body.character;
|
||||
expect((await read()).xpProgress.levelUpDue).toBe(true);
|
||||
await request(app).post(`/campaigns/${campaign.id}/level-up`).set(dmAuth).send({ characterIds: [id] });
|
||||
expect(await read()).toMatchObject({ levelUpsPending: 1, xpProgress: { levelUpDue: false } });
|
||||
});
|
||||
|
||||
itIfDb('where the campaign rolls hit points, the die is rolled here or typed in from the table', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('hproll');
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ hpOnLevelUp: 'ROLL', diceMode: 'PHYSICAL' });
|
||||
const start = (await request(app).get(`/characters/${id}`).set(dmAuth)).body.character.sheet;
|
||||
const before = start.hp.max;
|
||||
const con = start.abilities.CON.modifier; // whatever the species made of it
|
||||
const levelUp = (body) => request(app).post(`/characters/${id}/level-up`).set(dmAuth).send(body);
|
||||
expect((await request(app).get(`/characters/${id}/level-up`).set(dmAuth)).body.plan.hp).toEqual({ method: 'ROLL', die: 10, average: 6, physical: true });
|
||||
|
||||
expect((await levelUp({})).status).toBe(400); // the roll has to be entered
|
||||
expect((await levelUp({ hpRoll: 11 })).status).toBe(400); // not on a d10
|
||||
const low = await levelUp({ hpRoll: 2 });
|
||||
expect(low.body.hpRolled).toBe(2);
|
||||
// 2 from the die plus Constitution, where the average would have given 6.
|
||||
expect(low.body.character.sheet.hp.max).toBe(before + 2 + con);
|
||||
|
||||
// Digital dice: the server rolls, and the sheet follows whatever came up.
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ diceMode: 'DIGITAL' });
|
||||
const rolled = await levelUp({ hpRoll: 10, subclass: 'CHAMPION' }); // a typed value is ignored
|
||||
expect(rolled.body.hpRolled).toBeGreaterThanOrEqual(1);
|
||||
expect(rolled.body.hpRolled).toBeLessThanOrEqual(10);
|
||||
expect(rolled.body.character.sheet.hp.max).toBe(before + 2 + con + rolled.body.hpRolled + con);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a player picking spells on a level up', () => {
|
||||
itIfDb('is offered the campaign spells they can cast, and held to the level’s allowance', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('playerspells');
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Mira', 'WIZARD', 1);
|
||||
const spell = async (name, level) => (await request(app).post(`/campaigns/${campaign.id}/spells`).set(dmAuth).send({ name, level })).body.spell.id;
|
||||
const [a, b, c, high] = [await spell('Alarm', 1), await spell('Shield', 1), await spell('Sleep', 1), await spell('Fireball', 3)];
|
||||
await request(app).post(`/campaigns/${campaign.id}/level-up`).set(dmAuth).send({ characterIds: [id] });
|
||||
|
||||
const plan = (await request(app).get(`/characters/${id}/level-up`).set(playerAuth)).body.plan;
|
||||
expect(plan.spells).toMatchObject({ newSpells: 2, maxSpellLevel: 1 });
|
||||
expect(plan.spellOptions.map((s) => s.name)).toEqual(['Alarm', 'Shield', 'Sleep']); // not Fireball: too high
|
||||
|
||||
const levelUp = (spellIds) => request(app).post(`/characters/${id}/level-up`).set(playerAuth).send({ subclass: 'EVOCATION', spellIds });
|
||||
expect((await levelUp([a, b, c])).status).toBe(400); // one too many
|
||||
expect((await levelUp([a, high])).status).toBe(400); // out of reach
|
||||
const done = await levelUp([a, b]);
|
||||
expect(done.status).toBe(200);
|
||||
expect(done.body.character.spells.map((s) => s.spell.name).sort()).toEqual(['Alarm', 'Shield']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dice rolls', () => {
|
||||
itIfDb('are rolled by the server, kept, and shown to whoever may see them', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('rolls');
|
||||
const roll = (auth, body) => request(app).post(`/campaigns/${campaign.id}/rolls`).set(auth).send(body);
|
||||
const seen = async (auth) => (await request(app).get(`/campaigns/${campaign.id}/rolls`).set(auth)).body.rolls;
|
||||
|
||||
const check = await roll(playerAuth, { kind: 'check', label: 'Aria: Stealth', modifier: 5, mode: 'ADVANTAGE' });
|
||||
expect(check.status).toBe(201);
|
||||
const r = check.body.roll;
|
||||
expect(r).toMatchObject({ label: 'Aria: Stealth', notation: '1d20+5', mode: 'ADVANTAGE', physical: false, hidden: false, rollerName: 'Player' });
|
||||
expect(r.dice).toHaveLength(2);
|
||||
expect(r.natural).toBe(Math.max(...r.dice.map((d) => d.value)));
|
||||
expect(r.total).toBe(r.natural + 5);
|
||||
|
||||
const damage = (await roll(playerAuth, { kind: 'dice', expression: '2d6 + 3' })).body.roll;
|
||||
expect(damage.notation).toBe('2d6+3');
|
||||
expect(damage.total).toBe(damage.dice[0].value + damage.dice[1].value + 3);
|
||||
expect((await roll(playerAuth, { kind: 'dice', expression: 'a bite' })).status).toBe(400);
|
||||
expect((await roll(playerAuth, { kind: 'dice' })).status).toBe(400);
|
||||
|
||||
// A player cannot roll behind the screen; the DM can, and only the DM sees it.
|
||||
expect((await roll(playerAuth, { kind: 'check', hidden: true })).body.roll.hidden).toBe(false);
|
||||
const secret = (await roll(dmAuth, { kind: 'check', label: 'Goblin: Stealth', hidden: true })).body.roll;
|
||||
expect(secret.hidden).toBe(true);
|
||||
expect((await seen(dmAuth)).map((x) => x.id)).toContain(secret.id);
|
||||
expect((await seen(playerAuth)).map((x) => x.id)).not.toContain(secret.id);
|
||||
expect((await seen(dmAuth))[0].id).toBe(secret.id); // newest first
|
||||
|
||||
// With rolls kept to oneself, a player sees only their own; the DM still sees all.
|
||||
const open = (await roll(dmAuth, { kind: 'check', label: 'In the open' })).body.roll;
|
||||
expect((await seen(playerAuth)).map((x) => x.id)).toContain(open.id);
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ rollVisibility: 'OWN' });
|
||||
expect((await seen(playerAuth)).every((x) => x.rollerName === 'Player')).toBe(true);
|
||||
expect((await seen(dmAuth)).length).toBeGreaterThan((await seen(playerAuth)).length);
|
||||
});
|
||||
|
||||
itIfDb('follow the campaign\u2019s dice: typed in and checked with physical dice, never typed in with digital', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('rollsmode');
|
||||
const roll = (body) => request(app).post(`/campaigns/${campaign.id}/rolls`).set(playerAuth).send(body);
|
||||
const set = (diceMode) => request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ diceMode });
|
||||
|
||||
// Both: either way.
|
||||
expect((await roll({ kind: 'check', values: [14], modifier: 2 })).body.roll).toMatchObject({ total: 16, natural: 14, physical: true });
|
||||
expect((await roll({ kind: 'check', values: [21] })).status).toBe(400);
|
||||
expect((await roll({ kind: 'check', mode: 'DISADVANTAGE', values: [14] })).status).toBe(400); // two d20s
|
||||
expect((await roll({ kind: 'check', mode: 'DISADVANTAGE', values: [14, 6] })).body.roll.natural).toBe(6);
|
||||
expect((await roll({ kind: 'dice', expression: '1d8+1d6+1', values: [8, 6] })).body.roll.total).toBe(15);
|
||||
expect((await roll({ kind: 'dice', expression: '1d8+1d6', values: [8, 7] })).status).toBe(400);
|
||||
|
||||
await set('PHYSICAL');
|
||||
expect((await roll({ kind: 'check' })).status).toBe(400);
|
||||
expect((await roll({ kind: 'check', values: [3] })).status).toBe(201);
|
||||
await set('DIGITAL');
|
||||
expect((await roll({ kind: 'check', values: [20] })).status).toBe(400);
|
||||
expect((await roll({ kind: 'check' })).status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('battle actions', () => {
|
||||
// A battle with a fighter (a longsword in hand), a wizard and two goblins,
|
||||
// the fighter on turn. Everyone's numbers are set, so the sums can be checked.
|
||||
async function battle(prefix, settings = {}) {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign(prefix);
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 3);
|
||||
const mageId = await buildAs(campaign.id, dmAuth, 'Mira', 'WIZARD', 5);
|
||||
await prisma.character.updateMany({ where: { id: { in: [heroId, mageId] } }, data: { species: 'HUMAN', strScore: 15, dexScore: 13, intScore: 15, speciesBonusChoices: {} } });
|
||||
const sword = (await request(app).post(`${c}/weapons`).set(dmAuth).send({ name: 'Longsword', damageDice: '1d8', damageType: 'slashing', attackAbility: 'STR', properties: [] })).body.weapon;
|
||||
await request(app).post(`/characters/${heroId}/weapons`).set(dmAuth).send({ weaponId: sword.id });
|
||||
await request(app).patch(`/characters/${heroId}/weapons/${sword.id}`).set(playerAuth).send({ equipped: true });
|
||||
await request(app).post(`${c}/spells/from-library`).set(dmAuth).send({ key: 'fireball' });
|
||||
await request(app).post(`${c}/spells/from-library`).set(dmAuth).send({ key: 'fire-bolt' });
|
||||
for (const spell of (await request(app).get(`${c}/spells`).set(dmAuth)).body.spells) {
|
||||
await request(app).post(`/characters/${mageId}/spells`).set(dmAuth).send({ spellId: spell.id });
|
||||
}
|
||||
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster;
|
||||
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Ambush' })).body.encounter.id}`;
|
||||
const add = async (body) => (await request(app).post(`${e}/participants`).set(dmAuth).send(body)).body.participant;
|
||||
const hero = await add({ characterId: heroId, initiativeRoll: 20 });
|
||||
const mage = await add({ characterId: mageId, initiativeRoll: 15 });
|
||||
const one = await add({ monsterId: goblin.id, displayName: 'Goblin 1', initiativeRoll: 10 });
|
||||
const two = await add({ monsterId: goblin.id, displayName: 'Goblin 2', initiativeRoll: 5 });
|
||||
if (Object.keys(settings).length > 0) await request(app).patch(c).set(dmAuth).send(settings);
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
|
||||
const hp = async (id) => (await view(dmAuth)).participants.find((p) => p.id === id).currentHp;
|
||||
return { dmAuth, playerAuth, campaign, e, hero, mage, one, two, goblin, heroId, mageId, view, hp };
|
||||
}
|
||||
|
||||
itIfDb('a player declares an attack on their turn and enters the d20; the DM sees hit or miss, and applies it', async () => {
|
||||
const { dmAuth, playerAuth, e, one, two, mage, view, hp } = await battle('act');
|
||||
const declare = (auth, body) => request(app).post(`${e}/actions`).set(auth).send(body);
|
||||
const patch = (auth, id, body) => request(app).patch(`${e}/actions/${id}`).set(auth).send(body);
|
||||
const apply = (auth, id) => request(app).post(`${e}/actions/${id}/apply`).set(auth).send({});
|
||||
|
||||
const info = (await view(playerAuth)).participants.find((p) => p.displayName === 'Aria').characterCombatInfo;
|
||||
const sword = info.weapons[0];
|
||||
// Strength 16 (15, +1 human): +3, and proficiency +2.
|
||||
expect(sword).toMatchObject({ name: 'Longsword', attackBonus: 5, damageBonus: 3 });
|
||||
|
||||
// No target marked: nothing to aim at. A player has no "basic" attack.
|
||||
expect((await declare(playerAuth, { source: { type: 'weapon', characterWeaponId: sword.characterWeaponId } })).status).toBe(400);
|
||||
await request(app).put(`${e}/targets`).set(playerAuth).send({ participantIds: [one.id] });
|
||||
expect((await declare(playerAuth, { source: { type: 'basic' } })).status).toBe(403);
|
||||
|
||||
const declared = await declare(playerAuth, { source: { type: 'weapon', characterWeaponId: sword.characterWeaponId } });
|
||||
expect(declared.status).toBe(201);
|
||||
const actionId = declared.body.actionId;
|
||||
let action = (await view(dmAuth)).actions[0];
|
||||
expect(action).toMatchObject({ id: actionId, kind: 'ATTACK', name: 'Longsword', actorName: 'Aria', needsRolls: 1, ready: false });
|
||||
expect(action.targets[0]).toMatchObject({ name: 'Goblin 1', ac: 15, d20: null, outcome: null });
|
||||
// The player sees their attack, but not the goblin's AC.
|
||||
expect((await view(playerAuth)).actions[0].targets[0].ac).toBeNull();
|
||||
|
||||
// 9 + 5 = 14 against AC 15: a miss. Then the DM overrules it, and takes that back.
|
||||
expect((await patch(playerAuth, actionId, { rolls: [{ participantId: one.id, d20: 21 }] })).status).toBe(400);
|
||||
action = (await patch(playerAuth, actionId, { rolls: [{ participantId: one.id, d20: 9 }] })).body.encounter.actions[0];
|
||||
expect(action.targets[0]).toMatchObject({ d20: 9, total: 14, outcome: 'MISS', finalDamage: 0 });
|
||||
expect(action.ready).toBe(true);
|
||||
expect((await patch(playerAuth, actionId, { outcomes: [{ participantId: one.id, outcome: 'HIT' }] })).status).toBe(403);
|
||||
action = (await patch(dmAuth, actionId, { outcomes: [{ participantId: one.id, outcome: 'HIT' }] })).body.encounter.actions[0];
|
||||
expect(action.targets[0]).toMatchObject({ rolled: 'MISS', outcome: 'HIT', overruled: true });
|
||||
expect(action).toMatchObject({ needsDamage: true, ready: false });
|
||||
await patch(dmAuth, actionId, { outcomes: [{ participantId: one.id, outcome: null }] });
|
||||
|
||||
// 10 + 5 = 15: a hit. The damage is typed in, and only the DM lets it land.
|
||||
action = (await patch(playerAuth, actionId, { rolls: [{ participantId: one.id, d20: 10 }] })).body.encounter.actions[0];
|
||||
expect(action.targets[0]).toMatchObject({ total: 15, outcome: 'HIT' });
|
||||
expect((await apply(dmAuth, actionId)).status).toBe(400); // no damage yet
|
||||
action = (await patch(playerAuth, actionId, { damage: 4 })).body.encounter.actions[0];
|
||||
expect(action.targets[0].finalDamage).toBe(4);
|
||||
expect((await apply(playerAuth, actionId)).status).toBe(403);
|
||||
const before = await hp(one.id);
|
||||
const applied = await apply(dmAuth, actionId);
|
||||
expect(applied.status).toBe(200);
|
||||
expect(await hp(one.id)).toBe(before - 4);
|
||||
expect(applied.body.encounter.actions).toEqual([]);
|
||||
expect(applied.body.encounter.recentActions[0]).toMatchObject({ id: actionId, status: 'APPLIED' });
|
||||
expect((await apply(dmAuth, actionId)).status).toBe(400); // once
|
||||
|
||||
// The server can roll: the d20, then the damage where it hits. A natural 20 doubles the dice.
|
||||
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [two.id] });
|
||||
const second = (await declare(playerAuth, { source: { type: 'weapon', characterWeaponId: sword.characterWeaponId } })).body.actionId;
|
||||
action = (await patch(playerAuth, second, { rollRemaining: true })).body.encounter.actions[0];
|
||||
expect(action.targets[0].d20).toBeGreaterThanOrEqual(1);
|
||||
action = (await patch(dmAuth, second, { rolls: [{ participantId: two.id, d20: 20 }], rollDamage: true })).body.encounter.actions[0];
|
||||
expect(action.targets[0].outcome).toBe('CRIT');
|
||||
expect(action.damage.crit.dice).toHaveLength(2); // 1d8 rolled twice
|
||||
expect(action.targets[0].finalDamage).toBe(action.damage.crit.total);
|
||||
// The DM can set what a target takes by hand, and call the whole thing off.
|
||||
action = (await patch(dmAuth, second, { targetDamage: [{ participantId: two.id, damage: 1 }] })).body.encounter.actions[0];
|
||||
expect(action.targets[0].finalDamage).toBe(1);
|
||||
expect((await patch(dmAuth, second, { discard: true })).body.encounter.actions).toEqual([]);
|
||||
|
||||
// It is not the wizard's turn (and she is the DM's character): the player cannot act for her.
|
||||
expect((await declare(playerAuth, { actorParticipantId: mage.id, source: { type: 'basic' } })).status).toBe(403);
|
||||
});
|
||||
|
||||
itIfDb('a save spell asks each target for a save against the caster\u2019s DC, halves on a success, and takes its slot', async () => {
|
||||
const { dmAuth, playerAuth, e, one, two, mageId, view, hp } = await battle('actspell');
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth); // the wizard's turn
|
||||
const mira = (await view(dmAuth)).participants.find((p) => p.displayName === 'Mira').characterCombatInfo;
|
||||
const fireball = mira.spells.find((s) => s.name === 'Fireball');
|
||||
expect(fireball).toMatchObject({ saveAbility: 'DEX', halfOnSave: true, attackRoll: false });
|
||||
expect(mira.spells.find((s) => s.name === 'Fire Bolt')).toMatchObject({ attackRoll: true, saveAbility: null });
|
||||
const dc = mira.spellcasting.saveDC; // 8 + 3 proficiency + 3 Intelligence
|
||||
expect(dc).toBe(14);
|
||||
|
||||
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [one.id, two.id] });
|
||||
const actionId = (await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: fireball.characterSpellId } })).body.actionId;
|
||||
let action = (await view(dmAuth)).actions[0];
|
||||
// A goblin's Dexterity is 14: +2 to the save.
|
||||
expect(action).toMatchObject({ kind: 'SAVE', name: 'Fireball', needsRolls: 2 });
|
||||
expect(action.detail).toMatchObject({ saveAbility: 'DEX', saveDC: 14, halfOnSave: true, slotLevel: 3 });
|
||||
expect(action.targets.map((t) => t.saveBonus)).toEqual([2, 2]);
|
||||
|
||||
const patch = (body) => request(app).patch(`${e}/actions/${actionId}`).set(dmAuth).send(body);
|
||||
// 12 + 2 = 14 saves; 11 + 2 = 13 fails. 20 damage: 10 and 20.
|
||||
action = (await patch({ rolls: [{ participantId: one.id, d20: 12 }, { participantId: two.id, d20: 11 }], damage: 20 })).body.encounter.actions[0];
|
||||
expect(action.targets.map((t) => [t.outcome, t.finalDamage])).toEqual([['SAVED', 10], ['FAILED', 20]]);
|
||||
expect(action.ready).toBe(true);
|
||||
// A player cannot enter a monster's save.
|
||||
expect((await request(app).patch(`${e}/actions/${actionId}`).set(playerAuth).send({ rolls: [{ participantId: one.id, d20: 1 }] })).status).toBe(403);
|
||||
|
||||
const slotsBefore = (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels.find((l) => l.level === 3).remaining;
|
||||
const hpOne = await hp(one.id);
|
||||
const applied = await request(app).post(`${e}/actions/${actionId}/apply`).set(dmAuth).send({});
|
||||
expect(applied.status).toBe(200);
|
||||
expect(await hp(one.id)).toBe(Math.max(0, hpOne - 10));
|
||||
expect(await hp(two.id)).toBe(0); // 7 HP, 20 damage
|
||||
const slotsAfter = (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels.find((l) => l.level === 3).remaining;
|
||||
expect(slotsAfter).toBe(slotsBefore - 1);
|
||||
|
||||
// A cantrip takes no slot, however often it is cast. Spells are listed cantrips first.
|
||||
expect(mira.spells.map((s) => [s.name, s.level])).toEqual([['Fire Bolt', 0], ['Fireball', 3]]);
|
||||
const used = async () => (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels.map((l) => l.used);
|
||||
const before = await used();
|
||||
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [one.id] });
|
||||
const bolt = mira.spells.find((s) => s.name === 'Fire Bolt');
|
||||
const boltId = (await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: bolt.characterSpellId } })).body.actionId;
|
||||
action = (await request(app).patch(`${e}/actions/${boltId}`).set(dmAuth).send({ rolls: [{ participantId: one.id, d20: 19 }], damage: 3 })).body.encounter.actions[0];
|
||||
expect(action).toMatchObject({ kind: 'ATTACK', name: 'Fire Bolt' });
|
||||
expect(action.detail.slotLevel).toBeNull();
|
||||
const cast = await request(app).post(`${e}/actions/${boltId}/apply`).set(dmAuth).send({});
|
||||
expect(cast.status).toBe(200);
|
||||
expect(cast.body.slotNote).toBeNull();
|
||||
expect(await used()).toEqual(before);
|
||||
|
||||
// With her last 3rd-level slot gone, Fireball cannot be declared; the cantrip still can.
|
||||
await request(app).post(`/characters/${mageId}/spell-slots`).set(dmAuth).send({ action: 'set', level: 3, used: 2 });
|
||||
const dry = await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: fireball.characterSpellId } });
|
||||
expect(dry.status).toBe(400);
|
||||
expect(dry.body.error).toMatch(/no spell slot of level 3 or higher left/);
|
||||
expect((await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: bolt.characterSpellId } })).status).toBe(201);
|
||||
|
||||
// Every slot she has spent, at every level: the cantrip is still hers to cast, and lands.
|
||||
const all = (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels;
|
||||
for (const slot of all) await request(app).post(`/characters/${mageId}/spell-slots`).set(dmAuth).send({ action: 'set', level: slot.level, used: slot.max });
|
||||
const empty = (await view(dmAuth)).participants.find((p) => p.displayName === 'Mira').characterCombatInfo.spellSlots.levels;
|
||||
expect(empty.every((l) => l.remaining === 0)).toBe(true);
|
||||
const again = await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: bolt.characterSpellId } });
|
||||
expect(again.status).toBe(201);
|
||||
await request(app).patch(`${e}/actions/${again.body.actionId}`).set(dmAuth).send({ rolls: [{ participantId: one.id, d20: 1 }] });
|
||||
expect((await request(app).post(`${e}/actions/${again.body.actionId}/apply`).set(dmAuth).send({})).status).toBe(200);
|
||||
});
|
||||
|
||||
itIfDb('a weapon is changed as the battle begins or on your own turn after attacking, and not in between', async () => {
|
||||
const { dmAuth, playerAuth, campaign, e, one, heroId } = await battle('actswap');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const bow = (await request(app).post(`${c}/weapons`).set(dmAuth).send({ name: 'Shortbow', damageDice: '1d6', damageType: 'piercing', attackAbility: 'DEX', properties: [] })).body.weapon;
|
||||
await request(app).post(`/characters/${heroId}/weapons`).set(dmAuth).send({ weaponId: bow.id });
|
||||
await request(app).patch(c).set(dmAuth).send({ maxEquippedWeapons: 2 });
|
||||
const held = async () => (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character.weapons;
|
||||
const sword = (await held()).find((w) => w.weapon.name === 'Longsword');
|
||||
const equip = (auth, weaponId, equipped) => request(app).patch(`/characters/${heroId}/weapons/${weaponId}`).set(auth).send({ equipped });
|
||||
|
||||
// The battle has begun and nobody has made a move: the bow can come out, and go back.
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(200);
|
||||
expect((await equip(playerAuth, bow.id, false)).status).toBe(200);
|
||||
|
||||
// Aria attacks. While the attack is still being resolved nothing changes hands...
|
||||
await request(app).put(`${e}/targets`).set(playerAuth).send({ participantIds: [one.id] });
|
||||
const actionId = (await request(app).post(`${e}/actions`).set(playerAuth).send({ source: { type: 'weapon', characterWeaponId: sword.id } })).body.actionId;
|
||||
await request(app).patch(`${e}/actions/${actionId}`).set(playerAuth).send({ rolls: [{ participantId: one.id, d20: 2 }] });
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(400);
|
||||
await request(app).post(`${e}/actions/${actionId}/apply`).set(dmAuth).send({});
|
||||
// After her attack, before the next turn: she may swap.
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(200);
|
||||
|
||||
// The wizard's turn: Aria's hands stay as they are, either way. The DM can still change them.
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
const refused = await equip(playerAuth, bow.id, false);
|
||||
expect(refused.status).toBe(400);
|
||||
expect(refused.body.error).toMatch(/is in a battle \(Ambush\).*own turn once their attack has landed/);
|
||||
expect((await equip(playerAuth, sword.weaponId, false)).status).toBe(400);
|
||||
expect((await equip(dmAuth, bow.id, false)).status).toBe(200);
|
||||
// Other gear is not held to it.
|
||||
expect((await held()).find((w) => w.weapon.name === 'Shortbow').equipped).toBe(false);
|
||||
|
||||
// Round the table to Aria again: not before she has attacked this turn.
|
||||
for (let i = 0; i < 3; i += 1) await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
expect((await request(app).get(e).set(dmAuth)).body.encounter.currentRound).toBe(2);
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(400);
|
||||
|
||||
// Once the battle is over, gear is changed freely again.
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'COMPLETE' });
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(200);
|
||||
});
|
||||
|
||||
itIfDb('with auto-roll on, a monster\u2019s turn opens with its attack aimed and rolled, kept from the players until the DM accepts it', async () => {
|
||||
const { dmAuth, playerAuth, e, hero, mage, one, view } = await battle('actauto', { autoRollMonsters: true });
|
||||
// Aria, then Mira, then Goblin 1.
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
expect((await view(dmAuth)).actions).toEqual([]); // a character's turn: nothing declared for them
|
||||
const turn = (await request(app).post(`${e}/next-turn`).set(dmAuth)).body.encounter;
|
||||
expect(turn.activeParticipantId).toBe(one.id);
|
||||
expect(turn.actions).toHaveLength(1);
|
||||
const action = turn.actions[0];
|
||||
expect(action).toMatchObject({ actorName: 'Goblin 1', kind: 'ATTACK', auto: true, hidden: true, needsRolls: 0 });
|
||||
// It goes for someone on the other side, with the d20 already rolled, and damage where it hits.
|
||||
expect([hero.id, mage.id]).toContain(action.targets[0].participantId);
|
||||
expect(action.targets[0].d20).toBeGreaterThanOrEqual(1);
|
||||
expect(action.targets[0].total).toBe(action.targets[0].d20 + 4); // a goblin's +4
|
||||
if (['HIT', 'CRIT'].includes(action.targets[0].outcome)) expect(action.damage.total).toBeGreaterThan(0);
|
||||
expect(action.ready).toBe(true);
|
||||
// The players see nothing of it yet.
|
||||
expect((await view(playerAuth)).actions).toEqual([]);
|
||||
|
||||
// The DM changes its mind about the target: the same d20, against the other's AC.
|
||||
const other = action.targets[0].participantId === hero.id ? mage.id : hero.id;
|
||||
const moved = (await request(app).patch(`${e}/actions/${action.id}`).set(dmAuth).send({ targetIds: [other] })).body.encounter.actions[0];
|
||||
expect(moved.targets[0]).toMatchObject({ participantId: other, d20: action.targets[0].d20 });
|
||||
|
||||
// The DM makes sure it hits for 3, and accepts: the damage lands and everyone sees what happened.
|
||||
await request(app).patch(`${e}/actions/${action.id}`).set(dmAuth).send({ outcomes: [{ participantId: other, outcome: 'HIT' }], targetDamage: [{ participantId: other, damage: 3 }] });
|
||||
const before = (await view(dmAuth)).participants.find((p) => p.id === other).currentHp;
|
||||
const applied = (await request(app).post(`${e}/actions/${action.id}/apply`).set(dmAuth).send({})).body.encounter;
|
||||
expect(applied.participants.find((p) => p.id === other).currentHp).toBe(before - 3);
|
||||
expect((await view(playerAuth)).recentActions[0]).toMatchObject({ actorName: 'Goblin 1', status: 'APPLIED', hidden: false });
|
||||
|
||||
// Passing the turn clears anything left unresolved, and the next goblin declares its own.
|
||||
const next = (await request(app).post(`${e}/next-turn`).set(dmAuth)).body.encounter;
|
||||
expect(next.actions.every((a) => a.actorName === 'Goblin 2')).toBe(true);
|
||||
});
|
||||
|
||||
itIfDb('without the setting nothing is declared for a monster, and the DM can spell out an attack of their own', async () => {
|
||||
const { dmAuth, e, hero, one, view } = await battle('actmanual');
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
const turn = (await request(app).post(`${e}/next-turn`).set(dmAuth)).body.encounter;
|
||||
expect(turn.activeParticipantId).toBe(one.id);
|
||||
expect(turn.actions).toEqual([]);
|
||||
|
||||
// The goblin's own attack, aimed by hand.
|
||||
const basic = await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'basic' }, targetIds: [hero.id] });
|
||||
expect(basic.status).toBe(201);
|
||||
expect((await view(dmAuth)).actions[0]).toMatchObject({ kind: 'ATTACK', actorName: 'Goblin 1', detail: { attackBonus: 4 } });
|
||||
|
||||
// A trap, spelled out: Dexterity save DC 13, 2d6, half on a success. It replaces the unfinished attack.
|
||||
const trap = await request(app)
|
||||
.post(`${e}/actions`)
|
||||
.set(dmAuth)
|
||||
.send({ source: { type: 'custom', name: 'Dart trap', kind: 'SAVE', saveAbility: 'DEX', saveDC: 13, halfOnSave: true, damageDice: '2d6' }, targetIds: [hero.id] });
|
||||
expect(trap.status).toBe(201);
|
||||
const actions = (await view(dmAuth)).actions;
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0]).toMatchObject({ kind: 'SAVE', name: 'Dart trap', detail: { saveDC: 13 } });
|
||||
const rolled = (await request(app).patch(`${e}/actions/${actions[0].id}`).set(dmAuth).send({ rollRemaining: true, rollDamage: true })).body.encounter.actions[0];
|
||||
expect(rolled.ready).toBe(true);
|
||||
expect(rolled.damage.dice).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('free roam', () => {
|
||||
itIfDb('whoever the DM adds wanders between its places, and shops where they stand', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('roam');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const allyId = await buildCharacter(campaign.id, dmAuth, 'Bo');
|
||||
await request(app).post(`/characters/${heroId}/gold`).set(dmAuth).send({ amount: 50, reason: 'Purse' });
|
||||
const place = async (name) => (await request(app).post(`${c}/locations`).set(dmAuth).send({ name, description: `${name}.` })).body.location;
|
||||
const [market, inn, docks] = [await place('Market'), await place('Inn'), await place('Docks')];
|
||||
const rope = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope' })).body.item;
|
||||
const ale = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Ale' })).body.item;
|
||||
|
||||
// A shop at the market, and an innkeeper found at the inn who sells ale.
|
||||
expect((await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, itemId: rope.id, price: 2 })).status).toBe(201);
|
||||
expect((await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, npcId: 'x', itemId: rope.id, price: 2 })).status).toBe(400);
|
||||
const keeper = (await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Mara', role: 'Innkeeper', dmNotes: 'Waters the ale.', locationId: inn.id })).body.npc;
|
||||
expect((await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Nobody', locationId: 'nowhere' })).status).toBe(400);
|
||||
await request(app).post(`${c}/wares`).set(dmAuth).send({ npcId: keeper.id, itemId: ale.id, price: 1 });
|
||||
|
||||
const created = await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'A day in town', mode: 'FREE_ROAM', placeIds: [market.id, inn.id] });
|
||||
expect(created.status).toBe(201);
|
||||
const e = `/encounters/${created.body.encounter.id}`;
|
||||
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
|
||||
|
||||
// Nobody is in it until the DM adds them: the party may be split up.
|
||||
expect((await view(dmAuth)).participants).toEqual([]);
|
||||
expect((await request(app).get(e).set(playerAuth)).status).toBe(404);
|
||||
for (const characterId of [heroId, allyId]) await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId });
|
||||
let scene = await view(playerAuth);
|
||||
expect(scene.mode).toBe('FREE_ROAM');
|
||||
expect(scene.participants.map((p) => p.displayName).sort()).toEqual(['Aria', 'Bo']);
|
||||
expect(scene.places.map((p) => p.name)).toEqual(['Market', 'Inn']);
|
||||
expect(scene.places[0].wares[0].item.name).toBe('Rope');
|
||||
expect(scene.places[1].npcs[0]).toMatchObject({ name: 'Mara', role: 'Innkeeper' });
|
||||
expect(scene.places[1].npcs[0].dmNotes).toBeUndefined(); // the DM's alone
|
||||
expect(scene.places[1].npcs[0].wares[0].item.name).toBe('Ale');
|
||||
expect((await view(dmAuth)).places[1].npcs[0].dmNotes).toBe('Waters the ale.');
|
||||
|
||||
const visit = (auth, characterId, locationId) => request(app).post(`${e}/visit`).set(auth).send({ characterId, locationId });
|
||||
const buy = (auth, wareId, characterId) => request(app).post(`${e}/wares/${wareId}/buy`).set(auth).send({ characterId });
|
||||
const ropeWare = scene.places[0].wares[0].id;
|
||||
const aleWare = scene.places[1].npcs[0].wares[0].id;
|
||||
|
||||
// Nothing moves, and nothing is sold, before the scene begins.
|
||||
expect((await visit(playerAuth, heroId, market.id)).status).toBe(400);
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect((await buy(playerAuth, ropeWare, heroId)).status).toBe(400); // not at the market yet
|
||||
expect((await visit(playerAuth, heroId, docks.id)).status).toBe(400); // not one of the scene's places
|
||||
expect((await visit(playerAuth, allyId, market.id)).status).toBe(403); // not their character
|
||||
|
||||
scene = (await visit(playerAuth, heroId, market.id)).body.encounter;
|
||||
expect(scene.places[0].here.map((h) => h.name)).toEqual(['Aria']);
|
||||
expect((await buy(playerAuth, ropeWare, heroId)).status).toBe(201);
|
||||
// The innkeeper is at the inn: go there to buy her ale.
|
||||
const tooFar = await buy(playerAuth, aleWare, heroId);
|
||||
expect(tooFar.status).toBe(400);
|
||||
expect(tooFar.body.error).toMatch(/go to Inn first/);
|
||||
await visit(playerAuth, heroId, inn.id);
|
||||
expect((await buy(playerAuth, aleWare, heroId)).status).toBe(201);
|
||||
const hero = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character;
|
||||
expect(hero.inventoryItems.map((i) => i.item.name).sort()).toEqual(['Ale', 'Rope']);
|
||||
expect(hero.currentLocation.name).toBe('Inn');
|
||||
// The DM can send anyone, and opens another place while the party roams.
|
||||
expect((await visit(dmAuth, allyId, market.id)).status).toBe(200);
|
||||
expect((await request(app).put(`${e}/places`).set(playerAuth).send({ locationIds: [] })).status).toBe(403);
|
||||
const more = (await request(app).put(`${e}/places`).set(dmAuth).send({ locationIds: [market.id, inn.id, docks.id] })).body.encounter;
|
||||
expect(more.places.map((p) => p.name)).toEqual(['Market', 'Inn', 'Docks']);
|
||||
// Having been there, the player now knows the place for good.
|
||||
const known = (await request(app).get(`${c}/locations`).set(playerAuth)).body.locations.map((l) => l.name).sort();
|
||||
expect(known).toEqual(['Inn', 'Market']);
|
||||
});
|
||||
|
||||
itIfDb('a shop at a place does not sell in a quest, and only a free-roam scene has places', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('roamnot');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const market = (await request(app).post(`${c}/locations`).set(dmAuth).send({ name: 'Market' })).body.location;
|
||||
const rope = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope' })).body.item;
|
||||
const ware = (await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, itemId: rope.id, price: 1 })).body.ware;
|
||||
const quest = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Errand', mode: 'QUEST' })).body.encounter.id}`;
|
||||
await request(app).post(`${quest}/participants`).set(dmAuth).send({ characterId: heroId });
|
||||
await request(app).patch(quest).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect((await request(app).post(`${quest}/wares/${ware.id}/buy`).set(playerAuth).send({ characterId: heroId })).status).toBe(404);
|
||||
expect((await request(app).post(`${quest}/visit`).set(playerAuth).send({ characterId: heroId, locationId: market.id })).status).toBe(400);
|
||||
expect((await request(app).get(quest).set(playerAuth)).body.encounter.places).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('class features on the sheet', () => {
|
||||
itIfDb('a barbarian’s Unarmored Defense and Fast Movement go by the armor that is equipped', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('unarmored');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Korg', 'BARBARIAN', 5);
|
||||
await prisma.character.update({ where: { id }, data: { species: 'HUMAN', dexScore: 13, conScore: 15, speciesBonusChoices: {}, armorBonus: 0, speedOverride: null } });
|
||||
const sheet = async () => (await request(app).get(`/characters/${id}`).set(playerAuth)).body.character.sheet;
|
||||
|
||||
// Dexterity 14 (+2), Constitution 16 (+3): 10 + 2 + 3. A human's 30 ft, and 10 more.
|
||||
let s = await sheet();
|
||||
expect(s).toMatchObject({ armorClass: 15, speed: 40 });
|
||||
expect(s.features.map((f) => f.name)).toEqual(['Unarmored Defense', 'Fast Movement']);
|
||||
|
||||
// The library's armor comes marked. Hide (+2): 10 + 2 + 2, and no Unarmored Defense.
|
||||
const hide = (await request(app).post(`${c}/items/from-library`).set(dmAuth).send({ key: 'hide-armor' })).body.item;
|
||||
expect(hide.armorType).toBe('MEDIUM');
|
||||
await request(app).post(`/characters/${id}/inventory`).set(dmAuth).send({ itemId: hide.id });
|
||||
expect((await sheet()).armorClass).toBe(15); // carried, not worn
|
||||
await request(app).patch(`/characters/${id}/inventory/${hide.id}`).set(playerAuth).send({ equipped: true });
|
||||
s = await sheet();
|
||||
expect(s).toMatchObject({ armorClass: 14, speed: 40 });
|
||||
expect(s.features.map((f) => f.name)).toEqual(['Fast Movement']);
|
||||
|
||||
// The DM says what kind of thing an item is. Heavy: the extra speed goes. Not armor: all of it is back, on top of the +2.
|
||||
expect((await request(app).patch(`${c}/items/${hide.id}`).set(playerAuth).send({ armorType: 'HEAVY' })).status).toBe(403);
|
||||
expect((await request(app).patch(`${c}/items/${hide.id}`).set(dmAuth).send({ armorType: 'PLATE' })).status).toBe(400);
|
||||
await request(app).patch(`${c}/items/${hide.id}`).set(dmAuth).send({ armorType: 'HEAVY' });
|
||||
expect(await sheet()).toMatchObject({ armorClass: 14, speed: 30 });
|
||||
await request(app).patch(`${c}/items/${hide.id}`).set(dmAuth).send({ armorType: null });
|
||||
expect(await sheet()).toMatchObject({ armorClass: 17, speed: 40 });
|
||||
|
||||
// A speed the DM sets is used as it is.
|
||||
await request(app).patch(`/characters/${id}`).set(dmAuth).send({ speedOverride: 35 });
|
||||
expect((await sheet()).speed).toBe(35);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ const { hitDiceProblem, shortRest, longRest } = require('../utils/rest');
|
||||
const { CURRENCIES } = require('../utils/grants');
|
||||
const { subclassName } = require('../game/data/subclasses');
|
||||
const { classLevels } = require('../game/multiclass');
|
||||
const { parseDice, diceOf, validValues, rollExpression, rollD20, d20Count, D20_MODES, MAX_DICE } = require('../game/dice');
|
||||
const { findSeed, normalizeCode, CODE_LENGTH } = require('../seeds');
|
||||
const { createCampaignFromSeed } = require('../seeds/createCampaign');
|
||||
|
||||
@@ -255,6 +256,7 @@ function characterSummary(character, subclasses = true) {
|
||||
characterClass: character.characterClass,
|
||||
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
|
||||
level: character.level,
|
||||
levelUpsPending: character.levelUpsPending,
|
||||
// Every class it has levels in; one entry unless it has multiclassed.
|
||||
classes: classLevels(character).map((entry) => ({
|
||||
characterClass: entry.characterClass,
|
||||
@@ -262,7 +264,8 @@ function characterSummary(character, subclasses = true) {
|
||||
subclassName: subclasses ? subclassName(entry.characterClass, entry.subclass) : null,
|
||||
})),
|
||||
xp: character.xp,
|
||||
xpProgress: xpProgress(character.xp, character.level),
|
||||
// A level already approved is no longer due.
|
||||
xpProgress: xpProgress(character.xp, Math.min(MAX_LEVEL, character.level + character.levelUpsPending)),
|
||||
buildStage: character.buildStage,
|
||||
currentHp: character.currentHp,
|
||||
tempHp: character.tempHp,
|
||||
@@ -369,6 +372,62 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Level-ups — the DM approves a level for any of the party at once. Each
|
||||
// approval is a checklist waiting (Character.levelUpsPending): the
|
||||
// character's player is told and makes their own picks (POST
|
||||
// /characters/:id/level-up), so five players levelling together do not queue
|
||||
// for the DM. A character with no player is the DM's to finish.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const approveLevelUpSchema = z.object({
|
||||
characterIds: z.array(z.string()).min(1).max(200),
|
||||
// Takes an approval back (one not yet used) instead of giving one.
|
||||
revoke: z.boolean().default(false),
|
||||
});
|
||||
|
||||
router.post('/:id/level-up', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = approveLevelUpSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { revoke } = parsed.data;
|
||||
const ids = [...new Set(parsed.data.characterIds)];
|
||||
|
||||
const characters = await prisma.character.findMany({
|
||||
where: { id: { in: ids }, campaignId: req.campaign.id, buildStage: 'COMPLETE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (characters.length !== ids.length) return res.status(400).json({ error: 'Pick finished characters from this campaign.' });
|
||||
|
||||
const results = characters.map((character) => {
|
||||
const who = { characterId: character.id, name: character.name, userId: character.userId, level: character.level };
|
||||
if (revoke) {
|
||||
return character.levelUpsPending > 0
|
||||
? { ...who, levelUpsPending: character.levelUpsPending - 1, skipped: null }
|
||||
: { ...who, levelUpsPending: 0, skipped: `${character.name} has no level up waiting.` };
|
||||
}
|
||||
if (character.level + character.levelUpsPending >= MAX_LEVEL) {
|
||||
return { ...who, levelUpsPending: character.levelUpsPending, skipped: `${character.name} is already at (or approved up to) level ${MAX_LEVEL}.` };
|
||||
}
|
||||
return { ...who, levelUpsPending: character.levelUpsPending + 1, skipped: null };
|
||||
});
|
||||
|
||||
const changed = results.filter((result) => !result.skipped);
|
||||
await prisma.$transaction(
|
||||
changed.map((result) => prisma.character.update({ where: { id: result.characterId }, data: { levelUpsPending: result.levelUpsPending } }))
|
||||
);
|
||||
const io = req.app.get('io');
|
||||
for (const { characterId, userId } of changed) {
|
||||
io.to(`campaign:${req.campaign.id}`).emit('character:updated', { characterId });
|
||||
// Tells the character's player there is a level to take (ids only).
|
||||
if (!revoke) io.to(`campaign:${req.campaign.id}`).emit('character:level-up-granted', { characterId, userId });
|
||||
}
|
||||
res.json({ revoke, results });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rests — the DM rests any of the party at once (utils/rest.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -586,6 +645,11 @@ const spellSchema = z.object({
|
||||
damageDice: z.string().max(20).optional().nullable(),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
revives: z.boolean().optional(), // brings back the dead
|
||||
// How it lands in a battle: a spell attack against AC, or a save by each
|
||||
// target against the caster's DC (half damage on a success when halfOnSave).
|
||||
attackRoll: z.boolean().optional(),
|
||||
saveAbility: z.enum(['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']).optional().nullable(),
|
||||
halfOnSave: z.boolean().optional(),
|
||||
});
|
||||
|
||||
router.post('/:id/spells', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
@@ -642,6 +706,8 @@ const itemSchema = z.object({
|
||||
damageScaling: z.number().int().min(1).max(MAX_DAMAGE_SCALING).optional(),
|
||||
minLevel: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
// Armor and shields: what kind it is. Null for anything else.
|
||||
armorType: z.enum(['LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD']).optional().nullable(),
|
||||
...gearEffects,
|
||||
});
|
||||
|
||||
@@ -713,6 +779,20 @@ const monsterSchema = z.object({
|
||||
challengeRating: z.string().max(10).optional().nullable(),
|
||||
specialTrait: z.string().max(500).optional().nullable(),
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
// Named attacks, for a monster with more than the one above. `count` is how
|
||||
// often it is made each turn (a multiattack).
|
||||
attacks: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(1).max(40),
|
||||
attackBonus: z.number().int().min(-20).max(50),
|
||||
damageDice: z.string().max(60).optional().nullable(),
|
||||
damageType: z.string().max(30).optional().nullable(),
|
||||
count: z.number().int().min(1).max(10).optional(),
|
||||
})
|
||||
)
|
||||
.max(8)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
router.post('/:id/monsters', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
@@ -775,9 +855,11 @@ router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) =>
|
||||
|
||||
const createEncounterSchema = z.object({
|
||||
name: z.string().min(1).max(120),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST', 'FREE_ROAM']).optional(),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
locationId: z.string().optional().nullable(),
|
||||
// Free roam: the places the party can go to, from the campaign's locations.
|
||||
placeIds: z.array(z.string()).max(50).optional(),
|
||||
// Where it goes in the campaign's order: the top, the bottom, or straight
|
||||
// after another encounter (which wins over `position`). The bottom if
|
||||
// neither is given.
|
||||
@@ -789,11 +871,15 @@ router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req,
|
||||
try {
|
||||
const parsed = createEncounterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { position, afterEncounterId, ...fields } = parsed.data;
|
||||
const { position, afterEncounterId, placeIds = [], ...fields } = parsed.data;
|
||||
if (fields.locationId) {
|
||||
const location = await prisma.location.findFirst({ where: { id: fields.locationId, campaignId: req.campaign.id } });
|
||||
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
|
||||
}
|
||||
const places = [...new Set(placeIds)];
|
||||
if (places.length > 0 && (await prisma.location.count({ where: { id: { in: places }, campaignId: req.campaign.id } })) !== places.length) {
|
||||
return res.status(400).json({ error: 'Pick places from this campaign\'s locations.' });
|
||||
}
|
||||
|
||||
const order = await prisma.encounter.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
@@ -821,6 +907,12 @@ router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req,
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
// A free-roam scene's places. Who is in it is the DM's to add, as with
|
||||
// any encounter: the party may be split, some of them somewhere else.
|
||||
if (encounter.mode === 'FREE_ROAM' && places.length > 0) {
|
||||
await prisma.encounterLocation.createMany({ data: places.map((locationId) => ({ encounterId: encounter.id, locationId })) });
|
||||
}
|
||||
res.status(201).json({ encounter });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -883,8 +975,16 @@ const npcSchema = z.object({
|
||||
// null for an NPC who does not change money.
|
||||
exchangeGold: z.number().int().min(1).max(1000000).optional().nullable(),
|
||||
exchangeCash: z.number().int().min(1).max(1000000).optional().nullable(),
|
||||
// Where they are usually found: met there in a free-roam scene.
|
||||
locationId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
// Whether a location named for an NPC, a ware or a scene is one of this campaign's.
|
||||
async function locationInCampaign(locationId, campaignId) {
|
||||
return !locationId || Boolean(await prisma.location.findFirst({ where: { id: locationId, campaignId }, select: { id: true } }));
|
||||
}
|
||||
const NOT_A_PLACE = 'That location is not part of this campaign.';
|
||||
|
||||
// A rate has two sides: both are given, or both are cleared.
|
||||
function halfARate(data) {
|
||||
const sides = [data.exchangeGold, data.exchangeCash];
|
||||
@@ -904,6 +1004,7 @@ router.post('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res,
|
||||
const parsed = npcSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
|
||||
if (!(await locationInCampaign(parsed.data.locationId, req.campaign.id))) return res.status(400).json({ error: NOT_A_PLACE });
|
||||
const npc = await prisma.npc.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
|
||||
res.status(201).json({ npc });
|
||||
} catch (err) {
|
||||
@@ -916,6 +1017,7 @@ router.patch('/:id/npcs/:npcId', requireCampaignMember(), requireDM(), async (re
|
||||
const parsed = npcSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
|
||||
if (!(await locationInCampaign(parsed.data.locationId, req.campaign.id))) return res.status(400).json({ error: NOT_A_PLACE });
|
||||
const npc = await updatePoolEntry('npc', req.params.npcId, req.campaign.id, parsed.data);
|
||||
if (!npc) return res.status(404).json({ error: 'NPC not found in this campaign' });
|
||||
emitTradeChanged(req);
|
||||
@@ -1028,6 +1130,7 @@ const wareSchema = z
|
||||
.object({
|
||||
npcId: z.string().optional(),
|
||||
encounterId: z.string().optional(),
|
||||
locationId: z.string().optional(), // a shop that is always at this place
|
||||
weaponId: z.string().optional(),
|
||||
itemId: z.string().optional(),
|
||||
price: z.number().int().min(1).max(1000000),
|
||||
@@ -1035,7 +1138,7 @@ const wareSchema = z
|
||||
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||
stock: z.number().int().min(0).max(9999).optional().nullable(), // null: no limit
|
||||
})
|
||||
.refine((data) => Boolean(data.npcId) !== Boolean(data.encounterId), { message: 'Say who sells it: an NPC or an encounter, not both' })
|
||||
.refine((data) => [data.npcId, data.encounterId, data.locationId].filter(Boolean).length === 1, { message: 'Say where it is sold: by an NPC, at an encounter, or at a location (one of them)' })
|
||||
.refine((data) => Boolean(data.weaponId) !== Boolean(data.itemId), { message: 'Pick one weapon or one item to sell' });
|
||||
|
||||
router.post('/:id/wares', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
@@ -1045,7 +1148,7 @@ router.post('/:id/wares', requireCampaignMember(), requireDM(), async (req, res,
|
||||
const data = parsed.data;
|
||||
const inCampaign = async (model, id) => (id ? prisma[model].findFirst({ where: { id, campaignId: req.campaign.id } }) : null);
|
||||
|
||||
const seller = (await inCampaign('npc', data.npcId)) || (await inCampaign('encounter', data.encounterId));
|
||||
const seller = (await inCampaign('npc', data.npcId)) || (await inCampaign('encounter', data.encounterId)) || (await inCampaign('location', data.locationId));
|
||||
if (!seller) return res.status(400).json({ error: 'That seller is not part of this campaign.' });
|
||||
const entry = (await inCampaign('weapon', data.weaponId)) || (await inCampaign('item', data.itemId));
|
||||
if (!entry) return res.status(400).json({ error: `That ${data.weaponId ? 'weapon' : 'item'} is not part of this campaign.` });
|
||||
@@ -1185,4 +1288,128 @@ router.post('/:id/monsters/from-library', requireCampaignMember(), requireDM(),
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dice rolls — the table's dice roller (game/dice.js). A digital roll is made
|
||||
// here, never taken from the client; a physical one is typed in and checked
|
||||
// against its dice. Every roll is kept and announced, so the table (and above
|
||||
// all the DM) sees it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rollSchema = z
|
||||
.object({
|
||||
label: z.string().max(120).optional().nullable(),
|
||||
// A d20 plus `modifier` (a check, a save, an attack), or a dice expression (damage).
|
||||
kind: z.enum(['check', 'dice']),
|
||||
modifier: z.number().int().min(-99).max(99).default(0),
|
||||
mode: z.enum(D20_MODES).default('NORMAL'),
|
||||
expression: z.string().max(60).optional(),
|
||||
// Dice rolled at the table: what each showed, in order. Left out, the dice are rolled here.
|
||||
values: z.array(z.number().int().min(1).max(1000)).max(MAX_DICE).optional(),
|
||||
hidden: z.boolean().default(false), // the DM's alone
|
||||
encounterId: z.string().optional().nullable(),
|
||||
})
|
||||
.refine((roll) => roll.kind === 'check' || roll.expression, { message: 'A dice roll needs its dice, like "2d6+3".' });
|
||||
|
||||
// Rolls older than this are cleared away as new ones come in.
|
||||
const ROLL_KEEP_DAYS = 14;
|
||||
|
||||
const publicRoll = ({ id, userId, rollerName, label, notation, dice, modifier, total, natural, mode, physical, hidden, encounterId, createdAt }) => ({
|
||||
id,
|
||||
userId,
|
||||
rollerName,
|
||||
label,
|
||||
notation,
|
||||
dice,
|
||||
modifier,
|
||||
total,
|
||||
natural,
|
||||
mode,
|
||||
physical,
|
||||
hidden,
|
||||
encounterId,
|
||||
createdAt,
|
||||
});
|
||||
|
||||
// Which rolls a member may see: the DM every one; a player their own, and
|
||||
// everyone's (but the DM's hidden ones) where the campaign shows all rolls.
|
||||
function visibleRolls(req) {
|
||||
if (req.membership.role === 'DM') return {};
|
||||
if (req.campaign.rollVisibility === 'OWN') return { userId: req.user.id };
|
||||
return { OR: [{ userId: req.user.id }, { hidden: false }] };
|
||||
}
|
||||
|
||||
router.post('/:id/rolls', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = rollSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { label, kind, modifier, mode, expression, values, encounterId } = parsed.data;
|
||||
const isDM = req.membership.role === 'DM';
|
||||
|
||||
// The campaign's dice decide whether a roll is made here or typed in.
|
||||
const diceMode = req.campaign.diceMode;
|
||||
if (values && diceMode === 'DIGITAL') return res.status(400).json({ error: 'This campaign plays with digital dice: the app rolls.' });
|
||||
if (!values && diceMode === 'PHYSICAL') return res.status(400).json({ error: 'This campaign plays with physical dice: roll at the table and enter what the dice show.' });
|
||||
|
||||
let rolled;
|
||||
let notation;
|
||||
if (kind === 'check') {
|
||||
const dice = Array.from({ length: d20Count(mode) }, () => ({ sides: 20 }));
|
||||
if (values && !validValues(dice, values)) {
|
||||
return res.status(400).json({ error: `Enter ${dice.length === 1 ? 'the d20' : 'both d20s'} you rolled: whole numbers from 1 to 20.` });
|
||||
}
|
||||
rolled = rollD20(modifier, mode, { values });
|
||||
notation = `1d20${modifier ? (modifier > 0 ? `+${modifier}` : `${modifier}`) : ''}`;
|
||||
} else {
|
||||
const dice = parseDice(expression);
|
||||
if (!dice) return res.status(400).json({ error: 'Those dice cannot be read. Write them like "2d6+3".' });
|
||||
if (values && !validValues(diceOf(dice), values)) {
|
||||
return res.status(400).json({ error: 'Enter what each die showed, in order, each within its die.' });
|
||||
}
|
||||
rolled = { ...rollExpression(dice, { values }), natural: null };
|
||||
notation = expression.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
const [roll] = await prisma.$transaction([
|
||||
prisma.diceRoll.create({
|
||||
data: {
|
||||
campaignId: req.campaign.id,
|
||||
userId: req.user.id,
|
||||
rollerName: req.user.displayName || 'Someone',
|
||||
label: label || null,
|
||||
notation,
|
||||
dice: rolled.dice,
|
||||
modifier: rolled.modifier,
|
||||
total: rolled.total,
|
||||
natural: rolled.natural,
|
||||
mode: kind === 'check' ? mode : 'NORMAL',
|
||||
physical: Boolean(values),
|
||||
hidden: isDM && parsed.data.hidden, // only the DM rolls behind the screen
|
||||
encounterId: encounterId || null,
|
||||
},
|
||||
}),
|
||||
prisma.diceRoll.deleteMany({ where: { campaignId: req.campaign.id, createdAt: { lt: new Date(Date.now() - ROLL_KEEP_DAYS * 24 * 60 * 60 * 1000) } } }),
|
||||
]);
|
||||
// Ids only: each member's page asks for the roll and gets it only if it is theirs to see.
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('roll:created', { rollId: roll.id, userId: roll.userId });
|
||||
res.status(201).json({ roll: publicRoll(roll) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /campaigns/:id/rolls — the latest rolls this member may see, newest first.
|
||||
router.get('/:id/rolls', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 30, 1), 100);
|
||||
const rolls = await prisma.diceRoll.findMany({
|
||||
where: { campaignId: req.campaign.id, ...visibleRolls(req) },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
res.json({ rolls: rolls.map(publicRoll) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
computeCharacterSheet,
|
||||
computeWeaponStats,
|
||||
getClassSaveProficiencies,
|
||||
getClassHitDieSize,
|
||||
getClassSpellcastingAbility,
|
||||
scaledDamageDice,
|
||||
canUseGear,
|
||||
@@ -47,7 +48,9 @@ const { speciesBonusProblem } = require('../game/data/species');
|
||||
const { SKILLS } = require('../game/data/skills');
|
||||
const { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
|
||||
const { recordVisits } = require('../utils/locations');
|
||||
const { SHEET_INCLUDE, sheetOf, settleHp } = require('../utils/sheet');
|
||||
const { SHEET_INCLUDE, sheetOf, settleHp, bySpellLevel } = require('../utils/sheet');
|
||||
const { inTurnOrder, weaponSwapOpen } = require('../game/combat');
|
||||
const { isFallen } = require('../utils/actions');
|
||||
const { absorbDamage, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -65,7 +68,7 @@ async function loadFullCharacter(id) {
|
||||
spells: { include: { spell: true } },
|
||||
currentLocation: true,
|
||||
classLevels: true,
|
||||
campaign: { select: { subclassesEnabled: true, multiclassEnabled: true, diceMode: true } },
|
||||
campaign: { select: { subclassesEnabled: true, multiclassEnabled: true, diceMode: true, hpOnLevelUp: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -103,6 +106,8 @@ function serializeCharacter(character, isOwner, isDM = true) {
|
||||
// The 2024 subclass that updates the one the character follows, if any.
|
||||
subclassUpgrade: subclasses && newerVersionOf(character.characterClass, character.subclass) ? subclassSummary(newerVersionOf(character.characterClass, character.subclass)) : null,
|
||||
level: character.level,
|
||||
// Levels the DM has approved that are still to be taken (their picks made).
|
||||
levelUpsPending: character.levelUpsPending,
|
||||
// Every class the character has levels in, the first first; one entry
|
||||
// unless they have multiclassed.
|
||||
classes: classLevels(character).map((entry) => ({
|
||||
@@ -112,7 +117,8 @@ function serializeCharacter(character, isOwner, isDM = true) {
|
||||
subclassName: subclasses ? subclassName(entry.characterClass, entry.subclass) : null,
|
||||
})),
|
||||
xp: character.xp,
|
||||
xpProgress: xpProgress(character.xp, character.level),
|
||||
// A level already approved is no longer due.
|
||||
xpProgress: xpProgress(character.xp, Math.min(MAX_LEVEL, character.level + character.levelUpsPending)),
|
||||
motivation: character.motivation,
|
||||
speciesBonusChoices: character.speciesBonusChoices || {},
|
||||
rolledScores: {
|
||||
@@ -169,7 +175,7 @@ function serializeCharacter(character, isOwner, isDM = true) {
|
||||
usable: canUseGear(character.level, cw.level),
|
||||
};
|
||||
}),
|
||||
spells: character.spells.map((cs) => ({ ...cs, usable: canUseGear(character.level, cs.spell.level) })),
|
||||
spells: [...character.spells].sort(bySpellLevel).map((cs) => ({ ...cs, usable: canUseGear(character.level, cs.spell.level) })),
|
||||
sheet: character.buildStage === 'COMPLETE' ? sheet : null,
|
||||
};
|
||||
}
|
||||
@@ -449,7 +455,8 @@ router.post('/:id/spell-slots', requireCharacterAccess({ write: true }), async (
|
||||
if (input.action !== 'use' && !req.isDM) {
|
||||
return res.status(403).json({ error: 'Only the DM can give a spell slot back.' });
|
||||
}
|
||||
const slots = spellSlotState(character.characterClass, character.level, character.spellSlotsUsed);
|
||||
// By the sheet, so that a casting subclass and a multiclass count.
|
||||
const slots = sheetOf(await loadFullCharacter(character.id)).spellSlots;
|
||||
if (!slots) return res.status(400).json({ error: `${character.name} has no spell slots.` });
|
||||
|
||||
// The stored tally, squared up with what the class has at this level.
|
||||
@@ -549,11 +556,14 @@ router.post('/:id/resources', requireCharacterAccess({ write: true }), async (re
|
||||
// and the free weapon upgrade.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Answers the request and returns false when this character cannot be levelled up.
|
||||
// Answers the request and returns false when this character cannot be levelled
|
||||
// up. The DM always can; the character's own player can once the DM has
|
||||
// approved a level for them (levelUpsPending).
|
||||
function canLevelUp(req, res, character) {
|
||||
let refusal = null;
|
||||
if (!req.isDM) refusal = [403, 'Only the DM can level a character up.'];
|
||||
else if (character.buildStage !== 'COMPLETE') refusal = [400, 'Character build is not finished yet.'];
|
||||
if (!req.isDM && !(req.isOwner && character.levelUpsPending > 0)) {
|
||||
refusal = [403, req.isOwner ? 'Your DM has not approved a level up yet.' : 'Only the DM can level a character up.'];
|
||||
} else if (character.buildStage !== 'COMPLETE') refusal = [400, 'Character build is not finished yet.'];
|
||||
else if (character.level >= MAX_LEVEL) refusal = [400, `${character.name} is already level ${MAX_LEVEL}, the highest there is.`];
|
||||
if (refusal) res.status(refusal[0]).json({ error: refusal[1] });
|
||||
return !refusal;
|
||||
@@ -586,6 +596,19 @@ function planFor(req, res, character, toClass) {
|
||||
return { plan, sheet };
|
||||
}
|
||||
|
||||
// How the level's hit points are come by: the campaign's method, the die of
|
||||
// the class taking the level, the average it would count, and whether the
|
||||
// die is rolled at the table (physical dice) and typed in.
|
||||
function hpGain(character, plan) {
|
||||
const die = getClassHitDieSize(plan.characterClass);
|
||||
return {
|
||||
method: (character.campaign && character.campaign.hpOnLevelUp) || 'AVERAGE',
|
||||
die,
|
||||
average: Math.floor(die / 2) + 1,
|
||||
physical: effectiveDiceMode(character.campaign, character) === 'PHYSICAL',
|
||||
};
|
||||
}
|
||||
|
||||
// What the next level brings, for the DM to look over before saying yes.
|
||||
// `?class=` asks about a level in another class (multiclassing).
|
||||
router.get('/:id/level-up', requireCharacterAccess(), async (req, res, next) => {
|
||||
@@ -596,7 +619,17 @@ router.get('/:id/level-up', requireCharacterAccess(), async (req, res, next) =>
|
||||
if (!planned) return;
|
||||
const { plan, sheet } = planned;
|
||||
const after = sheetOf({ ...withLevelIn(character, plan.characterClass), spellSlotsUsed: [] });
|
||||
res.json({ plan: { ...plan, maxHp: { from: sheet.hp.max, to: after.hp.max }, spellSlots: after.spellSlots } });
|
||||
// The spells that could be granted now: the campaign's, of a level the
|
||||
// class can cast, that the character does not know. Sent with the plan
|
||||
// because a player otherwise sees only the spells they hold.
|
||||
const spellOptions = plan.spells
|
||||
? await prisma.spell.findMany({
|
||||
where: { campaignId: character.campaignId, level: { lte: plan.spells.maxSpellLevel }, id: { notIn: character.spells.map((held) => held.spellId) } },
|
||||
select: { id: true, name: true, level: true, school: true, description: true },
|
||||
orderBy: [{ level: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
: [];
|
||||
res.json({ plan: { ...plan, maxHp: { from: sheet.hp.max, to: after.hp.max }, hp: hpGain(character, plan), spellSlots: after.spellSlots, spellOptions } });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -607,6 +640,7 @@ const levelUpSchema = z.object({
|
||||
subclass: z.string().optional(), // which subclass, at the level the class chooses one
|
||||
boon: z.string().optional(), // which Boon, at a level that grants one
|
||||
weaponId: z.string().nullable().optional(), // the weapon that gets the free upgrade
|
||||
hpRoll: z.number().int().min(1).max(12).optional(), // the hit die rolled at the table, where HP is rolled with physical dice
|
||||
spellIds: z.array(z.string()).max(50).default([]), // spells newly granted
|
||||
});
|
||||
|
||||
@@ -635,6 +669,14 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
|
||||
if (new Set(spellIds).size !== spellIds.length || spellsFound !== spellIds.length) {
|
||||
return res.status(400).json({ error: "Pick the new spells from this campaign's spell list, each once." });
|
||||
}
|
||||
// The DM grants what they like; a player picks within what the level allows.
|
||||
if (!req.isDM && spellIds.length > 0) {
|
||||
const allowed = plan.spells ? plan.spells.newSpells + plan.spells.newCantrips : 0;
|
||||
const inReach = plan.spells ? await prisma.spell.count({ where: { id: { in: spellIds }, level: { lte: plan.spells.maxSpellLevel } } }) : 0;
|
||||
if (spellIds.length > allowed || inReach !== spellIds.length) {
|
||||
return res.status(400).json({ error: allowed > 0 ? `This level allows ${allowed} new spell${allowed === 1 ? '' : 's'}, of a level you can cast.` : 'This level grants no new spell.' });
|
||||
}
|
||||
}
|
||||
|
||||
// The subclass picked, or the one taken for them where the campaign has no choosing.
|
||||
const newSubclass = subclass || plan.subclassTaken || null;
|
||||
@@ -648,7 +690,21 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
const armorBonus = clamp(character.armorBonus + gained.armorBonus, -30, 30);
|
||||
const maxHp = sheetOf({ ...withLevelIn(character, plan.characterClass, newSubclass || undefined), armorBonus }).hp.max;
|
||||
// Rolled hit points: the die is rolled here, or typed in from the table.
|
||||
const hp = hpGain(character, plan);
|
||||
let hpRolled = null;
|
||||
if (hp.method === 'ROLL') {
|
||||
if (hp.physical) {
|
||||
if (!validateManualRoll(hp.die, parsed.data.hpRoll)) {
|
||||
return res.status(400).json({ error: `Roll a d${hp.die} for hit points and enter what it shows (1 to ${hp.die}).` });
|
||||
}
|
||||
hpRolled = parsed.data.hpRoll;
|
||||
} else {
|
||||
hpRolled = rollDie(hp.die);
|
||||
}
|
||||
}
|
||||
const hpRollAdjust = character.hpRollAdjust + (hpRolled == null ? 0 : hpRolled - hp.average);
|
||||
const maxHp = sheetOf({ ...withLevelIn(character, plan.characterClass, newSubclass || undefined), armorBonus, hpRollAdjust }).hp.max;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (weaponId) {
|
||||
@@ -670,6 +726,9 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
|
||||
where: { id: character.id },
|
||||
data: {
|
||||
level: plan.toLevel,
|
||||
hpRollAdjust,
|
||||
// An approved level is now taken.
|
||||
levelUpsPending: Math.max(0, character.levelUpsPending - 1),
|
||||
...(first && newSubclass ? { subclass: newSubclass } : {}),
|
||||
specialAbilities: gained.specialAbilities,
|
||||
armorBonus,
|
||||
@@ -683,7 +742,8 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
|
||||
|
||||
const full = await loadFullCharacter(character.id);
|
||||
emitCharacterUpdated(req);
|
||||
res.json({ character: serializeCharacter(full, req.isOwner) });
|
||||
// hpRolled: what the hit die showed, where HP is rolled (null otherwise).
|
||||
res.json({ character: serializeCharacter(full, req.isOwner, req.isDM), hpRolled });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -1381,7 +1441,7 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
|
||||
damageDice: ii.item.damageDice ? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel) : null,
|
||||
...effectsOf(ii.item),
|
||||
}));
|
||||
const spells = character.spells.map((cs) => ({
|
||||
const spells = [...character.spells].sort(bySpellLevel).map((cs) => ({
|
||||
characterSpellId: cs.id,
|
||||
name: cs.spell.name,
|
||||
level: cs.spell.level,
|
||||
@@ -1562,6 +1622,46 @@ function requireDmForGear(req, res) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A weapon is not swapped in the middle of a fight. For a character in a
|
||||
// battle that is under way, the weapon in hand changes only as the battle
|
||||
// begins (before anyone has made a move) or on their own turn once their
|
||||
// attack has landed, before the turn passes (game/combat.js, weaponSwapOpen).
|
||||
// Returns what to tell the player when it is not one of those moments. The
|
||||
// DM is not held to it: they referee, and put mistakes right.
|
||||
async function weaponSwapWait(character) {
|
||||
const rows = await prisma.encounterParticipant.findMany({
|
||||
where: { characterId: character.id, encounter: { status: 'ACTIVE', mode: 'BATTLE', archivedAt: null } },
|
||||
include: { encounter: true },
|
||||
});
|
||||
for (const row of rows) {
|
||||
const { encounter } = row;
|
||||
const participants = await prisma.encounterParticipant.findMany({
|
||||
where: { encounterId: encounter.id },
|
||||
include: { character: { select: { currentHp: true, deathSaveFailures: true, stable: true } } },
|
||||
});
|
||||
const first = inTurnOrder(participants).find((p) => !isFallen(p));
|
||||
// What has landed, and whether an attack somebody declared is still being
|
||||
// resolved (one the server lined up for a monster is not a move yet).
|
||||
const actions = await prisma.encounterAction.findMany({
|
||||
where: { encounterId: encounter.id, status: { in: ['APPLIED', 'PENDING'] } },
|
||||
select: { round: true, actorParticipantId: true, status: true, auto: true },
|
||||
});
|
||||
const applied = actions.filter((a) => a.status === 'APPLIED');
|
||||
const open = weaponSwapOpen({
|
||||
round: encounter.currentRound,
|
||||
activeId: encounter.activeParticipantId,
|
||||
firstId: first ? first.id : null,
|
||||
participantId: row.id,
|
||||
applied,
|
||||
declared: actions.some((a) => a.status === 'PENDING' && !a.auto),
|
||||
});
|
||||
if (!open) {
|
||||
return `${character.name} is in a battle (${encounter.name}). A weapon can be changed as the battle begins, before anyone has made a move, or on ${character.name}'s own turn once their attack has landed. The DM can change it at any time.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function emitCharacterUpdated(req) {
|
||||
req.app.get('io').to(`campaign:${req.character.campaignId}`).emit('character:updated', { characterId: req.character.id });
|
||||
}
|
||||
@@ -1687,6 +1787,11 @@ for (const gear of GEAR) {
|
||||
const blocked = await equipBlocked(req, gear.pool, req.params.poolId);
|
||||
if (blocked) return res.status(400).json({ error: blocked });
|
||||
}
|
||||
// Drawing or putting away a weapon in a battle waits for the right moment.
|
||||
if (gear.pool === 'weapon' && !req.isDM && parsed.data.equipped !== undefined && parsed.data.equipped !== existing.equipped) {
|
||||
const wait = await weaponSwapWait(req.character);
|
||||
if (wait) return res.status(400).json({ error: wait });
|
||||
}
|
||||
|
||||
const row = await prisma[gear.held].update({ where: whereHeld(req), data, include });
|
||||
if (gear.ownLevel) await settleHp(prisma, [req.character.id]);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const { z } = require('zod');
|
||||
const { Prisma } = require('@prisma/client');
|
||||
const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
const { computeCharacterSheet, computeWeaponStats, scaledDamageDice, canUseGear, abilityModifier, encounterDifficulty, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
const { CURRENCIES, InsufficientGoldError, adjustGold, spendCoins, giveGear } = require('../utils/grants');
|
||||
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
|
||||
const { SHEET_INCLUDE } = require('../utils/sheet');
|
||||
const { SHEET_INCLUDE, bySpellLevel } = require('../utils/sheet');
|
||||
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
|
||||
const { subclassName } = require('../game/data/subclasses');
|
||||
const { settle, rollDamage, rollCritDamage, inTurnOrder } = require('../game/combat');
|
||||
const { rollDie } = require('../game/dice');
|
||||
const { ActionError, targetRows, describeSource, autoActions } = require('../utils/actions');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -55,13 +59,6 @@ function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
// Highest initiative first; ties keep the order combatants were added in.
|
||||
// Anyone who has not rolled yet waits at the bottom.
|
||||
function inTurnOrder(participants) {
|
||||
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
|
||||
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
|
||||
}
|
||||
|
||||
// What is added to a combatant's d20 for initiative: the sheet's initiative
|
||||
// bonus for a character, the Dexterity modifier for a monster, and nothing for
|
||||
// an NPC (they have no ability scores). Takes a participant with its
|
||||
@@ -177,6 +174,7 @@ function characterCombatInfo(character, subclasses = true) {
|
||||
}));
|
||||
const spells = character.spells
|
||||
.filter((cs) => canUseGear(character.level, cs.spell.level))
|
||||
.sort(bySpellLevel)
|
||||
.map((cs) => ({
|
||||
characterSpellId: cs.id,
|
||||
name: cs.spell.name,
|
||||
@@ -184,6 +182,10 @@ function characterCombatInfo(character, subclasses = true) {
|
||||
description: cs.spell.description,
|
||||
damageDice: cs.spell.damageDice,
|
||||
prepared: cs.prepared,
|
||||
// How it lands: a spell attack, a save by each target, or neither.
|
||||
attackRoll: cs.spell.attackRoll,
|
||||
saveAbility: cs.spell.saveAbility,
|
||||
halfOnSave: cs.spell.halfOnSave,
|
||||
}));
|
||||
return {
|
||||
level: character.level,
|
||||
@@ -288,13 +290,16 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
// Likewise their conditions: the character's own, which outlast the scene.
|
||||
out.conditions = character.conditions;
|
||||
out.character = { id: character.id, name: character.name, userId: character.userId };
|
||||
// Where each character is, is part of a free-roam scene; elsewhere it stays on their sheet.
|
||||
if (encounter.mode === 'FREE_ROAM') out.character.currentLocationId = character.currentLocationId;
|
||||
out.characterCombatInfo = combatInfo;
|
||||
}
|
||||
out.conditionRounds = roundsFor(p.conditionRounds, out.conditions);
|
||||
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;
|
||||
// Where they are usually found is the DM's to know, like their notes and stats.
|
||||
const { dmNotes, ac, maxHp, attackBonus, damageDice, locationId, campaignId, createdAt, ...publicNpc } = npc;
|
||||
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice, locationId } : publicNpc;
|
||||
}
|
||||
if (hideHp && !character) {
|
||||
// Rounded up, so anything still standing shows a sliver of bar.
|
||||
@@ -305,6 +310,55 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
return out;
|
||||
});
|
||||
|
||||
// Attacks and spells in play: those still being resolved, and the last few
|
||||
// that landed. One the DM is keeping to themselves (a monster's auto-rolled
|
||||
// attack) is theirs alone until it is applied.
|
||||
const actionRows = await prisma.encounterAction.findMany({
|
||||
where: { encounterId, status: { in: ['PENDING', 'APPLIED'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 30,
|
||||
});
|
||||
const showAction = (action) => {
|
||||
const settled = settle(action);
|
||||
// A player is not told a monster's or NPC's AC.
|
||||
const targets = isDM ? settled.targets : settled.targets.map((t) => (t.kind === 'CHARACTER' ? t : { ...t, ac: null }));
|
||||
const { encounterId: _encounterId, createdById, ...rest } = settled;
|
||||
return { ...rest, targets };
|
||||
};
|
||||
const actions = actionRows
|
||||
.filter((a) => a.status === 'PENDING' && (isDM || !a.hidden))
|
||||
.reverse()
|
||||
.map(showAction);
|
||||
const recentActions = actionRows
|
||||
.filter((a) => a.status === 'APPLIED')
|
||||
.slice(0, 6)
|
||||
.map(showAction);
|
||||
|
||||
// Free roam: the places the party can go to, each with who is found there,
|
||||
// what is for sale there, and which of the characters in the scene are
|
||||
// there now. Everyone in the scene sees every place in it. An NPC's secrets
|
||||
// and stats stay with the DM, as anywhere.
|
||||
let places;
|
||||
if (encounter.mode === 'FREE_ROAM') {
|
||||
const rows = await prisma.encounterLocation.findMany({
|
||||
where: { encounterId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { location: { include: { npcs: { include: { wares: WARES }, orderBy: { name: 'asc' } }, wares: WARES } } },
|
||||
});
|
||||
places = rows.map(({ location: place }) => ({
|
||||
id: place.id,
|
||||
name: place.name,
|
||||
description: place.description,
|
||||
wares: place.wares,
|
||||
npcs: place.npcs.map(({ dmNotes: secrets, ac, maxHp, attackBonus, damageDice, locationId, campaignId, createdAt, ...publicNpc }) =>
|
||||
isDM ? { ...publicNpc, dmNotes: secrets, ac, maxHp, attackBonus, damageDice, locationId } : publicNpc
|
||||
),
|
||||
here: encounter.participants
|
||||
.filter((p) => p.character && p.character.currentLocationId === place.id)
|
||||
.map((p) => ({ characterId: p.character.id, name: p.displayName, userId: p.character.userId })),
|
||||
}));
|
||||
}
|
||||
|
||||
const { dmNotes, rewards, campaign, location, ...scene } = encounter;
|
||||
const knowsPlace = isDM || (location && (await visitedLocationIds(prisma, viewerId, encounter.campaignId)).has(location.id));
|
||||
return {
|
||||
@@ -316,6 +370,9 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
targetParticipantIds: isDM ? scene.targetParticipantIds : scene.targetParticipantIds.filter((id) => !hiddenIds.has(id)),
|
||||
participants,
|
||||
fallen,
|
||||
actions,
|
||||
recentActions,
|
||||
...(places ? { places } : {}),
|
||||
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
|
||||
};
|
||||
}
|
||||
@@ -338,7 +395,8 @@ async function syncCharacterLocations(req) {
|
||||
where: { id: req.encounter.id },
|
||||
include: { participants: { where: { characterId: { not: null } }, include: { character: { select: { id: true, currentLocationId: true } } } } },
|
||||
});
|
||||
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId) return;
|
||||
// In free roam each character goes where their player sends them.
|
||||
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId || encounter.mode === 'FREE_ROAM') return;
|
||||
|
||||
const elsewhere = encounter.participants.map((p) => p.character).filter((c) => c.currentLocationId !== encounter.locationId);
|
||||
await moveCharacters(prisma, elsewhere.map((c) => c.id), encounter.locationId);
|
||||
@@ -362,7 +420,7 @@ router.get('/:id', requireEncounterAccess, async (req, res, next) => {
|
||||
|
||||
const patchEncounterSchema = z.object({
|
||||
status: z.enum(['PLANNING', 'ACTIVE', 'COMPLETE']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST', 'FREE_ROAM']).optional(),
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
description: z.string().max(4000).nullable().optional(),
|
||||
dmNotes: z.string().max(4000).nullable().optional(),
|
||||
@@ -392,7 +450,8 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
|
||||
// Targets belong to the moment; beginning, ending or reopening clears them.
|
||||
if (data.status && data.status !== req.encounter.status) data.targetParticipantIds = [];
|
||||
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data });
|
||||
const updated = await prisma.encounter.update({ where: { id: req.encounter.id }, data });
|
||||
if (data.status === 'ACTIVE' && req.encounter.status !== 'ACTIVE') await declareAuto(req, updated);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -741,7 +800,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
|
||||
const index = order.findIndex((p) => p.id === req.encounter.activeParticipantId);
|
||||
const upNext = standingFrom(order, index + 1);
|
||||
const wrapped = index !== -1 && order.indexOf(upNext) <= index;
|
||||
await prisma.encounter.update({
|
||||
const updated = await prisma.encounter.update({
|
||||
where: { id: req.encounter.id },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
@@ -750,6 +809,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
|
||||
...(wrapped ? { currentRound: { increment: 1 } } : {}),
|
||||
},
|
||||
});
|
||||
await declareAuto(req, updated);
|
||||
// What wore off as the new round began, for the DM to read out.
|
||||
const expired = wrapped ? await tickConditions(req) : [];
|
||||
await respondWithEncounter(req, res, 200, { expired });
|
||||
@@ -772,6 +832,7 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
|
||||
},
|
||||
});
|
||||
const expired = await tickConditions(req);
|
||||
await declareAuto(req, updated);
|
||||
await syncCharacterLocations(req);
|
||||
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
|
||||
encounterId: updated.id,
|
||||
@@ -783,6 +844,381 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions — an attack or a spell, from being declared to its damage landing
|
||||
// (game/combat.js, utils/actions.js). A player declares their own character's
|
||||
// on their turn and rolls for it; the DM declares anyone's, enters or
|
||||
// overrules any roll, and is the one who applies it. With the campaign's
|
||||
// auto-roll settings a monster's or NPC's turn begins with its attack already
|
||||
// declared and rolled (declareAuto), for the DM to accept, change or discard.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Everything an action needs of the scene's combatants.
|
||||
const COMBATANTS = {
|
||||
character: { include: { ...SHEET_INCLUDE, spells: { include: { spell: true } } } },
|
||||
monster: true,
|
||||
npc: true,
|
||||
};
|
||||
|
||||
// What is left of the turn before: nothing. A turn passing discards whatever
|
||||
// was still being resolved, and then, where the campaign says so, the
|
||||
// combatant now on turn (a monster, or an NPC) declares its own attacks.
|
||||
async function declareAuto(req, encounter) {
|
||||
await prisma.encounterAction.updateMany({ where: { encounterId: encounter.id, status: 'PENDING' }, data: { status: 'DISCARDED' } });
|
||||
if (encounter.mode !== 'BATTLE' || encounter.status !== 'ACTIVE' || !encounter.activeParticipantId) return;
|
||||
const { autoRollMonsters, autoRollNpcs, areaAttackAcPenalty, critRule, hideMonsterRolls } = req.campaign;
|
||||
if (!autoRollMonsters && !autoRollNpcs) return;
|
||||
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: encounter.id }, include: COMBATANTS });
|
||||
const actor = participants.find((p) => p.id === encounter.activeParticipantId);
|
||||
if (!actor || actor.character) return;
|
||||
if (actor.npc ? !autoRollNpcs : !autoRollMonsters) return;
|
||||
|
||||
const history = await prisma.encounterAction.findMany({ where: { encounterId: encounter.id, status: 'APPLIED' }, orderBy: { appliedAt: 'desc' }, take: 40 });
|
||||
const declared = autoActions(actor, participants, { penaltyStep: areaAttackAcPenalty, critRule, history });
|
||||
for (const action of declared) {
|
||||
await prisma.encounterAction.create({ data: { ...action, encounterId: encounter.id, round: encounter.currentRound, hidden: hideMonsterRolls, createdById: null } });
|
||||
}
|
||||
}
|
||||
|
||||
const abilityEnum = z.enum(['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']);
|
||||
|
||||
const actionSourceSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('weapon'), characterWeaponId: z.string() }),
|
||||
z.object({ type: z.literal('spell'), characterSpellId: z.string(), slotLevel: z.number().int().min(1).max(9).optional() }),
|
||||
z.object({ type: z.literal('basic'), index: z.number().int().min(0).max(20).optional() }),
|
||||
z.object({
|
||||
type: z.literal('custom'),
|
||||
name: z.string().trim().min(1).max(60),
|
||||
kind: z.enum(['ATTACK', 'SAVE', 'AUTO']),
|
||||
attackBonus: z.number().int().min(-20).max(50).optional(),
|
||||
saveAbility: abilityEnum.optional(),
|
||||
saveDC: z.number().int().min(1).max(40).optional(),
|
||||
halfOnSave: z.boolean().optional(),
|
||||
damageDice: z.string().max(60).optional().nullable(),
|
||||
damageType: z.string().max(30).optional().nullable(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const createActionSchema = z.object({
|
||||
// Who is acting; whoever is on turn when left out.
|
||||
actorParticipantId: z.string().optional(),
|
||||
source: actionSourceSchema,
|
||||
// Who it is aimed at; the targets marked in the encounter when left out.
|
||||
targetIds: z.array(z.string()).max(50).optional(),
|
||||
hidden: z.boolean().optional(), // the DM's: keep it from the players until applied
|
||||
});
|
||||
|
||||
// Whether the requester's own character is this participant.
|
||||
const ownsParticipant = (req, p) => Boolean(p && p.character && p.character.userId === req.user.id);
|
||||
|
||||
router.post('/:id/actions', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = createActionSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { source } = parsed.data;
|
||||
if (req.encounter.status !== 'ACTIVE') return res.status(400).json({ error: 'Begin the encounter before anyone attacks.' });
|
||||
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id }, include: COMBATANTS });
|
||||
const actorId = parsed.data.actorParticipantId || req.encounter.activeParticipantId;
|
||||
const actor = participants.find((p) => p.id === actorId) || null;
|
||||
if (!req.isDM) {
|
||||
// A player acts for their own character, on its turn, with what it has.
|
||||
if (!ownsParticipant(req, actor) || actor.id !== req.encounter.activeParticipantId) {
|
||||
return res.status(403).json({ error: "You can only attack on your own character's turn." });
|
||||
}
|
||||
if (source.type === 'custom' || source.type === 'basic') return res.status(403).json({ error: 'Pick one of your weapons or spells.' });
|
||||
}
|
||||
|
||||
const targetIds = [...new Set(parsed.data.targetIds || req.encounter.targetParticipantIds)];
|
||||
const targets = targetIds.map((id) => participants.find((p) => p.id === id));
|
||||
if (targets.length === 0) return res.status(400).json({ error: 'Pick a target first.' });
|
||||
if (targets.some((p) => !p)) return res.status(400).json({ error: 'Combatant not found in this encounter' });
|
||||
|
||||
let described;
|
||||
try {
|
||||
described = describeSource(actor, source);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ActionError)) throw err;
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
const { kind, name, detail } = described;
|
||||
|
||||
// One thing at a time: a new action takes the place of the actor's unfinished one.
|
||||
await prisma.encounterAction.updateMany({
|
||||
where: { encounterId: req.encounter.id, status: 'PENDING', actorParticipantId: actor ? actor.id : null },
|
||||
data: { status: 'DISCARDED' },
|
||||
});
|
||||
const action = await prisma.encounterAction.create({
|
||||
data: {
|
||||
encounterId: req.encounter.id,
|
||||
round: req.encounter.currentRound,
|
||||
actorParticipantId: actor ? actor.id : null,
|
||||
actorName: actor ? actor.displayName : 'The DM',
|
||||
kind,
|
||||
name,
|
||||
detail,
|
||||
targets: targetRows(kind, detail, targets, req.campaign.areaAttackAcPenalty),
|
||||
hidden: req.isDM && Boolean(parsed.data.hidden),
|
||||
createdById: req.user.id,
|
||||
},
|
||||
});
|
||||
await respondWithEncounter(req, res, 201, { actionId: action.id });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const d20Schema = z.number().int().min(1, 'A d20 shows 1 to 20').max(20, 'A d20 shows 1 to 20');
|
||||
|
||||
const patchActionSchema = z.object({
|
||||
// A d20 typed in for a target: the attack roll against them, or their saving throw. null clears it.
|
||||
rolls: z.array(z.object({ participantId: z.string(), d20: d20Schema.nullable() })).max(50).optional(),
|
||||
// Have the server roll every d20 still missing that the requester may roll.
|
||||
rollRemaining: z.boolean().optional(),
|
||||
// The DM overruling the dice for a target; null goes back to what was rolled.
|
||||
outcomes: z.array(z.object({ participantId: z.string(), outcome: z.enum(['HIT', 'MISS', 'CRIT', 'SAVED', 'FAILED']).nullable() })).max(50).optional(),
|
||||
// The damage, typed in (dice rolled at the table), or rolled by the server.
|
||||
damage: z.number().int().min(0).max(100000).nullable().optional(),
|
||||
rollDamage: z.boolean().optional(),
|
||||
// The DM setting what one target takes; null goes back to what the action deals.
|
||||
targetDamage: z.array(z.object({ participantId: z.string(), damage: z.number().int().min(0).max(100000).nullable() })).max(50).optional(),
|
||||
// The DM aiming it at someone else: rolls already made stay with their place in the list.
|
||||
targetIds: z.array(z.string()).min(1).max(50).optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
discard: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Loads a pending action of this encounter with its actor, or answers and returns null.
|
||||
async function loadAction(req, res) {
|
||||
const action = await prisma.encounterAction.findUnique({ where: { id: req.params.actionId } });
|
||||
if (!action || action.encounterId !== req.encounter.id) {
|
||||
res.status(404).json({ error: 'That attack is not part of this encounter.' });
|
||||
return null;
|
||||
}
|
||||
if (action.status !== 'PENDING') {
|
||||
res.status(400).json({ error: action.status === 'APPLIED' ? 'That attack has already landed.' : 'That attack was discarded.' });
|
||||
return null;
|
||||
}
|
||||
if (!req.isDM && action.hidden) {
|
||||
res.status(404).json({ error: 'That attack is not part of this encounter.' });
|
||||
return null;
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
router.patch('/:id/actions/:actionId', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = patchActionSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const action = await loadAction(req, res);
|
||||
if (!action) return;
|
||||
const input = parsed.data;
|
||||
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id }, include: COMBATANTS });
|
||||
const actor = participants.find((p) => p.id === action.actorParticipantId) || null;
|
||||
const actsForActor = req.isDM || ownsParticipant(req, actor);
|
||||
// Who may put in the d20 for a target: for an attack, whoever is
|
||||
// attacking; for a save, the target's own player. The DM, always.
|
||||
const mayRollFor = (target) =>
|
||||
req.isDM || (action.kind === 'ATTACK' ? actsForActor : ownsParticipant(req, participants.find((p) => p.id === target.participantId)));
|
||||
// A player follows the campaign's dice; the DM enters or rolls as they like.
|
||||
const { diceMode } = req.campaign;
|
||||
const mayType = req.isDM || diceMode !== 'DIGITAL';
|
||||
const mayAsk = req.isDM || diceMode !== 'PHYSICAL';
|
||||
|
||||
if (input.discard) {
|
||||
if (!actsForActor) return res.status(403).json({ error: 'Only the DM or whoever declared it can call it off.' });
|
||||
await prisma.encounterAction.update({ where: { id: action.id }, data: { status: 'DISCARDED' } });
|
||||
return await respondWithEncounter(req, res);
|
||||
}
|
||||
const dmOnly = input.outcomes || input.targetDamage || input.targetIds || input.hidden !== undefined;
|
||||
if (dmOnly && !req.isDM) return res.status(403).json({ error: 'Only the DM overrules a roll, sets damage by hand or changes the targets.' });
|
||||
|
||||
let targets = action.targets.map((t) => ({ ...t }));
|
||||
let damage = action.damage;
|
||||
const data = {};
|
||||
|
||||
if (input.targetIds) {
|
||||
const next = [...new Set(input.targetIds)].map((id) => participants.find((p) => p.id === id));
|
||||
if (next.some((p) => !p)) return res.status(400).json({ error: 'Combatant not found in this encounter' });
|
||||
const fresh = targetRows(action.kind, action.detail, next, req.campaign.areaAttackAcPenalty);
|
||||
// A target kept keeps its roll; a new one in an old one's place takes the
|
||||
// roll already made (the die was cast before the aim was changed).
|
||||
targets = fresh.map((row, i) => {
|
||||
const kept = targets.find((t) => t.participantId === row.participantId);
|
||||
const d20 = kept ? kept.d20 : targets[i] && !next.some((p) => p.id === targets[i].participantId) ? targets[i].d20 : null;
|
||||
return { ...row, d20: action.kind === 'SAVE' && !kept ? null : d20 };
|
||||
});
|
||||
}
|
||||
|
||||
for (const { participantId, d20 } of input.rolls || []) {
|
||||
const target = targets.find((t) => t.participantId === participantId);
|
||||
if (!target) return res.status(400).json({ error: 'That is not one of its targets.' });
|
||||
if (!mayRollFor(target)) return res.status(403).json({ error: action.kind === 'SAVE' ? 'You can only enter the save for your own character.' : 'Only the attacker (or the DM) enters the attack roll.' });
|
||||
if (!mayType) return res.status(400).json({ error: 'This campaign plays with digital dice: the app rolls.' });
|
||||
target.d20 = d20;
|
||||
}
|
||||
if (input.rollRemaining) {
|
||||
if (!mayAsk) return res.status(400).json({ error: 'This campaign plays with physical dice: roll at the table and enter what the d20 shows.' });
|
||||
for (const target of targets) if (target.d20 == null && action.kind !== 'AUTO' && mayRollFor(target)) target.d20 = rollDie(20);
|
||||
}
|
||||
for (const { participantId, outcome } of input.outcomes || []) {
|
||||
const target = targets.find((t) => t.participantId === participantId);
|
||||
if (!target) return res.status(400).json({ error: 'That is not one of its targets.' });
|
||||
const allowed = action.kind === 'SAVE' ? ['SAVED', 'FAILED'] : ['HIT', 'MISS', 'CRIT'];
|
||||
if (outcome !== null && !allowed.includes(outcome)) return res.status(400).json({ error: `For this it is ${allowed.join(', ')}.` });
|
||||
target.outcome = outcome;
|
||||
}
|
||||
for (const { participantId, damage: amount } of input.targetDamage || []) {
|
||||
const target = targets.find((t) => t.participantId === participantId);
|
||||
if (!target) return res.status(400).json({ error: 'That is not one of its targets.' });
|
||||
target.damage = amount;
|
||||
}
|
||||
|
||||
if (input.damage !== undefined) {
|
||||
if (!actsForActor) return res.status(403).json({ error: 'Only the attacker (or the DM) enters the damage.' });
|
||||
if (!mayType) return res.status(400).json({ error: 'This campaign plays with digital dice: the app rolls.' });
|
||||
damage = input.damage === null ? null : { total: input.damage, entered: true };
|
||||
}
|
||||
const settledNow = () => settle({ ...action, targets, damage });
|
||||
if (input.rollDamage) {
|
||||
if (!actsForActor) return res.status(403).json({ error: 'Only the attacker (or the DM) rolls the damage.' });
|
||||
if (!mayAsk) return res.status(400).json({ error: 'This campaign plays with physical dice: roll the damage at the table and enter the total.' });
|
||||
const critical = settledNow().targets.some((t) => t.outcome === 'CRIT');
|
||||
damage = rollDamage(action.detail, { critical, critRule: req.campaign.critRule });
|
||||
if (!damage) return res.status(400).json({ error: 'It has no damage dice to roll. Enter the damage instead.' });
|
||||
}
|
||||
// A critical hit that turns up after the damage was rolled gets its dice.
|
||||
if (damage && !damage.entered && !damage.crit && settledNow().needsCrit) {
|
||||
damage = { ...damage, crit: rollCritDamage(action.detail, { critRule: req.campaign.critRule }) };
|
||||
}
|
||||
|
||||
if (input.hidden !== undefined) data.hidden = input.hidden;
|
||||
await prisma.encounterAction.update({ where: { id: action.id }, data: { ...data, targets, damage: damage === null ? Prisma.DbNull : damage } });
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// The DM lets it land: every target takes what the action comes to for them
|
||||
// (temp HP first, death saves at 0, as with any damage), a spell takes its
|
||||
// slot, and the action is done and shown to everyone. Answers with a line for
|
||||
// anyone hurt while concentrating, for the DM to call the save.
|
||||
router.post('/:id/actions/:actionId/apply', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const action = await loadAction(req, res);
|
||||
if (!action) return;
|
||||
const settled = settle(action);
|
||||
if (!settled.ready) {
|
||||
return res.status(400).json({
|
||||
error: settled.needsRolls > 0 ? `Still waiting on ${settled.needsRolls} d20${settled.needsRolls === 1 ? '' : 's'}.` : 'The damage has not been rolled or entered yet.',
|
||||
});
|
||||
}
|
||||
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id }, include: COMBATANTS });
|
||||
const concentration = [];
|
||||
for (const target of settled.targets) {
|
||||
const p = participants.find((x) => x.id === target.participantId);
|
||||
const amount = target.finalDamage || 0;
|
||||
if (!p || amount <= 0) continue;
|
||||
const conditions = p.character ? p.character.conditions : p.conditions;
|
||||
if (conditions.includes('Concentrating')) concentration.push({ name: p.displayName, damage: amount, dc: Math.max(10, Math.floor(amount / 2)) });
|
||||
if (p.character) {
|
||||
if (lifeState(p.character) === 'DEAD') continue;
|
||||
const maxHp = computeCharacterSheet(p.character, p.character.skillProficiencies, p.character.saveProficiencies).hp.max;
|
||||
const after = changeHp(p.character, p.character.currentHp - amount, maxHp);
|
||||
await prisma.character.update({ where: { id: p.character.id }, data: after });
|
||||
await prisma.encounterParticipant.update({ where: { id: p.id }, data: { currentHp: after.currentHp, maxHp } });
|
||||
emitCharacterUpdated(req, p.character.id);
|
||||
} else if (p.maxHp > 0) {
|
||||
await prisma.encounterParticipant.update({ where: { id: p.id }, data: { currentHp: clamp(p.currentHp - amount, 0, p.maxHp) } });
|
||||
}
|
||||
}
|
||||
|
||||
// A spell takes a slot of the level it was cast at, when the caster has one left.
|
||||
let slotNote = null;
|
||||
const { characterId, slotLevel } = action.detail;
|
||||
const caster = slotLevel ? participants.map((p) => p.character).find((c) => c && c.id === characterId) : null;
|
||||
if (caster) {
|
||||
const slots = computeCharacterSheet(caster, caster.skillProficiencies, caster.saveProficiencies).spellSlots;
|
||||
const slot = slots ? slots.levels.find((l) => l.level === slotLevel) : null;
|
||||
if (slot && slot.remaining > 0) {
|
||||
const used = Array.from({ length: slots.levels[slots.levels.length - 1].level }, (_, i) => {
|
||||
const at = slots.levels.find((l) => l.level === i + 1);
|
||||
return at ? at.used + (at.level === slotLevel ? 1 : 0) : 0;
|
||||
});
|
||||
await prisma.character.update({ where: { id: caster.id }, data: { spellSlotsUsed: used } });
|
||||
emitCharacterUpdated(req, caster.id);
|
||||
} else {
|
||||
slotNote = `${caster.name} had no level ${slotLevel} spell slot left for ${action.name}: nothing was spent.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Kept as it landed, so later turns know who hurt whom.
|
||||
await prisma.encounterAction.update({
|
||||
where: { id: action.id },
|
||||
data: { status: 'APPLIED', appliedAt: new Date(), hidden: false, targets: settled.targets },
|
||||
});
|
||||
await respondWithEncounter(req, res, 200, { concentration, slotNote });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Free roam — the party wanders between the places the DM has laid out for
|
||||
// the scene: to shop, to meet whoever is found there. The DM sets the places;
|
||||
// each player sends their own character to one (the DM can send anyone).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The whole list of places; an empty one clears it.
|
||||
router.put('/:id/places', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ locationIds: z.array(z.string()).max(50) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const locationIds = [...new Set(parsed.data.locationIds)];
|
||||
if ((await prisma.location.count({ where: { id: { in: locationIds }, campaignId: req.encounter.campaignId } })) !== locationIds.length) {
|
||||
return res.status(400).json({ error: "Pick places from this campaign's locations." });
|
||||
}
|
||||
const had = await prisma.encounterLocation.findMany({ where: { encounterId: req.encounter.id }, select: { locationId: true } });
|
||||
const keep = new Set(had.map((row) => row.locationId));
|
||||
await prisma.$transaction([
|
||||
prisma.encounterLocation.deleteMany({ where: { encounterId: req.encounter.id, locationId: { notIn: locationIds } } }),
|
||||
// Added one at a time, in the order given, so the list keeps that order.
|
||||
...locationIds.filter((id) => !keep.has(id)).map((locationId) => prisma.encounterLocation.create({ data: { encounterId: req.encounter.id, locationId } })),
|
||||
]);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// A character goes to one of the scene's places: their own player sends them, or the DM.
|
||||
router.post('/:id/visit', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ characterId: z.string(), locationId: z.string() }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { characterId, locationId } = parsed.data;
|
||||
if (req.encounter.mode !== 'FREE_ROAM') return res.status(400).json({ error: 'Only a free-roam scene has places to go to.' });
|
||||
if (req.encounter.status !== 'ACTIVE') return res.status(400).json({ error: 'The scene has not begun yet.' });
|
||||
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, characterId }, include: { character: true } });
|
||||
if (!row) return res.status(400).json({ error: 'That character is not in this scene.' });
|
||||
if (!req.isDM && row.character.userId !== req.user.id) return res.status(403).json({ error: 'You can only move your own character.' });
|
||||
if (isFallen(row)) return res.status(400).json({ error: `${row.character.name} is dead.` });
|
||||
const place = await prisma.encounterLocation.findFirst({ where: { encounterId: req.encounter.id, locationId } });
|
||||
if (!place) return res.status(400).json({ error: 'That is not one of the places in this scene.' });
|
||||
|
||||
await moveCharacters(prisma, [characterId], locationId);
|
||||
emitCharacterUpdated(req, characterId);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Experience — awarded once, shared evenly between the characters in the scene
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -973,10 +1409,18 @@ function money(amount, currency) {
|
||||
}
|
||||
|
||||
// Trading takes someone to trade with and the time to do it, so it happens
|
||||
// in a quest in progress and never in a battle.
|
||||
// in a quest or a free-roam scene in progress, and never in a battle.
|
||||
function assertTradeOpen(encounter) {
|
||||
if (encounter.mode !== 'QUEST') throw new TradeError('Trading happens in a quest, not in a battle.');
|
||||
if (encounter.status !== 'ACTIVE') throw new TradeError('Trading opens once the quest is in progress.');
|
||||
if (encounter.mode === 'BATTLE') throw new TradeError('Trading happens in a quest or while roaming, not in a battle.');
|
||||
if (encounter.status !== 'ACTIVE') throw new TradeError(`Trading opens once the ${encounter.mode === 'QUEST' ? 'quest' : 'scene'} is in progress.`);
|
||||
}
|
||||
|
||||
// In a free-roam scene a character trades where they are: the place has to
|
||||
// be one of the scene's, and they have to have gone there.
|
||||
async function assertAtPlace(req, character, locationId, what) {
|
||||
const place = locationId ? await prisma.encounterLocation.findFirst({ where: { encounterId: req.encounter.id, locationId }, include: { location: true } }) : null;
|
||||
if (!place) throw new TradeError(`${what} is not here to trade with.`);
|
||||
if (character.currentLocationId !== locationId) throw new TradeError(`${character.name} has to go to ${place.location.name} first.`);
|
||||
}
|
||||
|
||||
// The character doing the trading: in this scene, alive, and the player's
|
||||
@@ -989,8 +1433,15 @@ async function loadTrader(req, characterId) {
|
||||
return row.character;
|
||||
}
|
||||
|
||||
// An NPC to trade with has to be in the scene and still standing.
|
||||
async function loadTradingNpc(req, npcId) {
|
||||
// An NPC to trade with has to be in the scene and still standing; in a
|
||||
// free-roam scene, at one of its places, with the character there too.
|
||||
async function loadTradingNpc(req, npcId, character) {
|
||||
if (req.encounter.mode === 'FREE_ROAM') {
|
||||
const npc = await prisma.npc.findFirst({ where: { id: npcId, campaignId: req.encounter.campaignId } });
|
||||
if (!npc) throw new TradeError('That NPC is not here to trade with.');
|
||||
await assertAtPlace(req, character, npc.locationId, npc.name);
|
||||
return npc;
|
||||
}
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, npcId }, include: { npc: true } });
|
||||
if (!row || isFallen(row)) throw new TradeError('That NPC is not here to trade with.');
|
||||
return row.npc;
|
||||
@@ -1023,7 +1474,7 @@ router.post(
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
|
||||
const character = await loadTrader(req, characterId);
|
||||
const npc = await loadTradingNpc(req, npcId);
|
||||
const npc = await loadTradingNpc(req, npcId, character);
|
||||
if (!npc.exchangeGold || !npc.exchangeCash) throw new TradeError(`${npc.name} does not change money.`);
|
||||
const rate = { GOLD: npc.exchangeGold, CASH: npc.exchangeCash };
|
||||
if (amount % rate[from] !== 0) {
|
||||
@@ -1065,7 +1516,12 @@ router.post(
|
||||
if (!ware || ware.campaignId !== req.encounter.campaignId || (ware.encounterId && ware.encounterId !== req.encounter.id)) {
|
||||
throw new TradeError('That is not for sale here.', 404);
|
||||
}
|
||||
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId)).name : null;
|
||||
// A shop at a place sells to whoever has gone there, while that place is part of the scene.
|
||||
if (ware.locationId) {
|
||||
if (req.encounter.mode !== 'FREE_ROAM') throw new TradeError('That is not for sale here.', 404);
|
||||
await assertAtPlace(req, character, ware.locationId, 'That shop');
|
||||
}
|
||||
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId, character)).name : null;
|
||||
const kind = ware.weapon ? 'weapon' : 'item';
|
||||
const entry = ware[kind];
|
||||
const total = ware.price * quantity;
|
||||
|
||||
@@ -60,7 +60,7 @@ const POOLS = {
|
||||
},
|
||||
items: {
|
||||
library: ITEM_LIBRARY,
|
||||
row: z.strictObject({ name: text(120), description: optionalText(2000), damageDice: dice, ...gear }),
|
||||
row: z.strictObject({ name: text(120), description: optionalText(2000), damageDice: dice, armorType: z.enum(['LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD']).nullish(), ...gear }),
|
||||
},
|
||||
spells: {
|
||||
library: SPELL_LIBRARY,
|
||||
@@ -75,6 +75,10 @@ const POOLS = {
|
||||
description: optionalText(4000),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
revives: z.boolean().optional(),
|
||||
// How it lands in a battle: a spell attack, or a save by each target.
|
||||
attackRoll: z.boolean().optional(),
|
||||
saveAbility: z.enum(['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']).nullish(),
|
||||
halfOnSave: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
monsters: {
|
||||
@@ -95,6 +99,19 @@ const POOLS = {
|
||||
challengeRating: optionalText(10),
|
||||
specialTrait: optionalText(500),
|
||||
notes: optionalText(2000),
|
||||
// Named attacks, for more than the one above; `count` makes it a multiattack.
|
||||
attacks: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
name: text(40),
|
||||
attackBonus: z.number().int().min(-10).max(30),
|
||||
damageDice: damage,
|
||||
damageType: optionalText(30),
|
||||
count: z.number().int().min(1).max(10).optional(),
|
||||
})
|
||||
)
|
||||
.max(8)
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Battle actions against the database's shapes: reading a combatant's numbers
|
||||
// off a loaded participant, and putting together the action a weapon, a spell
|
||||
// or a creature's attack makes (EncounterAction). The rules themselves are in
|
||||
// game/combat.js. No queries here: everything is passed in loaded.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { computeCharacterSheet, computeWeaponStats, abilityModifier, canUseGear } = require('../game/calculations');
|
||||
const { attacksOf, opponentsOf, pickTarget, rollDamage, attackOutcome, lands } = require('../game/combat');
|
||||
const { rollDie } = require('../game/dice');
|
||||
const { lifeState } = require('./hp');
|
||||
|
||||
// A problem with what was asked for: the message is for whoever asked.
|
||||
class ActionError extends Error {}
|
||||
|
||||
const sheetOf = (character) => computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
|
||||
|
||||
const kindOf = (p) => (p.npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER');
|
||||
|
||||
// Out of the fight: a dead character, or a monster or NPC at 0 HP.
|
||||
function isFallen(p) {
|
||||
if (p.character) return lifeState(p.character) === 'DEAD';
|
||||
return p.maxHp > 0 && p.currentHp <= 0;
|
||||
}
|
||||
|
||||
// A participant's AC: the sheet's for a character, the stat block's for a
|
||||
// monster or NPC (10 for an NPC with no combat stats).
|
||||
function armorClassOf(p) {
|
||||
if (p.character) return sheetOf(p.character).armorClass;
|
||||
if (p.monster) return p.monster.ac;
|
||||
return p.npc && p.npc.ac != null ? p.npc.ac : 10;
|
||||
}
|
||||
|
||||
// What a participant adds to a saving throw with an ability: the sheet's save
|
||||
// bonus for a character, the ability modifier for a monster, nothing for an NPC.
|
||||
function saveBonusOf(p, ability) {
|
||||
if (p.character) return sheetOf(p.character).saves[ability].bonus;
|
||||
if (p.monster) return abilityModifier(p.monster[`${ability.toLowerCase()}Score`] ?? 10);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A participant as game/combat.js sees a combatant.
|
||||
const combatantOf = (p) => ({ id: p.id, name: p.displayName, kind: kindOf(p), disposition: p.npc ? p.npc.disposition : null, ac: armorClassOf(p), fallen: isFallen(p) });
|
||||
|
||||
const areaPenalty = (targetCount, step) => Math.max(0, targetCount - 1) * step;
|
||||
|
||||
// The targets of an action as it starts: who, and the number each roll is
|
||||
// against. `penaltyStep` is the campaign's area-attack AC penalty.
|
||||
function targetRows(kind, detail, targets, penaltyStep) {
|
||||
const penalty = kind === 'ATTACK' ? areaPenalty(targets.length, penaltyStep) : 0;
|
||||
return targets.map((p) => ({
|
||||
participantId: p.id,
|
||||
name: p.displayName,
|
||||
kind: kindOf(p),
|
||||
ac: kind === 'ATTACK' ? armorClassOf(p) : null,
|
||||
penalty,
|
||||
saveBonus: kind === 'SAVE' ? saveBonusOf(p, detail.saveAbility) : null,
|
||||
d20: null,
|
||||
outcome: null,
|
||||
damage: null,
|
||||
}));
|
||||
}
|
||||
|
||||
// What an action is made of, from what it is made with:
|
||||
// { type: 'weapon', characterWeaponId } a character's equipped weapon
|
||||
// { type: 'spell', characterSpellId, slotLevel? } a spell a character knows
|
||||
// { type: 'basic', index? } a monster's or NPC's own attack
|
||||
// { type: 'custom', name, kind, attackBonus?, saveAbility?, saveDC?, halfOnSave?, damageDice? }
|
||||
// anything else, spelled out (the DM's: a breath weapon, a trap)
|
||||
// Returns { kind, name, detail }. Throws ActionError when the actor can't.
|
||||
function describeSource(actor, source) {
|
||||
const character = actor ? actor.character : null;
|
||||
if (source.type === 'custom') {
|
||||
const kind = source.kind || 'ATTACK';
|
||||
return {
|
||||
kind,
|
||||
name: source.name || 'Attack',
|
||||
detail: {
|
||||
attackBonus: kind === 'ATTACK' ? source.attackBonus || 0 : undefined,
|
||||
saveAbility: kind === 'SAVE' ? source.saveAbility || 'DEX' : undefined,
|
||||
saveDC: kind === 'SAVE' ? source.saveDC || 10 : undefined,
|
||||
halfOnSave: kind === 'SAVE' ? Boolean(source.halfOnSave) : undefined,
|
||||
damageDice: source.damageDice || null,
|
||||
damageBonus: 0,
|
||||
damageType: source.damageType || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!actor) throw new ActionError('Nobody is on turn to make that attack.');
|
||||
|
||||
if (source.type === 'basic') {
|
||||
const attacks = attacksOf({ monster: actor.monster, npc: actor.npc });
|
||||
const attack = attacks[source.index || 0];
|
||||
if (!attack) throw new ActionError(`${actor.displayName} has no attack to make. Give it an attack bonus and damage first.`);
|
||||
return { kind: 'ATTACK', name: attack.name, detail: { attackBonus: attack.attackBonus, damageDice: attack.damageDice, damageBonus: 0, damageType: attack.damageType } };
|
||||
}
|
||||
|
||||
if (!character) throw new ActionError(`${actor.displayName} fights with its own attack, not a weapon or a spell.`);
|
||||
const sheet = sheetOf(character);
|
||||
|
||||
if (source.type === 'weapon') {
|
||||
const held = character.weapons.find((cw) => cw.id === source.characterWeaponId);
|
||||
if (!held) throw new ActionError(`${character.name} does not hold that weapon.`);
|
||||
if (!held.equipped || !canUseGear(character.level, held.level)) throw new ActionError(`${held.weapon.name} has to be equipped (and within ${character.name}'s level) to be used.`);
|
||||
const stats = computeWeaponStats(held.weapon, sheet.abilities, sheet.proficiencyBonus, true, held.level);
|
||||
return {
|
||||
kind: 'ATTACK',
|
||||
name: held.weapon.name,
|
||||
detail: { attackBonus: stats.attackBonus, damageDice: stats.damageDice, damageBonus: stats.damageBonus, damageType: stats.damageType, characterId: character.id },
|
||||
};
|
||||
}
|
||||
|
||||
if (source.type === 'spell') {
|
||||
const known = character.spells.find((cs) => cs.id === source.characterSpellId);
|
||||
if (!known) throw new ActionError(`${character.name} does not know that spell.`);
|
||||
const spell = known.spell;
|
||||
if (!canUseGear(character.level, spell.level)) throw new ActionError(`${spell.name} is beyond ${character.name}'s level.`);
|
||||
const casting = sheet.spellcasting;
|
||||
// A cantrip takes no slot; any other spell takes one of its own level, or
|
||||
// the lowest one above it that is left. With none left it cannot be cast.
|
||||
// (A character with no slots at all, handed a spell by the DM, casts it freely.)
|
||||
let slotLevel = null;
|
||||
if (spell.level > 0) {
|
||||
slotLevel = Math.max(spell.level, source.slotLevel || spell.level);
|
||||
if (sheet.spellSlots) {
|
||||
const wanted = slotLevel;
|
||||
const free = sheet.spellSlots.levels.find((l) => l.level >= wanted && l.remaining > 0);
|
||||
if (!free) throw new ActionError(`${character.name} has no spell slot of level ${wanted} or higher left, so cannot cast ${spell.name}.`);
|
||||
slotLevel = free.level;
|
||||
}
|
||||
}
|
||||
const base = { damageDice: spell.damageDice || null, damageBonus: 0, damageType: null, characterId: character.id, spellLevel: spell.level, slotLevel };
|
||||
if (spell.attackRoll) return { kind: 'ATTACK', name: spell.name, detail: { ...base, attackBonus: casting ? casting.attackBonus : 0 } };
|
||||
if (spell.saveAbility) {
|
||||
return { kind: 'SAVE', name: spell.name, detail: { ...base, saveAbility: spell.saveAbility, saveDC: casting ? casting.saveDC : 10, halfOnSave: spell.halfOnSave } };
|
||||
}
|
||||
return { kind: 'AUTO', name: spell.name, detail: base };
|
||||
}
|
||||
throw new ActionError('That is not something to attack with.');
|
||||
}
|
||||
|
||||
// A fair number from 0 up to (not including) 1, for picking a target.
|
||||
const randomUnit = () => crypto.randomInt(0, 1000000) / 1000000;
|
||||
|
||||
// The attacks a monster or NPC makes by itself on its turn, each already
|
||||
// aimed and rolled: who it goes for (game/combat.js, pickTarget), the d20, and
|
||||
// the damage where it hits. `history` is the encounter's applied actions,
|
||||
// newest first, for whom it fought last and who has hurt it. Returns the
|
||||
// actions to create (without ids); empty when it has no attack or nobody to
|
||||
// go for.
|
||||
function autoActions(actor, participants, { penaltyStep = 0, critRule = 'DOUBLE_DICE', history = [] } = {}) {
|
||||
const attacks = attacksOf({ monster: actor.monster, npc: actor.npc });
|
||||
const combatants = participants.map(combatantOf);
|
||||
const opponents = opponentsOf(combatantOf(actor), combatants);
|
||||
if (attacks.length === 0 || opponents.length === 0) return [];
|
||||
|
||||
const mine = history.find((action) => action.actorParticipantId === actor.id);
|
||||
let lastTargetId = mine && mine.targets[0] ? mine.targets[0].participantId : null;
|
||||
const hurtBy = history
|
||||
.filter((action) => (action.targets || []).some((t) => t.participantId === actor.id && t.finalDamage > 0))
|
||||
.map((action) => action.actorParticipantId)
|
||||
.filter(Boolean);
|
||||
|
||||
return attacks.map((attack) => {
|
||||
const picked = pickTarget(opponents, { lastTargetId, hurtBy }, randomUnit);
|
||||
lastTargetId = picked.id; // a creature with several attacks tends to keep at one foe
|
||||
const target = participants.find((p) => p.id === picked.id);
|
||||
const detail = { attackBonus: attack.attackBonus, damageDice: attack.damageDice, damageBonus: 0, damageType: attack.damageType };
|
||||
const [row] = targetRows('ATTACK', detail, [target], penaltyStep);
|
||||
row.d20 = rollDie(20);
|
||||
const outcome = attackOutcome(row.d20, detail.attackBonus, row.ac, row.penalty);
|
||||
return {
|
||||
actorParticipantId: actor.id,
|
||||
actorName: actor.displayName,
|
||||
kind: 'ATTACK',
|
||||
name: attack.name,
|
||||
detail,
|
||||
targets: [row],
|
||||
damage: lands(outcome) ? rollDamage(detail, { critical: outcome === 'CRIT', critRule }) : null,
|
||||
auto: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { ActionError, kindOf, isFallen, armorClassOf, saveBonusOf, combatantOf, areaPenalty, targetRows, describeSource, autoActions };
|
||||
@@ -34,4 +34,8 @@ async function settleHp(db, characterIds) {
|
||||
return changed;
|
||||
}
|
||||
|
||||
module.exports = { SHEET_INCLUDE, sheetOf, settleHp };
|
||||
// A character's spells in the order they are listed everywhere: cantrips
|
||||
// first, then by spell level, then by name.
|
||||
const bySpellLevel = (a, b) => a.spell.level - b.spell.level || a.spell.name.localeCompare(b.spell.name);
|
||||
|
||||
module.exports = { SHEET_INCLUDE, sheetOf, settleHp, bySpellLevel };
|
||||
|
||||
@@ -27,8 +27,8 @@ turning XP tracking off keeps everyone's XP; it just stops showing it.
|
||||
|
||||
| Setting | Default | What it does |
|
||||
|---|---|---|
|
||||
| `diceMode` | `BOTH` | `DIGITAL`: the app rolls every die. `PHYSICAL`: everyone rolls real dice and types in what they show (the server checks each value is on the die). `BOTH`: each character picks one or the other before their first build roll. |
|
||||
| `rollVisibility` | `ALL` | `ALL`: everyone sees every roll. `OWN`: a player sees only their own rolls. The DM always sees every roll. |
|
||||
| `diceMode` | `BOTH` | `DIGITAL`: the app rolls every die. `PHYSICAL`: everyone rolls real dice and types in what they show (the server checks each value is on the die). `BOTH`: either. In the builder each character picks one before their first roll; in the dice roller each person ticks "I roll real dice" when they want to. It covers the builder, the dice roller and the hit point roll on a level up. |
|
||||
| `rollVisibility` | `ALL` | Who sees a roll made in the dice roller. `ALL`: everyone sees every roll. `OWN`: a player sees only their own rolls. The DM always sees every roll, and can roll hidden either way. |
|
||||
| `hideMonsterRolls` | on | The DM's rolls for monsters and NPCs stay hidden from players until they are put into play. |
|
||||
|
||||
## Combat
|
||||
|
||||
+139
-39
@@ -147,8 +147,10 @@ this order:
|
||||
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 extra AC.
|
||||
does not calculate anything from most of them: Rage does not add damage by
|
||||
itself. The exception is the handful that change a number on the sheet
|
||||
(Unarmored Defense, Fast Movement and the rest): those are worked out for
|
||||
you, as section 8 ("Class features that are counted") sets out.
|
||||
|
||||
**Background** is five lines, one per question:
|
||||
|
||||
@@ -362,8 +364,10 @@ 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.
|
||||
- Only the DM can change level or XP. There are two ways to change a level,
|
||||
both on the sheet: the **⬆ Level Up…** button, and the Level field.
|
||||
- Only the DM decides that a character levels, and only the DM changes XP. A
|
||||
level is taken in one of three ways: the DM **approves** it and the player
|
||||
takes it themselves (below), the DM levels the character directly with
|
||||
**⬆ Level up now…** on the sheet, or the DM types into the Level field.
|
||||
- **XP** (experience points) is shown under the character's name on the sheet
|
||||
as "37 / 300 XP": what they have, and what their next level takes. The party
|
||||
overview shows the same under each level.
|
||||
@@ -372,7 +376,7 @@ Ability scores cannot be changed after the build is finalized.
|
||||
The DM can also type a character's XP into the **XP** field beside Level.
|
||||
- **XP does not level a character up.** When a character has the XP for
|
||||
their next level the sheet shows "⬆ Level up due" (the party overview shows
|
||||
⬆), and the DM levels them with "⬆ Level Up…" when the table is ready. A DM
|
||||
⬆), and the DM approves the level when the table is ready. A DM
|
||||
who levels by milestones can turn **Track experience points** off in the
|
||||
campaign settings. That hides XP everywhere, closes the award panel, and
|
||||
leaves levelling entirely to the DM.
|
||||
@@ -385,16 +389,50 @@ Ability scores cannot be changed after the build is finalized.
|
||||
| 4 | 2,700 | 9 | 48,000 | 14 | 140,000 | 19 | 305,000 |
|
||||
| 5 | 6,500 | 10 | 64,000 | 15 | 165,000 | 20 | 355,000 |
|
||||
|
||||
### Approving a level, and who takes it
|
||||
|
||||
So that five players levelling together do not queue for the DM, the DM
|
||||
**approves** and each player **takes** their own level:
|
||||
|
||||
1. The DM presses **⬆ Level up…** on the campaign page or the party overview
|
||||
(beside 🛏 Rest…). A pop-out lists the party; with XP tracked, those who have
|
||||
the XP for their next level start ticked ("Everyone", "Those with the XP"
|
||||
and "Nobody" tick in one go). **Approve** gives each ticked character one
|
||||
level to take. One character can also be approved from their sheet
|
||||
(**⬆ Approve a level up**).
|
||||
2. Each approved character's player is told at once, wherever in the campaign
|
||||
they are: a notice slides in ("⬆ Aria can level up!") with a button
|
||||
straight to the checklist. If they are not online, it waits: their sheet
|
||||
and their row on the campaign page show "⬆ Level up ready" until it is taken.
|
||||
3. The player opens the checklist, sees what the level gives, makes the picks
|
||||
that are theirs (the class for a multiclass, subclass, Boon, new spells,
|
||||
the weapon upgrade, the hit point roll) and confirms. The level is theirs
|
||||
then, healed to full, as if the DM had done it.
|
||||
|
||||
Things to know:
|
||||
|
||||
- An approval is one level. Approving twice lets a character take two, one
|
||||
checklist at a time. "Take one back" in the pop-out undoes an approval that
|
||||
has not been used.
|
||||
- The DM sees who still has picks to make: a banner on the party overview,
|
||||
and a badge on each character. The DM can open the checklist and finish it
|
||||
for anyone ("Open the checklist" on the sheet).
|
||||
- A character with no player (an ally the DM runs) is the DM's to finish.
|
||||
- A player may pick as many new spells as the level allows, of a level they
|
||||
can cast; the DM, doing it for them, is not held to that.
|
||||
- Once approved, a level no longer shows as "due".
|
||||
|
||||
### Level Up: the checklist
|
||||
|
||||
"⬆ Level Up…" takes a character up **one level** and does everything that
|
||||
goes with it. It opens a pop-out that lists the five steps and asks for the
|
||||
choices; nothing changes until the DM presses "Level up to N".
|
||||
The checklist takes a character up **one level** and does everything that
|
||||
goes with it. It opens as a pop-out that lists the steps and asks for the
|
||||
choices; nothing changes until "Level up to N" is pressed. It is the same
|
||||
whether a player takes an approved level or the DM uses **⬆ Level up now…**.
|
||||
|
||||
| Step | What the site does | What the DM chooses |
|
||||
| Step | What the site does | What is chosen |
|
||||
|---|---|---|
|
||||
| 1. Level | Raises the level by one. Proficiency bonus, saves, skills and spell DC follow | Nothing |
|
||||
| 2. Hit points | Works out the new max HP and heals to it (a dead character stays dead). One more Hit Die | Nothing |
|
||||
| 1. Level | Raises the level by one. Proficiency bonus, saves, skills and spell DC follow | With multiclassing on: which class gains the level |
|
||||
| 2. Hit points | Works out the new max HP and heals to it (a dead character stays dead). One more Hit Die. Where the campaign **rolls** hit points, the class's die is rolled by the site, or rolled at the table and typed in (physical dice); the roll takes the place of the average for good | The die roll, with physical dice |
|
||||
| 3. Special abilities | Writes the class's and the subclass's new features for that level into Special Abilities ([section 21](#21-what-each-class-gains-as-it-levels)) | The **subclass**, at the level the class chooses one; the **Boon**, at a level that grants one |
|
||||
| 4. Spells | Gives a caster a fresh set of spell slots for the new level, and grants the spells ticked | Which spells to grant, from the campaign's spell list |
|
||||
| 5. Gear | Raises one weapon by one gear level | Which weapon, or none |
|
||||
@@ -778,9 +816,9 @@ themselves.
|
||||
|
||||
| Stat | Formula |
|
||||
|---|---|
|
||||
| Armor Class | 10 + DEX modifier + equipped gear + the DM's extra AC |
|
||||
| Initiative | DEX modifier + equipped gear |
|
||||
| Speed | Species speed, unless a speed override is set, + equipped gear |
|
||||
| Armor Class | 10 + DEX modifier (or what a class feature gives without armor, below) + equipped gear + the DM's extra AC |
|
||||
| Initiative | DEX modifier + a bard's Jack of All Trades + equipped gear |
|
||||
| Speed | Species speed + a class's extra speed, unless a speed override is set, + equipped gear |
|
||||
| Passive Perception | 10 + Perception skill bonus + equipped gear |
|
||||
| Passive Investigation, Passive Insight | 10 + that skill's bonus (gear that raises the skill raises it too) |
|
||||
| Hit dice | One per level, of the class's die size (shown only; not spent or tracked) |
|
||||
@@ -796,6 +834,42 @@ item is doing what. An effect on an ability score changes the score itself,
|
||||
so everything in sections 5 to 10 that is built on that ability moves too.
|
||||
The full rules are in [ITEMS.md](ITEMS.md), section 5.
|
||||
|
||||
### Class features that are counted
|
||||
|
||||
These class features change a number, so the sheet works them out. Nobody
|
||||
adds them by hand, and the "From Class Features" card on the sheet lists the
|
||||
ones in force and what each is doing.
|
||||
|
||||
| Feature | From | What the sheet does |
|
||||
|---|---|---|
|
||||
| Unarmored Defense | Barbarian 1 | AC starts from 10 + DEX + CON while no armor is equipped. A shield still adds |
|
||||
| Unarmored Defense | Monk 1 | AC starts from 10 + DEX + WIS while no armor and no shield is equipped |
|
||||
| Draconic Resilience | Sorcerer (Draconic Bloodline) 3 | AC starts from 13 + DEX while no armor is equipped, and maximum HP is 1 higher for each sorcerer level |
|
||||
| Fast Movement | Barbarian 5 | +10 ft speed unless heavy armor is equipped |
|
||||
| Unarmored Movement | Monk 2 | +10 ft speed (15 at monk level 6, 20 at 10, 25 at 14, 30 at 18) while no armor and no shield is equipped |
|
||||
| Aura of Protection | Paladin 6 | Charisma modifier (at least +1) added to every one of the paladin's own saving throws. Allies in the aura add it by hand |
|
||||
| Diamond Soul | Monk 14 | Proficient in every saving throw |
|
||||
| Slippery Mind | Rogue 15 | Proficient in Wisdom saving throws |
|
||||
| Jack of All Trades | Bard 2 | Half the proficiency bonus (rounded down) added to initiative and to every skill the bard is not proficient in (and so to the passive scores) |
|
||||
|
||||
- **By class level.** A multiclassed character gets each feature at the level
|
||||
it comes in the class that gives it: a Paladin 6 / Barbarian 1 has the aura
|
||||
and Unarmored Defense, but not Fast Movement.
|
||||
- **One way of working out AC.** With more than one (a barbarian who is also a
|
||||
monk), the best is used, never both. It is left out when plain 10 + DEX is
|
||||
better (a barbarian with a Constitution penalty). Gear and the DM's Extra AC
|
||||
add on top either way.
|
||||
- **What is worn** is read from equipped items marked as **armor** (light,
|
||||
medium or heavy) or as a **shield** ([ITEMS.md](ITEMS.md), section 5). The
|
||||
library's armor and shield are marked already. An item that only gives +AC
|
||||
and is not marked (a ring) is not armor and stops nothing.
|
||||
- **A speed override wins.** When the DM has set a speed for a character it is
|
||||
used as it is, and a class's extra speed is not added to it.
|
||||
- **If the DM used to add these by hand** (Constitution put into Extra AC for
|
||||
a barbarian, say), take that out, or it now counts twice.
|
||||
|
||||
Every other class feature is still a line of text for the table to apply.
|
||||
|
||||
**Extra AC** is a number only the DM can set on the sheet, added to AC on top
|
||||
of gear, for anything an item does not cover. It can be negative. (It used to
|
||||
be called "armor bonus" and stood in for all armor; characters keep whatever
|
||||
@@ -826,9 +900,11 @@ calculations but is only ever set if the DM supplies it during step 5.
|
||||
|
||||
### Rolling from the sheet
|
||||
|
||||
Numbers with a dotted underline roll when clicked. The dice roller opens with
|
||||
the result under a label saying what it was ("Aria: Stealth"), the die that
|
||||
came up, the bonus and the total. A natural 20 or 1 is called out.
|
||||
Numbers with a dotted underline roll when clicked. The dice roller opens
|
||||
under a label saying what the roll is ("Aria: Stealth"): the dice tumble in,
|
||||
each drawn as its own shape (a d4's triangle, a d20's hexagon…), land on what
|
||||
they rolled, and the total follows with the bonus. A natural 20 glows and a
|
||||
natural 1 shakes.
|
||||
|
||||
| Click | Rolls |
|
||||
|---|---|
|
||||
@@ -847,10 +923,30 @@ came up, the bonus and the total. A natural 20 or 1 is called out.
|
||||
- **Advantage and disadvantage.** The roller has Disadv. / Normal / Adv.
|
||||
buttons. Whichever is lit applies to every d20 rolled until it is changed:
|
||||
two d20s are rolled and the higher or lower kept, and both are shown.
|
||||
- A roll is made in the browser of whoever clicked. It is not sent to the
|
||||
server, nobody else sees it, and it changes nothing: a damage roll is not
|
||||
applied, a spell slot is not spent, and an initiative roll still has to be
|
||||
typed into its field.
|
||||
- **The server rolls.** In a campaign every digital roll is made by the
|
||||
server, never by the browser, and is kept. A roll changes nothing by
|
||||
itself: a damage roll is not applied, a spell slot is not spent, and an
|
||||
initiative roll still has to be typed into its field.
|
||||
- **The DM sees every roll.** Each roll is announced to the table as it is
|
||||
made. The roller's second tab ("Everyone's rolls" for the DM, "Table" for a
|
||||
player) lists them, newest first: who rolled, what for, the dice as they
|
||||
fell, the total, and whether it was a natural 20 or 1, rolled with
|
||||
advantage, or rolled with real dice. Somebody else's roll also pops up
|
||||
beside the dice button for a few seconds as it lands, and an encounter page
|
||||
has the same list in its side column for the DM. What a **player** sees
|
||||
follows the campaign's "Who sees a roll" setting: everyone's rolls, or only
|
||||
their own.
|
||||
- **Hidden rolls.** The DM can tick "🙈 Roll hidden" in the roller: such a
|
||||
roll is the DM's alone, whatever the setting.
|
||||
- **Real dice.** The campaign's "Dice at this table" setting decides how a
|
||||
roll is made. *Digital*: the app rolls. *Physical*: the roller asks for the
|
||||
dice to be rolled at the table and typed in, one box per die (both d20s
|
||||
with advantage or disadvantage), and the server checks each value is on its
|
||||
die. *Both*: the app rolls unless the roller's "🎯 I roll real dice" is
|
||||
ticked, which each person chooses for themselves. A typed-in roll is marked
|
||||
"at the table" in the list.
|
||||
- Rolls are kept for two weeks. Outside a campaign (nothing opened yet) the
|
||||
roller still works, rolling in the browser and showing nobody.
|
||||
- Of dice written with more after them ("2d8+3 fire") the dice are rolled.
|
||||
Text that does not start with dice (a DM may type anything as an item's or
|
||||
a spell's dice) is shown as plain text.
|
||||
@@ -864,8 +960,9 @@ advantage or disadvantage; untick anyone who is not rolling. Each character
|
||||
gets their own d20 and their own bonus, and with a DC each is marked "made it"
|
||||
or "failed", with how many made it and whether a group check (at least half)
|
||||
succeeds. On the party overview it offers every finished character who is not
|
||||
dead; in an encounter, the characters in it who are still up. These rolls are
|
||||
the DM's own, like any other: the players do not see them.
|
||||
dead; in an encounter, the characters in it who are still up. These are the
|
||||
DM's own quick rolls, made on the DM's page: they are not in the table's list
|
||||
and the players do not see them.
|
||||
|
||||
## 10. Gear pools, starting options and gear levels
|
||||
|
||||
@@ -1238,7 +1335,8 @@ Another Campaign" card on its sheet.
|
||||
| Give inspiration, or take it away | No | Yes | No |
|
||||
| Spend inspiration | Yes | Yes | No |
|
||||
| Set exhaustion; give or remove a condition | No | Yes | No |
|
||||
| Level a character up (the checklist), pick a subclass or a Boon | No | Yes | No |
|
||||
| Approve a level up, level a character directly, change the Level field | No | Yes | No |
|
||||
| Take an approved level (the checklist): pick the subclass, Boon, spells, weapon | Yes, once approved | Yes | No |
|
||||
| Set or change the subclass on the sheet (in a campaign that uses subclasses) | No | Yes | No |
|
||||
| Switch subclasses on or off for the campaign | No | Yes | No |
|
||||
| Edit special abilities, background, appearance (after the build) | No | Yes | No |
|
||||
@@ -1283,7 +1381,9 @@ calculations for them.
|
||||
rest and lists conditions, but applies none of their effects
|
||||
- Counters for species traits, talents and Boons, and uses the special
|
||||
abilities give "a few times per day" beyond the ones in section 7
|
||||
- Armor rules beyond a flat bonus to AC (no "replaces 10 + DEX", no cap on DEX)
|
||||
- Armor rules beyond a flat bonus to AC: medium armor does not cap Dexterity
|
||||
and heavy armor does not ignore it. (Being unarmored is modelled: see
|
||||
"Class features that are counted" in section 8)
|
||||
- Weapon proficiency (everyone is proficient with everything)
|
||||
- Shared rolls: a roll made from the sheet is seen only by whoever clicked,
|
||||
and is not recorded
|
||||
@@ -1366,12 +1466,12 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
| Class | Abilities |
|
||||
|---|---|
|
||||
| Artificer | **Magical Tinkering** — Touch a tiny object to give it a small magic: it shines, plays a recorded message, gives off a smell or sound, or shows a picture. You can keep as many going as your Intelligence modifier.<br>**Spellcasting** — You cast artificer spells through tools: thieves’ tools, artisan’s tools or an infused item serve as your focus. |
|
||||
| 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). |
|
||||
| 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 (counted on your sheet). A shield still adds. |
|
||||
| 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). |
|
||||
| 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 or a shield, add your Wisdom modifier to your AC (counted on your sheet). |
|
||||
| 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. |
|
||||
@@ -1646,7 +1746,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
| 2 | **Reckless Attack**: On your first attack each turn you may attack with advantage; attacks against you have advantage until your next turn.<br>**Danger Sense**: Advantage on Dexterity saves against effects you can see (traps, breath, spells). |
|
||||
| 3 | **Rage** (replaces): Three times per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved. |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Fast Movement**: +10 ft speed when not in heavy armor (ask your DM to raise your speed). |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Fast Movement**: +10 ft speed when not in heavy armor (counted on your sheet). |
|
||||
| 6 | **Rage** (replaces): Four times per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved. |
|
||||
| 7 | **Feral Instinct**: Advantage on initiative; you can act normally in a surprise round if you rage first. |
|
||||
| 8 | **Boon** (choose one) |
|
||||
@@ -1716,7 +1816,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Jack of All Trades**: Add half your proficiency bonus (round down) to any check you are not proficient in.<br>**Song of Rest**: During a short rest, allies who spend Hit Dice heal an extra 1d6. |
|
||||
| 2 | **Jack of All Trades**: Add half your proficiency bonus (round down) to any check you are not proficient in (counted on your sheet for skills and initiative).<br>**Song of Rest**: During a short rest, allies who spend Hit Dice heal an extra 1d6. |
|
||||
| 3 | **Expertise**: Double proficiency on two proficient skills (add by hand). |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Bardic Inspiration** (replaces): A few times per day, as a bonus action, give an ally a d8 to add to one attack, check or save.<br>**Font of Inspiration**: Regain Bardic Inspiration on a short rest.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
|
||||
@@ -1999,23 +2099,23 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Ki**: Points equal to your level; they come back on a short rest. Ki save DC is 8 + proficiency bonus + Wisdom modifier.<br>**Flurry of Blows**: 1 ki: two unarmed strikes as a bonus action.<br>**Patient Defense**: 1 ki: Dodge as a bonus action.<br>**Step of the Wind**: 1 ki: Dash or Disengage as a bonus action, jump distance doubled.<br>**Unarmored Movement**: +10 ft speed without armor (ask your DM to raise your speed). |
|
||||
| 2 | **Ki**: Points equal to your level; they come back on a short rest. Ki save DC is 8 + proficiency bonus + Wisdom modifier.<br>**Flurry of Blows**: 1 ki: two unarmed strikes as a bonus action.<br>**Patient Defense**: 1 ki: Dodge as a bonus action.<br>**Step of the Wind**: 1 ki: Dash or Disengage as a bonus action, jump distance doubled.<br>**Unarmored Movement**: +10 ft speed without armor (counted on your sheet). |
|
||||
| 3 | **Deflect Missiles**: Reaction: reduce ranged weapon damage by 1d10 + Dexterity modifier + your level. |
|
||||
| 4 | **Boon** (choose one)<br>**Slow Fall**: Reaction: reduce falling damage by 5 × your level. |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Stunning Strike**: 1 ki after a melee hit: the target makes a Constitution save or is stunned until your next turn.<br>**Martial Arts** (replaces): Your unarmed strikes deal 1d6 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action. |
|
||||
| 6 | **Ki-Empowered Strikes**: Your unarmed strikes count as magical.<br>**Unarmored Movement** (replaces): +15 ft speed without armor (ask your DM to raise your speed). |
|
||||
| 6 | **Ki-Empowered Strikes**: Your unarmed strikes count as magical.<br>**Unarmored Movement** (replaces): +15 ft speed without armor (counted on your sheet). |
|
||||
| 7 | **Evasion**: On a Dexterity save for half damage, take none on a success and half on a failure.<br>**Stillness of Mind**: Action: end charm or fear on yourself. |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | **Unarmored Movement** (replaces): +15 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
|
||||
| 10 | **Purity of Body**: Immune to poison and disease.<br>**Unarmored Movement** (replaces): +20 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
|
||||
| 9 | **Unarmored Movement** (replaces): +15 ft speed without armor (counted on your sheet). You can run along walls and across water during your move. |
|
||||
| 10 | **Purity of Body**: Immune to poison and disease.<br>**Unarmored Movement** (replaces): +20 ft speed without armor (counted on your sheet). You can run along walls and across water during your move. |
|
||||
| 11 | **Martial Arts** (replaces): Your unarmed strikes deal 1d8 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action. |
|
||||
| 12 | **Boon** (choose one) |
|
||||
| 13 | **Tongue of the Sun and Moon**: You understand every spoken language, and anyone who speaks one understands you. |
|
||||
| 14 | **Diamond Soul**: You are proficient in every saving throw (add the bonus by hand); 1 ki rerolls a failed save.<br>**Unarmored Movement** (replaces): +25 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
|
||||
| 14 | **Diamond Soul**: You are proficient in every saving throw (counted on your sheet); 1 ki rerolls a failed save.<br>**Unarmored Movement** (replaces): +25 ft speed without armor (counted on your sheet). You can run along walls and across water during your move. |
|
||||
| 15 | **Timeless Body**: You need no food or water and suffer none of the frailty of old age. |
|
||||
| 16 | **Boon** (choose one) |
|
||||
| 17 | **Martial Arts** (replaces): Your unarmed strikes deal 1d10 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action. |
|
||||
| 18 | **Empty Body**: 4 ki: invisible for one minute, and all damage but force against you is halved. 8 ki: cast Astral Projection on yourself.<br>**Unarmored Movement** (replaces): +30 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
|
||||
| 18 | **Empty Body**: 4 ki: invisible for one minute, and all damage but force against you is halved. 8 ki: cast Astral Projection on yourself.<br>**Unarmored Movement** (replaces): +30 ft speed without armor (counted on your sheet). You can run along walls and across water during your move. |
|
||||
| 19 | **Boon** (choose one) |
|
||||
| 20 | **Perfect Self**: When you roll initiative with no ki left, regain 4 ki. |
|
||||
|
||||
@@ -2076,7 +2176,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
| 3 | **Divine Health**: Immune to disease. |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
|
||||
| 6 | **Aura of Protection**: You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious. |
|
||||
| 6 | **Aura of Protection**: You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet). |
|
||||
| 7 | No new feature |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | No new feature |
|
||||
@@ -2088,7 +2188,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
| 15 | No new feature |
|
||||
| 16 | **Boon** (choose one) |
|
||||
| 17 | No new feature |
|
||||
| 18 | **Aura of Protection** (replaces): You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious.<br>**Aura of Courage** (replaces): You and allies within 30 ft can’t be frightened. |
|
||||
| 18 | **Aura of Protection** (replaces): You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet).<br>**Aura of Courage** (replaces): You and allies within 30 ft can’t be frightened. |
|
||||
| 19 | **Boon** (choose one) |
|
||||
| 20 | No new feature |
|
||||
|
||||
@@ -2211,7 +2311,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
| 12 | **Boon** (choose one) |
|
||||
| 13 | **Sneak Attack** (replaces): Once per turn, deal an extra 7d6 damage to a target you have advantage against, or that has one of your allies next to it. |
|
||||
| 14 | **Blindsense**: If you can hear, you know where any hidden or invisible creature within 10 ft is. |
|
||||
| 15 | **Sneak Attack** (replaces): Once per turn, deal an extra 8d6 damage to a target you have advantage against, or that has one of your allies next to it.<br>**Slippery Mind**: You are proficient in Wisdom saves (add the bonus by hand). |
|
||||
| 15 | **Sneak Attack** (replaces): Once per turn, deal an extra 8d6 damage to a target you have advantage against, or that has one of your allies next to it.<br>**Slippery Mind**: You are proficient in Wisdom saves (counted on your sheet). |
|
||||
| 16 | **Boon** (choose one) |
|
||||
| 17 | **Sneak Attack** (replaces): Once per turn, deal an extra 9d6 damage to a target you have advantage against, or that has one of your allies next to it. |
|
||||
| 18 | **Elusive**: No attack against you has advantage while you are not incapacitated. |
|
||||
@@ -2287,7 +2387,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **Draconic Bloodline**<br>Dragon blood: tougher skin and one element at your call.<br>_Player’s Handbook_ | 3 | **Dragon Ancestor**: Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier (ask your DM about Extra AC). |
|
||||
| **Draconic Bloodline**<br>Dragon blood: tougher skin and one element at your call.<br>_Player’s Handbook_ | 3 | **Dragon Ancestor**: Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier: without armor your AC is 13 + Dexterity, and you have 1 more maximum HP for each sorcerer level (both counted on your sheet). |
|
||||
| — | 6 | **Elemental Affinity**: Draconic Bloodline. Add your Charisma modifier to one damage roll of your dragon’s element; spend 1 sorcery point to resist that element for 1 hour. |
|
||||
| — | 14 | **Dragon Wings**: Bonus action: sprout dragon wings and fly at your speed until you dismiss them. |
|
||||
| — | 18 | **Draconic Presence**: Action, 5 sorcery points: for a minute, creatures within 60 ft make a Wisdom save or are charmed (awe) or frightened (fear) of you. |
|
||||
|
||||
+200
-31
@@ -11,7 +11,7 @@ This describes the code as it stands. The rules live in
|
||||
## Contents
|
||||
|
||||
1. [The basics](#1-the-basics)
|
||||
2. [Battle or quest](#2-battle-or-quest)
|
||||
2. [Battle, quest or free roam](#2-battle-quest-or-free-roam)
|
||||
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)
|
||||
@@ -28,8 +28,8 @@ This describes the code as it stands. The rules live in
|
||||
|
||||
## 1. The basics
|
||||
|
||||
- An encounter is one **scene** the DM runs: a fight, or a meeting with
|
||||
people who want something.
|
||||
- An encounter is one **scene** the DM runs: a fight, a meeting with people
|
||||
who want something, or a stretch of free roaming between places.
|
||||
- Encounters belong to a campaign and are listed on the campaign page. The DM
|
||||
creates them and sees all of them.
|
||||
- A player only sees an encounter that **one of their own characters is in**.
|
||||
@@ -39,31 +39,64 @@ This describes the code as it stands. The rules live in
|
||||
- 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
|
||||
## 2. Battle, quest or free roam
|
||||
|
||||
Every encounter has a mode, chosen when it is created and switchable at any
|
||||
time from the top of the page.
|
||||
|
||||
| | Battle | NPC quest |
|
||||
|---|---|---|
|
||||
| For | A fight | A scene built around people and an objective |
|
||||
| Layout | Everyone in one list, in initiative order; once in progress, those who have had their turn move to "Next Round" at the bottom | Three groups: "People you meet", "The party", "Creatures" |
|
||||
| Rounds and turns | Yes | No |
|
||||
| Initiative | The d20 roll, typed in per combatant; the bonus is added | The same; each group lists highest first |
|
||||
| Start button | Begin Battle | Begin Quest |
|
||||
| End button | End Battle | Complete Quest |
|
||||
| Trade (section 10) | No | Yes, while in progress |
|
||||
| | Battle | NPC quest | Free roam |
|
||||
|---|---|---|---|
|
||||
| For | A fight | A scene built around people and an objective | The party wandering between places: shopping, meeting people |
|
||||
| Layout | Everyone in one list, in initiative order; once in progress, those who have had their turn move to "Next Round" at the bottom | Three groups: "People you meet", "The party", "Creatures" | A card per place, then the party |
|
||||
| Rounds and turns | Yes | No | No |
|
||||
| Initiative | The d20 roll, typed in per combatant; the bonus is added | The same; each group lists highest first | Not needed |
|
||||
| Start button | Begin Battle | Begin Quest | Begin Roaming |
|
||||
| End button | End Battle | Complete Quest | End Free Roam |
|
||||
| Trade (section 10) | No | Yes, while in progress, with the NPCs in the scene | Yes, while in progress, at the place a character has gone to |
|
||||
| Who is in it | Whoever the DM adds | Whoever the DM adds | Whoever the DM adds |
|
||||
|
||||
Everything else (hit points, conditions, rewards, the scene description, DM
|
||||
notes) works the same in both.
|
||||
notes) works the same in all three.
|
||||
|
||||
### Free roam
|
||||
|
||||
A free-roam encounter lays out **places the party can go to**: some of the
|
||||
campaign's locations, picked by the DM as the ones nearby.
|
||||
|
||||
- **Who is in it** is up to the DM, as with any encounter: nobody is added
|
||||
automatically, because the party may be split, with some of them somewhere
|
||||
else. Add the characters who are here from "Add to the scene"; a player
|
||||
sees the scene once their character is in it.
|
||||
- **Places.** On its page the DM ticks which of the campaign's locations are
|
||||
part of the scene ("Places in this scene"), and can tick more, or untick
|
||||
some, while it is in progress. Locations are made on the campaign page.
|
||||
- **Each place is a card** showing its description, who of the party is there
|
||||
now, the **NPCs found there** (name, role, description, what they sell and
|
||||
the rate they change money at), and the place's **own shop**. Everyone in
|
||||
the scene sees every place in it, whether or not they have been there.
|
||||
- **Going somewhere.** Once the DM presses Begin Roaming, a player picks their
|
||||
character and presses **Go here** on a place: the character is now there
|
||||
(it shows on their sheet too, and the place joins the ones they know). The
|
||||
DM can send anyone.
|
||||
- **Shopping.** A character buys from a place's shop, and buys from or changes
|
||||
money with an NPC found there, only while they are **at that place**. The
|
||||
Buy button says so otherwise.
|
||||
- **Setting it up (DM).** A place's own shop is stocked from its card ("Set up
|
||||
this place's own shop"): weapons and items from the campaign's pools, with a
|
||||
price and stock, exactly as an NPC's wares are. It belongs to the location,
|
||||
so it is there in every free-roam scene that includes the place. An NPC is
|
||||
put at a place with **Found at** on the NPC's pop-out
|
||||
([NPCS.md](NPCS.md)); what they sell is set there too.
|
||||
- An encounter's own location (the picker at the top) does not pull the party
|
||||
together in free roam: each character is where they were sent.
|
||||
|
||||
## 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 |
|
||||
| In progress | Being played | Begin Battle / Begin Quest / Begin Roaming, or Reopen |
|
||||
| Complete | Finished | End Battle / Complete Quest / End Free Roam |
|
||||
|
||||
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
|
||||
@@ -96,6 +129,13 @@ with three pages or more, a menu to jump to any page), and
|
||||
the DM's archived list is paged the same way. The order runs on across pages:
|
||||
moving the last encounter on a page down carries it to the top of the next.
|
||||
|
||||
**Completed encounters are hidden.** An encounter that is **Complete** is
|
||||
left off the list, for the DM and the players alike, so the list is what is
|
||||
still to play. A **Show completed (N)** tick box above the list brings them
|
||||
back; the choice is remembered per browser. ▲ and ▼ move an encounter past its
|
||||
neighbour on the list as shown. Opening a completed encounter from a link
|
||||
still works, and nothing about it changes.
|
||||
|
||||
**Archive.** "Archive" (on the list or the encounter page) puts an encounter
|
||||
away without changing it. It moves to the DM's "Archived" section and
|
||||
disappears from the players' list; a player whose character is in it can still
|
||||
@@ -187,8 +227,14 @@ Each has a card. The card's left edge is coloured by kind, and it shows:
|
||||
(● proficient, ◆ expertise) with passive Perception, Investigation and
|
||||
Insight, and special abilities. A weapon that is not equipped is not on the
|
||||
card and cannot be used; "No weapon equipped" shows when there is none.
|
||||
Equipping is changed from "📋 Sheet", at any point in the scene. The name
|
||||
links to the character sheet.
|
||||
Equipping is changed from "📋 Sheet". In a **battle in progress** a player
|
||||
can change the weapon in their character's hand (equip or unequip) at two
|
||||
moments only: **as the battle begins**, before anyone has made a move
|
||||
(round 1, still the first combatant's turn, nothing declared or landed),
|
||||
and **on their own turn once their attack has landed**, before the turn
|
||||
passes. At any other moment the sheet refuses and says why. The DM is not
|
||||
held to this and can change it at any time; items are not affected, and
|
||||
neither is a quest or free roam. 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,
|
||||
@@ -319,6 +365,21 @@ battles only.
|
||||
combatant it returns to the top and the **round** goes up by one.
|
||||
- **Next Round** skips straight to the top of the next round.
|
||||
- Anyone out of the fight (section 6, "The fallen") is passed over.
|
||||
- **The turn-order track.** While a battle is in progress a line of names
|
||||
stays at the top of the screen: whoever is on turn first (larger, with ▶),
|
||||
then everyone still to act this round, then a **🏁 Round N+1** flag where
|
||||
the round ends, then the whole order again for the round after. It is names
|
||||
only, coloured by what each is: **blue** for a player character, **green**
|
||||
for an NPC, **red** for a monster. A struck-through name is dying. The DM
|
||||
can read off who is next, and who follows the end of the round, at a glance.
|
||||
Before the battle begins the older strip (names with HP) is shown instead.
|
||||
- **What now.** Under the track the DM gets one line saying what the scene is
|
||||
waiting on: "Aria's turn: waiting for them to pick a target", "Aria's
|
||||
Longsword: enter the d20 against Goblin 1 (AC 15)", "Fireball: 3 saving
|
||||
throws to enter (DEX, DC 14)", "Goblin 2's Attack is ready (Aria: hit for
|
||||
5). Accept, change or discard it." A player gets the same line for what is
|
||||
theirs to do: "Your turn! Pick a target", "Roll your d20 for Longsword",
|
||||
"make a DEX saving throw and enter your d20".
|
||||
- **The list follows the turn.** While a battle is in progress, whoever is on
|
||||
turn is at the top of "Initiative order" with those still to act this round
|
||||
beneath them. Each combatant whose turn has passed moves down to a **Next
|
||||
@@ -333,8 +394,10 @@ Combatants can be marked as **targets**: whoever is being attacked (or
|
||||
healed) right now. One target is an ordinary attack; more than one is an
|
||||
**area attack**.
|
||||
|
||||
- Click **🎯 Target** beside a combatant's name to mark them; click it again
|
||||
to unmark. Each one marked is added to the targets.
|
||||
- Three ways to mark someone, each a toggle: click their **name on the
|
||||
turn-order track**; click **🎯 Target** beside their name on their card; or
|
||||
press **1 to 9** for the first nine combatants in turn order (leaving out
|
||||
whoever is on turn). Each one marked is added to the targets.
|
||||
- The **DM** can mark anyone at any time, in a battle or a quest. A **player**
|
||||
can while their own character is on turn in a battle, so they can show the
|
||||
DM who they are going for. A player cannot mark someone who is out of the
|
||||
@@ -344,8 +407,13 @@ healed) right now. One target is an ordinary attack; more than one is an
|
||||
on their own turn, use the buttons on their card.
|
||||
- A target's card is outlined in red for everyone and reads "Targeted by
|
||||
<whoever is on turn>".
|
||||
- While anyone is marked the DM gets an **attack bar** above the list, which
|
||||
stays in view while scrolling. It shows who is attacking whom, each target's
|
||||
- **In hand.** The card of whoever is on turn, and of every target, shows a
|
||||
highlighted "In hand" line: the weapons the character has equipped, with
|
||||
their to-hit and damage (or, for the DM, a creature's attacks). So both
|
||||
sides of an exchange show what they are fighting with.
|
||||
- While anyone is marked the DM also gets a quick **damage bar** above the
|
||||
list, for changing HP by hand without an attack (below). It shows who is
|
||||
attacking whom, each target's
|
||||
AC and HP, and the attacker's attacks: a character's equipped weapons (none,
|
||||
if nothing is equipped) and damaging spells, or a monster's or NPC's
|
||||
attack and damage. The amount box is ready to type in; **Damage** (or
|
||||
@@ -381,8 +449,96 @@ An attack on several targets is harder to land and its damage is shared.
|
||||
is applied.
|
||||
- A target with no hit points to track is left out of the sharing.
|
||||
|
||||
The app does not roll the attack or decide whether it hits: the DM compares
|
||||
the roll with the AC shown and types the damage.
|
||||
The damage bar does not roll or decide anything: it is for when the DM
|
||||
already knows the number. An attack that is to be rolled and judged goes
|
||||
through **Attacks and spells**, next.
|
||||
|
||||
### Attacks and spells
|
||||
|
||||
An attack or a spell is **declared**, **rolled**, and then **applied** by the
|
||||
DM. The app works out whether it hits (or whether each target saves) from the
|
||||
d20 that is entered, and the DM can overrule it at every step.
|
||||
|
||||
**1. Declare.** Once a target is marked, a box "<whoever is on turn> attacks
|
||||
with…" lists what they can use, one button each:
|
||||
|
||||
- a character's **equipped weapons** (outlined, so the weapon in hand stands
|
||||
out), each with its to-hit and damage;
|
||||
- the **spells** they know, each saying how it lands: a spell attack ("+6 to
|
||||
hit"), a saving throw ("DC 14 DEX save"), or no roll;
|
||||
- for the DM, a monster's or NPC's **own attacks**;
|
||||
- for the DM, **✎ Something else…**: an attack roll, a saving throw or an
|
||||
effect spelled out on the spot (a trap, a breath weapon, a monster's spell):
|
||||
its name, bonus or DC and ability, and damage dice.
|
||||
|
||||
A **player** declares for their own character, on its turn. The **DM**
|
||||
declares for anyone. Declaring replaces that combatant's unfinished action.
|
||||
|
||||
**2. Roll.** The action appears as a card for everyone, with a row per target:
|
||||
|
||||
| The action is | Each target's row asks for | And says |
|
||||
|---|---|---|
|
||||
| An attack (a weapon, a spell attack, a creature's attack) | The attacker's **d20** against that target | "17 vs AC 15": **HIT**, **MISS**, **CRITICAL HIT** on a natural 20, a miss on a natural 1 |
|
||||
| A saving throw spell | That target's **d20** for the save; their save bonus is filled in | "14 vs DC 14": **SAVED** or **FAILED** |
|
||||
| Something that just lands (Magic Missile) | Nothing | It hits |
|
||||
|
||||
- The d20 is **typed in** (press Enter or click away) or **rolled by the
|
||||
app** with "🎲 Roll the d20". A player follows the campaign's dice setting;
|
||||
the DM can always do either. With several targets an attack takes one d20
|
||||
per target, and the area-attack AC penalty is counted in.
|
||||
- The attacker's player enters the attack rolls; a player enters the **save
|
||||
for their own character** when a spell or trap is aimed at them; the DM can
|
||||
enter anything.
|
||||
- **Damage**: once something hits (or someone fails a save), "🎲 Roll damage"
|
||||
rolls the dice shown, or the total rolled at the table is typed in. On a
|
||||
**critical hit** the app rolls the critical damage as well, by the
|
||||
campaign's rule (the dice twice, or the dice at their most plus one roll),
|
||||
and that target takes it. A save that succeeds takes half, or nothing,
|
||||
as the spell says.
|
||||
|
||||
**3. Overrule (DM).** Beside each target the DM has **Hit / Miss / Crit** (or
|
||||
**Saved / Failed**) buttons that rule the outcome whatever the dice say (a
|
||||
ruling shows a ✎; pressing it again goes back to the dice), and a **dmg** box
|
||||
to set what that one target takes. "Aim it at" moves a single-target action
|
||||
to someone else, keeping the d20 already rolled.
|
||||
|
||||
**4. Apply (DM).** **✔ Apply** (or the **A** key) lets it land: each target
|
||||
loses what the card says, with temp HP, death saves and the fallen handled as
|
||||
for any damage; a spell spends the slot it was declared with from its caster
|
||||
(a cantrip spends nothing); anyone hurt while
|
||||
Concentrating gets a line telling the DM the save to call for. The card then
|
||||
moves to **⚔ What just happened** in the side column, where the last few
|
||||
actions stay for everyone to read. **✕ Discard** throws it away; a player can
|
||||
call off their own.
|
||||
|
||||
Passing the turn discards whatever was left unfinished.
|
||||
|
||||
### Auto-rolled monster and NPC attacks
|
||||
|
||||
With **Auto-roll monster attacks** (and, separately, **Auto-roll NPC
|
||||
attacks**) on in the campaign settings, a monster's or NPC's turn begins with
|
||||
its attack **already declared and rolled** by the server:
|
||||
|
||||
- **Who it goes for.** Monsters and hostile NPCs attack the party (characters
|
||||
and friendly NPCs); friendly NPCs attack monsters and hostile NPCs; a
|
||||
neutral NPC attacks nobody. Among those still standing the target is drawn
|
||||
at random, weighted: whoever it attacked last and whoever has hurt it count
|
||||
for more, and so, a little, does a lower AC. A creature with several
|
||||
attacks tends to keep at one foe.
|
||||
- **What is rolled.** The d20 against that target's AC, and, on a hit, the
|
||||
damage (critical damage on a natural 20). A monster with **named attacks**
|
||||
([MONSTERS.md](MONSTERS.md), section 2) makes each of them, as many times
|
||||
as its multiattack says: one card each.
|
||||
- **The DM decides.** The card reads, for example, "🤖 Goblin 2: Attack → Aria:
|
||||
17 vs AC 15, HIT, −5 HP". **✔ Accept** applies it; or change the target,
|
||||
the d20, the outcome or the damage first; or **✕ Discard** it and run the
|
||||
turn by hand.
|
||||
- **Players do not see it until it is accepted**, when the campaign keeps the
|
||||
DM's monster rolls hidden (the default); then it appears in "What just
|
||||
happened" like any other.
|
||||
|
||||
A creature with no attack bonus or damage, or with nobody to attack, declares
|
||||
nothing.
|
||||
|
||||
## 8. Conditions
|
||||
|
||||
@@ -514,8 +670,9 @@ characters' sheets. XP never levels anyone up by itself
|
||||
|
||||
## 10. Trade
|
||||
|
||||
In an NPC quest the party can **change money** and **buy things**. Both take
|
||||
someone to deal with and the time to do it, so:
|
||||
In an NPC quest the party can **change money** and **buy things**. (In free
|
||||
roam they do the same at the place each character has gone to: section 2,
|
||||
"Free roam".) Both take someone to deal with and the time to do it, so:
|
||||
|
||||
- Trading is open only in an **NPC quest that is in progress**. Before "Begin
|
||||
Quest" and after "Complete Quest" everything on offer is still shown, but
|
||||
@@ -570,7 +727,9 @@ when there is something to trade, and always for the DM in a quest.
|
||||
| Rewards | All | Only awarded ones |
|
||||
| Difficulty and the monsters' XP | Yes | Never (awarded XP shows on their own sheet) |
|
||||
| Exchange rates and things for sale | Yes | Yes, for NPCs they can see and for the scene itself |
|
||||
| Controls (add, damage, turns, rewards) | Yes | None |
|
||||
| An attack or spell being resolved | Yes, all | Yes, but not a monster's or NPC's AC, and not the DM's hidden (auto-rolled) ones until they are accepted |
|
||||
| What just happened (the last few actions) | Yes | Yes |
|
||||
| Controls (add, damage, turns, rewards) | Yes | None, apart from their own attacks and rolls |
|
||||
|
||||
The server only sends a player what the right-hand column lists, so hidden
|
||||
information cannot be dug out of the page.
|
||||
@@ -588,6 +747,14 @@ information cannot be dug out of the page.
|
||||
| Change HP or conditions | Yes | No |
|
||||
| Enter an initiative roll | Yes, for anyone | For their own character only |
|
||||
| Mark targets | Yes, any time | On their own character's turn |
|
||||
| Pick a free-roam scene's places, stock a place's shop | Yes | No |
|
||||
| Send a character to a place in free roam | Yes, anyone | Their own character |
|
||||
| Declare an attack or a spell | Yes, for anyone, and anything spelled out | For their own character, on its turn, with its weapons and spells |
|
||||
| Enter or roll an attack's d20 and damage | Yes | For their own character's attack |
|
||||
| Enter or roll a saving throw | Yes, for anyone | For their own character |
|
||||
| Overrule a roll, set a target's damage, change the target | Yes | No |
|
||||
| Apply or accept an action | Yes | No |
|
||||
| Discard an action | Yes | Their own |
|
||||
| Enter a death save, or stabilise | Yes, for anyone | For their own character only |
|
||||
| Set death-save tallies by hand | Yes | No |
|
||||
| Revive a dead character outright | Yes | No |
|
||||
@@ -608,10 +775,12 @@ People outside the campaign cannot open its encounters.
|
||||
|
||||
- Rolling initiative for characters into their field (a player clicks their
|
||||
Init chip to roll it, then types what came up)
|
||||
- Applying an attack's damage automatically: attacks and damage are rolled
|
||||
with a click ([CHARACTERS.md](CHARACTERS.md), "Rolling from the sheet") and
|
||||
the DM enters the result
|
||||
- Rolls that everyone at the table sees
|
||||
- Advantage and disadvantage on an attack or save within an action (roll both
|
||||
d20s and enter the one that counts), resistances and vulnerabilities (set
|
||||
the target's damage by hand), and upcasting a spell for more damage
|
||||
- Healing through an action (use + Heal on the card or the damage bar)
|
||||
- A monster choosing among different attacks by itself: with auto-roll it
|
||||
makes all of its named attacks, or its single one
|
||||
- Death saves for monsters and NPCs, and prompting for a save on a dying
|
||||
character's turn
|
||||
- A roll, a spell slot or a time limit for reviving the dead
|
||||
|
||||
@@ -66,6 +66,14 @@ passivePerception = 10 + perceptionSkillBonus
|
||||
armorClass = 10 + dexModifier + armorBonus
|
||||
```
|
||||
|
||||
Without armor, a class feature can replace the `10 + dexModifier` part:
|
||||
Unarmored Defense (barbarian: `10 + DEX + CON`; monk, also without a shield:
|
||||
`10 + DEX + WIS`) and Draconic Resilience (`13 + DEX`). The best one is used,
|
||||
and only when it beats `10 + dexModifier`. These, and the other class features
|
||||
that change a sheet number (Fast Movement, Unarmored Movement, Aura of
|
||||
Protection, Diamond Soul, Slippery Mind, Jack of All Trades), live in
|
||||
`backend/src/game/classFeatures.js`; CHARACTERS.md section 8 lists them.
|
||||
|
||||
`armorBonus` is a flat number the player enters representing whatever armor/shield
|
||||
they have equipped (this matches the workbook's "Armor Bonus (from armor worn)"
|
||||
input cell). Do not try to model armor types as a lookup table unless you want to
|
||||
|
||||
@@ -168,6 +168,16 @@ Armor, shields, rings, cloaks and the like are ordinary items that have
|
||||
**effects**. An effect is a whole number, positive for a bonus and negative
|
||||
for a penalty, on one of the sheet's numbers:
|
||||
|
||||
**Armor and shields are marked as such.** An item's pop-out has an **Armor**
|
||||
menu: not armor (the default), light, medium, heavy, or shield. Its AC is
|
||||
still an effect (+AC); the mark says what kind of thing it is, because some
|
||||
class features depend on what is worn: a barbarian's and a monk's Unarmored
|
||||
Defense and a draconic sorcerer's scales stop while armor is equipped, a
|
||||
monk's also stop behind a shield, and a barbarian's extra speed stops in
|
||||
heavy armor ([CHARACTERS.md](CHARACTERS.md), section 8). The library's armor
|
||||
and shield come marked; mark your own. A ring or cloak that gives +AC is not
|
||||
armor: leave it unmarked.
|
||||
|
||||
| It can change | Examples |
|
||||
|---|---|
|
||||
| AC | Chain mail +6, a shield +2, a cursed cloak −1 |
|
||||
|
||||
+9
-3
@@ -40,12 +40,17 @@ This describes the code as it stands. The built-in list is
|
||||
| Ability scores | STR, DEX, CON, INT, WIS, CHA. 10 unless set otherwise |
|
||||
| Attack bonus | Added to the monster's attack roll |
|
||||
| Damage | Dice for its main attack, e.g. `1d6+2`, with the damage type if you like: `1d6+2 necrotic` |
|
||||
| Named attacks | Optional, up to 8: each a name, an attack bonus, damage dice, a damage type and **how many times it is made each turn** (a multiattack: "Claw ×2, Bite ×1"). With any listed, these are what the monster attacks with in an encounter, in place of the single attack above |
|
||||
| Challenge rating | A rough danger rating, e.g. `1/4`, `2`. It sets what the monster is worth in XP and how hard an encounter is ([ENCOUNTERS.md](ENCOUNTERS.md), section 9); a monster with none, or with text that is not a rating, is left out of both |
|
||||
| Special trait | A short note such as "Pack Tactics" |
|
||||
| Notes | Anything else: what it attacks with, how it behaves |
|
||||
|
||||
Apart from the challenge rating, none of these are used in formulas. They
|
||||
are reference numbers shown to the DM during a fight. A monster's ability
|
||||
The challenge rating feeds the XP and difficulty sums. The AC, the attack
|
||||
bonus and damage (or the named attacks) and the ability scores are used when
|
||||
an attack or a spell is resolved in an encounter: an attack roll is judged
|
||||
against the AC, a saving throw adds the ability's modifier, and with
|
||||
auto-roll on the monster's attacks are aimed and rolled for the DM
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), "Attacks and spells"). A monster's ability
|
||||
modifiers are worked out from its scores for display, for its initiative
|
||||
bonus (Dexterity) and for the rolls a click makes (section 5).
|
||||
|
||||
@@ -186,7 +191,8 @@ Then they get the health bar only.
|
||||
|
||||
## 8. What is not modelled
|
||||
|
||||
- Multiple attacks, spells, legendary actions and resistances (use Notes)
|
||||
- Spells, legendary actions and resistances (use Notes; in an encounter the
|
||||
DM can spell out any attack or save on the spot)
|
||||
- Trade: monsters do not sell things or change money (NPCs do)
|
||||
- Saving throw proficiencies and skills (on an encounter card a click on an
|
||||
ability score rolls a d20 with its modifier, for a check or a save)
|
||||
|
||||
@@ -50,6 +50,13 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
|
||||
A new NPC is Neutral unless you choose otherwise.
|
||||
|
||||
**Found at.** An NPC can be given one of the campaign's locations as the place
|
||||
they are usually found, on their pop-out. In a **free-roam** encounter that
|
||||
includes that place, the NPC appears on its card, and a character who goes
|
||||
there can buy from them and change money with them
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), section 2, "Free roam"). It changes nothing in
|
||||
a quest or a battle, where an NPC takes part because the DM added them.
|
||||
|
||||
## 3. Managing NPCs
|
||||
|
||||
The NPC list is the "NPCs" part of the campaign page (DM only). Each NPC is a
|
||||
|
||||
@@ -164,6 +164,35 @@ 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.
|
||||
|
||||
### How a spell lands in a battle
|
||||
|
||||
Each spell says how it is resolved when it is cast in an encounter
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), "Attacks and spells"). The DM sets it under
|
||||
**In a battle** when adding a spell, or on its pop-out:
|
||||
|
||||
| Setting | In an encounter |
|
||||
|---|---|
|
||||
| It just takes effect | No roll: it lands (Magic Missile, a buff) |
|
||||
| The caster rolls a spell attack | A d20 plus the caster's spell attack bonus against each target's AC |
|
||||
| A save with an ability, half damage on a success | Each target rolls a d20 plus their save bonus against the caster's spell save DC; a success takes half (Fireball) |
|
||||
| A save with an ability, nothing on a success | The same, but a success takes nothing (Sacred Flame, Hold Person) |
|
||||
|
||||
The library's spells come already set (Fire Bolt and Guiding Bolt as spell
|
||||
attacks; Fireball, Burning Hands, Hold Person and the like as saves), and
|
||||
copies already in a campaign were marked to match when this was added.
|
||||
Casting a spell of 1st level or higher this way spends a slot when the DM
|
||||
applies it: one of the spell's own level, or, when those are gone, the lowest
|
||||
slot above it that is left. **With no slot of its level or higher left the
|
||||
spell cannot be declared**: its button is off and marked "no slot left".
|
||||
**Cantrips never take a slot** and can be cast every turn, with every slot
|
||||
the caster has spent or with none to begin with. A spell above what
|
||||
the character's level allows is not offered at all (section on levels), and a
|
||||
weapon has to be equipped and within the character's level to attack with.
|
||||
|
||||
Wherever a character's spells are listed (the sheet, the encounter card, the
|
||||
attack picker) they run **cantrips first, then by spell level**, each with
|
||||
its level beside the name.
|
||||
|
||||
## 7. Spellcasting numbers
|
||||
|
||||
Shown only for classes with a spellcasting ability:
|
||||
|
||||
+4
-1
@@ -109,7 +109,10 @@ On a character's sheet, the DM picks a weapon from the pool and clicks Give.
|
||||
keeps the level it has. A character holds one stack per weapon, so two
|
||||
copies of the same weapon at different levels is not possible.
|
||||
- The DM can take a weapon away with ✕ on the sheet.
|
||||
- The owning player, or the DM, ticks **Equipped**.
|
||||
- The owning player, or the DM, ticks **Equipped**. While the character is in
|
||||
a battle in progress, a player can only change it as the battle begins or on
|
||||
their own turn after their attack has landed
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), section 5); the DM always can.
|
||||
- **Only what is equipped counts.** A weapon has to be equipped to be used
|
||||
in an encounter, and gear only gives its effects while equipped. What is
|
||||
not equipped stays in the pack: still the character's, still listed on the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.24.0",
|
||||
"version": "1.30.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+109
-5
@@ -1,6 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
Ability,
|
||||
ArmorType,
|
||||
ActionPatch,
|
||||
ActionSource,
|
||||
BuildOptions,
|
||||
Campaign,
|
||||
CharacterClass,
|
||||
@@ -9,6 +13,7 @@ import type {
|
||||
CharacterDetail,
|
||||
CharacterSummary,
|
||||
Currency,
|
||||
DiceRollRecord,
|
||||
DiceMode,
|
||||
Encounter,
|
||||
EncounterMode,
|
||||
@@ -16,6 +21,7 @@ import type {
|
||||
GearEffect,
|
||||
GoldTransaction,
|
||||
Item,
|
||||
LevelUpApproval,
|
||||
LevelUpPlan,
|
||||
Location,
|
||||
MulticlassReference,
|
||||
@@ -27,6 +33,7 @@ import type {
|
||||
QuickStats,
|
||||
RestResult,
|
||||
RestType,
|
||||
RollSpec,
|
||||
Spell,
|
||||
Weapon,
|
||||
} from './types';
|
||||
@@ -268,9 +275,9 @@ export function useLevelUpPlan(characterId: string, enabled: boolean, toClass: C
|
||||
export function useLevelUp(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterClass?: CharacterClass; subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[] }) =>
|
||||
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/level-up`, data)).data.character,
|
||||
onSuccess: (character) => {
|
||||
mutationFn: async (data: { characterClass?: CharacterClass; subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[]; hpRoll?: number }) =>
|
||||
(await apiClient.post<{ character: CharacterDetail; hpRolled: number | null }>(`/characters/${characterId}/level-up`, data)).data,
|
||||
onSuccess: ({ character }) => {
|
||||
invalidateCharacter(qc, characterId);
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
|
||||
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
||||
@@ -294,6 +301,20 @@ export function useRest(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// The DM approves a level for any of the party at once (or takes an approval
|
||||
// back); each character's player then takes theirs.
|
||||
export function useApproveLevelUps(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterIds: string[]; revoke?: boolean }) =>
|
||||
(await apiClient.post<{ revoke: boolean; results: LevelUpApproval[] }>(`/campaigns/${campaignId}/level-up`, data)).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCharacter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -365,6 +386,28 @@ export function useBuildOptions() {
|
||||
});
|
||||
}
|
||||
|
||||
// The latest rolls at the table that this member may see, newest first.
|
||||
export function useRolls(campaignId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['campaign', campaignId, 'rolls'],
|
||||
enabled: Boolean(campaignId),
|
||||
queryFn: async () => (await apiClient.get<{ rolls: DiceRollRecord[] }>(`/campaigns/${campaignId}/rolls`, { params: { limit: 40 } })).data.rolls,
|
||||
});
|
||||
}
|
||||
|
||||
// Makes a roll at the table: the server rolls it (or checks dice typed in),
|
||||
// keeps it and tells the table.
|
||||
export function useRoll(campaignId: string | null | undefined) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (spec: RollSpec) => (await apiClient.post<{ roll: DiceRollRecord }>(`/campaigns/${campaignId}/rolls`, spec)).data.roll,
|
||||
onSuccess: (roll) => {
|
||||
// Shown at once in the feed, without waiting for the server to say so.
|
||||
qc.setQueryData<DiceRollRecord[]>(['campaign', campaignId, 'rolls'], (rolls) => (rolls && !rolls.some((r) => r.id === roll.id) ? [roll, ...rolls] : rolls));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Multiclassing requirements and the suggested routes.
|
||||
export function useMulticlassReference(enabled: boolean) {
|
||||
return useQuery({
|
||||
@@ -605,6 +648,12 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) {
|
||||
exchangeCash?: number | null;
|
||||
effects?: GearEffect[];
|
||||
effectNote?: string | null;
|
||||
// A spell: how it lands in a battle. An NPC: where they are found.
|
||||
attackRoll?: boolean;
|
||||
saveAbility?: Ability | null;
|
||||
halfOnSave?: boolean;
|
||||
locationId?: string | null;
|
||||
armorType?: ArmorType | null; // an item: what kind of armor it is, if any
|
||||
}) =>
|
||||
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
|
||||
onSuccess: () => {
|
||||
@@ -799,7 +848,7 @@ export function useCreateEncounter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
// Where it goes in the campaign's order: the top, the bottom (the default), or straight after another.
|
||||
mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string }) =>
|
||||
mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string; placeIds?: string[] }) =>
|
||||
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, data)).data.encounter,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }),
|
||||
});
|
||||
@@ -945,6 +994,61 @@ export function useSetTargets(encounterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Free roam: the DM sets the places the party can go to in the scene.
|
||||
export function useSetPlaces(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (locationIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/places`, { locationIds })).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
// Free roam: a character goes to one of the scene's places.
|
||||
export function useVisitPlace(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterId: string; locationId: string }) => (await apiClient.post(`/encounters/${encounterId}/visit`, data)).data,
|
||||
onSuccess: (data, { characterId }) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
qc.invalidateQueries({ queryKey: ['character', characterId] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] }); // the places a player knows, and where the party is
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Declares an attack or a spell: by whoever is on turn unless an actor is
|
||||
// named, at the targets marked unless some are given.
|
||||
export function useDeclareAction(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { source: ActionSource; actorParticipantId?: string; targetIds?: string[]; hidden?: boolean }) =>
|
||||
(await apiClient.post(`/encounters/${encounterId}/actions`, data)).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
// Puts a roll, a ruling or the damage into an action still being resolved.
|
||||
export function usePatchAction(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ actionId, ...data }: ActionPatch & { actionId: string }) => (await apiClient.patch(`/encounters/${encounterId}/actions/${actionId}`, data)).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
// The DM lets an action land. Answers with a save to call for anyone hurt while concentrating.
|
||||
export function useApplyAction(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (actionId: string) =>
|
||||
(await apiClient.post<{ concentration: { name: string; damage: number; dc: number }[]; slotNote: string | null }>(`/encounters/${encounterId}/actions/${actionId}/apply`)).data,
|
||||
onSuccess: (data) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
qc.invalidateQueries({ queryKey: ['character'] }); // HP, and a spell slot spent
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// The character on turn trying to bring a dead ally back; the server decides
|
||||
// whether they have the means.
|
||||
export function useReviveParticipant(encounterId: string) {
|
||||
@@ -1047,7 +1151,7 @@ export function useCreateNpc(campaignId: string) {
|
||||
// in progress, characters in the scene change money and buy
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
export type NewWare = ({ npcId: string } | { encounterId: string }) &
|
||||
export type NewWare = ({ npcId: string } | { encounterId: string } | { locationId: string }) &
|
||||
({ weaponId: string } | { itemId: string }) & { price: number; currency: Currency; level?: number; stock: number | null };
|
||||
|
||||
// Wares reach the screen inside the NPC list and inside each encounter.
|
||||
|
||||
+183
-2
@@ -303,6 +303,7 @@ export interface CharacterSummary extends CharacterCondition {
|
||||
characterClass: CharacterClass | null;
|
||||
subclassName: string | null;
|
||||
level: number;
|
||||
levelUpsPending: number; // levels the DM has approved, still to be taken
|
||||
classes: ClassLevel[];
|
||||
xp: number;
|
||||
xpProgress: XpProgress;
|
||||
@@ -383,6 +384,7 @@ export interface CharacterSheet {
|
||||
spellSlots: SpellSlots | null; // null for a character with no spell slots
|
||||
resources: ClassResource[]; // the class counters, with how many of each are left
|
||||
gear: ActiveGear;
|
||||
features: FeatureInForce[]; // class features already counted in the numbers above
|
||||
}
|
||||
|
||||
export interface Weapon {
|
||||
@@ -432,6 +434,11 @@ export interface Spell {
|
||||
damageDice: string | null;
|
||||
isStartingOption: boolean;
|
||||
revives: boolean; // brings back the dead: lets its caster revive a dead ally in a battle
|
||||
// How it lands in a battle: a spell attack against AC, or a save by each
|
||||
// target against the caster's DC (half damage on a success when halfOnSave).
|
||||
attackRoll: boolean;
|
||||
saveAbility: Ability | null;
|
||||
halfOnSave: boolean;
|
||||
}
|
||||
|
||||
export interface CharacterSpell {
|
||||
@@ -455,6 +462,18 @@ export interface Item {
|
||||
isStartingOption: boolean;
|
||||
effects: GearEffect[]; // armor is an item with an AC effect
|
||||
effectNote: string | null;
|
||||
armorType: ArmorType | null; // set on armor and shields: what kind it is
|
||||
}
|
||||
|
||||
// What kind of armor an item is. The sheet goes by what is equipped for the
|
||||
// class features that depend on it (Unarmored Defense, a monk's speed...).
|
||||
export type ArmorType = 'LIGHT' | 'MEDIUM' | 'HEAVY' | 'SHIELD';
|
||||
|
||||
// A class feature counted in the sheet's numbers, in a line.
|
||||
export interface FeatureInForce {
|
||||
name: string;
|
||||
source: string; // the class or subclass that gives it
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface InventoryItem {
|
||||
@@ -508,6 +527,7 @@ export interface CharacterDetail extends CharacterCondition {
|
||||
// The 2024 subclass that updates the one followed, if there is one.
|
||||
subclassUpgrade: SubclassSummary | null;
|
||||
level: number;
|
||||
levelUpsPending: number; // levels the DM has approved, still to be taken
|
||||
classes: ClassLevel[];
|
||||
xp: number;
|
||||
xpProgress: XpProgress;
|
||||
@@ -564,10 +584,21 @@ export interface Monster {
|
||||
challengeRating: string | null;
|
||||
specialTrait: string | null;
|
||||
notes: string | null;
|
||||
// Named attacks, for more than the one above; `count` makes it a multiattack.
|
||||
attacks: MonsterAttack[];
|
||||
}
|
||||
|
||||
export interface MonsterAttack {
|
||||
name: string;
|
||||
attackBonus: number;
|
||||
damageDice?: string | null;
|
||||
damageType?: string | null;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export type EncounterStatus = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
|
||||
export type EncounterMode = 'BATTLE' | 'QUEST';
|
||||
// A fight in initiative order, a quest scene around NPCs, or free roam between places the DM has laid out.
|
||||
export type EncounterMode = 'BATTLE' | 'QUEST' | 'FREE_ROAM';
|
||||
export type NpcDisposition = 'FRIENDLY' | 'NEUTRAL' | 'HOSTILE';
|
||||
|
||||
// Something for sale: a weapon or item from the campaign pools, at a price.
|
||||
@@ -577,6 +608,7 @@ export interface SaleOffer {
|
||||
campaignId: string;
|
||||
npcId: string | null;
|
||||
encounterId: string | null;
|
||||
locationId: string | null; // a shop that is always at this place
|
||||
weaponId: string | null;
|
||||
itemId: string | null;
|
||||
price: number; // for one, in `currency`
|
||||
@@ -599,6 +631,18 @@ export interface NpcPublic {
|
||||
exchangeGold: number | null;
|
||||
exchangeCash: number | null;
|
||||
wares?: SaleOffer[]; // what they have for sale
|
||||
locationId?: string | null; // where they are usually found: met there in a free-roam scene
|
||||
}
|
||||
|
||||
// A place the party can go to in a free-roam scene: who is found there, what
|
||||
// is for sale there, and which of the characters in the scene are there now.
|
||||
export interface FreeRoamPlace {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
wares: SaleOffer[];
|
||||
npcs: (NpcPublic & Partial<Npc>)[];
|
||||
here: { characterId: string; name: string; userId: string | null }[];
|
||||
}
|
||||
|
||||
// The DM's full record. dmNotes and the combat stats never reach a player.
|
||||
@@ -655,6 +699,79 @@ export interface CombatSpell {
|
||||
description: string | null;
|
||||
damageDice: string | null;
|
||||
prepared: boolean;
|
||||
attackRoll: boolean;
|
||||
saveAbility: Ability | null;
|
||||
halfOnSave: boolean;
|
||||
}
|
||||
|
||||
export type ActionOutcome = 'HIT' | 'MISS' | 'CRIT' | 'FUMBLE' | 'SAVED' | 'FAILED';
|
||||
|
||||
// One target of an action, as the dice (or the DM) have it.
|
||||
export interface ActionTarget {
|
||||
participantId: string;
|
||||
name: string;
|
||||
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
|
||||
ac: number | null; // the AC an attack is against; a player is not sent a monster's
|
||||
penalty: number; // what an area attack adds to it
|
||||
saveBonus: number | null; // what they add to the save, for a save
|
||||
d20: number | null;
|
||||
total: number | null; // the d20 plus the bonus
|
||||
rolled: ActionOutcome | null; // what the dice say
|
||||
outcome: ActionOutcome | null; // what stands: the dice, or the DM's ruling
|
||||
overruled: boolean;
|
||||
damage: number | null; // set by the DM for this target alone
|
||||
finalDamage: number | null; // what they take; null until it can be known
|
||||
}
|
||||
|
||||
// An attack or a spell in a battle, from being declared to its damage landing.
|
||||
export interface EncounterAction {
|
||||
id: string;
|
||||
round: number;
|
||||
actorParticipantId: string | null;
|
||||
actorName: string;
|
||||
kind: 'ATTACK' | 'SAVE' | 'AUTO'; // a d20 against AC | targets save against a DC | it just lands
|
||||
name: string;
|
||||
detail: {
|
||||
attackBonus?: number;
|
||||
saveAbility?: Ability;
|
||||
saveDC?: number;
|
||||
halfOnSave?: boolean;
|
||||
damageDice?: string | null;
|
||||
damageBonus?: number;
|
||||
damageType?: string | null;
|
||||
slotLevel?: number | null;
|
||||
};
|
||||
targets: ActionTarget[];
|
||||
damage: { total: number; dice?: RolledDie[]; modifier?: number; entered?: boolean; crit?: { total: number; dice: RolledDie[]; modifier: number } | null } | null;
|
||||
status: 'PENDING' | 'APPLIED' | 'DISCARDED';
|
||||
auto: boolean; // declared and rolled by the server, for a monster or NPC
|
||||
hidden: boolean; // the DM's alone until applied
|
||||
needsRolls: number; // targets still waiting on a d20
|
||||
needsDamage: boolean;
|
||||
needsCrit: boolean;
|
||||
ready: boolean;
|
||||
createdAt: string;
|
||||
appliedAt: string | null;
|
||||
}
|
||||
|
||||
// What an action is made with (POST /encounters/:id/actions).
|
||||
export type ActionSource =
|
||||
| { type: 'weapon'; characterWeaponId: string }
|
||||
| { type: 'spell'; characterSpellId: string; slotLevel?: number }
|
||||
| { type: 'basic'; index?: number }
|
||||
| { type: 'custom'; name: string; kind: 'ATTACK' | 'SAVE' | 'AUTO'; attackBonus?: number; saveAbility?: Ability; saveDC?: number; halfOnSave?: boolean; damageDice?: string | null };
|
||||
|
||||
// What can be changed on an action still being resolved (PATCH).
|
||||
export interface ActionPatch {
|
||||
rolls?: { participantId: string; d20: number | null }[];
|
||||
rollRemaining?: boolean;
|
||||
outcomes?: { participantId: string; outcome: 'HIT' | 'MISS' | 'CRIT' | 'SAVED' | 'FAILED' | null }[];
|
||||
damage?: number | null;
|
||||
rollDamage?: boolean;
|
||||
targetDamage?: { participantId: string; damage: number | null }[];
|
||||
targetIds?: string[];
|
||||
hidden?: boolean;
|
||||
discard?: boolean;
|
||||
}
|
||||
|
||||
export interface CharacterCombatInfo {
|
||||
@@ -712,7 +829,8 @@ export interface EncounterParticipant {
|
||||
// hidden from the players. Only the DM is ever sent one of these.
|
||||
hidden: boolean;
|
||||
npcId: string | null;
|
||||
character?: { id: string; name: string; userId: string | null } | null;
|
||||
// currentLocationId is sent in a free-roam scene only: where they have gone.
|
||||
character?: { id: string; name: string; userId: string | null; currentLocationId?: string | null } | null;
|
||||
// The DM gets the whole stat block; players only the id and name.
|
||||
monster?: ({ id: string; name: string } & Partial<Monster>) | null;
|
||||
npc?: (NpcPublic & Partial<Npc>) | null;
|
||||
@@ -744,6 +862,11 @@ export interface Encounter {
|
||||
// The dead characters in the scene. Players do not get their cards, only
|
||||
// this, so that an ally can try to revive them.
|
||||
fallen?: { participantId: string; characterId: string; name: string }[];
|
||||
// Attacks and spells still being resolved (a player does not get the DM's
|
||||
// hidden ones), and the last few that landed.
|
||||
actions?: EncounterAction[];
|
||||
recentActions?: EncounterAction[];
|
||||
places?: FreeRoamPlace[]; // a free-roam scene's places; absent for any other kind
|
||||
rewards?: EncounterReward[]; // players only receive the awarded ones
|
||||
wares?: SaleOffer[]; // for sale at the scene itself, not by an NPC in it
|
||||
}
|
||||
@@ -922,6 +1045,9 @@ export interface LevelUpPlan {
|
||||
// With multiclassing on: every class the level could go to, and why not.
|
||||
classChoice: { options: { characterClass: CharacterClass; level: number; eligible: boolean; problems: string[] }[] } | null;
|
||||
maxHp: { from: number; to: number };
|
||||
// How the level's hit points are come by: the average, or a roll of the
|
||||
// class's die (typed in from the table when `physical`).
|
||||
hp: { method: HpMethod; die: number; average: number; physical: boolean };
|
||||
// Set when a subclass has to be picked on the way to this level: the level
|
||||
// the class chooses at, and each option with everything it would write.
|
||||
subclass: { level: number; options: { key: string; name: string; summary: string; features: { name: string; text: string }[] }[] } | null;
|
||||
@@ -934,11 +1060,66 @@ export interface LevelUpPlan {
|
||||
// What the book lets a caster be granted now; null for a class with no spells.
|
||||
spells: { maxSpellLevel: number; unlocksSpellLevel: number | null; newSpells: number; newCantrips: number } | null;
|
||||
spellSlots: SpellSlots | null; // the slots at the new level
|
||||
// The campaign's spells that could be granted now: of a level the class can
|
||||
// cast, and not yet known.
|
||||
spellOptions: { id: string; name: string; level: number; school: string | null; description: string | null }[];
|
||||
// Held weapons that can take the free upgrade of one level.
|
||||
weapons: { weaponId: string; name: string; equipped: boolean; level: number; damageDice: string; nextDamageDice: string }[];
|
||||
beyondTables: boolean; // past level 20, where the class tables stop
|
||||
}
|
||||
|
||||
// What approving (or taking back) a level did for one character (POST /campaigns/:id/level-up).
|
||||
export interface LevelUpApproval {
|
||||
characterId: string;
|
||||
name: string;
|
||||
userId: string | null;
|
||||
level: number;
|
||||
levelUpsPending: number;
|
||||
skipped: string | null; // why nothing changed for them
|
||||
}
|
||||
|
||||
// One die of a roll: its size, what it showed, whether it counted (the lower
|
||||
// d20 with advantage does not), and whether it adds (1) or takes away (-1).
|
||||
// `maxed`: counted at its most rather than rolled (a critical hit).
|
||||
export interface RolledDie {
|
||||
sides: number;
|
||||
value: number;
|
||||
kept: boolean;
|
||||
sign: number;
|
||||
maxed?: boolean;
|
||||
}
|
||||
|
||||
// A roll made at the table's dice roller, as everyone who may see it gets it.
|
||||
export interface DiceRollRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
rollerName: string;
|
||||
label: string | null; // what it was for: "Aria: Stealth"
|
||||
notation: string; // "1d20+3"
|
||||
dice: RolledDie[];
|
||||
modifier: number;
|
||||
total: number;
|
||||
natural: number | null; // the d20 that counted, for a single d20
|
||||
mode: 'NORMAL' | 'ADVANTAGE' | 'DISADVANTAGE';
|
||||
physical: boolean; // rolled at the table and typed in
|
||||
hidden: boolean; // the DM's alone
|
||||
encounterId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// What is asked of the server for a roll: a d20 plus a modifier, or dice.
|
||||
// `values` are dice rolled at the table, in order.
|
||||
export interface RollSpec {
|
||||
label?: string | null;
|
||||
kind: 'check' | 'dice';
|
||||
modifier?: number;
|
||||
mode?: 'NORMAL' | 'ADVANTAGE' | 'DISADVANTAGE';
|
||||
expression?: string;
|
||||
values?: number[];
|
||||
hidden?: boolean;
|
||||
encounterId?: string | null;
|
||||
}
|
||||
|
||||
export type RestType = 'SHORT' | 'LONG';
|
||||
|
||||
// What a rest did for one character (POST /campaigns/:id/rest).
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { fmtMod } from '../lib/format';
|
||||
import { useCampaign, useRoll, useRolls } from '../api/queries';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useLastCampaignId } from '../lib/lastCampaign';
|
||||
import { extractErrorMessage, fmtMod } from '../lib/format';
|
||||
import { onRollRequest, parseDice, rollD20, rollDie, type RollMode, type RollRequest } from '../lib/diceRoller';
|
||||
import { DiceRow } from './dice/Die';
|
||||
import { settleMs } from '../lib/diceAnimation';
|
||||
import { RollFeed, RollLine } from './RollFeed';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import type { DiceRollRecord, RollSpec, RolledDie } from '../api/types';
|
||||
|
||||
const DICE = [4, 6, 8, 10, 12, 20, 100];
|
||||
const MAX_DICE = 20;
|
||||
const HISTORY_LENGTH = 8;
|
||||
// How long another player's roll stays up beside the dice button.
|
||||
const INCOMING_MS = 6000;
|
||||
const BY_HAND_KEY = 'dice-by-hand';
|
||||
|
||||
const MODES: { mode: RollMode; label: string; title: string }[] = [
|
||||
{ mode: 'DISADVANTAGE', label: 'Disadv.', title: 'Disadvantage: roll two d20s and keep the lower' },
|
||||
@@ -12,77 +23,164 @@ const MODES: { mode: RollMode; label: string; title: string }[] = [
|
||||
{ mode: 'ADVANTAGE', label: 'Adv.', title: 'Advantage: roll two d20s and keep the higher' },
|
||||
];
|
||||
|
||||
interface Roll {
|
||||
id: number;
|
||||
label?: string; // what it was for, when it was asked for from a sheet
|
||||
notation: string; // "1d20+3", "1d8+1d6+2"
|
||||
dice: number[]; // every die rolled, in order
|
||||
modifier: number;
|
||||
total: number;
|
||||
// The d20 that counted, for a roll of a single d20 (a natural 20 or 1 means something).
|
||||
natural: number | null;
|
||||
mode: RollMode; // how a single d20 was rolled
|
||||
}
|
||||
|
||||
function notation(count: number, sides: number, modifier: number): string {
|
||||
return `${count}d${sides}${modifier ? fmtMod(modifier) : ''}`;
|
||||
}
|
||||
|
||||
// A d20 plus a bonus, with advantage or disadvantage as chosen.
|
||||
function d20Roll(modifier: number, mode: RollMode, label?: string): Omit<Roll, 'id'> {
|
||||
const { kept, dice } = rollD20(mode);
|
||||
return { label, notation: notation(1, 20, modifier), dice, modifier, total: kept + modifier, natural: kept, mode };
|
||||
// What a spec is written as: "1d20+3", "2d6+1".
|
||||
function specNotation(spec: RollSpec): string {
|
||||
return spec.kind === 'check' ? notation(1, 20, spec.modifier ?? 0) : (spec.expression ?? '').replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
// What a request from a sheet comes to; null for dice that cannot be read.
|
||||
function answer(request: RollRequest, mode: RollMode): Omit<Roll, 'id'> | null {
|
||||
if (request.kind === 'check') return d20Roll(request.modifier, mode, request.label);
|
||||
const parsed = parseDice(request.expression);
|
||||
if (!parsed) return null;
|
||||
const dice: number[] = [];
|
||||
let total = parsed.flat;
|
||||
for (const term of parsed.dice) {
|
||||
for (let i = 0; i < term.count; i++) {
|
||||
const die = rollDie(term.sides);
|
||||
dice.push(die);
|
||||
total += term.sign * die;
|
||||
}
|
||||
// The size of each die a spec rolls, in order; null for dice that cannot be read.
|
||||
function specDice(spec: RollSpec): number[] | null {
|
||||
if (spec.kind === 'check') return spec.mode && spec.mode !== 'NORMAL' ? [20, 20] : [20];
|
||||
const parsed = parseDice(spec.expression ?? '');
|
||||
return parsed ? parsed.dice.flatMap((term) => Array.from({ length: term.count }, () => term.sides)) : null;
|
||||
}
|
||||
|
||||
let localId = 0;
|
||||
|
||||
// A roll made in the browser, in the shape the server gives: used outside a
|
||||
// campaign, where there is no table to show it to. `values` are dice typed in.
|
||||
function localRoll(spec: RollSpec, rollerName: string, values?: number[]): DiceRollRecord | null {
|
||||
const base = { id: `local-${++localId}`, userId: 'local', rollerName, label: spec.label ?? null, physical: Boolean(values), hidden: false, encounterId: null, createdAt: new Date().toISOString() };
|
||||
if (spec.kind === 'check') {
|
||||
const mode = spec.mode ?? 'NORMAL';
|
||||
const rolled = values ?? rollD20(mode).dice;
|
||||
const natural = mode === 'ADVANTAGE' ? Math.max(...rolled) : mode === 'DISADVANTAGE' ? Math.min(...rolled) : rolled[0];
|
||||
const keptAt = rolled.indexOf(natural);
|
||||
const dice: RolledDie[] = rolled.map((value, i) => ({ sides: 20, value, kept: i === keptAt, sign: 1 }));
|
||||
return { ...base, notation: specNotation(spec), dice, modifier: spec.modifier ?? 0, total: natural + (spec.modifier ?? 0), natural, mode };
|
||||
}
|
||||
return { label: request.label, notation: request.expression.replace(/\s+/g, ''), dice, modifier: parsed.flat, total, natural: null, mode: 'NORMAL' };
|
||||
const parsed = parseDice(spec.expression ?? '');
|
||||
if (!parsed) return null;
|
||||
let at = 0;
|
||||
const dice: RolledDie[] = parsed.dice.flatMap((term) =>
|
||||
Array.from({ length: term.count }, () => ({ sides: term.sides, value: values ? values[at++] : rollDie(term.sides), kept: true, sign: term.sign }))
|
||||
);
|
||||
return { ...base, notation: specNotation(spec), dice, modifier: parsed.flat, total: dice.reduce((sum, d) => sum + d.sign * d.value, 0) + parsed.flat, natural: null, mode: 'NORMAL' };
|
||||
}
|
||||
|
||||
// A table-side dice roller, reachable from every page. It rolls what is set
|
||||
// up here, and whatever is clicked on a sheet or an encounter card (a skill,
|
||||
// a save, an attack, damage: lib/diceRoller.ts), with the bonus already in.
|
||||
// These rolls are for the table: they are not sent to the server and do not
|
||||
// feed the character builder, whose dice stay locked in server-side.
|
||||
// The table's dice roller, reachable from every page. It rolls what is set up
|
||||
// here, and whatever is clicked on a sheet or an encounter card (a skill, a
|
||||
// save, an attack, damage: lib/diceRoller.ts), with the bonus already in.
|
||||
//
|
||||
// In a campaign the server makes every digital roll, keeps it and tells the
|
||||
// table: the DM sees each roll as it lands, and players see what the
|
||||
// campaign's "who sees a roll" setting allows. The campaign's dice setting
|
||||
// decides how a roll is made: by the app, by real dice typed in, or either.
|
||||
// Outside a campaign the dice are rolled here and go nowhere.
|
||||
export function DiceRoller() {
|
||||
const { user } = useAuth();
|
||||
const lastCampaignId = useLastCampaignId();
|
||||
const { data: campaignData } = useCampaign(user && lastCampaignId ? lastCampaignId : undefined);
|
||||
const campaign = campaignData?.campaign;
|
||||
const campaignId = campaign ? campaign.id : null;
|
||||
const isDM = campaign?.role === 'DM';
|
||||
const diceMode = campaign?.diceMode ?? 'DIGITAL';
|
||||
const { data: feed = [] } = useRolls(campaignId);
|
||||
const send = useRoll(campaignId);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<'roll' | 'table'>('roll');
|
||||
const [sides, setSides] = useState(20);
|
||||
const [count, setCount] = useState(1);
|
||||
const [modifier, setModifier] = useState(0);
|
||||
const [mode, setMode] = useState<RollMode>('NORMAL');
|
||||
const [history, setHistory] = useState<Roll[]>([]);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [byHand, setByHand] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(BY_HAND_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [latest, setLatest] = useState<DiceRollRecord | null>(null);
|
||||
const [history, setHistory] = useState<DiceRollRecord[]>([]); // outside a campaign only
|
||||
// A roll waiting for real dice to be typed in, and what has been typed.
|
||||
const [pending, setPending] = useState<RollSpec | null>(null);
|
||||
const [typed, setTyped] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The id of somebody else's roll that has just arrived, while it is shown.
|
||||
const [incomingId, setIncomingId] = useState<string | null>(null);
|
||||
|
||||
const push = (roll: Omit<Roll, 'id'>) => setHistory((prev) => [{ ...roll, id: (prev[0]?.id ?? 0) + 1 }, ...prev].slice(0, HISTORY_LENGTH));
|
||||
// Real dice are typed in when the campaign plays with them, or allows both and the roller prefers them.
|
||||
const typing = diceMode === 'PHYSICAL' || (diceMode === 'BOTH' && byHand);
|
||||
|
||||
// A roll asked for from elsewhere on the page: rolled, and shown. It is
|
||||
// rolled the way the roller is set at that moment.
|
||||
const currentMode = useRef(mode);
|
||||
async function perform(spec: RollSpec, values?: number[]) {
|
||||
setError(null);
|
||||
let roll: DiceRollRecord | null;
|
||||
if (campaignId) {
|
||||
try {
|
||||
roll = await send.mutateAsync({ ...spec, values, hidden: isDM && hidden });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
roll = localRoll(spec, 'You', values);
|
||||
if (!roll) return;
|
||||
setHistory((prev) => [roll!, ...prev].slice(0, HISTORY_LENGTH));
|
||||
}
|
||||
setLatest(roll);
|
||||
setPending(null);
|
||||
setTyped([]);
|
||||
}
|
||||
|
||||
// A roll to make: straight away, or held until its dice are typed in.
|
||||
function ask(spec: RollSpec) {
|
||||
if (!specDice(spec)) return;
|
||||
setTab('roll');
|
||||
setOpen(true);
|
||||
if (typing) {
|
||||
setPending(spec);
|
||||
setTyped([]);
|
||||
setError(null);
|
||||
} else {
|
||||
perform(spec);
|
||||
}
|
||||
}
|
||||
|
||||
// A roll asked for from elsewhere on the page, rolled the way the roller is set at that moment.
|
||||
const current = useRef({ ask, mode });
|
||||
useEffect(() => {
|
||||
currentMode.current = mode;
|
||||
current.current = { ask, mode };
|
||||
});
|
||||
useEffect(
|
||||
() =>
|
||||
onRollRequest((request) => {
|
||||
const roll = answer(request, currentMode.current);
|
||||
if (!roll) return;
|
||||
push(roll);
|
||||
setOpen(true);
|
||||
onRollRequest((request: RollRequest) => {
|
||||
const { ask: askNow, mode: modeNow } = current.current;
|
||||
askNow(request.kind === 'check' ? { kind: 'check', label: request.label, modifier: request.modifier, mode: modeNow } : { kind: 'dice', label: request.label, expression: request.expression });
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Somebody else's roll arriving: shown beside the dice button for a moment,
|
||||
// so the DM sees each roll without opening anything. What the feed held
|
||||
// when it first loaded is not news.
|
||||
const newest = feed[0];
|
||||
const newestId = newest?.id ?? null;
|
||||
const newestIsMine = newest?.userId === user?.id;
|
||||
const seen = useRef<string | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!campaignId) return;
|
||||
if (seen.current === undefined) {
|
||||
seen.current = newestId;
|
||||
return;
|
||||
}
|
||||
if (!newestId || newestId === seen.current) return;
|
||||
seen.current = newestId;
|
||||
// One's own roll is not news, and takes down whatever was showing.
|
||||
const show = setTimeout(() => setIncomingId(newestIsMine ? null : newestId), 0);
|
||||
const hide = setTimeout(() => setIncomingId((shown) => (shown === newestId ? null : shown)), INCOMING_MS);
|
||||
return () => {
|
||||
clearTimeout(show);
|
||||
clearTimeout(hide);
|
||||
};
|
||||
}, [campaignId, newestId, newestIsMine]);
|
||||
const incoming = incomingId ? (feed.find((roll) => roll.id === incomingId) ?? null) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
|
||||
@@ -90,17 +188,29 @@ export function DiceRoller() {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open]);
|
||||
|
||||
const latest = history[0];
|
||||
const singleD20 = sides === 20 && count === 1;
|
||||
const setup: RollSpec = singleD20 ? { kind: 'check', modifier, mode } : { kind: 'dice', expression: notation(count, sides, modifier) };
|
||||
const pendingDice = pending ? (specDice(pending) ?? []) : [];
|
||||
const typedValues = pendingDice.map((_, i) => Number(typed[i]));
|
||||
const typedOk = pendingDice.length > 0 && pendingDice.every((dieSides, i) => typed[i] && Number.isInteger(typedValues[i]) && typedValues[i] >= 1 && typedValues[i] <= dieSides);
|
||||
|
||||
function roll() {
|
||||
if (singleD20) return push(d20Roll(modifier, mode));
|
||||
const dice = Array.from({ length: count }, () => rollDie(sides));
|
||||
push({ notation: notation(count, sides, modifier), dice, modifier, total: dice.reduce((sum, d) => sum + d, 0) + modifier, natural: null, mode: 'NORMAL' });
|
||||
function chooseByHand(value: boolean) {
|
||||
setByHand(value);
|
||||
try {
|
||||
localStorage.setItem(BY_HAND_KEY, value ? '1' : '0');
|
||||
} catch {
|
||||
// Private browsing: the choice lasts as long as the page.
|
||||
}
|
||||
if (!value && pending) {
|
||||
// "Let the app roll" with a roll waiting: it is rolled now.
|
||||
const spec = pending;
|
||||
setPending(null);
|
||||
perform(spec);
|
||||
}
|
||||
}
|
||||
|
||||
const natural = latest?.natural ?? null;
|
||||
const modeNote = (r: Roll) => (r.mode === 'ADVANTAGE' ? 'advantage' : r.mode === 'DISADVANTAGE' ? 'disadvantage' : '');
|
||||
const settle = latest ? settleMs(latest.dice.length) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -114,111 +224,217 @@ export function DiceRoller() {
|
||||
🎲
|
||||
</button>
|
||||
|
||||
{incoming && !(open && tab === 'table') && (
|
||||
<button className="dice-incoming" onClick={() => {
|
||||
setTab('table');
|
||||
setOpen(true);
|
||||
setIncomingId(null);
|
||||
}} title="Open the table's rolls">
|
||||
<RollLine roll={incoming} mine={false} rolling size={30} />
|
||||
</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>
|
||||
{campaignId ? (
|
||||
<div className="dice-tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === 'roll'} className={`die-btn${tab === 'roll' ? ' is-selected' : ''}`} onClick={() => setTab('roll')}>
|
||||
Roll
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'table'} className={`die-btn${tab === 'table' ? ' is-selected' : ''}`} onClick={() => setTab('table')}>
|
||||
{isDM ? 'Everyone’s rolls' : 'Table'} {feed.length > 0 && <span className="combatant-sub">({feed.length})</span>}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
{/* For any single d20: one rolled here, and every check, save and attack clicked on a sheet. */}
|
||||
<div className="dice-choices mt-sm" role="group" aria-label="How a d20 is rolled">
|
||||
{MODES.map((m) => (
|
||||
<button key={m.mode} className={`die-btn${m.mode === mode ? ' is-selected' : ''}`} aria-pressed={m.mode === mode} title={m.title} onClick={() => setMode(m.mode)}>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary btn-block btn-lg mt-sm" onClick={roll}>
|
||||
🎲 Roll {notation(count, sides, modifier)}
|
||||
{singleD20 && mode !== 'NORMAL' && ` (${modeNote({ mode } as Roll)})`}
|
||||
</button>
|
||||
|
||||
<div className="dice-result" aria-live="polite">
|
||||
{latest ? (
|
||||
<>
|
||||
{latest.label && <div className="dice-label roll-label">{latest.label}</div>}
|
||||
{/* 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 || latest.label) && (
|
||||
<div className="dice-breakdown mono">
|
||||
{latest.natural != null && latest.dice.length > 1
|
||||
? `${latest.dice.join(' / ')} → ${latest.natural} (${modeNote(latest)})`
|
||||
: latest.natural != null
|
||||
? latest.natural
|
||||
: `${latest.notation}: ${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, or click a skill, save or attack on a sheet.
|
||||
{tab === 'table' && campaignId ? (
|
||||
<div className="mt-sm">
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
{isDM
|
||||
? 'Every roll made at this table, as it lands. Hidden ones are yours alone.'
|
||||
: campaign?.rollVisibility === 'OWN'
|
||||
? 'Your rolls. This campaign keeps each player’s rolls between them and the DM.'
|
||||
: 'Rolls made at this table.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{history.length > 1 && (
|
||||
<RollFeed rolls={feed} userId={user?.id} empty="Nobody has rolled yet." />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-between">
|
||||
<span className="dice-label">Earlier rolls</span>
|
||||
<button className="btn btn-ghost" onClick={() => setHistory([])}>
|
||||
Clear
|
||||
</button>
|
||||
<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>
|
||||
{history.slice(1).map((r) => (
|
||||
<div key={r.id} className="flex-between dice-history-row">
|
||||
<span>
|
||||
{r.label ? `${r.label} · ` : ''}
|
||||
{r.notation}
|
||||
{r.dice.length > 1 && <span className="dice-history-dice"> ({r.dice.join(', ')})</span>}
|
||||
|
||||
<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>
|
||||
<strong className="mono">{r.total}</strong>
|
||||
<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>
|
||||
|
||||
{/* For any single d20: one rolled here, and every check, save and attack clicked on a sheet. */}
|
||||
<div className="dice-choices mt-sm" role="group" aria-label="How a d20 is rolled">
|
||||
{MODES.map((m) => (
|
||||
<button key={m.mode} className={`die-btn${m.mode === mode ? ' is-selected' : ''}`} aria-pressed={m.mode === mode} title={m.title} onClick={() => setMode(m.mode)}>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(diceMode === 'BOTH' || (isDM && campaignId)) && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{diceMode === 'BOTH' && (
|
||||
<label className="checkbox-row" title="Roll real dice at the table and type in what they show">
|
||||
<input type="checkbox" checked={byHand} onChange={(e) => chooseByHand(e.target.checked)} />
|
||||
🎯 I roll real dice
|
||||
</label>
|
||||
)}
|
||||
{isDM && campaignId && (
|
||||
<label className="checkbox-row" title="Only you see a hidden roll">
|
||||
<input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} />
|
||||
🙈 Roll hidden
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{diceMode === 'PHYSICAL' && <p className="combatant-sub mt-sm" style={{ marginBottom: 0 }}>This campaign plays with real dice: roll at the table and enter what they show.</p>}
|
||||
|
||||
{pending ? (
|
||||
<form
|
||||
className="dice-entry mt-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (typedOk) perform(pending, typedValues);
|
||||
}}
|
||||
>
|
||||
<div className="dice-label roll-label">{pending.label || 'Your roll'}</div>
|
||||
<p style={{ margin: '0.2rem 0 0.4rem' }}>
|
||||
Roll <strong className="mono">{specNotation(pending)}</strong>
|
||||
{pending.kind === 'check' && pending.mode && pending.mode !== 'NORMAL' ? ` with ${pending.mode.toLowerCase()} (both d20s)` : ''} and enter what{' '}
|
||||
{pendingDice.length === 1 ? 'it shows' : 'each die shows'}:
|
||||
</p>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
{pendingDice.map((dieSides, i) => (
|
||||
<label key={i} className="dice-entry-die">
|
||||
<span className="dice-label">d{dieSides}</span>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={dieSides}
|
||||
placeholder={`1–${dieSides}`}
|
||||
value={typed[i] ?? ''}
|
||||
autoFocus={i === 0}
|
||||
onChange={(e) => setTyped((prev) => Object.assign([...prev], { [i]: e.target.value }))}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-row gap-sm mt-sm">
|
||||
<button type="submit" className="btn btn-primary" disabled={!typedOk || send.isPending}>
|
||||
Enter roll
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setPending(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
{diceMode === 'BOTH' && (
|
||||
<button type="button" className="btn btn-ghost" onClick={() => chooseByHand(false)}>
|
||||
Let the app roll
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button className="btn btn-primary btn-block btn-lg mt-sm" onClick={() => ask(setup)} disabled={send.isPending}>
|
||||
{typing ? '🎯' : '🎲'} Roll {notation(count, sides, modifier)}
|
||||
{singleD20 && mode !== 'NORMAL' && ` (${mode === 'ADVANTAGE' ? 'advantage' : 'disadvantage'})`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="dice-error">{error}</p>}
|
||||
|
||||
<div className="dice-result" aria-live="polite">
|
||||
{latest ? (
|
||||
// Keyed on the roll so the dice tumble again every time, even on a repeat result.
|
||||
<div key={latest.id} className="dice-stage">
|
||||
{latest.label && <div className="dice-label roll-label">{latest.label}</div>}
|
||||
<DiceRow dice={latest.dice} rolling size={latest.dice.length > 6 ? 40 : 58} />
|
||||
<div className={`roll-result roll-reveal${natural === 20 ? ' is-crit' : natural === 1 ? ' is-fumble' : ''}`} style={{ animationDelay: `${settle}ms` }}>
|
||||
{latest.total}
|
||||
</div>
|
||||
<div className="roll-reveal" style={{ animationDelay: `${settle}ms` }}>
|
||||
{natural === 20 && <div className="dice-callout">Natural 20!</div>}
|
||||
{natural === 1 && <div className="dice-callout is-fumble">Natural 1</div>}
|
||||
<div className="dice-breakdown mono">
|
||||
{latest.notation}
|
||||
{latest.mode !== 'NORMAL' && ` (${latest.mode === 'ADVANTAGE' ? 'advantage' : 'disadvantage'})`}
|
||||
{latest.modifier !== 0 && `: dice ${latest.total - latest.modifier} ${latest.modifier > 0 ? '+' : '−'} ${Math.abs(latest.modifier)}`} = {latest.total}
|
||||
{latest.hidden && ' · hidden'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="subtitle" style={{ margin: 0 }}>
|
||||
Pick a die and roll, or click a skill, save or attack on a sheet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!campaignId && 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>
|
||||
{r.label ? `${r.label} · ` : ''}
|
||||
{r.notation}
|
||||
{r.dice.length > 1 && <span className="dice-history-dice"> ({r.dice.map((d) => d.value).join(', ')})</span>}
|
||||
</span>
|
||||
<strong className="mono">{r.total}</strong>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{campaignId && (
|
||||
<p className="combatant-sub" style={{ margin: 0 }}>
|
||||
{isDM ? 'Your rolls are shown to the table unless hidden.' : campaign?.rollVisibility === 'OWN' ? 'Your DM sees this roll.' : 'The table sees this roll.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ const STAT_HELP: Record<string, string> = {
|
||||
SKILL_BONUS: 'Skill Bonus — added when you roll to use a skill. The governing ability modifier, plus your proficiency bonus if you are proficient (doubled if you have expertise).',
|
||||
EXPERTISE: 'Expertise — doubles your proficiency bonus for this skill, on top of being proficient.',
|
||||
ABILITY_MODIFIER: 'Ability Modifier — the number added to rolls that use this ability. floor((score − 10) / 2).',
|
||||
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item.',
|
||||
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item. Unarmored Defense (barbarian, monk) and a draconic sorcerer’s scales are worked out for you: do not add them here.',
|
||||
SPEED: 'Speed — how far you can move on your turn, in feet. Set by your species, unless overridden.',
|
||||
SPELL_SAVE_DC: 'Spell Save DC — the difficulty a target must beat to resist your spell. 8 + proficiency bonus + your spellcasting ability modifier.',
|
||||
SPELL_ATTACK_BONUS: 'Spell Attack Bonus — added when you make a spell attack roll. Proficiency bonus + your spellcasting ability modifier.',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAuth } from '../auth/useAuth';
|
||||
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
|
||||
import { DiceRoller } from './DiceRoller';
|
||||
import { CharacterPanel } from './CharacterPanel';
|
||||
import { Toasts } from './Toasts';
|
||||
|
||||
export function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
@@ -70,6 +71,7 @@ export function Layout() {
|
||||
)}
|
||||
<DiceRoller />
|
||||
{user && <CharacterPanel />}
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react';
|
||||
import { useApproveLevelUps } from '../api/queries';
|
||||
import { classLabel, extractErrorMessage } from '../lib/format';
|
||||
import { Modal } from './Modal';
|
||||
import { ErrorBanner } from './Spinner';
|
||||
import type { CharacterSummary, LevelUpApproval } from '../api/types';
|
||||
|
||||
const MAX_LEVEL = 20;
|
||||
|
||||
// The DM's level-up button: approve a level for any of the party at once.
|
||||
// Each approval is a checklist waiting for its character: their player is
|
||||
// told and makes the picks themselves, so nobody queues for the DM. A
|
||||
// character without a player is the DM's to finish, from its sheet.
|
||||
// `trackXp`: with XP kept, those who have the XP for their next level start
|
||||
// ticked; without it (milestones) the DM ticks who levels.
|
||||
export function LevelUpBulkModal({
|
||||
campaignId,
|
||||
characters,
|
||||
trackXp,
|
||||
onClose,
|
||||
}: {
|
||||
campaignId: string;
|
||||
characters: CharacterSummary[];
|
||||
trackXp: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const approve = useApproveLevelUps(campaignId);
|
||||
const party = characters.filter((c) => c.buildStage === 'COMPLETE');
|
||||
const canLevel = (c: CharacterSummary) => c.level + c.levelUpsPending < MAX_LEVEL;
|
||||
const [picked, setPicked] = useState<Set<string>>(() => new Set(party.filter((c) => trackXp && c.xpProgress.levelUpDue && canLevel(c)).map((c) => c.id)));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<LevelUpApproval[] | null>(null);
|
||||
|
||||
function toggle(id: string) {
|
||||
setPicked((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function send(characterIds: string[], revoke = false) {
|
||||
setError(null);
|
||||
try {
|
||||
const { results } = await approve.mutateAsync({ characterIds, revoke });
|
||||
if (!revoke) setDone(results);
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<Modal title="Level ups approved" subtitle="Each player makes their own picks" onClose={onClose}>
|
||||
{done.map((result) => (
|
||||
<div key={result.characterId} className="reward-row">
|
||||
<strong>{result.name}</strong>
|
||||
{result.skipped ? (
|
||||
<div className="combatant-sub">{result.skipped}</div>
|
||||
) : (
|
||||
<div className="combatant-sub">
|
||||
Level {result.level} → {result.level + 1} is theirs to take.{' '}
|
||||
{result.userId ? 'Their player has been told and chooses what they gain.' : 'No player: finish it from the sheet.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="modal-foot">
|
||||
<button className="btn btn-primary" onClick={onClose}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Level up" subtitle="Approve a level for the party; each player then picks what they gain" wide onClose={onClose}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<button className="btn" onClick={() => setPicked(new Set(party.filter(canLevel).map((c) => c.id)))}>
|
||||
Everyone
|
||||
</button>
|
||||
{trackXp && (
|
||||
<button className="btn" onClick={() => setPicked(new Set(party.filter((c) => c.xpProgress.levelUpDue && canLevel(c)).map((c) => c.id)))}>
|
||||
Those with the XP
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-ghost" onClick={() => setPicked(new Set())}>
|
||||
Nobody
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-md">
|
||||
{party.length === 0 && <p className="combatant-sub">No finished characters yet.</p>}
|
||||
{party.map((c) => (
|
||||
<label key={c.id} className="checkbox-row level-choice" style={canLevel(c) ? undefined : { opacity: 0.55 }}>
|
||||
<input type="checkbox" checked={picked.has(c.id)} disabled={!canLevel(c)} onChange={() => toggle(c.id)} />
|
||||
<span style={{ flex: 1 }}>
|
||||
<strong>{c.name}</strong>{' '}
|
||||
<span className="combatant-sub">
|
||||
{classLabel(c.characterClass, c.subclassName, c.classes)} · Level {c.level}
|
||||
</span>
|
||||
{trackXp && c.xpProgress.levelUpDue && <span className="badge badge-live" style={{ marginLeft: 6 }}>has the XP</span>}
|
||||
{c.levelUpsPending > 0 && (
|
||||
<span className="badge" style={{ marginLeft: 6 }}>
|
||||
{c.levelUpsPending} waiting on their picks
|
||||
</span>
|
||||
)}
|
||||
{!c.userId && <span className="badge" style={{ marginLeft: 6 }}>no player: yours to finish</span>}
|
||||
</span>
|
||||
{c.levelUpsPending > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
title="Takes back one approved level that has not been taken yet"
|
||||
disabled={approve.isPending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
send([c.id], true);
|
||||
}}
|
||||
>
|
||||
Take one back
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<button className="btn btn-primary" disabled={picked.size === 0 || approve.isPending} onClick={() => send([...picked])}>
|
||||
{approve.isPending ? 'Approving…' : `Approve a level for ${picked.size} character${picked.size === 1 ? '' : 's'}`}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import { useState, type ReactNode } from 'react';
|
||||
import { useLevelUp, useLevelUpPlan } from '../api/queries';
|
||||
import { CLASS_LABELS, classLabel, extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format';
|
||||
import { Modal } from './Modal';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import { Spinner, ErrorBanner } from './Spinner';
|
||||
import type { CharacterClass, CharacterDetail, LevelUpPlan, Spell } from '../api/types';
|
||||
import { showToast } from '../lib/toasts';
|
||||
import type { CharacterClass, CharacterDetail, LevelUpPlan } from '../api/types';
|
||||
|
||||
// One numbered step of the checklist.
|
||||
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
|
||||
@@ -27,8 +29,10 @@ function allowance(spells: NonNullable<LevelUpPlan['spells']>): string {
|
||||
// The levelling-up checklist for one character, one level at a time: the
|
||||
// level, HP, the class's new special abilities (with a subclass or a Boon
|
||||
// where the level calls for one), new spells and fresh slots, and the free weapon upgrade. The
|
||||
// site does every step; the DM makes the choices and says yes.
|
||||
export function LevelUpModal({ character, campaignSpells, onClose }: { character: CharacterDetail; campaignSpells: Spell[]; onClose: () => void }) {
|
||||
// site does every step; whoever is levelling makes the choices and says yes:
|
||||
// the DM, or the character's own player once the DM has approved the level.
|
||||
// A player picks spells within what the level allows; the DM grants freely.
|
||||
export function LevelUpModal({ character, isDM, onClose }: { character: CharacterDetail; isDM: boolean; onClose: () => void }) {
|
||||
// The class taking the level: the first one, unless another is picked (multiclassing).
|
||||
const [toClass, setToClass] = useState<CharacterClass | null>(null);
|
||||
const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true, toClass);
|
||||
@@ -38,6 +42,7 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
// undefined until picked: the equipped weapon is offered first.
|
||||
const [weaponChoice, setWeaponChoice] = useState<string | undefined>(undefined);
|
||||
const [spellIds, setSpellIds] = useState<Set<string>>(new Set());
|
||||
const [hpRoll, setHpRoll] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (isLoading || !plan) {
|
||||
@@ -49,13 +54,16 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
}
|
||||
|
||||
const weaponId = weaponChoice ?? plan.weapons.find((w) => w.equipped)?.weaponId ?? plan.weapons[0]?.weaponId ?? '';
|
||||
const known = new Set(character.spells.map((s) => s.spellId));
|
||||
const grantable = plan.spells
|
||||
? campaignSpells.filter((s) => !known.has(s.id) && s.level <= plan.spells!.maxSpellLevel).sort((a, b) => a.level - b.level || a.name.localeCompare(b.name))
|
||||
: [];
|
||||
const grantable = plan.spellOptions;
|
||||
// A player picks up to the level's allowance; the DM is not held to it.
|
||||
const spellAllowance = plan.spells ? plan.spells.newSpells + plan.spells.newCantrips : 0;
|
||||
const spellsFull = !isDM && spellIds.size >= spellAllowance;
|
||||
const needsHpRoll = plan.hp.method === 'ROLL' && plan.hp.physical;
|
||||
const hpRollValid = Number.isInteger(Number(hpRoll)) && Number(hpRoll) >= 1 && Number(hpRoll) <= plan.hp.die;
|
||||
const pickedBoon = plan.boon?.find((b) => b.key === boon);
|
||||
const pickedSubclass = plan.subclass?.options.find((option) => option.key === subclass);
|
||||
const owed = plan.subclass && !subclass ? 'Choose a subclass first' : plan.boon && !boon ? 'Choose a Boon first' : null;
|
||||
const owed =
|
||||
plan.subclass && !subclass ? 'Choose a subclass first' : plan.boon && !boon ? 'Choose a Boon first' : needsHpRoll && !hpRollValid ? `Roll your d${plan.hp.die} for hit points` : null;
|
||||
|
||||
function toggleSpell(id: string) {
|
||||
setSpellIds((current) => {
|
||||
@@ -68,9 +76,14 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
async function handleLevelUp() {
|
||||
setError(null);
|
||||
try {
|
||||
await levelUp.mutateAsync({
|
||||
const { hpRolled } = await levelUp.mutateAsync({
|
||||
hpRoll: needsHpRoll ? Number(hpRoll) : undefined,
|
||||
characterClass: plan!.characterClass,
|
||||
subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
|
||||
showToast({
|
||||
tone: 'good',
|
||||
text: `${character.name} is level ${plan!.toLevel}!${hpRolled != null ? ` The d${plan!.hp.die} for hit points came up ${hpRolled}.` : ''}`,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
@@ -124,9 +137,32 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
</Step>
|
||||
|
||||
<Step n={2} title="Hit points">
|
||||
<p>
|
||||
Max HP <strong>{plan.maxHp.from}</strong> → <strong>{plan.maxHp.to}</strong>, and healed to full. One more Hit Die.
|
||||
</p>
|
||||
{plan.hp.method === 'AVERAGE' ? (
|
||||
<p>
|
||||
Max HP <strong>{plan.maxHp.from}</strong> → <strong>{plan.maxHp.to}</strong>, and healed to full. One more Hit Die (d{plan.hp.die}).
|
||||
</p>
|
||||
) : needsHpRoll ? (
|
||||
<div>
|
||||
<p style={{ marginBottom: '0.3rem' }}>
|
||||
This campaign rolls for hit points. Roll a <strong>d{plan.hp.die}</strong> at the table and enter what it shows; your Constitution
|
||||
modifier is added for you. Healed to full, and one more Hit Die.
|
||||
</p>
|
||||
<label className="flex-row gap-sm">
|
||||
I rolled
|
||||
<NumberInput min={1} max={plan.hp.die} placeholder={`1–${plan.hp.die}`} value={hpRoll} onChange={(e) => setHpRoll(e.target.value)} style={{ width: 80 }} />
|
||||
{hpRollValid && (
|
||||
<span className="combatant-sub">
|
||||
Max HP {plan.maxHp.from} → {plan.maxHp.to - plan.hp.average + Number(hpRoll)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<p>
|
||||
This campaign rolls for hit points: a <strong>d{plan.hp.die}</strong> is rolled when you confirm (between {plan.maxHp.to - plan.hp.average + 1} and{' '}
|
||||
{plan.maxHp.to - plan.hp.average + plan.hp.die} max HP), plus healing to full and one more Hit Die.
|
||||
</p>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Special abilities">
|
||||
@@ -190,7 +226,9 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
</div>
|
||||
)}
|
||||
{(plan.features.length > 0 || plan.boon || plan.subclass) && (
|
||||
<p className="combatant-sub">Written into Special Abilities for you. Where a line says "choose", edit it there once the player has.</p>
|
||||
<p className="combatant-sub">
|
||||
Written into Special Abilities for you. {isDM ? 'Where a line says "choose", edit it there once the player has.' : 'Where a line says "choose", agree it with your DM.'}
|
||||
</p>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
@@ -216,16 +254,22 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
<div className="level-spells">
|
||||
{grantable.map((spell) => (
|
||||
<label key={spell.id} className="checkbox-row level-choice">
|
||||
<input type="checkbox" checked={spellIds.has(spell.id)} onChange={() => toggleSpell(spell.id)} />
|
||||
<span>
|
||||
{spell.name} <span className="combatant-sub">{spellLevelLabel(spell.level)}</span>
|
||||
<input type="checkbox" checked={spellIds.has(spell.id)} disabled={spellsFull && !spellIds.has(spell.id)} onChange={() => toggleSpell(spell.id)} />
|
||||
<span title={spell.description ?? undefined}>
|
||||
{spell.name}{' '}
|
||||
<span className="combatant-sub">
|
||||
{spellLevelLabel(spell.level)}
|
||||
{spell.school ? ` · ${spell.school}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="combatant-sub">
|
||||
{spellIds.size > 0 && `${spellIds.size} picked. `}A player may also swap one known spell for another: take the old one away on the sheet.
|
||||
{isDM
|
||||
? `${spellIds.size > 0 ? `${spellIds.size} picked. ` : ''}A player may also swap one known spell for another: take the old one away on the sheet.`
|
||||
: `${spellIds.size} of ${spellAllowance} picked. Hover a spell to read what it does.`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DiceRow } from './dice/Die';
|
||||
import type { DiceRollRecord } from '../api/types';
|
||||
|
||||
const MODE_NOTE = { NORMAL: '', ADVANTAGE: 'advantage', DISADVANTAGE: 'disadvantage' } as const;
|
||||
|
||||
function timeOf(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// One roll as a line: who, what for, the dice as they fell, and the total.
|
||||
export function RollLine({ roll, mine, rolling = false, size = 34 }: { roll: DiceRollRecord; mine: boolean; rolling?: boolean; size?: number }) {
|
||||
const crit = roll.natural === 20;
|
||||
const fumble = roll.natural === 1;
|
||||
return (
|
||||
<div className={`roll-line${crit ? ' is-crit' : fumble ? ' is-fumble' : ''}`}>
|
||||
<div className="roll-line-head">
|
||||
<span>
|
||||
<strong>{mine ? 'You' : roll.rollerName}</strong>
|
||||
{roll.label && <span> · {roll.label}</span>}
|
||||
</span>
|
||||
<span className="combatant-sub">{timeOf(roll.createdAt)}</span>
|
||||
</div>
|
||||
<div className="roll-line-body">
|
||||
<DiceRow dice={roll.dice} rolling={rolling} size={size} />
|
||||
<span className="roll-line-math mono">
|
||||
{roll.modifier !== 0 && `${roll.modifier > 0 ? '+' : '−'} ${Math.abs(roll.modifier)} `}=
|
||||
</span>
|
||||
<strong className="roll-line-total mono">{roll.total}</strong>
|
||||
</div>
|
||||
<div className="roll-line-tags">
|
||||
<span className="combatant-sub mono">{roll.notation}</span>
|
||||
{roll.mode !== 'NORMAL' && <span className="badge">{MODE_NOTE[roll.mode]}</span>}
|
||||
{crit && <span className="badge badge-friendly">Natural 20</span>}
|
||||
{fumble && <span className="badge badge-hostile">Natural 1</span>}
|
||||
{roll.physical && <span className="badge" title="Rolled with real dice and typed in">🎯 at the table</span>}
|
||||
{roll.hidden && <span className="badge badge-dm" title="Only you see this roll">🙈 hidden</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The table's rolls, newest first: every roll this member may see. For the
|
||||
// DM that is every roll anyone makes.
|
||||
export function RollFeed({ rolls, userId, empty }: { rolls: DiceRollRecord[]; userId: string | undefined; empty: string }) {
|
||||
if (rolls.length === 0) return <p className="subtitle">{empty}</p>;
|
||||
return (
|
||||
<div className="roll-feed">
|
||||
{rolls.map((roll) => (
|
||||
<RollLine key={roll.id} roll={roll} mine={roll.userId === userId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { dismissToast, subscribeToasts, type Toast } from '../lib/toasts';
|
||||
|
||||
// The stack of toasts (lib/toasts.ts), mounted once in the layout.
|
||||
export function Toasts() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
useEffect(() => subscribeToasts(setToasts), []);
|
||||
if (toasts.length === 0) return null;
|
||||
return (
|
||||
<div className="toasts" role="status" aria-live="polite">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast${toast.tone ? ` toast-${toast.tone}` : ''}`}>
|
||||
<span className="toast-text">{toast.text}</span>
|
||||
{toast.action && (
|
||||
<Link to={toast.action.to} className="btn btn-primary btn-sm" onClick={() => dismissToast(toast.id)}>
|
||||
{toast.action.label}
|
||||
</Link>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-sm" aria-label="Dismiss" onClick={() => dismissToast(toast.id)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { CharacterSummary, Encounter, Item, SaleOffer, Weapon } from '../ap
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// (A free-roam scene has its own panel: components/encounter/FreeRoam.tsx.)
|
||||
// Trade in a quest: changing gold and cash with an NPC who does that, at
|
||||
// their own rate, and buying what the NPCs in the scene — or the scene itself
|
||||
// — have for sale. Everyone sees what is on offer; a player trades for their
|
||||
|
||||
@@ -73,8 +73,8 @@ function WareRow({ campaignId, ware, onError }: { campaignId: string; ware: Sale
|
||||
);
|
||||
}
|
||||
|
||||
// The DM's list of what one seller has for sale — an NPC, or an encounter
|
||||
// itself — with a form to put something from the campaign pools up for sale.
|
||||
// The DM's list of what one seller has for sale — an NPC, an encounter
|
||||
// itself, or a location (a shop that is always there) — with a form to put something from the campaign pools up for sale.
|
||||
export function WaresEditor({
|
||||
campaignId,
|
||||
seller,
|
||||
@@ -84,7 +84,7 @@ export function WaresEditor({
|
||||
onError,
|
||||
}: {
|
||||
campaignId: string;
|
||||
seller: { npcId: string } | { encounterId: string };
|
||||
seller: { npcId: string } | { encounterId: string } | { locationId: string };
|
||||
wares: SaleOffer[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useDeleteMonster, useDeletePoolEntry, usePatchPoolEntry, useUpdateMonster } from '../../api/queries';
|
||||
import { useCampaignLocations, useDeleteMonster, useDeletePoolEntry, usePatchPoolEntry, useUpdateMonster } from '../../api/queries';
|
||||
import { Modal } from '../Modal';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { ErrorBanner } from '../Spinner';
|
||||
@@ -7,8 +7,9 @@ import { EffectsEditor } from '../EffectsEditor';
|
||||
import { WaresEditor } from '../Wares';
|
||||
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
|
||||
import { abilityLine, rateLabel, type EntryKind, type EntryRef } from '../../lib/campaignEntries';
|
||||
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, StartLevelSelect } from './parts';
|
||||
import type { GearEffect, Item, Monster, Npc, Spell, Weapon } from '../../api/types';
|
||||
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, SpellLandingSelect, StartLevelSelect } from './parts';
|
||||
import { landingValue } from '../../lib/campaignEntries';
|
||||
import type { GearEffect, Item, Monster, Npc, Spell, Weapon, ArmorType } from '../../api/types';
|
||||
|
||||
const KIND_NOUNS: Record<EntryKind, string> = { weapon: 'weapon', item: 'item', spell: 'spell', monster: 'monster', npc: 'NPC' };
|
||||
const ATTACK_ABILITY_LABELS: Record<Weapon['attackAbility'], string> = { STR: 'STR', DEX: 'DEX', FINESSE: 'Finesse (the better of STR and DEX)' };
|
||||
@@ -89,6 +90,27 @@ function GearDetail({ campaignId, pool, entry, added, onClose }: { added: ReactN
|
||||
Starting option: a player may pick it while building a character
|
||||
</label>
|
||||
|
||||
{!weapon && 'armorType' in entry && (
|
||||
<div className="mt-md">
|
||||
<Field label="Armor">
|
||||
{(id) => (
|
||||
<select id={id} value={entry.armorType ?? ''} disabled={patch.isPending} onChange={(e) => patch.mutate({ id: entry.id, armorType: (e.target.value || null) as ArmorType | null }, { onError })}>
|
||||
<option value="">Not armor</option>
|
||||
<option value="LIGHT">Light armor</option>
|
||||
<option value="MEDIUM">Medium armor</option>
|
||||
<option value="HEAVY">Heavy armor</option>
|
||||
<option value="SHIELD">Shield</option>
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
<p className="combatant-sub mt-sm">
|
||||
Its AC is still set below, as +AC. This says what kind it is, for the class features that depend on what is worn: a barbarian’s and a
|
||||
monk’s Unarmored Defense and a draconic sorcerer’s scales stop while armor is equipped, a monk’s also behind a shield, and a
|
||||
barbarian’s extra speed stops in heavy armor.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section-label mt-md">While equipped</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
These count for a character only while it is equipped and they are high enough level to use it. Armor is an item with
|
||||
@@ -140,6 +162,9 @@ function SpellDetail({ campaignId, spell, added, onClose }: { added: ReactNode;
|
||||
<Field label="Damage">
|
||||
{(id) => <SpellDamageSelect id={id} spellName={spell.name} value={spell.damageDice} onChange={(damageDice) => patch.mutate({ id: spell.id, damageDice }, { onError })} />}
|
||||
</Field>
|
||||
<Field label="In a battle">
|
||||
{(id) => <SpellLandingSelect id={id} value={landingValue(spell)} onChange={(landing) => patch.mutate({ id: spell.id, ...landing }, { onError })} />}
|
||||
</Field>
|
||||
</div>
|
||||
<label className="checkbox-row mt-md">
|
||||
<input type="checkbox" checked={spell.isStartingOption} onChange={(e) => patch.mutate({ id: spell.id, isStartingOption: e.target.checked }, { onError })} />
|
||||
@@ -191,6 +216,12 @@ function MonsterDetail({ campaignId, monster, added, onClose }: { added: ReactNo
|
||||
<Chip label="Speed">{monster.speed}</Chip>
|
||||
<Chip label="Attack">{fmtMod(monster.attackBonus)}</Chip>
|
||||
<Chip label="Damage">{monster.damageDice || '—'}</Chip>
|
||||
{(monster.attacks ?? []).map((a, i) => (
|
||||
<Chip key={i} label={`${a.name}${(a.count ?? 1) > 1 ? ` ×${a.count}` : ''}`}>
|
||||
{fmtMod(a.attackBonus)} · {a.damageDice || '—'}
|
||||
{a.damageType ? ` ${a.damageType}` : ''}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
<p className="mono mt-sm" style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{abilityLine(monster)}</p>
|
||||
{monster.specialTrait && (
|
||||
@@ -220,6 +251,7 @@ function MonsterDetail({ campaignId, monster, added, onClose }: { added: ReactNo
|
||||
function NpcDetail({ campaignId, npc, weapons, items, added, onClose }: { added: ReactNode; campaignId: string; npc: Npc; weapons: Weapon[]; items: Item[]; onClose: () => void }) {
|
||||
const patch = usePatchPoolEntry(campaignId, 'npcs');
|
||||
const remove = useDeletePoolEntry(campaignId, 'npcs');
|
||||
const { data: locations } = useCampaignLocations(campaignId);
|
||||
const [gold, setGold] = useState(npc.exchangeGold ? String(npc.exchangeGold) : '');
|
||||
const [cash, setCash] = useState(npc.exchangeCash ? String(npc.exchangeCash) : '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -253,6 +285,26 @@ function NpcDetail({ campaignId, npc, weapons, items, added, onClose }: { added:
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="section-label mt-md">Found at</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Where the party meets {npc.name} in a free-roam scene: whoever goes to that place can talk and trade with them there. (In a quest, they
|
||||
are met when you add them to it.)
|
||||
</p>
|
||||
<select
|
||||
aria-label={`Where ${npc.name} is found`}
|
||||
value={npc.locationId ?? ''}
|
||||
disabled={patch.isPending}
|
||||
onChange={(e) => patch.mutate({ id: npc.id, locationId: e.target.value || null }, { onError })}
|
||||
style={{ maxWidth: 320 }}
|
||||
>
|
||||
<option value="">Nowhere in particular</option>
|
||||
{(locations ?? []).map((loc) => (
|
||||
<option key={loc.id} value={loc.id}>
|
||||
📍 {loc.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="section-label mt-md">Changes money</div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Set a rate and the party can change gold into cash, or cash into gold, with {npc.name}. Silver and copper are not
|
||||
|
||||
@@ -18,7 +18,8 @@ import { effectsSummary } from '../../lib/gear';
|
||||
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
|
||||
import { MAX_GEAR_LEVEL } from '../../lib/previewCalc';
|
||||
import { abilityLine, rateLabel, type EntryRef } from '../../lib/campaignEntries';
|
||||
import { EntryList, Field, LibraryPicker, MonsterForm, ScalingSelect, SpellDamageSelect, StartLevelSelect } from './parts';
|
||||
import { EntryList, Field, LibraryPicker, MonsterForm, ScalingSelect, SpellDamageSelect, SpellLandingSelect, StartLevelSelect } from './parts';
|
||||
import { landingFrom } from '../../lib/campaignEntries';
|
||||
import type { CampaignLibrary, Item, LibraryItem, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Spell, Weapon } from '../../api/types';
|
||||
|
||||
interface SectionProps<T> {
|
||||
@@ -258,6 +259,7 @@ export function ItemsSection({ campaignId, entries, onOpen, formRequested, onFor
|
||||
isStartingOption: fd.get('isStartingOption') === 'on',
|
||||
effects: [],
|
||||
effectNote: null,
|
||||
armorType: null,
|
||||
});
|
||||
close();
|
||||
onOpen({ kind: 'item', id: item.id, isNew: true });
|
||||
@@ -347,6 +349,7 @@ export function SpellsSection({ campaignId, entries, onOpen, formRequested, onFo
|
||||
damageDice: String(fd.get('damageDice') || '') || null,
|
||||
isStartingOption: fd.get('isStartingOption') === 'on',
|
||||
revives: fd.get('revives') === 'on',
|
||||
...landingFrom(String(fd.get('landing') || '')),
|
||||
});
|
||||
close();
|
||||
onOpen({ kind: 'spell', id: spell.id, isNew: true });
|
||||
@@ -412,6 +415,7 @@ export function SpellsSection({ campaignId, entries, onOpen, formRequested, onFo
|
||||
</Field>
|
||||
<Field label="School (optional)">{(id) => <input id={id} name="school" />}</Field>
|
||||
<Field label="Damage">{(id) => <SpellDamageSelect id={id} name="damageDice" />}</Field>
|
||||
<Field label="In a battle" style={{ gridColumn: '1 / -1' }}>{(id) => <SpellLandingSelect id={id} name="landing" />}</Field>
|
||||
<Field label="What it does (one sentence)" style={{ gridColumn: '1 / -1' }}>
|
||||
{(id) => <input id={id} name="description" maxLength={4000} required />}
|
||||
</Field>
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Pagination } from '../Paginated';
|
||||
import { usePagination } from '../../hooks/usePagination';
|
||||
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS } from '../../lib/format';
|
||||
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../../lib/previewCalc';
|
||||
import { matchesSearch, scoreField, type MonsterInput } from '../../lib/campaignEntries';
|
||||
import type { Monster } from '../../api/types';
|
||||
import { landingFrom, matchesSearch, scoreField, type MonsterInput, type SpellLanding } from '../../lib/campaignEntries';
|
||||
import type { Ability, Monster, MonsterAttack } from '../../api/types';
|
||||
|
||||
// A labelled field in a pop-out form. The child is handed the id its label points at.
|
||||
export function Field({ label, style, children }: { label: string; style?: CSSProperties; children: (id: string) => ReactNode }) {
|
||||
@@ -19,6 +19,30 @@ export function Field({ label, style, children }: { label: string; style?: CSSPr
|
||||
);
|
||||
}
|
||||
|
||||
const LANDING_ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
const ABILITY_NAMES: Record<Ability, string> = { STR: 'Strength', DEX: 'Dexterity', CON: 'Constitution', INT: 'Intelligence', WIS: 'Wisdom', CHA: 'Charisma' };
|
||||
|
||||
// How a spell lands in a battle, as one choice (lib/campaignEntries.ts, SpellLanding).
|
||||
export function SpellLandingSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: string; onChange?: (landing: SpellLanding) => void }) {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={name}
|
||||
title="In a battle: whether the caster rolls to hit, each target makes a saving throw, or it simply happens"
|
||||
{...(onChange ? { value, onChange: (e) => onChange(landingFrom(e.target.value)) } : { defaultValue: '' })}
|
||||
>
|
||||
<option value="">It just takes effect (no roll)</option>
|
||||
<option value="ATTACK">The caster rolls a spell attack</option>
|
||||
{LANDING_ABILITIES.map((a) => (
|
||||
<optgroup key={a} label={`${ABILITY_NAMES[a]} save`}>
|
||||
<option value={`${a}:HALF`}>{ABILITY_NAMES[a]} save, half damage on a success</option>
|
||||
<option value={`${a}:NONE`}>{ABILITY_NAMES[a]} save, nothing on a success</option>
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// How fast a weapon's or item's dice grow as the DM levels a held copy up.
|
||||
export function ScalingSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: number; onChange?: (scaling: number) => void }) {
|
||||
return (
|
||||
@@ -119,6 +143,17 @@ export function MonsterForm({
|
||||
onSave: (data: MonsterInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
// Named attacks, as typed: kept as text until saved.
|
||||
const [attacks, setAttacks] = useState<{ name: string; attackBonus: string; damageDice: string; damageType: string; count: string }[]>(() =>
|
||||
(monster?.attacks ?? []).map((a) => ({
|
||||
name: a.name,
|
||||
attackBonus: String(a.attackBonus),
|
||||
damageDice: a.damageDice ?? '',
|
||||
damageType: a.damageType ?? '',
|
||||
count: String(a.count ?? 1),
|
||||
}))
|
||||
);
|
||||
const setAttack = (index: number, change: Partial<(typeof attacks)[number]>) => setAttacks((rows) => rows.map((row, i) => (i === index ? { ...row, ...change } : row)));
|
||||
const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => (
|
||||
<div className="field" style={{ marginBottom: 0, ...style }}>
|
||||
<label htmlFor={`monster-${name}`}>{label}</label>
|
||||
@@ -164,6 +199,17 @@ export function MonsterForm({
|
||||
chaScore: num('chaScore'),
|
||||
specialTrait: text('specialTrait'),
|
||||
notes: text('notes'),
|
||||
attacks: attacks
|
||||
.filter((a) => a.name.trim())
|
||||
.map(
|
||||
(a): MonsterAttack => ({
|
||||
name: a.name.trim(),
|
||||
attackBonus: Math.trunc(Number(a.attackBonus)) || 0,
|
||||
damageDice: a.damageDice.trim() || null,
|
||||
damageType: a.damageType.trim() || null,
|
||||
count: Math.min(10, Math.max(1, Math.trunc(Number(a.count)) || 1)),
|
||||
})
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
// Whoever opened the form shows the error; what was typed stays.
|
||||
@@ -190,6 +236,30 @@ export function MonsterForm({
|
||||
<div className="grid mt-sm" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))' }}>
|
||||
{ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))}
|
||||
</div>
|
||||
<div className="field" style={{ marginTop: '0.5rem', marginBottom: 0 }}>
|
||||
<label>Named attacks (optional)</label>
|
||||
<p className="combatant-sub" style={{ margin: '0 0 0.3rem' }}>
|
||||
For a creature with more than the one attack above: each with its own bonus and damage, and how many times it is made each turn (a multiattack).
|
||||
With any listed here, these are what it attacks with.
|
||||
</p>
|
||||
{attacks.map((a, i) => (
|
||||
<div key={i} className="flex-row flex-wrap gap-sm" style={{ marginBottom: '0.3rem' }}>
|
||||
<input aria-label="Attack name" placeholder="Claw" value={a.name} maxLength={40} onChange={(e) => setAttack(i, { name: e.target.value })} style={{ flex: '2 1 110px' }} />
|
||||
<NumberInput aria-label="Attack bonus" placeholder="+hit" min={-20} max={50} value={a.attackBonus} onChange={(e) => setAttack(i, { attackBonus: e.target.value })} style={{ width: 70 }} />
|
||||
<input aria-label="Damage dice" placeholder="2d6+3" value={a.damageDice} maxLength={60} onChange={(e) => setAttack(i, { damageDice: e.target.value })} style={{ flex: '1 1 80px' }} />
|
||||
<input aria-label="Damage type" placeholder="slashing" value={a.damageType} maxLength={30} onChange={(e) => setAttack(i, { damageType: e.target.value })} style={{ flex: '1 1 80px' }} />
|
||||
<NumberInput aria-label="Times per turn" title="How many times it is made each turn" min={1} max={10} value={a.count} onChange={(e) => setAttack(i, { count: e.target.value })} style={{ width: 60 }} />
|
||||
<button type="button" className="btn btn-ghost" aria-label={`Remove attack ${a.name || i + 1}`} onClick={() => setAttacks((rows) => rows.filter((_, at) => at !== i))}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{attacks.length < 8 && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setAttacks((rows) => [...rows, { name: '', attackBonus: String(monster?.attackBonus ?? 0), damageDice: '', damageType: '', count: '1' }])}>
|
||||
+ Add an attack
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{field(
|
||||
'specialTrait',
|
||||
'Special trait',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { STAGGER_MS, TUMBLE_MS, prefersStill } from '../../lib/diceAnimation';
|
||||
import type { RolledDie } from '../../api/types';
|
||||
|
||||
// The outline of each die, seen face on, in a 100 × 100 box: the shape that
|
||||
// says which die it is, and the lines that give it facets.
|
||||
const SHAPES: Record<number, { outline: string; facets?: string[] }> = {
|
||||
4: { outline: '50,8 94,86 6,86', facets: ['50,8 50,86'] },
|
||||
6: { outline: '14,14 86,14 86,86 14,86' },
|
||||
8: { outline: '50,4 94,50 50,96 6,50', facets: ['6,50 94,50'] },
|
||||
10: { outline: '50,4 92,40 50,96 8,40', facets: ['8,40 50,58 92,40', '50,58 50,96'] },
|
||||
12: { outline: '50,4 93,35 77,90 23,90 7,35', facets: ['50,4 50,28', '93,35 72,42', '77,90 64,70', '23,90 36,70', '7,35 28,42', '50,28 72,42 64,70 36,70 28,42 50,28'] },
|
||||
20: { outline: '50,3 91,26 91,74 50,97 9,74 9,26', facets: ['50,22 80,70 20,70 50,22', '50,3 50,22', '91,74 80,70', '9,74 20,70', '9,26 50,22 91,26', '20,70 50,97 80,70'] },
|
||||
};
|
||||
|
||||
// Where the number sits: lower in a triangle, whose middle is below its centre.
|
||||
const TEXT_Y: Record<number, number> = { 4: 66, 10: 46, 20: 58 };
|
||||
|
||||
const randomFace = (sides: number) => Math.floor(Math.random() * sides) + 1;
|
||||
|
||||
// One die, drawn as its own shape with the number it shows. With `rolling` it
|
||||
// tumbles in, flickering through faces, and lands on its value; `order` is
|
||||
// its place among the dice rolled with it. A die that was dropped (the lower
|
||||
// d20 with advantage) is faded; a natural 20 or 1 on a d20 is marked.
|
||||
export function Die({ die, rolling = false, order = 0, size = 56 }: { die: RolledDie; rolling?: boolean; order?: number; size?: number }) {
|
||||
const animate = rolling && !prefersStill();
|
||||
const [landed, setLanded] = useState(!animate);
|
||||
const [face, setFace] = useState(animate ? randomFace(die.sides) : die.value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animate) return;
|
||||
const flicker = setInterval(() => setFace(randomFace(die.sides)), 70);
|
||||
const land = setTimeout(
|
||||
() => {
|
||||
clearInterval(flicker);
|
||||
setFace(die.value);
|
||||
setLanded(true);
|
||||
},
|
||||
TUMBLE_MS + order * STAGGER_MS
|
||||
);
|
||||
return () => {
|
||||
clearInterval(flicker);
|
||||
clearTimeout(land);
|
||||
};
|
||||
}, [animate, die.sides, die.value, order]);
|
||||
|
||||
const shape = SHAPES[die.sides];
|
||||
const shown = landed ? die.value : face;
|
||||
const crit = landed && die.sides === 20 && die.kept && die.value === 20;
|
||||
const fumble = landed && die.sides === 20 && die.kept && die.value === 1;
|
||||
const classes = ['die', `die-d${shape ? die.sides : 'x'}`, landed ? 'is-landed' : 'is-rolling', die.kept ? '' : 'is-dropped', crit ? 'is-crit' : '', fumble ? 'is-fumble' : '', die.sign < 0 ? 'is-minus' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classes}
|
||||
style={{ width: size, height: size, animationDelay: animate ? `${order * STAGGER_MS}ms` : undefined, animationDuration: animate ? `${TUMBLE_MS}ms` : undefined }}
|
||||
title={`d${die.sides}: ${die.value}${die.kept ? '' : ' (dropped)'}${die.maxed ? ' (at its most, for the critical hit)' : ''}`}
|
||||
aria-label={`d${die.sides} showing ${landed ? die.value : 'rolling'}`}
|
||||
>
|
||||
<svg viewBox="0 0 100 100" width={size} height={size} aria-hidden="true">
|
||||
{shape ? (
|
||||
<>
|
||||
<polygon className="die-body" points={shape.outline} strokeLinejoin="round" />
|
||||
{shape.facets?.map((points) => (
|
||||
<polyline key={points} className="die-facet" points={points} fill="none" />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
// Any other die (a d100, a d3...): a plain round one.
|
||||
<circle className="die-body" cx="50" cy="50" r="44" />
|
||||
)}
|
||||
<text className="die-face" x="50" y={TEXT_Y[die.sides] ?? 52} textAnchor="middle" dominantBaseline="middle" fontSize={shown > 99 ? 26 : shown > 9 ? 34 : 40}>
|
||||
{shown}
|
||||
</text>
|
||||
</svg>
|
||||
<span className="die-kind">d{die.sides}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// A roll's dice in a row, landing one after another.
|
||||
export function DiceRow({ dice, rolling = false, size = 56 }: { dice: RolledDie[]; rolling?: boolean; size?: number }) {
|
||||
return (
|
||||
<span className="dice-row">
|
||||
{dice.map((die, i) => (
|
||||
<Die key={i} die={die} rolling={rolling} order={i} size={size} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import { useState } from 'react';
|
||||
import { useApplyAction, useDeclareAction, usePatchAction } from '../../api/queries';
|
||||
import { ABILITY_LABELS, fmtMod, slotLevelLabel, spellLevelLabel } from '../../lib/format';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { DiceRow } from '../dice/Die';
|
||||
import type { Ability, ActionOutcome, ActionSource, ActionTarget, CampaignDiceMode, EncounterAction, EncounterParticipant, MonsterAttack } from '../../api/types';
|
||||
|
||||
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
|
||||
const OUTCOME_LABELS: Record<ActionOutcome, string> = { HIT: 'HIT', MISS: 'MISS', CRIT: 'CRITICAL HIT', FUMBLE: 'NATURAL 1: MISS', SAVED: 'SAVED', FAILED: 'FAILED' };
|
||||
const OUTCOME_CLASS: Record<ActionOutcome, string> = { HIT: 'is-hit', CRIT: 'is-crit', MISS: 'is-miss', FUMBLE: 'is-miss', SAVED: 'is-saved', FAILED: 'is-hit' };
|
||||
|
||||
// What an action rolls with, in a few words: "+5 to hit", "DC 14 Dexterity save, half on a success".
|
||||
function rollLine(action: EncounterAction): string {
|
||||
const { detail } = action;
|
||||
if (action.kind === 'ATTACK') return `${fmtMod(detail.attackBonus ?? 0)} to hit`;
|
||||
if (action.kind === 'SAVE') {
|
||||
return `DC ${detail.saveDC} ${detail.saveAbility ? ABILITY_LABELS[detail.saveAbility] : ''} save, ${detail.halfOnSave ? 'half damage' : 'nothing'} on a success`;
|
||||
}
|
||||
return 'no roll: it just lands';
|
||||
}
|
||||
|
||||
function damageLine(action: EncounterAction): string | null {
|
||||
const { damageDice, damageBonus, damageType } = action.detail;
|
||||
if (!damageDice) return null;
|
||||
return `${damageDice}${damageBonus ? ` ${fmtMod(damageBonus)}` : ''}${damageType ? ` ${damageType}` : ''}`;
|
||||
}
|
||||
|
||||
// One attack or spell being resolved: the d20 for each target (typed in or
|
||||
// rolled), whether it hits or they save, the damage, and, for the DM, the
|
||||
// means to overrule any of it and let it land. A monster's auto-rolled attack
|
||||
// arrives here with everything already rolled, to accept, change or discard.
|
||||
export function ActionCard({
|
||||
action,
|
||||
participants,
|
||||
isDM,
|
||||
userId,
|
||||
diceMode,
|
||||
encounterId,
|
||||
onNotice,
|
||||
onError,
|
||||
}: {
|
||||
action: EncounterAction;
|
||||
participants: EncounterParticipant[];
|
||||
isDM: boolean;
|
||||
userId: string | undefined;
|
||||
diceMode: CampaignDiceMode;
|
||||
encounterId: string;
|
||||
// Lines for the DM once it has landed: a concentration save to call for, a slot that was not there.
|
||||
onNotice: (...lines: string[]) => void;
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const patch = usePatchAction(encounterId);
|
||||
const apply = useApplyAction(encounterId);
|
||||
const [damageTyped, setDamageTyped] = useState('');
|
||||
const fail = { onError };
|
||||
const send = (data: Parameters<typeof patch.mutate>[0]) => patch.mutate(data, fail);
|
||||
|
||||
const ownerOf = (participantId: string | null) => participants.find((p) => p.id === participantId)?.character?.userId;
|
||||
const mine = Boolean(userId) && ownerOf(action.actorParticipantId) === userId;
|
||||
const actsForActor = isDM || mine;
|
||||
// Who may put in the d20 for a target: the attacker for an attack; the target's own player for a save.
|
||||
const mayRollFor = (t: ActionTarget) => isDM || (action.kind === 'ATTACK' ? mine : Boolean(userId) && ownerOf(t.participantId) === userId);
|
||||
// A player follows the campaign's dice; the DM types or rolls as they like.
|
||||
const mayType = isDM || diceMode !== 'DIGITAL';
|
||||
const mayAsk = isDM || diceMode !== 'PHYSICAL';
|
||||
const waitingOnMe = action.targets.filter((t) => t.d20 == null && mayRollFor(t));
|
||||
const busy = patch.isPending || apply.isPending;
|
||||
const outcomes: ('HIT' | 'MISS' | 'CRIT' | 'SAVED' | 'FAILED')[] = action.kind === 'SAVE' ? ['SAVED', 'FAILED'] : ['HIT', 'MISS', 'CRIT'];
|
||||
const damage = damageLine(action);
|
||||
const total = action.targets.reduce((sum, t) => sum + (t.finalDamage ?? 0), 0);
|
||||
|
||||
function land() {
|
||||
apply.mutate(action.id, {
|
||||
onError,
|
||||
onSuccess: (data) =>
|
||||
onNotice(
|
||||
...data.concentration.map((c) => `${c.name} is concentrating and took ${c.damage} damage: Constitution save, DC ${c.dc}. On a failure the spell ends: remove Concentrating.`),
|
||||
...(data.slotNote ? [data.slotNote] : [])
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`card action-card${action.auto ? ' is-auto' : ''}${action.ready ? ' is-ready' : ''}`}>
|
||||
<div className="flex-between flex-wrap">
|
||||
<div className="action-title">
|
||||
{action.auto ? '🤖' : action.kind === 'SAVE' ? '✨' : '⚔'} {action.actorName}: <strong>{action.name}</strong>
|
||||
</div>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
{action.auto && <span className="badge badge-dm" title="Aimed and rolled by the app as this turn began">auto-rolled</span>}
|
||||
{action.hidden && <span className="badge" title="The players do not see this until you accept it">🙈 hidden from players</span>}
|
||||
{action.detail.slotLevel != null && <span className="badge">{slotLevelLabel(action.detail.slotLevel)}-level slot</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="combatant-sub">
|
||||
{rollLine(action)}
|
||||
{damage && ` · ${damage}`}
|
||||
</div>
|
||||
|
||||
{action.targets.map((t) => {
|
||||
const canEnter = mayRollFor(t) && mayType && action.kind !== 'AUTO';
|
||||
const bonus = action.kind === 'SAVE' ? (t.saveBonus ?? 0) : (action.detail.attackBonus ?? 0);
|
||||
const against = action.kind === 'ATTACK' ? (t.ac != null ? `AC ${t.ac + t.penalty}${t.penalty > 0 ? ` (${t.ac} +${t.penalty} area)` : ''}` : 'their AC') : action.kind === 'SAVE' ? `DC ${action.detail.saveDC}` : '';
|
||||
return (
|
||||
<div key={t.participantId} className="action-target">
|
||||
<span className="action-target-name">
|
||||
{action.kind === 'SAVE' ? '🛡' : '🎯'} {t.name}
|
||||
</span>
|
||||
{action.kind !== 'AUTO' && (
|
||||
<span className="action-roll">
|
||||
{canEnter ? (
|
||||
<NumberInput
|
||||
// Remounted when the roll changes elsewhere (the server rolled it, or someone else typed it).
|
||||
key={`${t.participantId}:${t.d20}`}
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="d20"
|
||||
aria-label={action.kind === 'SAVE' ? `${t.name}'s saving throw (d20)` : `Attack roll against ${t.name} (d20)`}
|
||||
defaultValue={t.d20 ?? ''}
|
||||
autoFocus={t.d20 == null && waitingOnMe[0]?.participantId === t.participantId}
|
||||
style={{ width: 64 }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
||||
onBlur={(e) => {
|
||||
const d20 = e.target.value === '' ? null : Number(e.target.value);
|
||||
if (d20 !== t.d20) send({ actionId: action.id, rolls: [{ participantId: t.participantId, d20 }] });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="mono">{t.d20 ?? '—'}</span>
|
||||
)}
|
||||
<span className="mono combatant-sub">
|
||||
{fmtMod(bonus)}
|
||||
{t.total != null && ` = ${t.total}`}
|
||||
{against && ` vs ${against}`}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{t.outcome ? (
|
||||
<span className={`action-outcome ${OUTCOME_CLASS[t.outcome]}`}>
|
||||
{OUTCOME_LABELS[t.outcome]}
|
||||
{t.overruled && <span title={`The dice said ${t.rolled ? OUTCOME_LABELS[t.rolled] : 'nothing yet'}; you ruled otherwise`}> ✎</span>}
|
||||
</span>
|
||||
) : (
|
||||
<span className="action-outcome is-waiting">{action.kind === 'SAVE' ? 'waiting for their save' : 'waiting for the d20'}</span>
|
||||
)}
|
||||
{t.finalDamage != null && t.finalDamage > 0 && <span className="action-damage">−{t.finalDamage} HP</span>}
|
||||
{isDM && (
|
||||
<span className="action-overrule">
|
||||
{outcomes.map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
type="button"
|
||||
className={`btn btn-sm${t.outcome === o || (o === 'MISS' && t.outcome === 'FUMBLE') ? ' is-on' : ''}`}
|
||||
title={t.overruled && t.outcome === o ? 'Go back to what the dice say' : `Rule it ${OUTCOME_LABELS[o].toLowerCase()}, whatever the dice say`}
|
||||
disabled={busy}
|
||||
onClick={() => send({ actionId: action.id, outcomes: [{ participantId: t.participantId, outcome: t.overruled && t.outcome === o ? null : o }] })}
|
||||
>
|
||||
{o === 'CRIT' ? 'Crit' : o.charAt(0) + o.slice(1).toLowerCase()}
|
||||
</button>
|
||||
))}
|
||||
<NumberInput
|
||||
key={`dmg:${t.participantId}:${t.damage}`}
|
||||
min={0}
|
||||
max={99999}
|
||||
placeholder="dmg"
|
||||
title="Set what this target takes by hand. Empty: what the action deals."
|
||||
aria-label={`Damage ${t.name} takes, by hand`}
|
||||
defaultValue={t.damage ?? ''}
|
||||
style={{ width: 64 }}
|
||||
onBlur={(e) => {
|
||||
const amount = e.target.value === '' ? null : Number(e.target.value);
|
||||
if (amount !== t.damage) send({ actionId: action.id, targetDamage: [{ participantId: t.participantId, damage: amount }] });
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The DM aims a creature's own attack elsewhere: the d20 already rolled stays. */}
|
||||
{isDM && action.targets.length === 1 && action.actorParticipantId && (
|
||||
<label className="flex-row flex-wrap gap-sm mt-sm combatant-sub">
|
||||
Aim it at
|
||||
<select value={action.targets[0].participantId} disabled={busy} onChange={(e) => send({ actionId: action.id, targetIds: [e.target.value] })} style={{ width: 'auto' }}>
|
||||
{participants
|
||||
.filter((p) => !p.hidden && p.id !== action.actorParticipantId)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span>(the d20 already rolled stays)</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{action.damage && (
|
||||
<div className="action-damage-roll">
|
||||
<span className="dice-label">Damage</span>
|
||||
{action.damage.dice && <DiceRow dice={action.damage.dice} size={30} />}
|
||||
<strong className="mono">{action.damage.total}</strong>
|
||||
{action.damage.entered && <span className="combatant-sub">entered by hand</span>}
|
||||
{action.damage.crit && (
|
||||
<>
|
||||
<span className="dice-label">Critical</span>
|
||||
<DiceRow dice={action.damage.crit.dice} size={30} />
|
||||
<strong className="mono">{action.damage.crit.total}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{action.targets.some((t) => t.outcome === 'CRIT') && action.damage?.entered && (
|
||||
<p className="combatant-sub" style={{ margin: '0.3rem 0 0' }}>
|
||||
A critical hit with damage entered by hand: roll the damage dice twice and put the total in that target’s “dmg” box.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{waitingOnMe.length > 0 && mayAsk && action.kind !== 'AUTO' && (
|
||||
<button className="btn" disabled={busy} onClick={() => send({ actionId: action.id, rollRemaining: true })}>
|
||||
🎲 Roll {waitingOnMe.length === 1 ? 'the d20' : `${waitingOnMe.length} d20s`}
|
||||
</button>
|
||||
)}
|
||||
{actsForActor && damage && (action.needsDamage || action.damage) && (
|
||||
<>
|
||||
{mayAsk && (
|
||||
<button className={`btn${action.needsDamage ? ' btn-primary' : ''}`} disabled={busy} onClick={() => send({ actionId: action.id, rollDamage: true })}>
|
||||
🎲 {action.damage ? 'Reroll' : 'Roll'} damage
|
||||
</button>
|
||||
)}
|
||||
{mayType && (
|
||||
<span className="flex-row gap-sm">
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={99999}
|
||||
placeholder="damage"
|
||||
aria-label="Damage rolled at the table"
|
||||
value={damageTyped}
|
||||
onChange={(e) => setDamageTyped(e.target.value)}
|
||||
style={{ width: 90 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && damageTyped !== '') {
|
||||
send({ actionId: action.id, damage: Number(damageTyped) });
|
||||
setDamageTyped('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={busy || damageTyped === ''}
|
||||
onClick={() => {
|
||||
send({ actionId: action.id, damage: Number(damageTyped) });
|
||||
setDamageTyped('');
|
||||
}}
|
||||
>
|
||||
Enter damage
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
{isDM ? (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !action.ready}
|
||||
title={
|
||||
action.ready
|
||||
? 'Let it land: the damage is applied and everyone sees what happened. Shortcut: A'
|
||||
: action.needsRolls > 0
|
||||
? `Waiting on ${action.needsRolls} d20${action.needsRolls === 1 ? '' : 's'}`
|
||||
: 'Waiting on the damage'
|
||||
}
|
||||
onClick={land}
|
||||
>
|
||||
✔ {action.auto ? 'Accept' : 'Apply'}
|
||||
{action.ready && total > 0 ? ` (${total} damage)` : ''}
|
||||
</button>
|
||||
<button className="btn btn-ghost" disabled={busy} onClick={() => send({ actionId: action.id, discard: true })}>
|
||||
✕ Discard
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
mine && (
|
||||
<>
|
||||
<span className="combatant-sub">{action.ready ? 'Ready: your DM lets it land.' : ''}</span>
|
||||
<button className="btn btn-ghost" disabled={busy} onClick={() => send({ actionId: action.id, discard: true })}>
|
||||
✕ Call it off
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// What a monster or NPC attacks with, as the server works it out
|
||||
// (game/combat.js, attacksOf): its named attacks, each as often as it makes
|
||||
// it, or its single attack. Empty for a player (who is not sent the stats).
|
||||
function creatureAttacks(p: EncounterParticipant): (MonsterAttack & { index: number })[] {
|
||||
const named = (p.monster?.attacks ?? []).filter((a) => a.name);
|
||||
if (named.length > 0) {
|
||||
return named.flatMap((a) => Array.from({ length: Math.max(1, a.count ?? 1) }, () => a)).map((a, index) => ({ ...a, index }));
|
||||
}
|
||||
const stats = p.monster ?? p.npc;
|
||||
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
|
||||
return [{ name: 'Attack', attackBonus: stats.attackBonus ?? 0, damageDice: stats.damageDice, index: 0 }];
|
||||
}
|
||||
|
||||
// What whoever is acting can attack with, one button each: a character's
|
||||
// equipped weapons and their spells (each saying how it lands), or a
|
||||
// creature's own attacks. Pressing one declares it against the targets
|
||||
// marked. The DM can also spell out something else (a trap, a breath weapon).
|
||||
export function ActionComposer({
|
||||
actor,
|
||||
targetCount,
|
||||
isDM,
|
||||
encounterId,
|
||||
onError,
|
||||
}: {
|
||||
actor: EncounterParticipant | undefined;
|
||||
targetCount: number;
|
||||
isDM: boolean;
|
||||
encounterId: string;
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const declare = useDeclareAction(encounterId);
|
||||
const [custom, setCustom] = useState(false);
|
||||
const [kind, setKind] = useState<'ATTACK' | 'SAVE' | 'AUTO'>('ATTACK');
|
||||
const info = actor?.characterCombatInfo;
|
||||
const attacks = actor && !info ? creatureAttacks(actor) : [];
|
||||
const noTarget = targetCount === 0;
|
||||
const go = (source: ActionSource) => declare.mutate({ source, actorParticipantId: actor?.id }, { onError });
|
||||
|
||||
return (
|
||||
<div className="card action-composer">
|
||||
<div className="flex-between flex-wrap">
|
||||
<div className="action-title">{actor ? `${actor.displayName} attacks with…` : 'An attack from nobody in particular'}</div>
|
||||
{noTarget && <span className="badge badge-hostile">Pick a target first: click a name on the track, or 🎯 on a card</span>}
|
||||
</div>
|
||||
|
||||
<div className="action-options">
|
||||
{info?.weapons.map((w) => (
|
||||
<button
|
||||
key={w.characterWeaponId}
|
||||
className="btn action-option is-equipped"
|
||||
disabled={noTarget || declare.isPending}
|
||||
title={`${w.name}: equipped. Roll a d20 ${fmtMod(w.attackBonus)} against the target's AC.`}
|
||||
onClick={() => go({ type: 'weapon', characterWeaponId: w.characterWeaponId })}
|
||||
>
|
||||
<span className="action-option-name">⚔ {w.name}</span>
|
||||
<span className="combatant-sub mono">
|
||||
{fmtMod(w.attackBonus)} to hit · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{info && info.weapons.length === 0 && <span className="combatant-sub">No weapon equipped: equip one on the sheet to attack with it.</span>}
|
||||
{info?.spells.map((s) => {
|
||||
const cantrip = s.level === 0;
|
||||
// The slot it would take: its own level, or the lowest one above it that is left.
|
||||
const slot = cantrip ? undefined : info.spellSlots?.levels.find((l) => l.level >= s.level && l.remaining > 0);
|
||||
const spent = !cantrip && Boolean(info.spellSlots) && !slot;
|
||||
const how = s.attackRoll
|
||||
? `${fmtMod(info.spellcasting?.attackBonus ?? 0)} to hit`
|
||||
: s.saveAbility
|
||||
? `DC ${info.spellcasting?.saveDC ?? '?'} ${s.saveAbility} save`
|
||||
: 'no roll';
|
||||
return (
|
||||
<button
|
||||
key={s.characterSpellId}
|
||||
className="btn action-option"
|
||||
disabled={noTarget || spent || declare.isPending}
|
||||
title={[
|
||||
s.description,
|
||||
cantrip ? 'A cantrip: no spell slot needed.' : null,
|
||||
spent ? `No spell slot of ${slotLevelLabel(s.level)} level or higher left: it cannot be cast until slots come back.` : null,
|
||||
slot ? `Takes a ${slotLevelLabel(slot.level)}-level slot when it lands (${slot.remaining} left).` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' — ')}
|
||||
onClick={() => go({ type: 'spell', characterSpellId: s.characterSpellId })}
|
||||
>
|
||||
<span className="action-option-name">
|
||||
✨ {s.name} <span className="badge">{spellLevelLabel(s.level)}</span> {spent && <span className="badge badge-hostile">no slot left</span>}
|
||||
</span>
|
||||
<span className="combatant-sub mono">
|
||||
{cantrip ? 'no slot' : slot && slot.level > s.level ? `${slotLevelLabel(slot.level)}-level slot` : 'uses a slot'} · {how}
|
||||
{s.damageDice ? ` · ${s.damageDice}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{attacks.map((a) => (
|
||||
<button
|
||||
key={a.index}
|
||||
className="btn action-option is-equipped"
|
||||
disabled={noTarget || declare.isPending}
|
||||
onClick={() => go({ type: 'basic', index: a.index })}
|
||||
>
|
||||
<span className="action-option-name">⚔ {a.name}</span>
|
||||
<span className="combatant-sub mono">
|
||||
{fmtMod(a.attackBonus)} to hit{a.damageDice ? ` · ${a.damageDice}` : ''}
|
||||
{a.damageType ? ` ${a.damageType}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{isDM && (
|
||||
<button className={`btn action-option${custom ? ' is-on' : ''}`} onClick={() => setCustom((c) => !c)} title="Spell out an attack, a save or an effect of your own: a trap, a breath weapon, a monster's spell">
|
||||
<span className="action-option-name">✎ Something else…</span>
|
||||
<span className="combatant-sub">a trap, a breath weapon, a monster’s spell</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDM && custom && (
|
||||
<form
|
||||
className="flex-row flex-wrap gap-sm mt-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
go({
|
||||
type: 'custom',
|
||||
name: String(fd.get('name') || '').trim() || 'Attack',
|
||||
kind,
|
||||
attackBonus: Number(fd.get('attackBonus')) || 0,
|
||||
saveAbility: (fd.get('saveAbility') as Ability) || 'DEX',
|
||||
saveDC: Number(fd.get('saveDC')) || 10,
|
||||
halfOnSave: fd.get('halfOnSave') === 'on',
|
||||
damageDice: String(fd.get('damageDice') || '').trim() || null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input name="name" placeholder="What it is (Fire breath)" maxLength={60} aria-label="What it is" style={{ flex: '2 1 140px' }} />
|
||||
<select value={kind} onChange={(e) => setKind(e.target.value as typeof kind)} aria-label="How it lands" style={{ width: 'auto' }}>
|
||||
<option value="ATTACK">Attack roll vs AC</option>
|
||||
<option value="SAVE">Targets make a save</option>
|
||||
<option value="AUTO">It just lands</option>
|
||||
</select>
|
||||
{kind === 'ATTACK' && <NumberInput name="attackBonus" min={-20} max={50} placeholder="+ to hit" aria-label="Attack bonus" style={{ width: 90 }} />}
|
||||
{kind === 'SAVE' && (
|
||||
<>
|
||||
<NumberInput name="saveDC" min={1} max={40} placeholder="DC" aria-label="Save DC" style={{ width: 70 }} />
|
||||
<select name="saveAbility" defaultValue="DEX" aria-label="Save ability" style={{ width: 'auto' }}>
|
||||
{ABILITIES.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" name="halfOnSave" defaultChecked /> half on a success
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<input name="damageDice" placeholder="Damage (6d6)" maxLength={60} aria-label="Damage dice" style={{ flex: '1 1 100px' }} />
|
||||
<button type="submit" className="btn btn-primary" disabled={noTarget || declare.isPending}>
|
||||
Declare
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The last few attacks and spells that landed, newest first, one line each.
|
||||
export function RecentActions({ actions }: { actions: EncounterAction[] }) {
|
||||
if (actions.length === 0) return <p className="subtitle">Nothing has landed yet.</p>;
|
||||
return (
|
||||
<div className="roll-feed">
|
||||
{actions.map((action) => (
|
||||
<div key={action.id} className="roll-line">
|
||||
<div className="roll-line-head">
|
||||
<span>
|
||||
<strong>{action.actorName}</strong> · {action.name}
|
||||
</span>
|
||||
<span className="combatant-sub">Round {action.round}</span>
|
||||
</div>
|
||||
{action.targets.map((t) => (
|
||||
<div key={t.participantId} className="combatant-sub">
|
||||
→ {t.name}: {t.outcome ? OUTCOME_LABELS[t.outcome].toLowerCase() : '—'}
|
||||
{t.d20 != null && ` (d20 ${t.d20})`}
|
||||
{t.finalDamage ? `, ${t.finalDamage} damage` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBuyWare, useExchangeWithNpc, useSetPlaces, useVisitPlace } from '../../api/queries';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { WaresEditor } from '../Wares';
|
||||
import { stockLabel, wareName } from '../../lib/wares';
|
||||
import { formatMoney, formatPurse } from '../../lib/format';
|
||||
import type { CharacterSummary, Encounter, FreeRoamPlace, Item, Location, SaleOffer, Weapon } from '../../api/types';
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// A free-roam scene: the places the DM has laid out, each with who is found
|
||||
// there and what is for sale, and who of the party is there now. A player
|
||||
// sends their own character to a place and shops where they stand; the DM
|
||||
// can send anyone, picks which of the campaign's locations are part of the
|
||||
// scene, and stocks each place's own shop here. (What an NPC sells, and where
|
||||
// they are found, is set on the NPC, on the campaign page.)
|
||||
export function FreeRoamPanel({
|
||||
encounter,
|
||||
isDM,
|
||||
userId,
|
||||
characters,
|
||||
locations,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
isDM: boolean;
|
||||
userId: string | undefined;
|
||||
characters: CharacterSummary[];
|
||||
locations: Location[]; // the campaign's, for the DM to pick places from
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const setPlaces = useSetPlaces(encounter.id);
|
||||
const visit = useVisitPlace(encounter.id);
|
||||
const buy = useBuyWare(encounter.id);
|
||||
const exchange = useExchangeWithNpc(encounter.id);
|
||||
const [roamerId, setRoamerId] = useState('');
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({});
|
||||
const [sides, setSides] = useState<Record<string, ExchangeSide>>({});
|
||||
|
||||
const places = encounter.places ?? [];
|
||||
const open = encounter.status === 'ACTIVE';
|
||||
// Who can be moved and can shop: a player's own characters in the scene; for the DM, anyone's.
|
||||
const roamers = (encounter.participants ?? []).flatMap((p) => (p.character && !p.hidden && (isDM || p.character.userId === userId) ? [p.character] : []));
|
||||
const roamer = roamers.find((c) => c.id === roamerId) ?? roamers[0];
|
||||
const purse = characters.find((c) => c.id === roamer?.id);
|
||||
const whereabouts = places.find((place) => place.here.some((h) => h.characterId === roamer?.id));
|
||||
const busy = visit.isPending || buy.isPending || exchange.isPending;
|
||||
const placeIds = places.map((p) => p.id);
|
||||
|
||||
function go(place: FreeRoamPlace) {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
visit.mutate({ characterId: roamer.id, locationId: place.id }, { onError, onSuccess: () => setDone(`${roamer.name} goes to ${place.name}.`) });
|
||||
}
|
||||
|
||||
function handleBuy(ware: SaleOffer) {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
buy.mutate(
|
||||
{ wareId: ware.id, characterId: roamer.id },
|
||||
{ onError, onSuccess: () => setDone(`${roamer.name} bought ${(ware.weapon ?? ware.item)?.name} for ${formatMoney(ware.price, ware.currency, true)}.`) }
|
||||
);
|
||||
}
|
||||
|
||||
const wareRow = (ware: SaleOffer, atPlace: boolean) => (
|
||||
<div key={ware.id} className="reward-row">
|
||||
<div className="flex-between">
|
||||
<span>
|
||||
<strong>{wareName(ware)}</strong>
|
||||
<span className="combatant-sub"> · {stockLabel(ware)}</span>
|
||||
</span>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!open || !roamer || !atPlace || ware.stock === 0 || busy}
|
||||
title={!open ? 'The scene has not begun' : !atPlace ? `${roamer?.name ?? 'Your character'} has to go here first` : undefined}
|
||||
onClick={() => handleBuy(ware)}
|
||||
>
|
||||
Buy · {formatMoney(ware.price, ware.currency)}
|
||||
</button>
|
||||
</div>
|
||||
{ware.item?.description && <div className="combatant-sub">{ware.item.description}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<div className="card-title">
|
||||
<span>🧭 Places to go</span>
|
||||
{!open && <span className="badge">{encounter.status === 'PLANNING' ? 'Not begun yet' : 'Over'}</span>}
|
||||
</div>
|
||||
{roamers.length > 0 ? (
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<label className="flex-row gap-sm" style={{ flex: 1, minWidth: 180 }}>
|
||||
{roamers.length > 1 ? 'Moving' : 'You are'}
|
||||
<select aria-label="Which character is moving and shopping" value={roamer?.id ?? ''} onChange={(e) => setRoamerId(e.target.value)} style={{ flex: 1 }}>
|
||||
{roamers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="combatant-sub">📍 {whereabouts ? whereabouts.name : 'not at any of these places yet'}</span>
|
||||
{purse && purse.gold !== null && <span className="mono">{formatPurse(purse)}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="combatant-sub" style={{ margin: 0 }}>
|
||||
{isDM ? 'No characters are in this scene yet: add them from "Add to the scene".' : 'Your character has to be in the scene to go anywhere.'}
|
||||
</p>
|
||||
)}
|
||||
{done && <p className="combatant-sub" style={{ marginBottom: 0 }}>✓ {done}</p>}
|
||||
{places.length === 0 && (
|
||||
<p className="combatant-sub" style={{ marginBottom: 0 }}>
|
||||
{isDM ? 'No places yet. Tick the locations the party can go to, below.' : 'The DM has not opened any places yet.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isDM && (
|
||||
<details className="mt-sm" open={places.length === 0}>
|
||||
<summary className="section-label" style={{ cursor: 'pointer' }}>
|
||||
Places in this scene ({places.length} of {locations.length} locations)
|
||||
</summary>
|
||||
{locations.length === 0 ? (
|
||||
<p className="combatant-sub">
|
||||
This campaign has no locations yet. Add some under Locations on the <Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{locations.map((loc) => {
|
||||
const included = placeIds.includes(loc.id);
|
||||
return (
|
||||
<label key={loc.id} className="checkbox-row" title={loc.description ?? undefined}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={included}
|
||||
disabled={setPlaces.isPending}
|
||||
onChange={() => setPlaces.mutate(included ? placeIds.filter((id) => id !== loc.id) : [...placeIds, loc.id], { onError })}
|
||||
/>
|
||||
📍 {loc.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="place-grid">
|
||||
{places.map((place) => {
|
||||
const atPlace = Boolean(roamer) && place.here.some((h) => h.characterId === roamer?.id);
|
||||
return (
|
||||
<div key={place.id} className={`card place-card${atPlace ? ' is-here' : ''}`}>
|
||||
<div className="card-title">
|
||||
<span>📍 {place.name}</span>
|
||||
{atPlace ? (
|
||||
<span className="badge badge-live">{roamer?.name} is here</span>
|
||||
) : (
|
||||
<button className="btn btn-primary" disabled={!open || !roamer || busy} onClick={() => go(place)}>
|
||||
Go here
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{place.description && <p style={{ margin: '0 0 0.4rem', whiteSpace: 'pre-wrap' }}>{place.description}</p>}
|
||||
<div className="combatant-sub">{place.here.length > 0 ? `Here now: ${place.here.map((h) => h.name).join(', ')}` : 'Nobody from the party is here.'}</div>
|
||||
|
||||
{place.npcs.map((npc) => {
|
||||
const rate = { GOLD: npc.exchangeGold ?? 0, CASH: npc.exchangeCash ?? 0 };
|
||||
const changes = rate.GOLD > 0 && rate.CASH > 0;
|
||||
const from = sides[npc.id] ?? 'GOLD';
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
const amount = Math.floor(Number(amounts[npc.id]));
|
||||
const whole = changes && amount > 0 && amount % rate[from] === 0;
|
||||
return (
|
||||
<div key={npc.id} className="mt-sm">
|
||||
<div className="section-label">
|
||||
🧑 {npc.name}
|
||||
{npc.role ? `, ${npc.role}` : ''}
|
||||
</div>
|
||||
{npc.description && <p className="combatant-sub" style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{npc.description}</p>}
|
||||
{npc.dmNotes && (
|
||||
<p className="combatant-sub" style={{ margin: 0 }} title="Only you can see this">
|
||||
🔒 {npc.dmNotes}
|
||||
</p>
|
||||
)}
|
||||
{(npc.wares ?? []).map((ware) => wareRow(ware, atPlace))}
|
||||
{changes && (
|
||||
<div className="reward-row">
|
||||
<span className="combatant-sub">
|
||||
💱 Changes {formatMoney(rate.GOLD, 'GOLD', true)} for {formatMoney(rate.CASH, 'CASH')}, either way.
|
||||
</span>
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label={`How much to change with ${npc.name}`}
|
||||
value={amounts[npc.id] ?? ''}
|
||||
onChange={(e) => setAmounts((a) => ({ ...a, [npc.id]: e.target.value }))}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<select aria-label={`What to hand ${npc.name}`} value={from} onChange={(e) => setSides((s) => ({ ...s, [npc.id]: e.target.value as ExchangeSide }))}>
|
||||
<option value="GOLD">gold into cash</option>
|
||||
<option value="CASH">cash into gold</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!open || !roamer || !atPlace || !whole || busy}
|
||||
onClick={() => {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
exchange.mutate(
|
||||
{ characterId: roamer.id, npcId: npc.id, from, amount },
|
||||
{
|
||||
onError,
|
||||
onSuccess: ({ received, currency }) => {
|
||||
setDone(`${roamer.name} changed ${formatMoney(amount, from, true)} for ${formatMoney(received, currency, true)} with ${npc.name}.`);
|
||||
setAmounts((a) => ({ ...a, [npc.id]: '' }));
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
{whole ? `Get ${formatMoney((amount / rate[from]) * rate[to], to, true)}` : 'Exchange'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(npc.wares ?? []).length === 0 && !changes && <div className="combatant-sub">Nothing to sell: someone to talk to.</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{place.wares.length > 0 && (
|
||||
<div className="mt-sm">
|
||||
<div className="section-label">🛒 For sale here</div>
|
||||
{place.wares.map((ware) => wareRow(ware, atPlace))}
|
||||
</div>
|
||||
)}
|
||||
{place.npcs.length === 0 && place.wares.length === 0 && !isDM && <div className="combatant-sub mt-sm">Nothing to buy here.</div>}
|
||||
|
||||
{isDM && (
|
||||
<details className="mt-sm">
|
||||
<summary className="section-label" style={{ cursor: 'pointer' }}>
|
||||
Set up this place’s own shop
|
||||
</summary>
|
||||
<p className="combatant-sub" style={{ marginTop: '0.3rem' }}>
|
||||
Sold here whenever this place is part of a free-roam scene. To have an NPC found here, set their location on the NPC (campaign page).
|
||||
</p>
|
||||
<WaresEditor campaignId={encounter.campaignId} seller={{ locationId: place.id }} wares={place.wares} weapons={weapons} items={items} onError={onError} />
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { EncounterParticipant } from '../../api/types';
|
||||
|
||||
const KIND_CLASS = { CHARACTER: 'track-pc', NPC: 'track-npc', MONSTER: 'track-monster' } as const;
|
||||
const KIND_WORD = { CHARACTER: 'player character', NPC: 'NPC', MONSTER: 'monster' } as const;
|
||||
|
||||
// Who acts when, as one line of names: whoever is on turn first, then
|
||||
// everyone still to act this round, a flag where the round ends, and the
|
||||
// whole order again for the round after. Names only, coloured by what they
|
||||
// are (player character, NPC, monster), so the DM can see what is coming.
|
||||
//
|
||||
// Clicking a name marks them as a target when the viewer may pick targets
|
||||
// (the DM, or a player on their own turn); otherwise it goes to their card.
|
||||
export function InitiativeTrack({
|
||||
participants,
|
||||
activeId,
|
||||
round,
|
||||
targetIds,
|
||||
userId,
|
||||
canTarget,
|
||||
onTarget,
|
||||
onJump,
|
||||
}: {
|
||||
participants: EncounterParticipant[]; // those still standing, in initiative order
|
||||
activeId: string | undefined;
|
||||
round: number;
|
||||
targetIds: string[];
|
||||
userId: string | undefined;
|
||||
canTarget: boolean;
|
||||
onTarget: (participantId: string) => void;
|
||||
onJump: (participantId: string) => void;
|
||||
}) {
|
||||
const activeIndex = participants.findIndex((p) => p.id === activeId);
|
||||
// This round: from whoever is on turn to the end of the order.
|
||||
const thisRound = activeIndex >= 0 ? participants.slice(activeIndex) : [];
|
||||
|
||||
const chip = (p: EncounterParticipant, key: string, upcoming: boolean) => {
|
||||
const isActive = !upcoming && p.id === activeId;
|
||||
const isTargeted = targetIds.includes(p.id);
|
||||
const isMine = p.character != null && p.character.userId === userId;
|
||||
const targetable = canTarget && p.id !== activeId;
|
||||
const dying = p.characterCombatInfo?.deathSaves.state === 'DYING';
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`track-chip ${KIND_CLASS[p.kind]}${isActive ? ' is-active' : ''}${isTargeted ? ' is-targeted' : ''}${upcoming ? ' is-upcoming' : ''}${dying ? ' is-dying' : ''}`}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
aria-pressed={targetable ? isTargeted : undefined}
|
||||
title={[
|
||||
`${p.displayName} (${KIND_WORD[p.kind]})`,
|
||||
isActive && 'on turn',
|
||||
dying && 'dying',
|
||||
isTargeted && 'targeted',
|
||||
targetable ? (isTargeted ? 'click to stop targeting' : 'click to target') : 'click to go to their card',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
onClick={() => (targetable ? onTarget(p.id) : onJump(p.id))}
|
||||
>
|
||||
{isActive && <span aria-hidden="true">▶ </span>}
|
||||
{isMine && <span aria-hidden="true">★ </span>}
|
||||
{isTargeted && <span aria-hidden="true">🎯 </span>}
|
||||
{p.displayName}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="initiative-track" aria-label="Turn order: this round, then the next">
|
||||
<span className="track-round">Round {round}</span>
|
||||
{thisRound.map((p) => chip(p, `now-${p.id}`, false))}
|
||||
<span className="track-flag" title={`The round ends here. Round ${round + 1} starts from the top of the order.`}>
|
||||
🏁 Round {round + 1}
|
||||
</span>
|
||||
{participants.map((p) => chip(p, `next-${p.id}`, true))}
|
||||
<span className="track-legend" aria-hidden="true">
|
||||
<span className="track-dot track-pc" /> PC
|
||||
<span className="track-dot track-npc" /> NPC
|
||||
<span className="track-dot track-monster" /> Monster
|
||||
</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { API_URL } from '../api/client';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { showToast } from '../lib/toasts';
|
||||
import type { CharacterDetail } from '../api/types';
|
||||
|
||||
let sharedSocket: Socket | null = null;
|
||||
|
||||
@@ -24,8 +26,9 @@ const FLUSH_MS = 50;
|
||||
// sync with server-pushed events (HP/condition changes, encounter rounds,
|
||||
// new characters) so every open tab in the campaign updates live.
|
||||
export function useCampaignSocket(campaignId: string | undefined) {
|
||||
const { token } = useAuth();
|
||||
const { token, user } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const userId = user?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaignId || !token) return;
|
||||
@@ -56,6 +59,17 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
// A character arriving somewhere new adds it to the places their player can see.
|
||||
invalidate(['campaign', campaignId, 'locations']);
|
||||
};
|
||||
// The DM approved a level for a character: its player is told, wherever
|
||||
// in the campaign they are, with a way straight to the checklist.
|
||||
const onLevelUpGranted = ({ characterId, userId: ownerId }: { characterId?: string; userId?: string | null } = {}) => {
|
||||
if (!characterId || !ownerId || ownerId !== userId) return;
|
||||
const name = qc.getQueryData<CharacterDetail>(['character', characterId])?.name ?? 'Your character';
|
||||
showToast({ text: `⬆ ${name} can level up! See what you gain and make your picks.`, tone: 'good', action: { label: 'Level up', to: `/characters/${characterId}?levelup=1` } });
|
||||
};
|
||||
// Somebody rolled: the feed asks again, and gets the roll if it is this member's to see.
|
||||
const onRollCreated = () => {
|
||||
invalidate(['campaign', campaignId, 'rolls']);
|
||||
};
|
||||
const onCharacterCreated = () => {
|
||||
invalidate(['campaign', campaignId, 'characters']);
|
||||
};
|
||||
@@ -74,6 +88,8 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
|
||||
socket.on('character:updated', onCharacterUpdated);
|
||||
socket.on('character:created', onCharacterCreated);
|
||||
socket.on('character:level-up-granted', onLevelUpGranted);
|
||||
socket.on('roll:created', onRollCreated);
|
||||
socket.on('encounter:participant-updated', onParticipantUpdated);
|
||||
socket.on('encounter:round-advanced', onRoundAdvanced);
|
||||
socket.on('campaign:deleted', onCampaignDeleted);
|
||||
@@ -86,9 +102,11 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
socket.emit('campaign:leave', campaignId);
|
||||
socket.off('character:updated', onCharacterUpdated);
|
||||
socket.off('character:created', onCharacterCreated);
|
||||
socket.off('character:level-up-granted', onLevelUpGranted);
|
||||
socket.off('roll:created', onRollCreated);
|
||||
socket.off('encounter:participant-updated', onParticipantUpdated);
|
||||
socket.off('encounter:round-advanced', onRoundAdvanced);
|
||||
socket.off('campaign:deleted', onCampaignDeleted);
|
||||
};
|
||||
}, [campaignId, token, qc]);
|
||||
}, [campaignId, token, qc, userId]);
|
||||
}
|
||||
|
||||
+607
-4
@@ -403,6 +403,65 @@ input[type='radio'] {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Toasts: notices that slide in over any page (components/Toasts.tsx). */
|
||||
.toasts {
|
||||
position: fixed;
|
||||
top: 4.2rem;
|
||||
right: 1rem;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: min(26rem, calc(100vw - 2rem));
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
|
||||
animation: toastIn 0.25s ease-out;
|
||||
}
|
||||
.toast-good {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45), 0 0 14px rgba(201, 162, 39, 0.3);
|
||||
}
|
||||
.toast-text {
|
||||
flex: 1;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(1.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A notice across the top of a card or page that something is waiting. */
|
||||
.notice-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 0.9rem;
|
||||
border: 1px solid var(--accent-strong);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(160deg, var(--bg-card-hover) 0%, var(--bg-inset) 100%);
|
||||
}
|
||||
|
||||
/* A group of campaign settings under its own heading. */
|
||||
.settings-group {
|
||||
border: 1px solid var(--border);
|
||||
@@ -553,6 +612,552 @@ input[type='radio'] {
|
||||
animation: rollPop 0.4s ease-out;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Dice (components/dice/Die.tsx): each die drawn as its own shape. It */
|
||||
/* tumbles in, flickering through faces, and lands on what it rolled. */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
.dice-row {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
.die {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
.die-body {
|
||||
fill: #2b2212;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 4;
|
||||
}
|
||||
.die-facet {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.5;
|
||||
opacity: 0.35;
|
||||
}
|
||||
.die-face {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
fill: var(--accent-strong);
|
||||
paint-order: stroke;
|
||||
stroke: #1a140a;
|
||||
stroke-width: 5;
|
||||
}
|
||||
.die-kind {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
line-height: 1;
|
||||
}
|
||||
/* Each kind of die has its own tint, as a set of real dice might. */
|
||||
.die-d4 .die-body { fill: #2a1d1d; }
|
||||
.die-d6 .die-body { fill: #2b2212; }
|
||||
.die-d8 .die-body { fill: #1c2a22; }
|
||||
.die-d10 .die-body { fill: #1b2431; }
|
||||
.die-d12 .die-body { fill: #2a1f31; }
|
||||
.die-d20 .die-body { fill: #33260f; }
|
||||
|
||||
.die.is-rolling {
|
||||
animation-name: dieTumble;
|
||||
animation-timing-function: cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
.die.is-rolling .die-face {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.die.is-landed {
|
||||
animation: dieLand 0.28s ease-out;
|
||||
}
|
||||
.die.is-dropped {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.die.is-dropped .die-face {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.die.is-minus .die-body {
|
||||
stroke: var(--danger-strong);
|
||||
}
|
||||
.die.is-crit .die-body {
|
||||
stroke: var(--success-strong);
|
||||
fill: #20331a;
|
||||
}
|
||||
.die.is-crit {
|
||||
filter: drop-shadow(0 0 10px rgba(122, 178, 102, 0.9));
|
||||
animation: dieLand 0.28s ease-out, dieGlow 1.2s ease-in-out 0.28s 2;
|
||||
}
|
||||
.die.is-crit .die-face {
|
||||
fill: #c9f0b8;
|
||||
}
|
||||
.die.is-fumble .die-body {
|
||||
stroke: var(--danger-strong);
|
||||
fill: #331616;
|
||||
}
|
||||
.die.is-fumble {
|
||||
filter: drop-shadow(0 0 8px rgba(216, 84, 84, 0.8));
|
||||
animation: dieShake 0.45s ease-in-out;
|
||||
}
|
||||
.die.is-fumble .die-face {
|
||||
fill: #f1b0b0;
|
||||
}
|
||||
@keyframes dieTumble {
|
||||
0% {
|
||||
transform: translate(-70px, -90px) rotate(-540deg) scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
25% {
|
||||
opacity: 1;
|
||||
}
|
||||
55% {
|
||||
transform: translate(10px, 6px) rotate(-90deg) scale(1.08);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-4px, -10px) rotate(20deg) scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0) rotate(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes dieLand {
|
||||
0% {
|
||||
transform: scale(1.18);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes dieGlow {
|
||||
50% {
|
||||
filter: drop-shadow(0 0 18px rgba(122, 178, 102, 1));
|
||||
}
|
||||
}
|
||||
@keyframes dieShake {
|
||||
20% {
|
||||
transform: translateX(-4px) rotate(-6deg);
|
||||
}
|
||||
40% {
|
||||
transform: translateX(4px) rotate(5deg);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(-3px) rotate(-3deg);
|
||||
}
|
||||
80% {
|
||||
transform: translateX(2px) rotate(2deg);
|
||||
}
|
||||
}
|
||||
/* The total, and what goes with it, appear once the dice have landed. */
|
||||
.roll-reveal {
|
||||
animation: rollReveal 0.35s ease-out both;
|
||||
}
|
||||
@keyframes rollReveal {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.6);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.dice-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
}
|
||||
.dice-tabs {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.dice-tabs .die-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
.dice-entry {
|
||||
padding: 0.6rem;
|
||||
border: 1px dashed var(--accent);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.dice-entry-die {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.dice-error {
|
||||
color: var(--danger-strong);
|
||||
font-size: 0.9rem;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
/* A roll as a line of the table's feed (components/RollFeed.tsx). */
|
||||
.roll-feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.roll-line {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-inset);
|
||||
text-align: left;
|
||||
}
|
||||
.roll-line.is-crit {
|
||||
border-left-color: var(--success-strong);
|
||||
}
|
||||
.roll-line.is-fumble {
|
||||
border-left-color: var(--danger-strong);
|
||||
}
|
||||
.roll-line-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.roll-line-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.roll-line-body .dice-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.roll-line-total {
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.roll-line-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.roll-line-tags .badge {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
/* Somebody else's roll, shown beside the dice button as it lands. */
|
||||
.dice-incoming {
|
||||
position: fixed;
|
||||
bottom: 1.25rem;
|
||||
left: calc(1.25rem + 60px + 0.6rem);
|
||||
z-index: 84;
|
||||
width: min(300px, calc(100vw - 6.5rem));
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
cursor: pointer;
|
||||
animation: toastIn 0.25s ease-out;
|
||||
}
|
||||
.dice-incoming .roll-line {
|
||||
border-color: var(--accent);
|
||||
background: linear-gradient(180deg, #1c160d 0%, #120e08 100%);
|
||||
box-shadow: var(--shadow-deep);
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.die.is-rolling,
|
||||
.die.is-landed,
|
||||
.die.is-crit,
|
||||
.die.is-fumble,
|
||||
.roll-reveal,
|
||||
.dice-incoming {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* The battle: the turn-order track, the DM's cue, and attacks being */
|
||||
/* resolved (components/encounter/*.tsx). */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
/* Who acts when, as one line of names: this round, a flag, the next round. */
|
||||
.initiative-track {
|
||||
position: sticky;
|
||||
top: 3.6rem;
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 -0.25rem 0.75rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.track-round,
|
||||
.track-flag {
|
||||
flex: 0 0 auto;
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.track-flag {
|
||||
padding: 0.2rem 0.6rem;
|
||||
margin: 0 0.25rem;
|
||||
border-left: 2px dashed var(--accent);
|
||||
border-right: 2px dashed var(--accent);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.track-chip {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
border-left-width: 5px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.track-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
/* What they are, by colour: a player character, an NPC, a monster. */
|
||||
.track-pc {
|
||||
border-color: var(--arcane-strong);
|
||||
background: rgba(92, 127, 176, 0.16);
|
||||
}
|
||||
.track-npc {
|
||||
border-color: var(--success-strong);
|
||||
background: rgba(95, 138, 79, 0.16);
|
||||
}
|
||||
.track-monster {
|
||||
border-color: var(--danger-strong);
|
||||
background: rgba(178, 58, 58, 0.16);
|
||||
}
|
||||
.track-chip.is-active {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
padding: 0.4rem 0.9rem;
|
||||
box-shadow: 0 0 0 2px var(--accent-strong), 0 0 14px rgba(201, 162, 39, 0.35);
|
||||
}
|
||||
.track-chip.is-targeted {
|
||||
box-shadow: 0 0 0 2px var(--danger-strong);
|
||||
}
|
||||
.track-chip.is-upcoming {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.track-chip.is-dying {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--danger-strong);
|
||||
}
|
||||
.track-legend {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
margin-left: 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.track-dot {
|
||||
display: inline-block;
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid;
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
/* Free roam: a card per place the party can go to. */
|
||||
.place-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 0.9rem;
|
||||
margin-top: 0.9rem;
|
||||
align-items: start;
|
||||
}
|
||||
.place-card {
|
||||
margin: 0;
|
||||
}
|
||||
.place-card.is-here {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: 0 0 14px rgba(201, 162, 39, 0.2);
|
||||
}
|
||||
|
||||
/* What the scene is waiting on, in a sentence. */
|
||||
.cue-card {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
.cue-card.is-urgent {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: 0 0 14px rgba(201, 162, 39, 0.2);
|
||||
}
|
||||
.cue-label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.cue-keys {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* An attack or a spell being resolved. */
|
||||
.action-card {
|
||||
border-color: var(--accent);
|
||||
border-left-width: 4px;
|
||||
}
|
||||
.action-card.is-auto {
|
||||
border-color: var(--danger-strong);
|
||||
}
|
||||
.action-card.is-ready {
|
||||
box-shadow: 0 0 14px rgba(122, 178, 102, 0.25);
|
||||
}
|
||||
.action-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.action-target {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
.action-target-name {
|
||||
font-family: var(--font-display);
|
||||
min-width: 7rem;
|
||||
}
|
||||
.action-roll {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.action-outcome {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.action-outcome.is-hit {
|
||||
color: var(--danger-strong);
|
||||
border-color: var(--danger-strong);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.action-outcome.is-crit {
|
||||
color: #1a140a;
|
||||
border-color: var(--accent-strong);
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
.action-outcome.is-miss,
|
||||
.action-outcome.is-saved {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.action-outcome.is-waiting {
|
||||
color: var(--text-dim);
|
||||
border-style: dashed;
|
||||
font-weight: 400;
|
||||
}
|
||||
.action-damage {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--danger-strong);
|
||||
}
|
||||
.action-overrule {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.action-overrule .btn.is-on,
|
||||
.action-option.is-on {
|
||||
border-color: var(--accent-strong);
|
||||
color: var(--accent-strong);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.action-damage-roll {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.action-damage-roll strong {
|
||||
font-size: 1.3rem;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.action-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.action-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.1rem;
|
||||
text-align: left;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
.action-option-name {
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
/* The equipped weapon stands out among what can be attacked with. */
|
||||
.action-option.is-equipped {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: inset 3px 0 0 var(--accent-strong);
|
||||
}
|
||||
|
||||
/* What whoever is on turn (and anyone targeted) is fighting with. */
|
||||
.in-hand {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border: 1px solid var(--accent-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.in-hand-label {
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.in-hand-weapon {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.in-hand-weapon .mono {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
/* Quick stats drawer */
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
@@ -853,11 +1458,9 @@ table.data-table tbody tr:hover {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* The DM's attack bar stays in reach while scrolling the initiative order. */
|
||||
/* The DM's quick damage bar. (Attacks being resolved sit above it, so it no
|
||||
longer follows the scroll.) */
|
||||
.attack-bar {
|
||||
position: sticky;
|
||||
top: 4.5rem;
|
||||
z-index: 20;
|
||||
border-color: var(--danger-strong);
|
||||
}
|
||||
.attack-target {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ABILITY_ORDER } from './previewCalc';
|
||||
import { formatMoney } from './format';
|
||||
import type { Monster, Npc } from '../api/types';
|
||||
import type { Ability, Monster, Npc } from '../api/types';
|
||||
|
||||
// One thing in a campaign's lists that opens in a pop-out.
|
||||
export type EntryKind = 'weapon' | 'item' | 'spell' | 'monster' | 'npc';
|
||||
@@ -33,3 +33,25 @@ export function matchesSearch(query: string, ...fields: (string | number | null
|
||||
const text = fields.filter((f) => f !== null && f !== undefined).join(' ').toLowerCase();
|
||||
return words.every((word) => text.includes(word));
|
||||
}
|
||||
|
||||
// How a spell lands in a battle, as one choice: it just takes effect, the
|
||||
// caster rolls a spell attack, or each target makes a save with an ability
|
||||
// (taking half damage on a success, or none). `landingValue` and
|
||||
// `landingFrom` turn it into the value of a select and back.
|
||||
export interface SpellLanding {
|
||||
attackRoll: boolean;
|
||||
saveAbility: Ability | null;
|
||||
halfOnSave: boolean;
|
||||
}
|
||||
|
||||
export function landingValue(spell: SpellLanding): string {
|
||||
if (spell.attackRoll) return 'ATTACK';
|
||||
return spell.saveAbility ? `${spell.saveAbility}:${spell.halfOnSave ? 'HALF' : 'NONE'}` : '';
|
||||
}
|
||||
|
||||
export function landingFrom(value: string): SpellLanding {
|
||||
if (value === 'ATTACK') return { attackRoll: true, saveAbility: null, halfOnSave: false };
|
||||
if (!value) return { attackRoll: false, saveAbility: null, halfOnSave: false };
|
||||
const [ability, half] = value.split(':');
|
||||
return { attackRoll: false, saveAbility: ability as Ability, halfOnSave: half === 'HALF' };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// How the dice move (components/dice/Die.tsx), for whatever needs to wait for
|
||||
// them: the total appears once the last die has landed.
|
||||
|
||||
// How long a die tumbles before it lands, and how much later each die after
|
||||
// the first lands, so a handful settle one after another.
|
||||
export const TUMBLE_MS = 850;
|
||||
export const STAGGER_MS = 110;
|
||||
|
||||
// Whether the visitor has asked their device for less motion.
|
||||
export const prefersStill = () => typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
|
||||
|
||||
// How long a roll of this many dice takes to settle.
|
||||
export const settleMs = (count: number) => (prefersStill() ? 0 : TUMBLE_MS + Math.max(0, count - 1) * STAGGER_MS);
|
||||
@@ -0,0 +1,45 @@
|
||||
// Toasts: short notices that slide in over any page ("Aria can level up").
|
||||
// A tiny store of its own, so anything can raise one (a socket event, a
|
||||
// mutation) without being inside a particular component. <Toasts />
|
||||
// (components/Toasts.tsx), mounted once in the layout, shows them.
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
text: string;
|
||||
// Where "tone" is 'good' the toast is a piece of good news; default is neutral.
|
||||
tone?: 'good' | 'warn';
|
||||
// A link to follow up on it: its label and where it goes.
|
||||
action?: { label: string; to: string };
|
||||
}
|
||||
|
||||
type Listener = (toasts: Toast[]) => void;
|
||||
|
||||
let toasts: Toast[] = [];
|
||||
let nextId = 1;
|
||||
const listeners = new Set<Listener>();
|
||||
const publish = () => listeners.forEach((listener) => listener(toasts));
|
||||
|
||||
// How long a toast stays before it goes by itself.
|
||||
const TOAST_MS = 12000;
|
||||
|
||||
export function showToast(toast: Omit<Toast, 'id'>): number {
|
||||
const id = nextId++;
|
||||
toasts = [...toasts, { ...toast, id }].slice(-5);
|
||||
publish();
|
||||
setTimeout(() => dismissToast(id), TOAST_MS);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function dismissToast(id: number) {
|
||||
if (!toasts.some((toast) => toast.id === id)) return;
|
||||
toasts = toasts.filter((toast) => toast.id !== id);
|
||||
publish();
|
||||
}
|
||||
|
||||
export function subscribeToasts(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
listener(toasts);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
@@ -32,13 +32,14 @@ import { NumberInput } from '../components/NumberInput';
|
||||
import { SectionNav, type Section } from '../components/SectionNav';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { RestModal } from '../components/RestModal';
|
||||
import { LevelUpBulkModal } from '../components/LevelUpBulkModal';
|
||||
import { Pagination } from '../components/Paginated';
|
||||
import { usePagination } from '../hooks/usePagination';
|
||||
import { CampaignSearch, type SearchGroup } from '../components/campaign/CampaignSearch';
|
||||
import { EntryDetail } from '../components/campaign/EntryDetail';
|
||||
import { ItemsSection, MonstersSection, NpcsSection, SpellsSection, WeaponsSection } from '../components/campaign/PoolSections';
|
||||
import { effectsSummary } from '../lib/gear';
|
||||
import { extractErrorMessage, spellLevelLabel } from '../lib/format';
|
||||
import { SPECIES_LABELS, classLabel, extractErrorMessage, spellLevelLabel } from '../lib/format';
|
||||
import { matchesSearch, type EntryKind, type EntryRef } from '../lib/campaignEntries';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
import type { CampaignDiceMode, CharacterSummary, CritRule, Encounter, EncounterMode, HpMethod, RollVisibility, ScoreMethod } from '../api/types';
|
||||
@@ -67,6 +68,17 @@ function storedEncounterSort(): EncounterSort {
|
||||
const ENCOUNTER_PLACE_KEY = 'dnd_encounter_place';
|
||||
const AFTER = 'after:';
|
||||
|
||||
// Finished encounters are left off the list until asked for; the choice is remembered.
|
||||
const SHOW_COMPLETED_KEY = 'dnd_show_completed_encounters';
|
||||
|
||||
function storedShowCompleted(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(SHOW_COMPLETED_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function storedEncounterPlace(): string {
|
||||
return localStorage.getItem(ENCOUNTER_PLACE_KEY) === 'TOP' ? 'TOP' : 'BOTTOM';
|
||||
}
|
||||
@@ -79,14 +91,15 @@ function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter
|
||||
custom: () => 0,
|
||||
name: (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }),
|
||||
status: (a, b) => STATUS_RANK[a.status] - STATUS_RANK[b.status],
|
||||
type: (a, b) => Number(a.mode === 'QUEST') - Number(b.mode === 'QUEST'),
|
||||
type: (a, b) => MODE_RANK[a.mode] - MODE_RANK[b.mode],
|
||||
newest: (a, b) => time(b) - time(a),
|
||||
oldest: (a, b) => time(a) - time(b),
|
||||
};
|
||||
return [...encounters].sort(compare[sort]);
|
||||
}
|
||||
|
||||
function CharacterRow({ character, isDM, onDelete }: { character: CharacterSummary; isDM: boolean; onDelete: () => void }) {
|
||||
// `mine`: the viewer's own character, who gets the way into a level up that is waiting.
|
||||
function CharacterRow({ character, isDM, mine = false, onDelete }: { character: CharacterSummary; isDM: boolean; mine?: boolean; onDelete: () => void }) {
|
||||
const inProgress = character.buildStage !== 'COMPLETE';
|
||||
return (
|
||||
<div className="card flex-between flex-wrap">
|
||||
@@ -97,10 +110,15 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
|
||||
{character.name}
|
||||
</Link>
|
||||
{inProgress && <span className="badge">Building…</span>}
|
||||
{character.levelUpsPending > 0 && (isDM || mine) && (
|
||||
<span className="badge badge-live" title={mine ? 'Your DM has approved a level: open the sheet to choose what you gain' : 'Approved, and waiting for the picks to be made'}>
|
||||
⬆ Level up {mine ? 'ready' : 'waiting on picks'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||
{character.species ? `${character.species} ` : ''}
|
||||
{character.characterClass || (inProgress ? 'Not yet built' : '')}{character.subclassName && ` (${character.subclassName})`} · Level {character.level}
|
||||
{character.species ? `${SPECIES_LABELS[character.species] ?? character.species} ` : ''}
|
||||
{character.characterClass ? classLabel(character.characterClass, character.subclassName, character.classes) : inProgress ? 'Not yet built' : ''} · Level {character.level}
|
||||
{character.currentLocationName && ` · 📍 ${character.currentLocationName}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -110,6 +128,11 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
|
||||
<LifeBadge deathSaves={character.deathSaves} /> HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
|
||||
</div>
|
||||
)}
|
||||
{mine && character.levelUpsPending > 0 && !inProgress && (
|
||||
<Link to={`/characters/${character.id}?levelup=1`} className="btn btn-primary">
|
||||
⬆ Level up
|
||||
</Link>
|
||||
)}
|
||||
<Link to={inProgress ? `/characters/${character.id}/build` : `/characters/${character.id}`} className="btn">
|
||||
{inProgress ? 'Continue Build' : 'Open Sheet'}
|
||||
</Link>
|
||||
@@ -123,7 +146,8 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
|
||||
);
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest' };
|
||||
const MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest', FREE_ROAM: '🧭 Free roam' };
|
||||
const MODE_RANK: Record<EncounterMode, number> = { BATTLE: 0, QUEST: 1, FREE_ROAM: 2 };
|
||||
|
||||
// A long note, cut down to a line for a search result.
|
||||
const snippet = (text: string) => (text.length > 110 ? `${text.slice(0, 110).trimEnd()}…` : text);
|
||||
@@ -180,13 +204,17 @@ export function CampaignHomePage() {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [resting, setResting] = useState(false);
|
||||
const [levelling, setLevelling] = useState(false);
|
||||
const [showCompleted, setShowCompleted] = useState(storedShowCompleted);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('');
|
||||
|
||||
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
||||
// An encounter picked to go after may have been archived or deleted since.
|
||||
const encounterPlace = newEncounterPlace.startsWith(AFTER) && !liveEncounters.some((e) => AFTER + e.id === newEncounterPlace) ? 'BOTTOM' : newEncounterPlace;
|
||||
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
|
||||
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
|
||||
// Complete encounters are hidden unless "Show completed" is ticked.
|
||||
const completedCount = liveEncounters.filter((e) => e.status === 'COMPLETE').length;
|
||||
const shownEncounters = sortEncounters(showCompleted ? liveEncounters : liveEncounters.filter((e) => e.status !== 'COMPLETE'), encounterSort);
|
||||
const encountersPage = usePagination(shownEncounters, 10);
|
||||
const archivedEncountersPage = usePagination(archivedEncounters, 10);
|
||||
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
|
||||
@@ -230,11 +258,15 @@ export function CampaignHomePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Swaps an encounter with its neighbour in the campaign's own order.
|
||||
function moveEncounter(index: number, step: -1 | 1) {
|
||||
// Swaps an encounter with its neighbour on the list as shown (which may
|
||||
// leave the completed ones out) in the campaign's own order.
|
||||
function moveEncounter(shownIndex: number, step: -1 | 1) {
|
||||
const neighbour = shownEncounters[shownIndex + step];
|
||||
const moving = shownEncounters[shownIndex];
|
||||
if (!neighbour || !moving) return;
|
||||
const ids = liveEncounters.map((e) => e.id);
|
||||
const target = index + step;
|
||||
if (target < 0 || target >= ids.length) return;
|
||||
const index = ids.indexOf(moving.id);
|
||||
const target = ids.indexOf(neighbour.id);
|
||||
[ids[index], ids[target]] = [ids[target], ids[index]];
|
||||
reorderEncounters.mutate([...ids, ...archivedEncounters.map((e) => e.id)], {
|
||||
onError: (err) => setFormError(extractErrorMessage(err)),
|
||||
@@ -440,7 +472,7 @@ export function CampaignHomePage() {
|
||||
<div className="section-block">
|
||||
<h2 style={{ fontSize: '1.1rem' }}>Your Character{myCharacters.length > 1 ? 's' : ''}</h2>
|
||||
{myCharacters.map((c) => (
|
||||
<CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} />
|
||||
<CharacterRow key={c.id} character={c} isDM={false} mine onDelete={() => {}} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -450,9 +482,23 @@ export function CampaignHomePage() {
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
|
||||
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
|
||||
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
|
||||
🛏 Rest…
|
||||
</button>
|
||||
<div className="flex-row flex-wrap">
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => setLevelling(true)}
|
||||
title="Approve a level for any of the party at once: each player is told, and picks what they gain themselves"
|
||||
>
|
||||
⬆ Level up…
|
||||
{campaign.trackXp && characters.some((c) => c.xpProgress.levelUpDue) && (
|
||||
<span className="badge badge-live" style={{ marginLeft: 6 }}>
|
||||
{characters.filter((c) => c.xpProgress.levelUpDue).length} due
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
|
||||
🛏 Rest…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handleCreateCharacter} className="flex-row flex-wrap mt-sm" style={{ marginBottom: '0.75rem' }}>
|
||||
@@ -484,6 +530,23 @@ export function CampaignHomePage() {
|
||||
<div className="section-block">
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
|
||||
{completedCount > 0 && (
|
||||
<label className="checkbox-row subtitle" title="Encounters that are complete are left off the list until this is ticked">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showCompleted}
|
||||
onChange={(e) => {
|
||||
setShowCompleted(e.target.checked);
|
||||
try {
|
||||
localStorage.setItem(SHOW_COMPLETED_KEY, e.target.checked ? '1' : '0');
|
||||
} catch {
|
||||
// Private browsing: the choice lasts as long as the page.
|
||||
}
|
||||
}}
|
||||
/>
|
||||
Show completed ({completedCount})
|
||||
</label>
|
||||
)}
|
||||
{liveEncounters.length > 1 && (
|
||||
<label className="flex-row subtitle">
|
||||
Sort by
|
||||
@@ -515,6 +578,7 @@ export function CampaignHomePage() {
|
||||
<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>
|
||||
<option value="FREE_ROAM">Free roam</option>
|
||||
</select>
|
||||
{liveEncounters.length > 0 && (
|
||||
<select
|
||||
@@ -539,7 +603,7 @@ export function CampaignHomePage() {
|
||||
</select>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={createEncounter.isPending}>
|
||||
{newEncounterMode === 'QUEST' ? '📜 Start Quest' : '⚔ Start Encounter'}
|
||||
{newEncounterMode === 'QUEST' ? '📜 Start Quest' : newEncounterMode === 'FREE_ROAM' ? '🧭 Start Free Roam' : '⚔ Start Encounter'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
@@ -552,6 +616,9 @@ export function CampaignHomePage() {
|
||||
{encounters && liveEncounters.length === 0 && (
|
||||
<div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div>
|
||||
)}
|
||||
{liveEncounters.length > 0 && shownEncounters.length === 0 && (
|
||||
<div className="empty-state">Every encounter here is complete. Tick “Show completed” to see them.</div>
|
||||
)}
|
||||
{encountersPage.pageItems.map((enc) => {
|
||||
// Its place in the whole list, not on this page: that is what moving it changes.
|
||||
const index = shownEncounters.indexOf(enc);
|
||||
@@ -1010,6 +1077,9 @@ export function CampaignHomePage() {
|
||||
)}
|
||||
|
||||
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
|
||||
{isDM && levelling && (
|
||||
<LevelUpBulkModal campaignId={campaignId} characters={characters ?? []} trackXp={campaign.trackXp} onClose={() => setLevelling(false)} />
|
||||
)}
|
||||
|
||||
{isDM && opened && (
|
||||
<EntryDetail
|
||||
|
||||
@@ -1589,6 +1589,8 @@ function BuildPreview({
|
||||
for (const ability of all) if (!abilityList.some((a) => a.name === ability.name)) abilityList.push(ability);
|
||||
}
|
||||
const wisMod = abilityFinals.WIS !== undefined ? abilityModifier(abilityFinals.WIS) : null;
|
||||
// Unarmored Defense, as the sheet will count it: a new character wears nothing yet.
|
||||
const unarmoredDefense = Math.max(0, characterClass === 'BARBARIAN' ? (conMod ?? 0) : characterClass === 'MONK' ? (wisMod ?? 0) : 0);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
@@ -1651,7 +1653,7 @@ function BuildPreview({
|
||||
<span className="stat-label">
|
||||
AC <InfoTip term="AC" />
|
||||
</span>
|
||||
<div className="stat-value">{dexMod !== null ? armorClass(dexMod, armorBonus) : '—'}</div>
|
||||
<div className="stat-value">{dexMod !== null ? armorClass(dexMod, armorBonus) + unarmoredDefense : '—'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="stat-label">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
useCharacter,
|
||||
usePatchCharacter,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
useCampaigns,
|
||||
useCopyCharacter,
|
||||
useCampaignLocations,
|
||||
useApproveLevelUps,
|
||||
} from '../api/queries';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
@@ -251,6 +252,7 @@ export function CharacterSheetPage() {
|
||||
const { data: character, isLoading, error } = useCharacter(characterId);
|
||||
useRememberCampaign(character?.campaignId);
|
||||
const patchCharacter = usePatchCharacter(characterId);
|
||||
const approveLevelUp = useApproveLevelUps(character?.campaignId ?? '');
|
||||
useCampaignSocket(character?.campaignId);
|
||||
|
||||
const { data: campaignData } = useCampaign(character?.campaignId);
|
||||
@@ -292,7 +294,9 @@ export function CharacterSheetPage() {
|
||||
const [pickedSpellId, setPickedSpellId] = useState('');
|
||||
const [copyTargetId, setCopyTargetId] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [levellingUp, setLevellingUp] = useState(false);
|
||||
// ?levelup=1 (the link in the "can level up" notice) opens the checklist straight away.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [levellingUp, setLevellingUp] = useState(searchParams.get('levelup') === '1');
|
||||
|
||||
const weaponsPage = usePagination(character?.weapons, 8);
|
||||
const spellsPage = usePagination(character?.spells, 8);
|
||||
@@ -428,6 +432,22 @@ export function CharacterSheetPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{character.levelUpsPending > 0 && canEdit && (
|
||||
<div className="notice-banner" role="status">
|
||||
<span style={{ flex: 1 }}>
|
||||
<strong>⬆ {isOwner ? 'You can level up!' : `${character.name} has a level up waiting.`}</strong>{' '}
|
||||
{isOwner
|
||||
? `Level ${character.level} → ${character.level + 1}: see what you gain and make your picks.`
|
||||
: character.userId
|
||||
? 'Their player has been told; you can also finish it for them.'
|
||||
: 'No player: it is yours to finish.'}
|
||||
{character.levelUpsPending > 1 && ` (${character.levelUpsPending} levels approved.)`}
|
||||
</span>
|
||||
<button className="btn btn-primary" onClick={() => setLevellingUp(true)}>
|
||||
{isOwner ? 'Choose what you gain' : 'Open the checklist'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>
|
||||
@@ -606,13 +626,23 @@ export function CharacterSheetPage() {
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{character.userId && character.userId !== user?.id && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => approveLevelUp.mutate({ characterIds: [character.id] }, { onError: (err) => setFormError(extractErrorMessage(err)) })}
|
||||
disabled={character.level + character.levelUpsPending >= 20 || approveLevelUp.isPending}
|
||||
title="Approves one level: their player is told, sees what they gain and makes the picks themselves"
|
||||
>
|
||||
⬆ Approve a level up{character.levelUpsPending > 0 ? ` (${character.levelUpsPending} waiting)` : ''}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
className="btn"
|
||||
onClick={() => setLevellingUp(true)}
|
||||
disabled={character.level >= 20}
|
||||
title="Go up one level with the levelling-up checklist: HP, new special abilities, spells and the free weapon upgrade"
|
||||
title="Go up one level yourself with the levelling-up checklist: HP, new special abilities, spells and the free weapon upgrade"
|
||||
>
|
||||
⬆ Level Up…
|
||||
⬆ Level up now…
|
||||
</button>
|
||||
<label className="flex-row gap-sm" title="Hit Dice this character still has to spend on a short rest. The Rest button on the campaign page keeps count for you.">
|
||||
Hit Dice left
|
||||
@@ -652,6 +682,27 @@ export function CharacterSheetPage() {
|
||||
<p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p>
|
||||
)}
|
||||
|
||||
{sheet.features.length > 0 && (
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">From Class Features</div>
|
||||
{sheet.features.map((feature) => (
|
||||
<div key={`${feature.source}:${feature.name}:${feature.text}`} className="detail-row">
|
||||
<span>
|
||||
<strong>{feature.name}</strong>
|
||||
<span className="combatant-sub" style={{ marginLeft: 6 }}>
|
||||
{feature.source}
|
||||
</span>
|
||||
</span>
|
||||
<span className="combatant-sub">{feature.text}</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="subtitle" style={{ margin: '0.4rem 0 0' }}>
|
||||
Already counted in every number on this sheet: nothing to add by hand. What is worn decides some of them (armor and shields are
|
||||
items marked as such); a speed set by the DM is used as it is.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(sheet.gear.effects.length > 0 || sheet.gear.notes.length > 0) && (
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">From Equipped Gear</div>
|
||||
@@ -857,8 +908,15 @@ 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 }}>
|
||||
<span
|
||||
className="badge"
|
||||
style={{ marginLeft: 6 }}
|
||||
title={cs.spell.level === 0 ? 'A cantrip: cast as often as you like, no spell slot needed.' : `Casting it takes a spell slot of level ${cs.spell.level} or higher.`}
|
||||
>
|
||||
{spellLevelLabel(cs.spell.level)}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
|
||||
{cs.spell.level === 0 ? 'no slot needed' : 'uses a slot'}
|
||||
{cs.spell.damageDice && (
|
||||
<>
|
||||
{' · '}
|
||||
@@ -1288,7 +1346,16 @@ export function CharacterSheetPage() {
|
||||
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} onJump={notesPage.setPage} />
|
||||
</div>
|
||||
|
||||
{isDM && levellingUp && <LevelUpModal character={character} campaignSpells={campaignSpells ?? []} onClose={() => setLevellingUp(false)} />}
|
||||
{(isDM || (isOwner && character.levelUpsPending > 0)) && levellingUp && (
|
||||
<LevelUpModal
|
||||
character={character}
|
||||
isDM={isDM}
|
||||
onClose={() => {
|
||||
setLevellingUp(false);
|
||||
if (searchParams.has('levelup')) setSearchParams({}, { replace: true });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,10 +24,16 @@ import {
|
||||
useAddReward,
|
||||
useDeleteReward,
|
||||
useAwardReward,
|
||||
useRolls,
|
||||
useApplyAction,
|
||||
type NewReward,
|
||||
} from '../api/queries';
|
||||
import { useCampaignSocket } from '../hooks/useCampaignSocket';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { RollFeed } from '../components/RollFeed';
|
||||
import { InitiativeTrack } from '../components/encounter/InitiativeTrack';
|
||||
import { ActionCard, ActionComposer, RecentActions } from '../components/encounter/ActionPanel';
|
||||
import { FreeRoamPanel } from '../components/encounter/FreeRoam';
|
||||
import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
@@ -49,6 +55,7 @@ import type {
|
||||
CharacterSummary,
|
||||
Currency,
|
||||
Encounter,
|
||||
EncounterAction,
|
||||
EncounterMode,
|
||||
EncounterParticipant,
|
||||
EncounterReward,
|
||||
@@ -66,7 +73,7 @@ const areaPenalty = (targetCount: number, step: number) => Math.max(0, targetCou
|
||||
|
||||
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 MODE_LABELS: Record<EncounterMode, string> = { BATTLE: '⚔ Battle', QUEST: '📜 Quest', FREE_ROAM: '🧭 Free roam' };
|
||||
const STATUS_LABELS = { PLANNING: 'Planning', ACTIVE: 'In progress', COMPLETE: 'Complete' } as const;
|
||||
|
||||
function StatChip({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -296,6 +303,50 @@ function HeadlineChips({ participant: p }: { participant: EncounterParticipant }
|
||||
);
|
||||
}
|
||||
|
||||
// What a combatant is fighting with right now, set apart so it catches the
|
||||
// eye: a character's equipped weapons, or a creature's attacks. Shown on the
|
||||
// card of whoever is on turn and of anyone targeted. A player is not sent a
|
||||
// monster's attacks, so for them a monster shows nothing here.
|
||||
function InHand({ participant: p }: { participant: EncounterParticipant }) {
|
||||
const info = p.characterCombatInfo;
|
||||
if (info) {
|
||||
return (
|
||||
<div className="in-hand" title="Equipped: what they fight with">
|
||||
<span className="in-hand-label">In hand</span>
|
||||
{info.weapons.length === 0 && <span className="combatant-sub">nothing equipped</span>}
|
||||
{info.weapons.map((w) => (
|
||||
<span key={w.characterWeaponId} className="in-hand-weapon">
|
||||
⚔ {w.name}{' '}
|
||||
<span className="mono">
|
||||
{fmtMod(w.attackBonus)} · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const named = (p.monster?.attacks ?? []).filter((a) => a.name);
|
||||
const stats = p.monster ?? p.npc;
|
||||
const single = stats && (stats.attackBonus != null || stats.damageDice) ? [{ name: 'Attack', attackBonus: stats.attackBonus ?? 0, damageDice: stats.damageDice, count: 1 }] : [];
|
||||
const attacks = named.length > 0 ? named : single;
|
||||
if (attacks.length === 0) return null;
|
||||
return (
|
||||
<div className="in-hand" title="What it attacks with">
|
||||
<span className="in-hand-label">Attacks</span>
|
||||
{attacks.map((a, i) => (
|
||||
<span key={i} className="in-hand-weapon">
|
||||
⚔ {a.name}
|
||||
{(a.count ?? 1) > 1 ? ` ×${a.count}` : ''}{' '}
|
||||
<span className="mono">
|
||||
{fmtMod(a.attackBonus)}
|
||||
{a.damageDice ? ` · ${a.damageDice}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// "2 rd" on a condition that wears off by itself.
|
||||
function roundsNote(p: EncounterParticipant, condition: string): string | undefined {
|
||||
const left = p.conditionRounds?.[condition];
|
||||
@@ -599,6 +650,7 @@ function CombatantCard({
|
||||
</div>
|
||||
|
||||
<HeadlineChips participant={p} />
|
||||
{(isActive || isTargeted) && <InHand participant={p} />}
|
||||
|
||||
{/* 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>}
|
||||
@@ -704,7 +756,7 @@ function attackLines(p: EncounterParticipant): string[] {
|
||||
);
|
||||
const spells = info.spells
|
||||
.filter((s) => s.damageDice)
|
||||
.map((s) => `${s.name}: ${s.damageDice}${info.spellcasting ? ` · ${fmtMod(info.spellcasting.attackBonus)} to hit / DC ${info.spellcasting.saveDC}` : ''}`);
|
||||
.map((s) => `${s.name} (${spellLevelLabel(s.level)}): ${s.damageDice}${info.spellcasting ? ` · ${fmtMod(info.spellcasting.attackBonus)} to hit / DC ${info.spellcasting.saveDC}` : ''}`);
|
||||
return [...weapons, ...spells];
|
||||
}
|
||||
const stats = p.monster ?? p.npc;
|
||||
@@ -862,6 +914,68 @@ function AttackBar({
|
||||
);
|
||||
}
|
||||
|
||||
// The one thing the scene is waiting on, in a sentence: for the DM, what to
|
||||
// do next; for a player, what is theirs to do. `urgent` when it is a roll or a
|
||||
// ruling that holds the fight up. Null when there is nothing to say.
|
||||
function whatNow({
|
||||
encounter,
|
||||
isDM,
|
||||
active,
|
||||
targets,
|
||||
actions,
|
||||
myTurn,
|
||||
unrolled,
|
||||
userId,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
isDM: boolean;
|
||||
active: EncounterParticipant | undefined;
|
||||
targets: EncounterParticipant[];
|
||||
actions: EncounterAction[];
|
||||
myTurn: boolean;
|
||||
unrolled: number;
|
||||
userId: string | undefined;
|
||||
}): { text: string; urgent: boolean } | null {
|
||||
if (encounter.mode !== 'BATTLE') return null;
|
||||
const names = (list: { name: string }[]) => list.map((t) => t.name).join(', ');
|
||||
if (!isDM) {
|
||||
const mine = (encounter.participants ?? []).filter((p) => p.character?.userId === userId).map((p) => p.id);
|
||||
const save = actions.find((a) => a.kind === 'SAVE' && a.targets.some((t) => t.d20 == null && mine.includes(t.participantId)));
|
||||
if (save) return { text: `${save.actorName}'s ${save.name}: make a ${save.detail.saveAbility} saving throw and enter your d20.`, urgent: true };
|
||||
const own = actions.find((a) => a.actorParticipantId != null && mine.includes(a.actorParticipantId));
|
||||
if (own?.needsRolls) return { text: `Roll your d20 for ${own.name} against ${names(own.targets.filter((t) => t.d20 == null))}.`, urgent: true };
|
||||
if (own?.needsDamage) return { text: `It hits! Roll the damage for ${own.name}.`, urgent: true };
|
||||
if (own?.ready) return { text: `${own.name} is ready: your DM lets it land.`, urgent: false };
|
||||
if (myTurn) return targets.length === 0 ? { text: 'Your turn! Pick a target: click a name on the track above.', urgent: true } : { text: `Your turn: choose what you attack ${names(targets.map((t) => ({ name: t.displayName })))} with.`, urgent: true };
|
||||
return null;
|
||||
}
|
||||
if (encounter.status === 'PLANNING') {
|
||||
return { text: unrolled > 0 ? `Setting up: ${unrolled} still to roll initiative. Then press Begin Battle.` : 'Everyone has rolled initiative. Press Begin Battle.', urgent: false };
|
||||
}
|
||||
if (encounter.status !== 'ACTIVE') return null;
|
||||
const pending = actions[0];
|
||||
if (pending) {
|
||||
const who = `${pending.actorName}'s ${pending.name}`;
|
||||
const waiting = pending.targets.filter((t) => !t.outcome);
|
||||
if (pending.needsRolls > 0) {
|
||||
return pending.kind === 'SAVE'
|
||||
? { text: `${who}: ${waiting.length} saving throw${waiting.length === 1 ? '' : 's'} to enter (${pending.detail.saveAbility}, DC ${pending.detail.saveDC}) for ${names(waiting)}.`, urgent: true }
|
||||
: { text: `${who}: enter the d20 against ${names(waiting)}${waiting[0]?.ac != null ? ` (AC ${waiting[0].ac + waiting[0].penalty})` : ''}.`, urgent: true };
|
||||
}
|
||||
if (pending.needsDamage) return { text: `${who} hits: roll or enter the damage.`, urgent: true };
|
||||
const result = pending.targets.map((t) => `${t.name}: ${t.outcome?.toLowerCase() ?? '—'}${t.finalDamage ? ` for ${t.finalDamage}` : ''}`).join('; ');
|
||||
return { text: `${who} is ready (${result}). ${pending.auto ? 'Accept, change or discard it.' : 'Apply it, or overrule anything first.'}`, urgent: true };
|
||||
}
|
||||
if (unrolled > 0) return { text: `${unrolled} combatant${unrolled === 1 ? ' has' : 's have'} no initiative roll yet.`, urgent: false };
|
||||
if (!active) return null;
|
||||
if (targets.length === 0) {
|
||||
return active.character
|
||||
? { text: `${active.displayName}'s turn: waiting for them to pick a target (or pick one for them: click a name).`, urgent: false }
|
||||
: { text: `${active.displayName}'s turn: click a name to pick its target, or press Next Turn.`, urgent: false };
|
||||
}
|
||||
return { text: `${active.displayName} → ${names(targets.map((t) => ({ name: t.displayName })))}: choose the attack below, or apply damage by hand.`, urgent: false };
|
||||
}
|
||||
|
||||
const RATING_LABELS = { TRIVIAL: 'Trivial', EASY: 'Easy', MEDIUM: 'Medium', HARD: 'Hard', DEADLY: 'Deadly' } as const;
|
||||
const RATING_BADGES = { TRIVIAL: '', EASY: 'badge-friendly', MEDIUM: 'badge-dm', HARD: 'badge-hostile', DEADLY: 'badge-hostile' } as const;
|
||||
|
||||
@@ -1182,6 +1296,8 @@ export function EncounterPage() {
|
||||
useRememberCampaign(encounter?.campaignId);
|
||||
|
||||
const { data: campaignData } = useCampaign(encounter?.campaignId);
|
||||
// The table's rolls, for the DM to watch beside the fight.
|
||||
const { data: rolls } = useRolls(encounter?.campaignId);
|
||||
const isDM = campaignData?.campaign.role === 'DM';
|
||||
const { data: characters } = useCampaignCharacters(encounter?.campaignId);
|
||||
const { data: monsters } = useCampaignMonsters(encounter?.campaignId, isDM);
|
||||
@@ -1198,6 +1314,7 @@ export function EncounterPage() {
|
||||
const nextTurn = useNextTurn(encounterId);
|
||||
const setTargets = useSetTargets(encounterId);
|
||||
const revive = useReviveParticipant(encounterId);
|
||||
const applyAction = useApplyAction(encounterId);
|
||||
const patchEncounter = usePatchEncounter(encounterId);
|
||||
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
|
||||
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
|
||||
@@ -1224,18 +1341,47 @@ export function EncounterPage() {
|
||||
// The combatant whose stats are kept in view in place of whoever is on turn.
|
||||
const [pinnedId, setPinnedId] = useState<string | null>(null);
|
||||
|
||||
// The DM's keys: N passes the turn, S opens the sheet of whoever is on turn.
|
||||
// The DM's keys: N passes the turn, S opens the sheet of whoever is on turn,
|
||||
// A accepts the first attack that is ready. For whoever may pick targets
|
||||
// (the DM, or a player on their own turn), 1 to 9 mark the combatants in
|
||||
// turn order, leaving out whoever is on turn.
|
||||
const onTurn = encounter?.participants?.find((p) => p.id === encounter.activeParticipantId);
|
||||
const dmRunning = isDM && encounter?.mode === 'BATTLE' && encounter.status === 'ACTIVE';
|
||||
const running = encounter?.mode === 'BATTLE' && encounter.status === 'ACTIVE';
|
||||
const dmRunning = isDM && running;
|
||||
const marked = encounter?.targetParticipantIds ?? [];
|
||||
const mayMark = isDM || (running && onTurn?.character != null && onTurn.character.userId === user?.id);
|
||||
const byKey = (encounter?.participants ?? []).filter((p) => !p.hidden && !(running && p.id === encounter?.activeParticipantId)).slice(0, 9);
|
||||
const readyAction = (encounter?.actions ?? []).find((a) => a.ready);
|
||||
useHotkeys({
|
||||
n: dmRunning && !nextTurn.isPending ? () => nextTurn.mutate(undefined, turnPassed) : undefined,
|
||||
s: dmRunning && onTurn ? () => (onTurn.character ? openCharacterPanel(onTurn.character.id) : setPinnedId(onTurn.id)) : undefined,
|
||||
a:
|
||||
isDM && readyAction && !applyAction.isPending
|
||||
? () =>
|
||||
applyAction.mutate(readyAction.id, {
|
||||
onError: (err: unknown) => setFormError(extractErrorMessage(err)),
|
||||
onSuccess: (data) =>
|
||||
notify(
|
||||
...data.concentration.map((c) => `${c.name} is concentrating and took ${c.damage} damage: Constitution save, DC ${c.dc}. On a failure the spell ends: remove Concentrating.`),
|
||||
...(data.slotNote ? [data.slotNote] : [])
|
||||
),
|
||||
})
|
||||
: undefined,
|
||||
...Object.fromEntries(
|
||||
byKey.map((p, i) => [
|
||||
String(i + 1),
|
||||
mayMark
|
||||
? () => setTargets.mutate(marked.includes(p.id) ? marked.filter((id) => id !== p.id) : [...marked, p.id], { onError: (err: unknown) => setFormError(extractErrorMessage(err)) })
|
||||
: undefined,
|
||||
])
|
||||
),
|
||||
});
|
||||
|
||||
if (isLoading) return <Spinner label="Setting the scene…" />;
|
||||
if (error || !encounter) return <ErrorBanner message="Could not load this encounter." />;
|
||||
|
||||
const isBattle = encounter.mode === 'BATTLE';
|
||||
const isRoam = encounter.mode === 'FREE_ROAM';
|
||||
const participants = encounter.participants ?? []; // already in initiative order
|
||||
// The fallen: dead characters, and monsters and NPCs at 0 HP. Only the DM is
|
||||
// sent their cards, and they sit apart from those still in the fight.
|
||||
@@ -1257,6 +1403,14 @@ export function EncounterPage() {
|
||||
const builtCharacters = characters?.filter((c) => c.buildStage === 'COMPLETE') ?? [];
|
||||
const onError = (err: unknown) => setFormError(extractErrorMessage(err));
|
||||
const fail = { onError };
|
||||
const toggleTarget = (participantId: string) =>
|
||||
setTargets.mutate(targetIds.includes(participantId) ? targetIds.filter((id) => id !== participantId) : [...targetIds, participantId], fail);
|
||||
// Attacks and spells being resolved, and whether it is this viewer's own character's turn.
|
||||
const actions = encounter.actions ?? [];
|
||||
const myTurn = active?.character != null && active.character.userId === user?.id;
|
||||
const diceMode = campaignData?.campaign.diceMode ?? 'BOTH';
|
||||
// What the DM is waiting on right now, in one line; for a player, what is theirs to do.
|
||||
const cue = whatNow({ encounter, isDM, active, targets, actions, myTurn, unrolled: participants.filter((p) => p.initiativeRoll == null && !p.hidden).length, userId: user?.id });
|
||||
|
||||
const pickOptions: { id: string; name: string }[] =
|
||||
pickType === 'character'
|
||||
@@ -1314,7 +1468,7 @@ export function EncounterPage() {
|
||||
onTarget={
|
||||
// Nobody targets themselves: the combatant on turn has no button.
|
||||
canTarget && p.id !== active?.id
|
||||
? () => setTargets.mutate(targetIds.includes(p.id) ? targetIds.filter((id) => id !== p.id) : [...targetIds, p.id], fail)
|
||||
? () => toggleTarget(p.id)
|
||||
: undefined
|
||||
}
|
||||
isPinned={p.id === pinnedId}
|
||||
@@ -1355,7 +1509,7 @@ export function EncounterPage() {
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>
|
||||
{isBattle ? '⚔' : '📜'} {encounter.name}
|
||||
{isBattle ? '⚔' : isRoam ? '🧭' : '📜'} {encounter.name}
|
||||
</h1>
|
||||
<div className="flex-row flex-wrap">
|
||||
<span className="badge badge-dm">{MODE_LABELS[encounter.mode]}</span>
|
||||
@@ -1387,6 +1541,7 @@ export function EncounterPage() {
|
||||
>
|
||||
<option value="BATTLE">Battle</option>
|
||||
<option value="QUEST">NPC quest</option>
|
||||
<option value="FREE_ROAM">Free roam</option>
|
||||
</select>
|
||||
<select
|
||||
aria-label="Location"
|
||||
@@ -1408,7 +1563,7 @@ export function EncounterPage() {
|
||||
)}
|
||||
{encounter.status === 'PLANNING' && (
|
||||
<button className="btn btn-primary" onClick={() => patchEncounter.mutate({ status: 'ACTIVE' }, fail)}>
|
||||
{isBattle ? 'Begin Battle' : 'Begin Quest'}
|
||||
{isBattle ? 'Begin Battle' : isRoam ? 'Begin Roaming' : 'Begin Quest'}
|
||||
</button>
|
||||
)}
|
||||
{encounter.status === 'ACTIVE' && isBattle && (
|
||||
@@ -1428,7 +1583,7 @@ export function EncounterPage() {
|
||||
)}
|
||||
{encounter.status === 'ACTIVE' && (
|
||||
<button className="btn btn-danger" onClick={() => patchEncounter.mutate({ status: 'COMPLETE' }, fail)}>
|
||||
{isBattle ? 'End Battle' : 'Complete Quest'}
|
||||
{isBattle ? 'End Battle' : isRoam ? 'End Free Roam' : 'Complete Quest'}
|
||||
</button>
|
||||
)}
|
||||
{encounter.status === 'COMPLETE' && (
|
||||
@@ -1478,13 +1633,31 @@ export function EncounterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{standing.length > 1 && (
|
||||
<TurnStrip participants={standing} activeId={active?.id} targetIds={targetIds} userId={user?.id} hasActed={hasActed} />
|
||||
{active ? (
|
||||
<InitiativeTrack
|
||||
participants={standing}
|
||||
activeId={active.id}
|
||||
round={encounter.currentRound}
|
||||
targetIds={targetIds}
|
||||
userId={user?.id}
|
||||
canTarget={canTarget}
|
||||
onTarget={toggleTarget}
|
||||
onJump={jumpToCard}
|
||||
/>
|
||||
) : (
|
||||
standing.length > 1 && <TurnStrip participants={standing} activeId={undefined} targetIds={targetIds} userId={user?.id} hasActed={hasActed} />
|
||||
)}
|
||||
{cue && (
|
||||
<div className={`card cue-card${cue.urgent ? ' is-urgent' : ''}`} role="status">
|
||||
<span className="cue-label">{isDM ? 'What now' : 'Your move'}</span>
|
||||
<span>{cue.text}</span>
|
||||
{isDM && running && <span className="combatant-sub cue-keys">Keys: 1–9 target · A accept · N next turn · S sheet</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isDM || encounter.description) && (
|
||||
<div className="card">
|
||||
<div className="card-title">{isBattle ? 'The Scene' : 'Quest'}</div>
|
||||
<div className="card-title">{isBattle ? 'The Scene' : isRoam ? 'Where the party is' : 'Quest'}</div>
|
||||
{isDM ? (
|
||||
<textarea
|
||||
key={`description:${encounter.description ?? ''}`}
|
||||
@@ -1495,7 +1668,9 @@ export function EncounterPage() {
|
||||
placeholder={
|
||||
isBattle
|
||||
? 'Describe the battlefield: terrain, cover, hazards… (shown to players)'
|
||||
: 'What is being asked of the party, and by whom? (shown to players)'
|
||||
: isRoam
|
||||
? 'Where the party finds itself, and what is around to visit… (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;
|
||||
@@ -1508,8 +1683,37 @@ export function EncounterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRoam && (
|
||||
<FreeRoamPanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
userId={user?.id}
|
||||
characters={builtCharacters}
|
||||
locations={locations ?? []}
|
||||
weapons={weapons ?? []}
|
||||
items={items ?? []}
|
||||
onError={onError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="encounter-layout mt-md">
|
||||
<div>
|
||||
{actions.map((action) => (
|
||||
<ActionCard
|
||||
key={action.id}
|
||||
action={action}
|
||||
participants={participants}
|
||||
isDM={isDM}
|
||||
userId={user?.id}
|
||||
diceMode={diceMode}
|
||||
encounterId={encounterId}
|
||||
onNotice={notify}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
{encounter.status === 'ACTIVE' && isBattle && (isDM || myTurn) && (active || isDM) && (
|
||||
<ActionComposer actor={active} targetCount={targets.length} isDM={isDM} encounterId={encounterId} onError={onError} />
|
||||
)}
|
||||
{isDM && targets.length > 0 && (
|
||||
<AttackBar
|
||||
attacker={active}
|
||||
@@ -1654,6 +1858,27 @@ export function EncounterPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBattle && (encounter.recentActions ?? []).length > 0 && (
|
||||
<details className="card" open>
|
||||
<summary className="card-title" style={{ cursor: 'pointer', marginBottom: 0 }}>
|
||||
⚔ What just happened
|
||||
</summary>
|
||||
<div className="mt-sm">
|
||||
<RecentActions actions={encounter.recentActions ?? []} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{isDM && (
|
||||
<details className="card" open={(rolls ?? []).length > 0}>
|
||||
<summary className="card-title" style={{ cursor: 'pointer', marginBottom: 0 }}>
|
||||
🎲 Rolls at the table
|
||||
</summary>
|
||||
<div className="mt-sm">
|
||||
<RollFeed rolls={(rolls ?? []).slice(0, 8)} userId={user?.id} empty="Nobody has rolled yet. Every roll made in the dice roller shows here." />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<TradePanel
|
||||
encounter={encounter}
|
||||
isDM={isDM}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { usePagination } from '../hooks/usePagination';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
import { SPECIES_LABELS, classLabel, formatPurse, slotsSummary, xpLabel } from '../lib/format';
|
||||
import { RestModal } from '../components/RestModal';
|
||||
import { LevelUpBulkModal } from '../components/LevelUpBulkModal';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { ConditionChip, StateBadges } from '../components/CharacterState';
|
||||
@@ -29,6 +30,7 @@ export function PartyOverviewPage() {
|
||||
const { data: characters, isLoading: charsLoading, error: charsError } = useCampaignCharacters(campaignId);
|
||||
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(characters, 12);
|
||||
const [resting, setResting] = useState(false);
|
||||
const [levelling, setLevelling] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
if (campaignLoading || charsLoading) return <Spinner label="Gathering the party…" />;
|
||||
@@ -51,6 +53,11 @@ export function PartyOverviewPage() {
|
||||
🎲 Call for a roll…
|
||||
</button>
|
||||
)}
|
||||
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
|
||||
<button className="btn" onClick={() => setLevelling(true)} title="Approve a level for any of the party at once: each player is told, and picks what they gain themselves">
|
||||
⬆ Level up…
|
||||
</button>
|
||||
)}
|
||||
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
|
||||
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
|
||||
🛏 Rest…
|
||||
@@ -64,6 +71,18 @@ export function PartyOverviewPage() {
|
||||
|
||||
{characters?.length === 0 && <div className="empty-state">No characters yet.</div>}
|
||||
|
||||
{isDM && characters?.some((c) => c.levelUpsPending > 0) && (
|
||||
<div className="notice-banner" role="status">
|
||||
<span style={{ flex: 1 }}>
|
||||
<strong>⬆ Level ups waiting on picks:</strong>{' '}
|
||||
{characters
|
||||
.filter((c) => c.levelUpsPending > 0)
|
||||
.map((c) => `${c.name}${c.userId ? '' : ' (no player: yours to finish)'}`)
|
||||
.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{characters && characters.length > 0 && (
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
@@ -125,6 +144,7 @@ export function PartyOverviewPage() {
|
||||
<td data-label="Class">{c.characterClass ? classLabel(c.characterClass, c.subclassName, c.classes) : inProgress ? 'Building…' : '—'}</td>
|
||||
<td data-label="Level">
|
||||
{c.level}
|
||||
{c.levelUpsPending > 0 && <span title="A level up is approved and waiting for its picks"> ⬆</span>}
|
||||
{!inProgress && data?.campaign.trackXp && (
|
||||
<div className="combatant-sub mono" style={{ whiteSpace: 'nowrap' }}>
|
||||
{xpLabel(c.xp, c.xpProgress)}
|
||||
@@ -167,6 +187,9 @@ export function PartyOverviewPage() {
|
||||
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
|
||||
{isDM && checking && <GroupCheckModal characters={checkers} onClose={() => setChecking(false)} />}
|
||||
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
|
||||
{isDM && levelling && (
|
||||
<LevelUpBulkModal campaignId={campaignId} characters={characters ?? []} trackXp={data?.campaign.trackXp ?? true} onClose={() => setLevelling(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.24.0",
|
||||
"version": "1.30.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.24.0",
|
||||
"version": "1.30.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.24.0",
|
||||
"version": "1.30.0",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.24.0",
|
||||
"version": "1.30.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.24.0",
|
||||
"version": "1.30.0",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user