Compare commits

..
7 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 4bd301182b V1.32.0: battles that keep to the fight
- The scene description shows during a battle only between rounds (while
  the first in the order is on turn), and always before and after it
- Combatant details start closed in a battle: the spotlight has whoever is
  on turn, or on a narrow screen their own card opens
- While a battle is under way, Add to the scene, Experience and Rewards
  fold to their titles
- Button labels wrap inside their buttons instead of spilling out
- Sticky turn order and side column follow the measured top bar height
- Phone: quick stats no longer sits on Log out, incoming rolls clear the
  bottom bar, the damage bar no longer scrolls on its own
- Docs updated; all packages at version 1.32.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:19:48 +02:00
joshuaandClaude Opus 5.5 cbda10668b V1.31.0: armor holds Dexterity back
- All of a Dexterity modifier counts toward AC in light armor, at most +2
  in medium armor, none in heavy armor, going by the equipped item's kind
- The gear card says when the armor is holding something back
- Library and seed armor notes and docs brought in line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:24:28 +02:00
joshuaandClaude Opus 5.5 16b5ac05ca V1.30.0: class features that change a number are worked out on the sheet
- Unarmored Defense (barbarian, monk), Draconic Resilience, Fast Movement,
  Unarmored Movement, Aura of Protection, Diamond Soul, Slippery Mind and
  Jack of All Trades are counted, by level in the class that gives them
- Armor and shields are items marked with what kind they are, so the sheet
  knows when a character is unarmored or in heavy armor; the library's and
  the seeds' are marked
- The sheet lists the class features in force; a speed the DM set is final
- A cantrip can be cast with every spell slot spent (tested end to end)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:13:09 +02:00
joshuaandClaude Opus 5.5 9d0a4a2252 V1.29.0: free roam by invitation, no slot no spell, weapons change at the right moment
- Free roam no longer adds the whole party: the DM adds who is there
- A spell with no slot of its level or higher left cannot be declared; it
  takes the lowest slot left at or above its level. Cantrips take none
- In a battle in progress a player changes the weapon in hand only as the
  battle begins or on their own turn after their attack has landed
- Spells are listed cantrips first, then by level, with the level shown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:05:25 +02:00
joshuaandClaude Opus 5.5 555765916f V1.28.0: free roam, and completed encounters out of the way
- Free roam: a third kind of encounter. The DM ticks the nearby locations,
  the whole party is in it from the start, and each player sends their own
  character to a place
- A place shows who is found there and what is for sale; a character shops
  (a place's own shop, or an NPC's wares and exchange) only where they stand
- Locations can have a shop of their own; an NPC can be "found at" a location
- Completed encounters are hidden from the campaign's list until "Show
  completed" is ticked; moving an encounter works on the list as shown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:30:16 +02:00
joshuaandClaude Opus 5.5 9b3637576d V1.27.0: battles that run themselves: track, targets, hit or miss, saves, auto-rolls
- Turn-order track: names coloured by PC, NPC or monster, this round, a flag
  where it ends, and the next round
- Target by clicking a name on the track, the card, or keys 1-9; whoever is
  on turn and every target show what they have in hand
- Attacks and spells are declared, rolled and applied: type or roll the d20
  and the app says hit, miss or critical; a save spell asks each target for
  its save against the caster's DC; the DM overrules anything and applies
- Critical damage by the campaign's rule; a spell spends its slot; a
  concentration save is called for
- Auto-rolled monster and NPC attacks: aimed (weighted), rolled, and offered
  to the DM to accept, edit or discard; hidden from players until accepted
- Monsters can have named attacks and multiattacks; spells say how they land
- A "What now" line tells the DM (and each player) what the scene waits on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:14:04 +02:00
joshuaandClaude Opus 5.5 8811f14732 V1.26.0: dice that tumble, rolled by the server, seen by the DM
- Each die is drawn as its own shape, tumbles in and lands on its result;
  a natural 20 glows, a natural 1 shakes; respects reduced motion
- In a campaign the server makes every digital roll and keeps it
- The DM sees each roll as it lands: an "Everyone's rolls" tab in the
  roller, a pop-up beside the dice button, and a list on the encounter page
- Players see everyone's rolls or only their own, by the campaign setting;
  the DM can roll hidden
- Physical dice are typed in, one box per die, and checked; with "both",
  each person chooses in the roller
- game/dice.js: parsing, rolling, checking typed dice, critical damage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:46:22 +02:00
64 changed files with 5839 additions and 407 deletions
+37
View File
@@ -99,6 +99,43 @@ 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.
- **Armor holds Dexterity back** as in the rules: all of it counts toward AC
in light armor, at most +2 in medium, none in heavy.
- **Battles that keep to the fight.** While a battle is under way the scene
description shows only between rounds, cards' details start closed (the
spotlight, or on a phone the card on turn, has them), and the DM's setup,
Experience and Rewards panels fold to their titles. Button labels wrap
inside their buttons, and on a phone quick stats no longer sits on Log out
nor incoming rolls on the bottom bar.
- **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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.25.0",
"version": "1.32.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -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';
+129 -7
View File
@@ -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")
@@ -561,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)
@@ -572,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())
@@ -626,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[]
@@ -669,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[]
@@ -695,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[]
@@ -739,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).
@@ -771,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`
@@ -791,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")
}
+3 -3
View File
@@ -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.' },
];
File diff suppressed because one or more lines are too long
+49 -12
View File
@@ -73,6 +73,15 @@ function armorClass(dexMod, armorBonus = 0) {
return 10 + dexMod + armorBonus;
}
// How much of a Dexterity modifier counts toward AC in the armor worn: all
// of it in light armor or none, at most +2 in medium armor, and none of it
// (a penalty included) in heavy armor.
function armorDexterity(dexMod, worn = null) {
if (worn === 'HEAVY') return 0;
if (worn === 'MEDIUM') return Math.min(dexMod, 2);
return dexMod;
}
function initiative(dexMod) {
return dexMod;
}
@@ -287,6 +296,12 @@ 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 worn = ['HEAVY', 'MEDIUM', 'LIGHT'].find((kind) => kinds.includes(kind)) || null;
const suit = worn ? held.find((h) => h.entry.armorType === worn) : null;
const armor = { worn, name: suit ? suit.entry.name : 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 +310,7 @@ function activeGear(character) {
}
if (entry.effectNote) notes.push({ source: entry.name, note: entry.effectNote });
}
return { effects, notes };
return { effects, notes, armor };
}
/**
@@ -347,11 +362,30 @@ 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);
// Medium and heavy armor hold Dexterity back; said on the gear card when it bites.
const acDex = armorDexterity(abilities.DEX.modifier, gear.armor.worn);
if (acDex !== abilities.DEX.modifier) {
gear.notes.push({
source: gear.armor.name,
note: gear.armor.worn === 'HEAVY' ? 'Heavy armor: Dexterity does not count toward AC.' : 'Medium armor: at most +2 from Dexterity counts toward AC.',
});
}
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 = {};
@@ -364,14 +398,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) }))
@@ -386,14 +416,17 @@ 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(acDex, 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: {
// 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) + fromGear('MAX_HP')
(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),
@@ -404,6 +437,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) })),
@@ -549,6 +584,7 @@ module.exports = {
passivePerception,
passiveScore,
armorClass,
armorDexterity,
initiative,
hpMax,
spellSaveDC,
@@ -586,3 +622,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');
+111
View File
@@ -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 };
+168
View File
@@ -0,0 +1,168 @@
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: [] });
});
});
describe('Dexterity in armor', () => {
const { armorDexterity } = require('./calculations');
// Dexterity 18 (+4), and a clumsy one at 8 (-1).
const nimble = (inventoryItems) => sheet({ characterClass: 'FIGHTER', dexScore: 17, inventoryItems });
const clumsy = (inventoryItems) => sheet({ characterClass: 'FIGHTER', dexScore: 7, inventoryItems });
test('all of it in light armor or none, at most +2 in medium, none in heavy', () => {
expect([null, 'LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD'].map((worn) => armorDexterity(4, worn))).toEqual([4, 4, 2, 0, 4]);
expect([null, 'LIGHT', 'MEDIUM', 'HEAVY'].map((worn) => armorDexterity(-1, worn))).toEqual([-1, -1, -1, 0]);
});
test('the sheet’s AC follows, and the gear card says why', () => {
expect(nimble([]).armorClass).toBe(14);
expect(nimble([wearing('Leather Armor', 'LIGHT', 1)]).armorClass).toBe(15);
const scale = nimble([wearing('Scale Mail', 'MEDIUM', 4)]);
expect(scale.armorClass).toBe(16); // 10 + 2 of the 4 + 4
expect(scale.gear.notes).toEqual([{ source: 'Scale Mail', note: 'Medium armor: at most +2 from Dexterity counts toward AC.' }]);
const plate = nimble([wearing('Plate Armor', 'HEAVY', 8), wearing('Shield', 'SHIELD', 2)]);
expect(plate.armorClass).toBe(20); // 18, and the shield
expect(plate.gear.notes[0]).toEqual({ source: 'Plate Armor', note: 'Heavy armor: Dexterity does not count toward AC.' });
// Heavy armor hides a Dexterity penalty too; medium does not.
expect(clumsy([wearing('Plate Armor', 'HEAVY', 8)]).armorClass).toBe(18);
expect(clumsy([wearing('Scale Mail', 'MEDIUM', 4)]).armorClass).toBe(13);
// Nothing to say when the armor holds nothing back, and only Dexterity's part in AC changes.
expect(sheet({ characterClass: 'FIGHTER', inventoryItems: [wearing('Scale Mail', 'MEDIUM', 4)] }).gear.notes).toEqual([]);
expect(plate.initiative).toBe(4);
expect(plate.saves.DEX.bonus).toBe(4);
});
test('an item with +AC that is not marked as armor holds nothing back', () => {
expect(nimble([wearing('Ring of Protection', null, 1)]).armorClass).toBe(15);
});
});
+212
View File
@@ -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,
};
+175
View File
@@ -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);
});
});
+2 -2
View File
@@ -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.' },
+18 -14
View File
@@ -3,13 +3,15 @@
// matches the `Item` Prisma model minus `id`/`campaignId`, plus a stable `key`
// for "add to campaign" lookups and a `category` for the library picker.
//
// Armor here is a flat bonus to AC on top of 10 + Dexterity, because that is
// how gear effects work (game/calculations.js, GEAR_STATS): the standard
// rules' "replaces 10 + DEX" and "DEX bonus capped at 2" are not modelled.
// Each suit's bonus is its standard AC less 10, and what the bonus cannot say
// is in the note. A DM who wants it stricter edits the copy in their campaign.
// Armor's AC is a bonus on top of 10 + Dexterity, because that is how gear
// effects work (game/calculations.js, GEAR_STATS): each suit's bonus is its
// standard AC less 10. What kind of armor it is (armorType, from the category)
// tells the sheet how much Dexterity counts in it: all of it in light armor,
// at most +2 in medium, none in heavy (calculations.js, armorDexterity).
// What neither can say (a Strength requirement, Stealth) is in the note.
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 +20,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,
@@ -36,17 +40,17 @@ const ITEM_LIBRARY = [
armor('studded-leather', 'Studded Leather', 'Light Armor', 2, 'Tough leather set with close rivets and spikes.'),
// Medium armor
armor('hide-armor', 'Hide Armor', 'Medium Armor', 2, 'Thick furs and pelts.', 'Standard rules: counts at most +2 from Dexterity.'),
armor('chain-shirt', 'Chain Shirt', 'Medium Armor', 3, 'Interlocking rings worn between layers of clothing.', 'Standard rules: counts at most +2 from Dexterity.'),
armor('scale-mail', 'Scale Mail', 'Medium Armor', 4, 'A coat and leggings of leather covered in overlapping metal scales.', `${STEALTH} Standard rules: counts at most +2 from Dexterity.`),
armor('breastplate', 'Breastplate', 'Medium Armor', 4, 'A fitted metal chest piece worn with supple leather.', 'Standard rules: counts at most +2 from Dexterity.'),
armor('half-plate', 'Half Plate', 'Medium Armor', 5, 'Shaped metal plates covering most of the body.', `${STEALTH} Standard rules: counts at most +2 from Dexterity.`),
armor('hide-armor', 'Hide Armor', 'Medium Armor', 2, 'Thick furs and pelts.', 'Medium armor: at most +2 from Dexterity counts (worked out on the sheet).'),
armor('chain-shirt', 'Chain Shirt', 'Medium Armor', 3, 'Interlocking rings worn between layers of clothing.', 'Medium armor: at most +2 from Dexterity counts (worked out on the sheet).'),
armor('scale-mail', 'Scale Mail', 'Medium Armor', 4, 'A coat and leggings of leather covered in overlapping metal scales.', `${STEALTH} Medium armor: at most +2 from Dexterity counts (worked out on the sheet).`),
armor('breastplate', 'Breastplate', 'Medium Armor', 4, 'A fitted metal chest piece worn with supple leather.', 'Medium armor: at most +2 from Dexterity counts (worked out on the sheet).'),
armor('half-plate', 'Half Plate', 'Medium Armor', 5, 'Shaped metal plates covering most of the body.', `${STEALTH} Medium armor: at most +2 from Dexterity counts (worked out on the sheet).`),
// Heavy armor: slow, loud, and in the standard rules it ignores Dexterity.
armor('ring-mail', 'Ring Mail', 'Heavy Armor', 4, 'Leather armor with heavy rings sewn into it.', `${STEALTH} Standard rules: adds no Dexterity.`),
armor('chain-mail', 'Chain Mail', 'Heavy Armor', 6, 'Interlocking metal rings over a layer of quilted fabric, with gauntlets.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 13.`, [{ stat: 'SPEED', amount: -10 }]),
armor('splint-armor', 'Splint Armor', 'Heavy Armor', 7, 'Narrow vertical strips of metal riveted to a leather backing.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
armor('plate-armor', 'Plate Armor', 'Heavy Armor', 8, 'Shaped, interlocking metal plates covering the whole body.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
armor('ring-mail', 'Ring Mail', 'Heavy Armor', 4, 'Leather armor with heavy rings sewn into it.', `${STEALTH} Heavy armor: Dexterity does not count (worked out on the sheet).`),
armor('chain-mail', 'Chain Mail', 'Heavy Armor', 6, 'Interlocking metal rings over a layer of quilted fabric, with gauntlets.', `${STEALTH} Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 13.`, [{ stat: 'SPEED', amount: -10 }]),
armor('splint-armor', 'Splint Armor', 'Heavy Armor', 7, 'Narrow vertical strips of metal riveted to a leather backing.', `${STEALTH} Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
armor('plate-armor', 'Plate Armor', 'Heavy Armor', 8, 'Shaped, interlocking metal plates covering the whole body.', `${STEALTH} Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
// Shields
armor('shield', 'Shield', 'Shields', 2, 'Wood or metal, carried in one hand.'),
+12 -12
View File
@@ -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.' }],
+57
View File
@@ -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 };
+1 -1
View File
@@ -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.' }],
+105
View File
@@ -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 };
+77
View File
@@ -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);
});
});
+465 -5
View File
@@ -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, name: null, shield: false } });
// Only the DM says what gear does, and only with numbers the sheet has.
const mail = {
@@ -3748,7 +3748,9 @@ describe('approved level-ups', () => {
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 before = (await request(app).get(`/characters/${id}`).set(dmAuth)).body.character.sheet.hp.max;
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 });
@@ -3756,15 +3758,15 @@ describe('approved level-ups', () => {
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 and +2 Constitution (CON 14), where the average would have given 6 + 2.
expect(low.body.character.sheet.hp.max).toBe(before + 2 + 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 + 4 + rolled.body.hpRolled + 2);
expect(rolled.body.character.sheet.hp.max).toBe(before + 2 + con + rolled.body.hpRolled + con);
});
});
@@ -3788,3 +3790,461 @@ describe('a player picking spells on a level up', () => {
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, and Dexterity
// no longer counts (10 + the armor's 2). 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: 12, 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);
});
});
+173 -4
View File
@@ -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');
@@ -644,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) => {
@@ -700,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,
});
@@ -771,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) => {
@@ -833,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.
@@ -847,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 },
@@ -879,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);
@@ -941,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];
@@ -962,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) {
@@ -974,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);
@@ -1086,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),
@@ -1093,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) => {
@@ -1103,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.` });
@@ -1243,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;
+52 -4
View File
@@ -48,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();
@@ -173,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,
};
}
@@ -453,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.
@@ -1438,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,
@@ -1619,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 });
}
@@ -1744,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]);
+477 -21
View File
@@ -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;
+18 -1
View File
@@ -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(),
}),
},
};
+184
View File
@@ -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 };
+5 -1
View File
@@ -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 };
+2 -2
View File
@@ -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
+106 -29
View File
@@ -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:
@@ -814,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 (as much of it as the armor worn lets through; 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) |
@@ -832,6 +834,57 @@ 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.
**Dexterity in armor.** How much of the DEX modifier counts toward AC goes by
the armor that is equipped (items are marked light, medium or heavy:
[ITEMS.md](ITEMS.md), section 5):
| Armor worn | DEX modifier counted toward AC |
|---|---|
| None, or light | All of it |
| Medium | At most +2 (a penalty counts in full) |
| Heavy | None of it, a penalty included |
Only AC is affected: initiative, Dexterity saves and skills use the full
modifier. When the armor is holding something back, the "From Equipped Gear"
card says so. A shield changes nothing here, and neither does an item with
+AC that is not marked as armor.
### 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
@@ -862,9 +915,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 |
|---|---|
@@ -883,10 +938,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.
@@ -900,8 +975,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
@@ -1320,7 +1396,8 @@ 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 proficiency, a Strength score needed for heavy armor, and
disadvantage on Stealth (these are notes on the armor)
- 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
@@ -1403,12 +1480,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. |
@@ -1683,7 +1760,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) |
@@ -1753,7 +1830,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). |
@@ -2036,23 +2113,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. |
@@ -2113,7 +2190,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 |
@@ -2125,7 +2202,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 |
@@ -2248,7 +2325,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. |
@@ -2324,7 +2401,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. |
+216 -32
View File
@@ -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
@@ -115,6 +155,16 @@ characters, and characters keep their current HP.
Both save when the DM clicks out of the box.
In a battle under way the Scene box is only there between rounds: while the
first in the turn order is on turn. For the rest of the round it is out of
the way, for the DM and the players alike. Before the battle begins and once
it is over it is always there.
**Folded panels.** While a battle is in progress, "Add to the scene",
Experience and Rewards on the DM's side column start folded to their title
(click the title to open one), so the fight is what fills the screen. They
open again when the battle ends, or before it begins.
## 5. Who is in the scene
The DM adds people from the "Add to the scene" panel.
@@ -187,14 +237,25 @@ 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,
damage and DM notes.
Details start open for the DM and closed for players. Weapons and spells
In a battle every card's details start closed, so the list stays short: on a
wide screen the spotlight shows whoever is on turn in full, and on a narrower
one the card of whoever is on turn opens its details by itself (and closes
them again when the turn passes). In a quest or free roam the details start
open for the DM and closed for players. "▸ Show details" / "▾ Hide details"
on a card overrides this for that card. Weapons and spells
above a character's level are left out, because the character cannot use
them.
@@ -319,6 +380,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 +409,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 +422,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 +464,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 +685,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 +742,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 +762,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 +790,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
+13
View File
@@ -66,6 +66,19 @@ passivePerception = 10 + perceptionSkillBonus
armorClass = 10 + dexModifier + armorBonus
```
`dexModifier` here is as much of it as the equipped armor lets through
(`armorDexterity`): all of it with none or light armor, at most +2 in medium
armor, none in heavy armor. Armor is an item marked with its kind
(`Item.armorType`); its own AC is a gear effect.
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
+28 -15
View File
@@ -168,6 +168,19 @@ 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, its usual AC less 10); the mark says what kind of thing
it is. The sheet counts **all** of a character's Dexterity modifier toward AC
in light armor, **at most +2** in medium armor and **none** in heavy armor
([CHARACTERS.md](CHARACTERS.md), section 8), so plate is +8 and comes to AC
18 whoever wears it. Some class features depend on what is worn too: 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 |
@@ -244,8 +257,8 @@ and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
## 9. What is not modelled
- Armor rules beyond a flat bonus: armor does not replace 10 + DEX, cap the
DEX bonus or need a Strength score (set the AC effect to match)
- A Strength score needed for heavy armor, armor proficiency and Stealth
disadvantage: these are notes on the armor for the table to apply
- Effects on attack rolls, damage, spell save DC or proficiency bonus
- Effects that depend on something (only at night, only against undead), or
that grow with the item's level
@@ -265,10 +278,10 @@ and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
Everything "📚 From Library" offers. "While equipped" is what the item does
to the sheet of whoever has it on.
Armor is a flat bonus to AC on top of 10 + Dexterity: each suit's bonus is its
standard AC less 10. What a flat bonus cannot say (heavy armor ignoring
Dexterity, a Strength requirement, disadvantage on Stealth) is in the note,
for the DM to apply or to build into the numbers of their own copy.
Armor is a bonus to AC on top of 10 + Dexterity: each suit's bonus is its
standard AC less 10, and it is marked light, medium or heavy, which sets how
much Dexterity counts in it (section 5). What that cannot say (a Strength
requirement, disadvantage on Stealth) is in the note, for the DM to apply.
This list is written from the app's own data by `npm run docs:lists` (in `/backend`). Re-run it after changing the data; do not edit the list by hand.
@@ -285,20 +298,20 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Hide Armor | +2 AC | Standard rules: counts at most +2 from Dexterity. | — | Thick furs and pelts. |
| Chain Shirt | +3 AC | Standard rules: counts at most +2 from Dexterity. | — | Interlocking rings worn between layers of clothing. |
| Scale Mail | +4 AC | Disadvantage on Stealth checks. Standard rules: counts at most +2 from Dexterity. | — | A coat and leggings of leather covered in overlapping metal scales. |
| Breastplate | +4 AC | Standard rules: counts at most +2 from Dexterity. | — | A fitted metal chest piece worn with supple leather. |
| Half Plate | +5 AC | Disadvantage on Stealth checks. Standard rules: counts at most +2 from Dexterity. | — | Shaped metal plates covering most of the body. |
| Hide Armor | +2 AC | Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | Thick furs and pelts. |
| Chain Shirt | +3 AC | Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | Interlocking rings worn between layers of clothing. |
| Scale Mail | +4 AC | Disadvantage on Stealth checks. Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | A coat and leggings of leather covered in overlapping metal scales. |
| Breastplate | +4 AC | Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | A fitted metal chest piece worn with supple leather. |
| Half Plate | +5 AC | Disadvantage on Stealth checks. Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | Shaped metal plates covering most of the body. |
### Heavy Armor (4)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Ring Mail | +4 AC | Disadvantage on Stealth checks. Standard rules: adds no Dexterity. | — | Leather armor with heavy rings sewn into it. |
| Chain Mail | +6 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 13. | — | Interlocking metal rings over a layer of quilted fabric, with gauntlets. |
| Splint Armor | +7 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 15. | — | Narrow vertical strips of metal riveted to a leather backing. |
| Plate Armor | +8 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 15. | — | Shaped, interlocking metal plates covering the whole body. |
| Ring Mail | +4 AC | Disadvantage on Stealth checks. Heavy armor: Dexterity does not count (worked out on the sheet). | — | Leather armor with heavy rings sewn into it. |
| Chain Mail | +6 AC, −10 Speed | Disadvantage on Stealth checks. Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 13. | — | Interlocking metal rings over a layer of quilted fabric, with gauntlets. |
| Splint Armor | +7 AC, −10 Speed | Disadvantage on Stealth checks. Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 15. | — | Narrow vertical strips of metal riveted to a leather backing. |
| Plate Armor | +8 AC, −10 Speed | Disadvantage on Stealth checks. Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 15. | — | Shaped, interlocking metal plates covering the whole body. |
### Shields (2)
+9 -3
View File
@@ -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)
+7
View File
@@ -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
+29
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.25.0",
"version": "1.32.0",
"type": "module",
"scripts": {
"dev": "vite",
+91 -2
View File
@@ -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,
@@ -28,6 +33,7 @@ import type {
QuickStats,
RestResult,
RestType,
RollSpec,
Spell,
Weapon,
} from './types';
@@ -380,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({
@@ -620,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: () => {
@@ -814,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'] }),
});
@@ -960,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) {
@@ -1062,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.
+165 -2
View File
@@ -384,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 {
@@ -433,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 {
@@ -456,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 {
@@ -566,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.
@@ -579,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`
@@ -601,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.
@@ -657,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 {
@@ -714,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;
@@ -746,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
}
@@ -957,6 +1078,48 @@ export interface LevelUpApproval {
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).
+361 -145
View File
@@ -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>
+1 -1
View File
@@ -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, and medium or heavy armor holds back how much Dexterity counts. 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.',
+15 -2
View File
@@ -1,4 +1,4 @@
import { Suspense } from 'react';
import { Suspense, useEffect, useRef } from 'react';
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
import { useAuth } from '../auth/useAuth';
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
@@ -15,6 +15,19 @@ export function Layout() {
useRememberCampaign(onCampaign?.params.campaignId);
const campaignId = useLastCampaignId();
// How tall the top bar really is, as --topbar-h, so that what sticks under
// it (the turn order in a battle) sits right below it on any screen.
const topbar = useRef<HTMLElement>(null);
useEffect(() => {
const bar = topbar.current;
if (!bar) return;
const measure = () => document.documentElement.style.setProperty('--topbar-h', `${bar.offsetHeight}px`);
measure();
const observer = new ResizeObserver(measure);
observer.observe(bar);
return () => observer.disconnect();
}, []);
// The same destinations twice: along the top bar on a wide screen, and as a
// bar along the bottom, in reach of a thumb, on a phone.
const destinations = [
@@ -39,7 +52,7 @@ export function Layout() {
return (
<div className="app-shell">
<header className="topbar">
<header className="topbar" ref={topbar}>
<Link to="/campaigns" className="topbar-brand">
<span className="die" aria-hidden>
⚄
+53
View File
@@ -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>
);
}
+1
View File
@@ -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
+3 -3
View File
@@ -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,29 @@ 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 (its usual AC less 10). This says what kind it is. The sheet counts all of a character’s
Dexterity toward AC in light armor, at most +2 in medium armor and none in heavy armor. It also decides 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 +164,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 +218,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 +253,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 +287,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>
+72 -2
View File
@@ -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',
+92
View File
@@ -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>
);
}
+6
View File
@@ -66,6 +66,10 @@ export function useCampaignSocket(campaignId: string | undefined) {
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']);
};
@@ -85,6 +89,7 @@ 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);
@@ -98,6 +103,7 @@ export function useCampaignSocket(campaignId: string | undefined) {
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);
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useState } from 'react';
// Whether a CSS media query matches right now, kept up to date as the window
// is resized: useMediaQuery('(min-width: 960px)').
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const list = window.matchMedia(query);
const onChange = () => setMatches(list.matches);
onChange();
list.addEventListener('change', onChange);
return () => list.removeEventListener('change', onChange);
}, [query]);
return matches;
}
+575 -31
View File
@@ -25,6 +25,8 @@
--radius: 8px;
--font-display: 'Cinzel', Georgia, serif;
--font-body: 'Crimson Pro', Georgia, 'Times New Roman', serif;
/* The top bar's height, measured by Layout.tsx; this is only until then. */
--topbar-h: 3.6rem;
}
@media (prefers-color-scheme: light) {
@@ -262,7 +264,12 @@ textarea {
color: var(--text);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
/* A label longer than the room there is wraps inside the button rather
than running out of it (or under the button beside it). */
max-width: 100%;
line-height: 1.25;
text-align: center;
overflow-wrap: break-word;
}
.btn:hover:not(:disabled) {
border-color: var(--accent);
@@ -612,6 +619,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: var(--topbar-h);
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;
@@ -912,11 +1465,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 {
@@ -1632,16 +2183,26 @@ textarea {
.fab-dice:hover {
transform: none;
}
.dice-incoming {
left: 0.85rem;
bottom: calc(var(--bottom-nav-height) + 0.6rem);
width: calc(100vw - 1.7rem);
}
.dice-panel {
left: 0.85rem;
bottom: calc(var(--bottom-nav-height) + 0.6rem);
width: calc(100vw - 1.7rem);
max-height: calc(100dvh - var(--bottom-nav-height) - 5rem);
}
/* Log out has a set width on a phone, so quick stats can sit just left of it. */
.topbar-user .btn {
width: 5.2rem;
padding: 0.45rem 0.3rem;
}
.fab-quickstats {
top: 0.3rem;
top: calc((var(--topbar-h) - 40px) / 2);
bottom: auto;
right: 6.4rem;
right: calc(0.85rem + 5.2rem + 0.5rem);
z-index: 31;
width: 40px;
height: 40px;
@@ -1650,7 +2211,7 @@ textarea {
}
/* The brand makes room for it. */
.app-shell:has(.fab-quickstats) .topbar-brand {
max-width: calc(100vw - 11rem);
max-width: calc(100vw - 0.85rem - 6.55rem - 40px - 1.25rem);
}
.drawer {
width: 100vw;
@@ -1693,7 +2254,6 @@ textarea {
min-width: min(100%, 8rem) !important;
}
.btn {
white-space: normal;
padding: 0.6rem 0.9rem;
}
.grid-cols-2 {
@@ -1745,11 +2305,6 @@ textarea {
.jump-link {
padding: 0.5rem 0.8rem;
}
.attack-bar {
top: 3.4rem;
max-height: 55dvh;
overflow-y: auto;
}
[data-jump] {
scroll-margin-top: 3.8rem;
}
@@ -2204,7 +2759,7 @@ button.slot-pip:disabled {
/* Everyone in the scene on one line that stays under the top bar. */
.turn-strip {
position: sticky;
top: 3.6rem;
top: var(--topbar-h);
z-index: 25;
display: flex;
gap: 0.4rem;
@@ -2269,20 +2824,17 @@ button.slot-pip:disabled {
.turn-chip.has-acted {
opacity: 0.55;
}
/* The attack bar sits under the strip, not behind it. */
.attack-bar {
top: 7.6rem;
}
/* On a wide screen the side column stays in view, scrolling on its own. */
@media (min-width: 960px) {
.encounter-side {
position: sticky;
top: 7.6rem;
max-height: calc(100vh - 8.4rem);
top: calc(var(--topbar-h) + 4rem);
max-height: calc(100vh - var(--topbar-h) - 4.8rem);
overflow-y: auto;
scrollbar-width: thin;
padding-right: 2px;
/* Room to scroll the last card up past the quick-stats button. */
padding-bottom: 5rem;
}
}
.spotlight {
@@ -2342,6 +2894,7 @@ button.slot-pip:disabled {
summary.card-title {
cursor: pointer;
list-style: none;
justify-content: flex-start;
}
summary.card-title::-webkit-details-marker {
display: none;
@@ -2358,15 +2911,6 @@ details:not([open]) > summary.card-title::after {
content: '▸';
}
@media (max-width: 900px) {
.turn-strip {
top: 2.9rem;
}
.attack-bar {
top: 6.6rem;
}
}
/* In one column the card on turn is already at the top of the list. */
@media (max-width: 959px) {
.spotlight,
+23 -1
View File
@@ -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' };
}
+13
View File
@@ -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);
+48 -8
View File
@@ -68,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';
}
@@ -80,7 +91,7 @@ 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),
};
@@ -135,7 +146,8 @@ function CharacterRow({ character, isDM, mine = false, onDelete }: { character:
);
}
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);
@@ -193,13 +205,16 @@ export function CampaignHomePage() {
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');
@@ -243,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)),
@@ -511,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
@@ -542,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
@@ -566,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>
)}
@@ -579,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);
+3 -1
View File
@@ -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">
+29 -1
View File
@@ -682,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>
@@ -887,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 && (
<>
{' · '}
+275 -27
View File
@@ -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';
@@ -39,6 +45,7 @@ import { GearInForce } from '../components/CharacterPanel';
import { SheetNumbers } from '../components/SheetNumbers';
import { ConditionChip, ConditionPicker, ResourceTracker, StateBadges } from '../components/CharacterState';
import { useHotkeys } from '../hooks/useHotkeys';
import { useMediaQuery } from '../hooks/useMediaQuery';
import { Rollable } from '../components/Rollable';
import { GroupCheckModal } from '../components/GroupCheckModal';
import { withBonus } from '../lib/diceRoller';
@@ -49,6 +56,7 @@ import type {
CharacterSummary,
Currency,
Encounter,
EncounterAction,
EncounterMode,
EncounterParticipant,
EncounterReward,
@@ -66,7 +74,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 +304,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];
@@ -445,6 +497,7 @@ function CombatantCard({
onTarget,
isPinned,
onPin,
detailsOpen,
onRemove,
onError,
}: {
@@ -468,17 +521,18 @@ function CombatantCard({
// Whether this combatant's stats are the ones kept in view, and the switch for it.
isPinned: boolean;
onPin: () => void;
// Whether the details start open (until the viewer opens or closes them).
detailsOpen: boolean;
onRemove: () => void;
onError: (err: unknown) => void;
}) {
const [hpDelta, setHpDelta] = useState('');
// How many rounds the next condition added lasts; empty for until removed.
const [rounds, setRounds] = useState('');
// Details start open for the DM (who runs the scene from this screen) and
// closed for players; null = "not toggled yet", so it follows isDM once the
// campaign role has loaded.
// null = "not toggled yet", so the details follow detailsOpen (which
// changes as the turn passes, and once the campaign role has loaded).
const [detailsToggled, setDetailsToggled] = useState<boolean | null>(null);
const showDetails = detailsToggled ?? isDM;
const showDetails = detailsToggled ?? detailsOpen;
const info = p.characterCombatInfo;
const monster = p.monster;
@@ -599,6 +653,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 +759,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 +917,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;
@@ -873,12 +990,14 @@ function ExperiencePanel({
difficulty,
xpAwarded,
partySize,
folded,
onError,
}: {
encounterId: string;
difficulty: EncounterDifficulty;
xpAwarded: number | null;
partySize: number;
folded: boolean; // starts closed, as it does while a battle is under way
onError: (err: unknown) => void;
}) {
const awardXp = useAwardXp(encounterId);
@@ -887,11 +1006,11 @@ function ExperiencePanel({
const { thresholds, rating } = difficulty;
return (
<div className="card">
<div className="card-title">
<details className="card" open={!folded}>
<summary className="card-title">
<span>Experience</span>
{rating && <span className={`badge ${RATING_BADGES[rating]}`}>{RATING_LABELS[rating]}</span>}
</div>
</summary>
{difficulty.monsters === 0 ? (
<p className="combatant-sub" style={{ marginTop: 0 }}>
No monster here has a challenge rating, so there is nothing to judge the difficulty by. XP can still be awarded: type an amount.
@@ -946,7 +1065,7 @@ function ExperiencePanel({
</button>
</div>
)}
</div>
</details>
);
}
@@ -962,6 +1081,7 @@ function RewardsPanel({
characters,
weapons,
items,
folded,
onError,
}: {
encounter: Encounter;
@@ -969,6 +1089,7 @@ function RewardsPanel({
characters: CharacterSummary[];
weapons: Weapon[];
items: Item[];
folded: boolean; // starts closed, as it does while a battle is under way
onError: (err: unknown) => void;
}) {
const addReward = useAddReward(encounter.id);
@@ -1016,8 +1137,8 @@ function RewardsPanel({
}
return (
<div className="card">
<div className="card-title">Rewards</div>
<details className="card" open={!folded}>
<summary className="card-title">Rewards{rewards.length > 0 && <span className="section-count">{rewards.length}</span>}</summary>
{isDM && rewards.length === 0 && (
<p className="combatant-sub" style={{ marginTop: 0 }}>
Line up coins, weapons and items here. Players only see a reward once you award it.
@@ -1170,7 +1291,7 @@ function RewardsPanel({
</div>
</div>
)}
</div>
</details>
);
}
@@ -1182,6 +1303,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,10 +1321,13 @@ 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 ?? '');
const navigate = useNavigate();
// Wide enough for the side column, and so for the spotlight in it.
const wide = useMediaQuery('(min-width: 960px)');
const [pickType, setPickType] = useState<'character' | 'monster' | 'npc'>('character');
const [pickId, setPickId] = useState('');
@@ -1224,18 +1350,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 +1412,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'
@@ -1300,6 +1463,17 @@ export function EncounterPage() {
);
const unrolled = participants.filter((p) => p.kind !== 'CHARACTER' && p.initiativeRoll == null).length;
// In a battle every card's details start closed, so the list stays short:
// the spotlight beside it shows whoever is on turn in full, and where there
// is no room for the spotlight (one column) their own card opens instead.
// Elsewhere the DM, who runs the scene from this screen, gets them open.
const detailsOpen = (p: EncounterParticipant) => (isBattle ? p.id === active?.id && !wide : isDM);
// A battle under way folds away what is for before and after it.
const inBattle = isBattle && encounter.status === 'ACTIVE';
// The scene is set before the fight and between rounds (while the first in
// the order is on turn), and stays out of the way during the round.
const showScene = !inBattle || active?.id === standing[0]?.id;
const card = (p: EncounterParticipant) => (
<CombatantCard
key={p.id}
@@ -1314,11 +1488,12 @@ 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}
onPin={() => setPinnedId(p.id === pinnedId ? null : p.id)}
detailsOpen={detailsOpen(p)}
onRemove={() => removeParticipant.mutate(p.id, fail)}
onError={onError}
/>
@@ -1355,7 +1530,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 +1562,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 +1584,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 +1604,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 +1654,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) && (
{(isDM || encounter.description) && showScene && (
<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 +1689,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 +1704,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}
@@ -1586,8 +1811,8 @@ export function EncounterPage() {
/>
)}
{isDM && (
<div className="card">
<div className="card-title">Add to the scene</div>
<details className="card" open={!inBattle}>
<summary className="card-title">Add to the scene</summary>
<div className="flex-row flex-wrap gap-sm">
<select
aria-label="What to add"
@@ -1651,7 +1876,28 @@ export function EncounterPage() {
Create {pickType === 'npc' ? 'NPCs' : 'monsters'} on the <Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link> first.
</p>
)}
</div>
</details>
)}
{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
@@ -1670,6 +1916,7 @@ export function EncounterPage() {
difficulty={encounter.difficulty}
xpAwarded={encounter.xpAwarded ?? null}
partySize={participants.filter((p) => p.kind === 'CHARACTER' && p.character).length}
folded={inBattle}
onError={onError}
/>
)}
@@ -1680,6 +1927,7 @@ export function EncounterPage() {
characters={builtCharacters}
weapons={weapons ?? []}
items={items ?? []}
folded={inBattle}
onError={onError}
/>
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.25.0",
"version": "1.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.25.0",
"version": "1.32.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.25.0",
"version": "1.32.0",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.25.0",
"version": "1.32.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.25.0",
"version": "1.32.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [