Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bd301182b | ||
|
|
cbda10668b | ||
|
|
16b5ac05ca | ||
|
|
9d0a4a2252 | ||
|
|
555765916f | ||
|
|
9b3637576d | ||
|
|
8811f14732 | ||
|
|
c3f2b0dfa7 | ||
|
|
86ef106969 | ||
|
|
18d9e7301c |
@@ -93,9 +93,57 @@ including a character sheet or an encounter) and **Account**.
|
||||
inspiration, exhaustion, conditions (which stay with the character from one
|
||||
encounter to the next) and class counters such as Rage and Ki, which the
|
||||
player spends and a rest gives back.
|
||||
- Every class has **three subclasses**, picked in the level-up checklist at
|
||||
level 2 or 3 and shown beside the class everywhere; the DM can change one
|
||||
from the sheet. A campaign can do without them: untick "Characters choose a
|
||||
- Every class has its **full catalogue of official subclasses** (123, from the
|
||||
2014 books and the 2024 Player’s Handbook), with features up to level 20.
|
||||
One is picked in the builder (a die can suggest one) or in the level-up
|
||||
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
|
||||
level. The sheet suggests well-known multiclass routes from where the
|
||||
character stands. Eldritch Knights and Arcane Tricksters now have their
|
||||
spell slots.
|
||||
- **43 species** (the 2014 core, the 2024 Aasimar and Goliath, and the species
|
||||
of Monsters of the Multiverse) and **13 classes**, the Artificer included. A campaign can do without them: untick "Characters choose a
|
||||
subclass" in its Settings and every class follows one path, unasked.
|
||||
- **Campaign settings** come in four groups. **Characters**: starting gold
|
||||
and gear, how ability scores are made (4d6, standard array or point buy),
|
||||
@@ -103,9 +151,13 @@ including a character sheet or an encounter) and **Account**.
|
||||
who sees a roll. **Combat**: auto-rolled monster and NPC attacks, the
|
||||
critical hit rule, monster HP, the area penalty. **Progression**: XP
|
||||
tracking (off for milestone levelling) and HP on a level up.
|
||||
- **⬆ Level Up…** on a sheet (DM) takes the character up a level with the
|
||||
whole checklist: HP, the class's new special abilities, a Boon, spells and
|
||||
the free weapon upgrade.
|
||||
- **⬆ Level up…** (DM, beside Rest) approves a level for any of the party at
|
||||
once. Each player gets a notice, sees what the level gives and makes their
|
||||
own picks in the checklist: HP (the average, or a roll where the campaign
|
||||
rolls), the class's new special abilities, a subclass, a Boon, spells and
|
||||
the free weapon upgrade. A level waiting shows on the sheet and the
|
||||
campaign page until it is taken; the DM sees who still has picks to make,
|
||||
and can finish any of them.
|
||||
- The character sheet is long, so it starts with a row of **section links**
|
||||
that jump straight to that part of the page.
|
||||
- On a small screen every table (weapons, items, spells, monsters, the party
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.22.0",
|
||||
"version": "1.32.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
-- More species: the 2024 Aasimar and Goliath, and the species of Monsters of
|
||||
-- the Multiverse. Most of them choose their ability bonuses (+2/+1 or
|
||||
-- +1/+1/+1), so a Half-Elf's two +1 picks become one general set of chosen
|
||||
-- bonuses, { ability: amount }. Existing Half-Elves keep theirs.
|
||||
|
||||
ALTER TYPE "Species" ADD VALUE 'AASIMAR';
|
||||
ALTER TYPE "Species" ADD VALUE 'GOLIATH';
|
||||
ALTER TYPE "Species" ADD VALUE 'AARAKOCRA';
|
||||
ALTER TYPE "Species" ADD VALUE 'BUGBEAR';
|
||||
ALTER TYPE "Species" ADD VALUE 'CENTAUR';
|
||||
ALTER TYPE "Species" ADD VALUE 'CHANGELING';
|
||||
ALTER TYPE "Species" ADD VALUE 'DEEP_GNOME';
|
||||
ALTER TYPE "Species" ADD VALUE 'DUERGAR';
|
||||
ALTER TYPE "Species" ADD VALUE 'ELADRIN';
|
||||
ALTER TYPE "Species" ADD VALUE 'FAIRY';
|
||||
ALTER TYPE "Species" ADD VALUE 'FIRBOLG';
|
||||
ALTER TYPE "Species" ADD VALUE 'AIR_GENASI';
|
||||
ALTER TYPE "Species" ADD VALUE 'EARTH_GENASI';
|
||||
ALTER TYPE "Species" ADD VALUE 'FIRE_GENASI';
|
||||
ALTER TYPE "Species" ADD VALUE 'WATER_GENASI';
|
||||
ALTER TYPE "Species" ADD VALUE 'GITHYANKI';
|
||||
ALTER TYPE "Species" ADD VALUE 'GITHZERAI';
|
||||
ALTER TYPE "Species" ADD VALUE 'GOBLIN';
|
||||
ALTER TYPE "Species" ADD VALUE 'HARENGON';
|
||||
ALTER TYPE "Species" ADD VALUE 'HOBGOBLIN';
|
||||
ALTER TYPE "Species" ADD VALUE 'KENKU';
|
||||
ALTER TYPE "Species" ADD VALUE 'KOBOLD';
|
||||
ALTER TYPE "Species" ADD VALUE 'LIZARDFOLK';
|
||||
ALTER TYPE "Species" ADD VALUE 'MINOTAUR';
|
||||
ALTER TYPE "Species" ADD VALUE 'SEA_ELF';
|
||||
ALTER TYPE "Species" ADD VALUE 'SHADAR_KAI';
|
||||
ALTER TYPE "Species" ADD VALUE 'SHIFTER';
|
||||
ALTER TYPE "Species" ADD VALUE 'TABAXI';
|
||||
ALTER TYPE "Species" ADD VALUE 'TORTLE';
|
||||
ALTER TYPE "Species" ADD VALUE 'TRITON';
|
||||
ALTER TYPE "Species" ADD VALUE 'YUAN_TI';
|
||||
ALTER TYPE "Species" ADD VALUE 'OWLIN';
|
||||
ALTER TYPE "Species" ADD VALUE 'SATYR';
|
||||
|
||||
ALTER TABLE "characters" ADD COLUMN "speciesBonusChoices" JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
UPDATE "characters"
|
||||
SET "speciesBonusChoices" = jsonb_build_object("halfElfBonusAbility1"::text, 1, "halfElfBonusAbility2"::text, 1)
|
||||
WHERE "halfElfBonusAbility1" IS NOT NULL AND "halfElfBonusAbility2" IS NOT NULL;
|
||||
|
||||
ALTER TABLE "characters" DROP COLUMN "halfElfBonusAbility1",
|
||||
DROP COLUMN "halfElfBonusAbility2";
|
||||
@@ -0,0 +1,5 @@
|
||||
-- The Artificer joins the classes, and the builder gets a step to choose a
|
||||
-- subclass right after the class.
|
||||
ALTER TYPE "BuildStage" ADD VALUE 'CHOOSING_SUBCLASS' AFTER 'ROLLING_CLASS';
|
||||
|
||||
ALTER TYPE "CharacterClass" ADD VALUE 'ARTIFICER';
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Multiclassing: levels a character takes in classes besides their first.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "character_class_levels" (
|
||||
"id" TEXT NOT NULL,
|
||||
"characterId" TEXT NOT NULL,
|
||||
"characterClass" "CharacterClass" NOT NULL,
|
||||
"level" INTEGER NOT NULL DEFAULT 1,
|
||||
"subclass" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "character_class_levels_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "character_class_levels_characterId_characterClass_key" ON "character_class_levels"("characterId", "characterClass");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "character_class_levels" ADD CONSTRAINT "character_class_levels_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Level-ups the DM approves for players to take, and rolled HP on a level up.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "characters" ADD COLUMN "hpRollAdjust" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "levelUpsPending" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Rolls made at the table's dice roller, kept so the DM can see each one.
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "dice_rolls" (
|
||||
"id" TEXT NOT NULL,
|
||||
"campaignId" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"rollerName" TEXT NOT NULL,
|
||||
"label" TEXT,
|
||||
"notation" TEXT NOT NULL,
|
||||
"dice" JSONB NOT NULL,
|
||||
"modifier" INTEGER NOT NULL DEFAULT 0,
|
||||
"total" INTEGER NOT NULL,
|
||||
"natural" INTEGER,
|
||||
"mode" TEXT NOT NULL DEFAULT 'NORMAL',
|
||||
"physical" BOOLEAN NOT NULL DEFAULT false,
|
||||
"hidden" BOOLEAN NOT NULL DEFAULT false,
|
||||
"encounterId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "dice_rolls_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "dice_rolls_campaignId_createdAt_idx" ON "dice_rolls"("campaignId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dice_rolls" ADD CONSTRAINT "dice_rolls_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Battle actions: an attack or spell from being declared to its damage
|
||||
-- landing; named attacks for monsters; and how each spell lands (a spell
|
||||
-- attack, or a saving throw). Spells already in campaigns that came from
|
||||
-- the library are marked by name.
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "monsters" ADD COLUMN "attacks" JSONB NOT NULL DEFAULT '[]';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "spells" ADD COLUMN "attackRoll" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "halfOnSave" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "saveAbility" "Ability";
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_actions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounterId" TEXT NOT NULL,
|
||||
"round" INTEGER NOT NULL,
|
||||
"actorParticipantId" TEXT,
|
||||
"actorName" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"detail" JSONB NOT NULL,
|
||||
"targets" JSONB NOT NULL,
|
||||
"damage" JSONB,
|
||||
"status" TEXT NOT NULL DEFAULT 'PENDING',
|
||||
"auto" BOOLEAN NOT NULL DEFAULT false,
|
||||
"hidden" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"appliedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "encounter_actions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "encounter_actions_encounterId_status_idx" ON "encounter_actions"("encounterId", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_actions" ADD CONSTRAINT "encounter_actions_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- How the library's spells land, for copies already in campaigns.
|
||||
UPDATE "spells" SET "attackRoll" = true WHERE lower("name") IN ('chill touch', 'eldritch blast', 'fire bolt', 'produce flame', 'ray of frost', 'shocking grasp', 'thorn whip', 'guiding bolt', 'witch bolt', 'scorching ray', 'spiritual weapon', 'contagion');
|
||||
UPDATE "spells" SET "saveAbility" = 'STR', "halfOnSave" = false WHERE lower("name") IN ('entangle', 'gust of wind');
|
||||
UPDATE "spells" SET "saveAbility" = 'DEX', "halfOnSave" = true WHERE lower("name") IN ('burning hands', 'flaming sphere', 'call lightning', 'fireball', 'lightning bolt', 'guardian of faith', 'ice storm', 'wall of fire', 'flame strike');
|
||||
UPDATE "spells" SET "saveAbility" = 'DEX', "halfOnSave" = false WHERE lower("name") IN ('acid splash', 'sacred flame', 'faerie fire', 'web', 'sleet storm');
|
||||
UPDATE "spells" SET "saveAbility" = 'CON', "halfOnSave" = true WHERE lower("name") IN ('thunderwave', 'moonbeam', 'shatter', 'blight', 'cloudkill', 'cone of cold', 'insect plague');
|
||||
UPDATE "spells" SET "saveAbility" = 'CON', "halfOnSave" = false WHERE lower("name") IN ('poison spray', 'blindness/deafness', 'levitate', 'stinking cloud');
|
||||
UPDATE "spells" SET "saveAbility" = 'WIS', "halfOnSave" = true WHERE lower("name") IN ('spirit guardians');
|
||||
UPDATE "spells" SET "saveAbility" = 'WIS', "halfOnSave" = false WHERE lower("name") IN ('vicious mockery', 'animal friendship', 'charm person', 'command', 'hold person', 'suggestion', 'fear', 'slow', 'confusion', 'polymorph', 'dominate person', 'hold monster');
|
||||
UPDATE "spells" SET "saveAbility" = 'CHA', "halfOnSave" = false WHERE lower("name") IN ('bane', 'zone of truth', 'banishment');
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Free roam: a kind of encounter where the party wanders between places the
|
||||
-- DM has laid out. A location can have NPCs found there and things for sale.
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "EncounterMode" ADD VALUE 'FREE_ROAM';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "npcs" ADD COLUMN "locationId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "sale_offers" ADD COLUMN "locationId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "encounter_locations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"encounterId" TEXT NOT NULL,
|
||||
"locationId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "encounter_locations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "encounter_locations_encounterId_locationId_key" ON "encounter_locations"("encounterId", "locationId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sale_offers_locationId_idx" ON "sale_offers"("locationId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "npcs" ADD CONSTRAINT "npcs_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Armor and shields say what kind they are, so the sheet can tell when a
|
||||
-- character is unarmored (Unarmored Defense, a monk's speed) or in heavy
|
||||
-- armor (a barbarian's Fast Movement). Copies of the library's armor already
|
||||
-- in a campaign are marked by name; anything else the DM marks on the item.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ArmorType" AS ENUM ('LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "items" ADD COLUMN "armorType" "ArmorType";
|
||||
|
||||
UPDATE "items" SET "armorType" = 'LIGHT' WHERE "name" IN ('Padded Armor', 'Leather Armor', 'Studded Leather');
|
||||
UPDATE "items" SET "armorType" = 'MEDIUM' WHERE "name" IN ('Hide Armor', 'Chain Shirt', 'Scale Mail', 'Breastplate', 'Half Plate');
|
||||
UPDATE "items" SET "armorType" = 'HEAVY' WHERE "name" IN ('Ring Mail', 'Chain Mail', 'Splint Armor', 'Plate Armor');
|
||||
UPDATE "items" SET "armorType" = 'SHIELD' WHERE "name" = 'Shield';
|
||||
+194
-10
@@ -32,6 +32,39 @@ enum Species {
|
||||
TIEFLING
|
||||
HALF_ORC
|
||||
ORC
|
||||
AASIMAR
|
||||
GOLIATH
|
||||
AARAKOCRA
|
||||
BUGBEAR
|
||||
CENTAUR
|
||||
CHANGELING
|
||||
DEEP_GNOME
|
||||
DUERGAR
|
||||
ELADRIN
|
||||
FAIRY
|
||||
FIRBOLG
|
||||
AIR_GENASI
|
||||
EARTH_GENASI
|
||||
FIRE_GENASI
|
||||
WATER_GENASI
|
||||
GITHYANKI
|
||||
GITHZERAI
|
||||
GOBLIN
|
||||
HARENGON
|
||||
HOBGOBLIN
|
||||
KENKU
|
||||
KOBOLD
|
||||
LIZARDFOLK
|
||||
MINOTAUR
|
||||
SEA_ELF
|
||||
SHADAR_KAI
|
||||
SHIFTER
|
||||
TABAXI
|
||||
TORTLE
|
||||
TRITON
|
||||
YUAN_TI
|
||||
OWLIN
|
||||
SATYR
|
||||
}
|
||||
|
||||
enum CharacterClass {
|
||||
@@ -47,6 +80,7 @@ enum CharacterClass {
|
||||
WARLOCK
|
||||
DRUID
|
||||
MONK
|
||||
ARTIFICER
|
||||
}
|
||||
|
||||
enum Ability {
|
||||
@@ -83,6 +117,7 @@ enum BuildStage {
|
||||
NOT_STARTED
|
||||
ROLLING_SPECIES
|
||||
ROLLING_CLASS
|
||||
CHOOSING_SUBCLASS
|
||||
ROLLING_MOTIVATION
|
||||
ASSIGNING_SCORES
|
||||
ASSIGNING_PROFICIENCIES
|
||||
@@ -99,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 {
|
||||
@@ -255,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
|
||||
@@ -270,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")
|
||||
@@ -322,6 +393,13 @@ model Character {
|
||||
// until the class reaches the level it chooses at.
|
||||
subclass String?
|
||||
level Int @default(1)
|
||||
// Levels the DM has approved that the character has not yet taken: each is
|
||||
// one level-up checklist waiting for its picks. The player resolves them
|
||||
// (or the DM does), one at a time.
|
||||
levelUpsPending Int @default(0)
|
||||
// Where the campaign rolls HP on a level up: how far the rolls so far are
|
||||
// from the average they replace, in all (a 2 on a d8 is -3). Added to max HP.
|
||||
hpRollAdjust Int @default(0)
|
||||
xp Int @default(0)
|
||||
motivation String?
|
||||
|
||||
@@ -333,9 +411,10 @@ model Character {
|
||||
wisScore Int @default(10)
|
||||
chaScore Int @default(10)
|
||||
|
||||
// Half-Elf's flexible +1/+1 choice; null unless species = HALF_ELF
|
||||
halfElfBonusAbility1 Ability?
|
||||
halfElfBonusAbility2 Ability?
|
||||
// The ability bonuses the player chose for their species, on top of its
|
||||
// fixed ones, as { ability: amount }: a Half-Elf's +1/+1, a Goblin's +2/+1...
|
||||
// Empty for a species with nothing to choose (game/data/species.js).
|
||||
speciesBonusChoices Json @default("{}")
|
||||
|
||||
currentHp Int @default(0)
|
||||
tempHp Int @default(0)
|
||||
@@ -400,12 +479,31 @@ model Character {
|
||||
notes Note[]
|
||||
encounterEntries EncounterParticipant[]
|
||||
locationVisits CharacterLocationVisit[]
|
||||
// Levels in classes other than characterClass, for a multiclassed character
|
||||
// (game/multiclass.js). The first class's own level is `level` less these.
|
||||
classLevels CharacterClassLevel[]
|
||||
|
||||
@@index([campaignId])
|
||||
@@index([userId])
|
||||
@@map("characters")
|
||||
}
|
||||
|
||||
// A class a character has taken levels in besides their first (characterClass),
|
||||
// with its own level and subclass. A single-class character has none.
|
||||
model CharacterClassLevel {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
characterClass CharacterClass
|
||||
level Int @default(1)
|
||||
subclass String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([characterId, characterClass])
|
||||
@@map("character_class_levels")
|
||||
}
|
||||
|
||||
model CharacterSkillProficiency {
|
||||
id String @id @default(cuid())
|
||||
characterId String
|
||||
@@ -499,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)
|
||||
@@ -510,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())
|
||||
@@ -564,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[]
|
||||
@@ -607,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[]
|
||||
@@ -633,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[]
|
||||
|
||||
@@ -677,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).
|
||||
@@ -709,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`
|
||||
@@ -729,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")
|
||||
}
|
||||
|
||||
|
||||
@@ -133,7 +133,11 @@ function levelFeatures() {
|
||||
Object.keys(subclass.features)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.map((level, i) => [i === 0 ? `**${subclass.name}**<br>${subclass.summary}` : '', level, subclass.features[level].map(line).join('<br>')])
|
||||
.map((level, i) => [
|
||||
i === 0 ? `**${subclass.name}**<br>${subclass.summary}<br>_${subclass.source}${subclass.replaces ? `, updates ${SUBCLASSES[key].find((s) => s.key === subclass.replaces).name}` : ''}_` : '',
|
||||
level,
|
||||
subclass.features[level].map(line).join('<br>'),
|
||||
])
|
||||
);
|
||||
return [
|
||||
`### ${CLASSES[key].name}`,
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
{ "key": "BARD", "name": "Bard", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["DEX", "CHA"], "spellcastingAbility": "CHA", "skillChoices": { "count": 3, "options": ["ACROBATICS", "ANIMAL_HANDLING", "ARCANA", "ATHLETICS", "DECEPTION", "HISTORY", "INSIGHT", "INTIMIDATION", "INVESTIGATION", "MEDICINE", "NATURE", "PERCEPTION", "PERFORMANCE", "PERSUASION", "RELIGION", "SLEIGHT_OF_HAND", "STEALTH", "SURVIVAL"] } },
|
||||
{ "key": "WARLOCK", "name": "Warlock", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "CHA", "skillChoices": { "count": 2, "options": ["ARCANA", "DECEPTION", "HISTORY", "INTIMIDATION", "INVESTIGATION", "NATURE", "RELIGION"] } },
|
||||
{ "key": "DRUID", "name": "Druid", "hitDie": 8, "primaryAbility": "Wis", "saveProficiencies": ["INT", "WIS"], "spellcastingAbility": "WIS", "skillChoices": { "count": 2, "options": ["ARCANA", "ANIMAL_HANDLING", "INSIGHT", "MEDICINE", "NATURE", "PERCEPTION", "RELIGION", "SURVIVAL"] } },
|
||||
{ "key": "MONK", "name": "Monk", "hitDie": 8, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": null, "skillChoices": { "count": 2, "options": ["ACROBATICS", "ATHLETICS", "HISTORY", "INSIGHT", "RELIGION", "STEALTH"] } }
|
||||
{ "key": "MONK", "name": "Monk", "hitDie": 8, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": null, "skillChoices": { "count": 2, "options": ["ACROBATICS", "ATHLETICS", "HISTORY", "INSIGHT", "RELIGION", "STEALTH"] } },
|
||||
{ "key": "ARTIFICER", "name": "Artificer", "hitDie": 8, "primaryAbility": "Int", "saveProficiencies": ["CON", "INT"], "spellcastingAbility": "INT", "skillChoices": { "count": 2, "options": ["ARCANA", "HISTORY", "INVESTIGATION", "MEDICINE", "NATURE", "PERCEPTION", "SLEIGHT_OF_HAND"] } }
|
||||
]
|
||||
|
||||
@@ -1,12 +1,45 @@
|
||||
[
|
||||
{ "key": "HUMAN", "name": "Human", "abilityBonuses": { "STR": 1, "DEX": 1, "CON": 1, "INT": 1, "WIS": 1, "CHA": 1 }, "speed": 30, "notes": null },
|
||||
{ "key": "HALF_ELF", "name": "Half-Elf", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 2 }, "speed": 30, "notes": "Fixed +2 CHA plus a flexible +1/+1 to two other abilities of the player's choice. The builder UI must prompt for this choice; do not hardcode a default." },
|
||||
{ "key": "ELF", "name": "Elf", "abilityBonuses": { "STR": 0, "DEX": 2, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 30, "notes": null },
|
||||
{ "key": "GNOME", "name": "Gnome", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 0, "INT": 2, "WIS": 0, "CHA": 0 }, "speed": 25, "notes": null },
|
||||
{ "key": "DWARF", "name": "Dwarf", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 2, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 25, "notes": null },
|
||||
{ "key": "HALFLING", "name": "Halfling", "abilityBonuses": { "STR": 0, "DEX": 2, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 25, "notes": null },
|
||||
{ "key": "DRAGONBORN", "name": "Dragonborn", "abilityBonuses": { "STR": 2, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 1 }, "speed": 30, "notes": null },
|
||||
{ "key": "TIEFLING", "name": "Tiefling", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 0, "INT": 1, "WIS": 0, "CHA": 2 }, "speed": 30, "notes": null },
|
||||
{ "key": "HALF_ORC", "name": "Half-Orc", "abilityBonuses": { "STR": 2, "DEX": 0, "CON": 1, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 30, "notes": null },
|
||||
{ "key": "ORC", "name": "Orc", "abilityBonuses": { "STR": 2, "DEX": 0, "CON": 1, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 30, "notes": null }
|
||||
{"key": "HUMAN", "name": "Human", "abilityBonuses": {"STR": 1, "DEX": 1, "CON": 1, "INT": 1, "WIS": 1, "CHA": 1}, "speed": 30, "notes": null},
|
||||
{"key": "HALF_ELF", "name": "Half-Elf", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 2}, "speed": 30, "flexible": {"patterns": [[1, 1]], "exclude": ["CHA"]}, "notes": "Fixed +2 CHA plus a flexible +1/+1 to two other abilities of the player's choice. The builder UI must prompt for this choice; do not hardcode a default."},
|
||||
{"key": "ELF", "name": "Elf", "abilityBonuses": {"STR": 0, "DEX": 2, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "notes": null},
|
||||
{"key": "GNOME", "name": "Gnome", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 2, "WIS": 0, "CHA": 0}, "speed": 25, "notes": null},
|
||||
{"key": "DWARF", "name": "Dwarf", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 2, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 25, "notes": null},
|
||||
{"key": "HALFLING", "name": "Halfling", "abilityBonuses": {"STR": 0, "DEX": 2, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 25, "notes": null},
|
||||
{"key": "DRAGONBORN", "name": "Dragonborn", "abilityBonuses": {"STR": 2, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 1}, "speed": 30, "notes": null},
|
||||
{"key": "TIEFLING", "name": "Tiefling", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 1, "WIS": 0, "CHA": 2}, "speed": 30, "notes": null},
|
||||
{"key": "HALF_ORC", "name": "Half-Orc", "abilityBonuses": {"STR": 2, "DEX": 0, "CON": 1, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "notes": null},
|
||||
{"key": "ORC", "name": "Orc", "abilityBonuses": {"STR": 2, "DEX": 0, "CON": 1, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "notes": null},
|
||||
{"key": "AASIMAR", "name": "Aasimar", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "GOLIATH", "name": "Goliath", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 35, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "AARAKOCRA", "name": "Aarakocra", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "BUGBEAR", "name": "Bugbear", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "CENTAUR", "name": "Centaur", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 40, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "CHANGELING", "name": "Changeling", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "DEEP_GNOME", "name": "Deep Gnome", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "DUERGAR", "name": "Duergar", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "ELADRIN", "name": "Eladrin", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "FAIRY", "name": "Fairy", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "FIRBOLG", "name": "Firbolg", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "AIR_GENASI", "name": "Air Genasi", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 35, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "EARTH_GENASI", "name": "Earth Genasi", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "FIRE_GENASI", "name": "Fire Genasi", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "WATER_GENASI", "name": "Water Genasi", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "GITHYANKI", "name": "Githyanki", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "GITHZERAI", "name": "Githzerai", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "GOBLIN", "name": "Goblin", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "HARENGON", "name": "Harengon", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "HOBGOBLIN", "name": "Hobgoblin", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "KENKU", "name": "Kenku", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "KOBOLD", "name": "Kobold", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "LIZARDFOLK", "name": "Lizardfolk", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "MINOTAUR", "name": "Minotaur", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "SEA_ELF", "name": "Sea Elf", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "SHADAR_KAI", "name": "Shadar-kai", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "SHIFTER", "name": "Shifter", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "TABAXI", "name": "Tabaxi", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "TORTLE", "name": "Tortle", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "TRITON", "name": "Triton", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "YUAN_TI", "name": "Yuan-ti", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "OWLIN", "name": "Owlin", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
|
||||
{"key": "SATYR", "name": "Satyr", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 35, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."}
|
||||
]
|
||||
|
||||
@@ -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
@@ -7,6 +7,7 @@ const campaignsRoutes = require('./routes/campaigns.routes');
|
||||
const charactersRoutes = require('./routes/characters.routes');
|
||||
const encountersRoutes = require('./routes/encounters.routes');
|
||||
const notesRoutes = require('./routes/notes.routes');
|
||||
const referenceRoutes = require('./routes/reference.routes');
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
@@ -27,6 +28,7 @@ function createApp() {
|
||||
app.use('/characters', charactersRoutes);
|
||||
app.use('/encounters', encountersRoutes);
|
||||
app.use('/notes', notesRoutes);
|
||||
app.use('/reference', referenceRoutes);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
|
||||
@@ -9,15 +9,21 @@ const { SPECIES_TRAITS, CLASS_FEATURES, TALENTS, TALENT_PICKS } = require('./dat
|
||||
const ALL_SPECIES = Object.keys(SPECIES);
|
||||
const ALL_CLASSES = Object.keys(CLASSES);
|
||||
|
||||
// A d8 picks a family of species; the player chooses one from it.
|
||||
const SPECIES_ROLL_DIE = 8;
|
||||
const SPECIES_ROLL_TABLE = {
|
||||
1: ['HUMAN', 'HALF_ELF'],
|
||||
2: ['ELF', 'GNOME'],
|
||||
3: ['DWARF', 'HALFLING'],
|
||||
4: ['DRAGONBORN', 'TIEFLING'],
|
||||
5: ['HALF_ORC', 'ORC'],
|
||||
6: 'WILDCARD',
|
||||
1: ['HUMAN', 'HALF_ELF', 'ELF', 'HALF_ORC'],
|
||||
2: ['DWARF', 'GNOME', 'HALFLING', 'DEEP_GNOME', 'DUERGAR'],
|
||||
3: ['DRAGONBORN', 'TIEFLING', 'AASIMAR', 'GOLIATH'],
|
||||
4: ['ORC', 'GOBLIN', 'HOBGOBLIN', 'BUGBEAR', 'KOBOLD'],
|
||||
5: ['ELADRIN', 'SEA_ELF', 'SHADAR_KAI', 'GITHYANKI', 'GITHZERAI', 'CHANGELING'],
|
||||
6: ['AIR_GENASI', 'EARTH_GENASI', 'FIRE_GENASI', 'WATER_GENASI', 'TRITON', 'TORTLE'],
|
||||
7: ['TABAXI', 'KENKU', 'AARAKOCRA', 'HARENGON', 'LIZARDFOLK', 'OWLIN', 'MINOTAUR', 'CENTAUR', 'SATYR', 'SHIFTER', 'FIRBOLG', 'FAIRY', 'YUAN_TI'],
|
||||
8: 'WILDCARD',
|
||||
};
|
||||
|
||||
// 7 offers the Artificer, or one reroll; whatever the reroll gives is final.
|
||||
const CLASS_ROLL_DIE = 8;
|
||||
const CLASS_ROLL_TABLE = {
|
||||
1: ['FIGHTER', 'BARBARIAN'],
|
||||
2: ['ROGUE', 'RANGER'],
|
||||
@@ -25,7 +31,7 @@ const CLASS_ROLL_TABLE = {
|
||||
4: ['CLERIC', 'PALADIN'],
|
||||
5: ['BARD', 'WARLOCK'],
|
||||
6: ['DRUID', 'MONK'],
|
||||
7: 'REROLL',
|
||||
7: 'ARTIFICER_OR_REROLL',
|
||||
8: 'WILDCARD',
|
||||
};
|
||||
|
||||
@@ -164,16 +170,20 @@ function speciesChoicesForRoll(roll) {
|
||||
return { type: 'choice', options: entry };
|
||||
}
|
||||
|
||||
function classChoicesForRoll(roll) {
|
||||
// `rerolled`: this roll is itself the reroll a 7 allowed, so it can't be
|
||||
// rerolled again.
|
||||
function classChoicesForRoll(roll, { rerolled = false } = {}) {
|
||||
const entry = CLASS_ROLL_TABLE[roll];
|
||||
if (entry === 'WILDCARD') return { type: 'wildcard', options: ALL_CLASSES };
|
||||
if (entry === 'REROLL') return { type: 'reroll', options: [] };
|
||||
if (entry === 'ARTIFICER_OR_REROLL') return { type: 'choice', options: ['ARTIFICER'], canReroll: !rerolled };
|
||||
return { type: 'choice', options: entry };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SPECIES_ROLL_TABLE,
|
||||
SPECIES_ROLL_DIE,
|
||||
CLASS_ROLL_TABLE,
|
||||
CLASS_ROLL_DIE,
|
||||
ALL_SPECIES,
|
||||
ALL_CLASSES,
|
||||
rollDie,
|
||||
|
||||
@@ -7,7 +7,16 @@ const { CLASSES } = require('./data/classes');
|
||||
const { SKILLS } = require('./data/skills');
|
||||
const { resourceState } = require('./classResources');
|
||||
const { CR_XP, XP_THRESHOLDS, COUNT_MULTIPLIERS, LEVEL_XP } = require('./data/xp');
|
||||
const { FULL_CASTER_SLOTS, HALF_CASTER_SLOTS, PACT_SLOTS, CASTER_KIND, MAX_SLOT_LEVEL } = require('./data/spellSlots');
|
||||
const {
|
||||
FULL_CASTER_SLOTS,
|
||||
HALF_CASTER_SLOTS,
|
||||
THIRD_CASTER_SLOTS,
|
||||
PACT_SLOTS,
|
||||
CASTER_KIND,
|
||||
CLASS_SLOT_TABLES,
|
||||
SUBCLASS_CASTER_KIND,
|
||||
MAX_SLOT_LEVEL,
|
||||
} = require('./data/spellSlots');
|
||||
|
||||
const ABILITIES = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
|
||||
@@ -19,20 +28,18 @@ function proficiencyBonus(level) {
|
||||
return 2 + Math.floor((level - 1) / 4);
|
||||
}
|
||||
|
||||
// Half-Elf's flexible +1/+1 lands on whichever two abilities the player picked
|
||||
// (halfElfBonusAbilities), on top of the fixed +2 CHA in the species table.
|
||||
function speciesAbilityBonus(ability, species, halfElfBonusAbilities = []) {
|
||||
// A species' fixed bonus, plus whatever the player chose for its flexible
|
||||
// bonuses ({ ability: amount }: a Half-Elf's +1/+1, a Goblin's +2/+1...).
|
||||
// Choices count only for a species that has some to make.
|
||||
function speciesAbilityBonus(ability, species, choices = {}) {
|
||||
const speciesData = species ? SPECIES[species] : null;
|
||||
if (!speciesData) return 0;
|
||||
let bonus = speciesData.abilityBonuses[ability] || 0;
|
||||
if (species === 'HALF_ELF' && halfElfBonusAbilities.includes(ability)) {
|
||||
bonus += 1;
|
||||
}
|
||||
return bonus;
|
||||
const chosen = speciesData.flexible ? Number((choices || {})[ability]) || 0 : 0;
|
||||
return (speciesData.abilityBonuses[ability] || 0) + chosen;
|
||||
}
|
||||
|
||||
function finalAbilityScore(rolledScore, ability, species, halfElfBonusAbilities = []) {
|
||||
return rolledScore + speciesAbilityBonus(ability, species, halfElfBonusAbilities);
|
||||
function finalAbilityScore(rolledScore, ability, species, choices = {}) {
|
||||
return rolledScore + speciesAbilityBonus(ability, species, choices);
|
||||
}
|
||||
|
||||
function speciesSpeed(species) {
|
||||
@@ -66,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;
|
||||
}
|
||||
@@ -124,9 +140,18 @@ function getClassHitDieSize(characterClass) {
|
||||
return data ? data.hitDie : 8;
|
||||
}
|
||||
|
||||
function getClassSpellcastingAbility(characterClass) {
|
||||
// The ability a class casts with; a subclass that casts in a class that does
|
||||
// not (Eldritch Knight, Arcane Trickster) uses Intelligence.
|
||||
function getClassSpellcastingAbility(characterClass, subclass = null) {
|
||||
const data = characterClass ? CLASSES[characterClass] : null;
|
||||
return data ? data.spellcastingAbility : null;
|
||||
if (data && data.spellcastingAbility) return data.spellcastingAbility;
|
||||
return SUBCLASS_CASTER_KIND[subclass] ? 'INT' : null;
|
||||
}
|
||||
|
||||
// How a class casts (FULL, HALF, THIRD or PACT), counting a casting subclass;
|
||||
// null for none.
|
||||
function casterKind(characterClass, subclass = null) {
|
||||
return CASTER_KIND[characterClass] || SUBCLASS_CASTER_KIND[subclass] || null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -142,15 +167,16 @@ function clampLevel(level) {
|
||||
// How many spell slots a class has at a level, by slot level (1st first), and
|
||||
// which rest gives them back. Null for a class that never has any. A warlock's
|
||||
// slots are all of one slot level, so the lower ones are 0.
|
||||
function spellSlotsFor(characterClass, level) {
|
||||
const kind = CASTER_KIND[characterClass];
|
||||
function spellSlotsFor(characterClass, level, subclass = null) {
|
||||
const kind = casterKind(characterClass, subclass);
|
||||
if (!kind) return null;
|
||||
const row = clampLevel(level) - 1;
|
||||
if (kind === 'PACT') {
|
||||
const [count, slotLevel] = PACT_SLOTS[row];
|
||||
return { kind, restoresOn: 'SHORT', max: Array.from({ length: slotLevel }, (_, i) => (i === slotLevel - 1 ? count : 0)) };
|
||||
}
|
||||
return { kind, restoresOn: 'LONG', max: [...(kind === 'FULL' ? FULL_CASTER_SLOTS : HALF_CASTER_SLOTS)[row]] };
|
||||
const table = CLASS_SLOT_TABLES[characterClass] || { FULL: FULL_CASTER_SLOTS, HALF: HALF_CASTER_SLOTS, THIRD: THIRD_CASTER_SLOTS }[kind];
|
||||
return { kind, restoresOn: 'LONG', max: [...table[row]] };
|
||||
}
|
||||
|
||||
// The slot tracker: for each slot level the character has, how many slots
|
||||
@@ -158,8 +184,12 @@ function spellSlotsFor(characterClass, level) {
|
||||
// first), which may be shorter than the class's row or hold more than it now
|
||||
// allows. Null when there are no slots to track (not a caster, or a level 1
|
||||
// paladin).
|
||||
function spellSlotState(characterClass, level, used = []) {
|
||||
const slots = spellSlotsFor(characterClass, level);
|
||||
function spellSlotState(characterClass, level, used = [], subclass = null) {
|
||||
return slotStateOf(spellSlotsFor(characterClass, level, subclass), used);
|
||||
}
|
||||
|
||||
// The slot tracker for a set of slots (spellSlotsFor's shape).
|
||||
function slotStateOf(slots, used = []) {
|
||||
if (!slots) return null;
|
||||
const levels = slots.max
|
||||
.map((max, i) => {
|
||||
@@ -171,8 +201,8 @@ function spellSlotState(characterClass, level, used = []) {
|
||||
}
|
||||
|
||||
// The highest level of spell a class can cast at a level; 0 for none.
|
||||
function maxSpellLevel(characterClass, level) {
|
||||
const slots = spellSlotsFor(characterClass, level);
|
||||
function maxSpellLevel(characterClass, level, subclass = null) {
|
||||
const slots = spellSlotsFor(characterClass, level, subclass);
|
||||
return slots ? slots.max.length : 0;
|
||||
}
|
||||
|
||||
@@ -266,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) {
|
||||
@@ -274,7 +310,7 @@ function activeGear(character) {
|
||||
}
|
||||
if (entry.effectNote) notes.push({ source: entry.name, note: entry.effectNote });
|
||||
}
|
||||
return { effects, notes };
|
||||
return { effects, notes, armor };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -299,15 +335,14 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
intScore = 10,
|
||||
wisScore = 10,
|
||||
chaScore = 10,
|
||||
halfElfBonusAbility1 = null,
|
||||
halfElfBonusAbility2 = null,
|
||||
speciesBonusChoices = {},
|
||||
armorBonus = 0,
|
||||
speedOverride = null,
|
||||
spellSlotsUsed = [],
|
||||
hitDiceUsed = 0,
|
||||
hpRollAdjust = 0,
|
||||
} = character;
|
||||
|
||||
const halfElfBonusAbilities = [halfElfBonusAbility1, halfElfBonusAbility2].filter(Boolean);
|
||||
const rolled = { STR: strScore, DEX: dexScore, CON: conScore, INT: intScore, WIS: wisScore, CHA: chaScore };
|
||||
|
||||
const gear = activeGear(character);
|
||||
@@ -317,7 +352,7 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
const abilities = {};
|
||||
for (const ability of ABILITIES) {
|
||||
// A score never drops below 1, whatever is weighing the character down.
|
||||
const final = Math.max(1, finalAbilityScore(rolled[ability], ability, species, halfElfBonusAbilities) + fromGear(ability));
|
||||
const final = Math.max(1, finalAbilityScore(rolled[ability], ability, species, speciesBonusChoices) + fromGear(ability));
|
||||
abilities[ability] = {
|
||||
rolled: rolled[ability],
|
||||
final,
|
||||
@@ -327,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 = {};
|
||||
@@ -344,21 +398,38 @@ 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}`),
|
||||
};
|
||||
}
|
||||
|
||||
const hitDieSize = getClassHitDieSize(characterClass);
|
||||
const spellcastingAbility = getClassSpellcastingAbility(characterClass);
|
||||
const casters = classes
|
||||
.map((entry) => ({ ...entry, ability: getClassSpellcastingAbility(entry.characterClass, entry.subclass) }))
|
||||
.filter((entry) => entry.ability && (casterKind(entry.characterClass, entry.subclass) !== 'THIRD' || entry.level >= 3));
|
||||
const casting = (ability) => ({
|
||||
ability,
|
||||
saveDC: spellSaveDC(profBonus, abilities[ability].modifier),
|
||||
attackBonus: spellAttackBonus(profBonus, abilities[ability].modifier),
|
||||
});
|
||||
const spellcastingAbility = casters.length > 0 ? casters[0].ability : null;
|
||||
|
||||
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')),
|
||||
hp: { max: Math.max(1, hpMax(level, hitDieSize, abilities.CON.modifier) + fromGear('MAX_HP')) },
|
||||
hitDice: hitDiceState(characterClass, level, hitDiceUsed),
|
||||
// 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) + features.hp + fromGear('MAX_HP')
|
||||
),
|
||||
},
|
||||
hitDice: multi ? multiclass.multiclassHitDice(classes, hitDiceUsed) : hitDiceState(characterClass, level, hitDiceUsed),
|
||||
saves,
|
||||
skills,
|
||||
passivePerception: passivePerception(skills.PERCEPTION.bonus) + fromGear('PASSIVE_PERCEPTION'),
|
||||
@@ -366,17 +437,17 @@ 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,
|
||||
spellcasting: spellcastingAbility
|
||||
? {
|
||||
ability: spellcastingAbility,
|
||||
saveDC: spellSaveDC(profBonus, abilities[spellcastingAbility].modifier),
|
||||
attackBonus: spellAttackBonus(profBonus, abilities[spellcastingAbility].modifier),
|
||||
}
|
||||
: null,
|
||||
// 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) })),
|
||||
// The slot tracker; null for a character with no spell slots.
|
||||
spellSlots: spellSlotState(characterClass, level, spellSlotsUsed),
|
||||
spellSlots: multi ? slotStateOf(multiclass.multiclassSpellSlots(classes), spellSlotsUsed) : spellSlotState(characterClass, level, spellSlotsUsed, character.subclass),
|
||||
// A multiclassed warlock's pact slots, apart from the others; null otherwise.
|
||||
pactSlots: multi ? multiclass.pactSlotsOf(classes) : null,
|
||||
// The class counters (Rage, Ki...), with how many of each are left.
|
||||
resources: resourceState(character, abilities, profBonus),
|
||||
resources: multi ? multiclass.multiclassResources(classes, character.resourcesUsed, abilities, profBonus) : resourceState(character, abilities, profBonus),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -513,6 +584,7 @@ module.exports = {
|
||||
passivePerception,
|
||||
passiveScore,
|
||||
armorClass,
|
||||
armorDexterity,
|
||||
initiative,
|
||||
hpMax,
|
||||
spellSaveDC,
|
||||
@@ -527,6 +599,8 @@ module.exports = {
|
||||
MAX_LEVEL,
|
||||
MAX_SLOT_LEVEL,
|
||||
spellSlotsFor,
|
||||
slotStateOf,
|
||||
casterKind,
|
||||
spellSlotState,
|
||||
maxSpellLevel,
|
||||
hitDiceState,
|
||||
@@ -544,3 +618,8 @@ module.exports = {
|
||||
scaledDamageDice,
|
||||
canUseGear,
|
||||
};
|
||||
|
||||
// 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');
|
||||
|
||||
@@ -58,10 +58,20 @@ describe('finalAbilityScore / species bonuses', () => {
|
||||
});
|
||||
|
||||
test('half-elf gets fixed +2 CHA plus chosen +1/+1', () => {
|
||||
expect(finalAbilityScore(10, 'CHA', 'HALF_ELF', ['STR', 'INT'])).toBe(12);
|
||||
expect(finalAbilityScore(10, 'STR', 'HALF_ELF', ['STR', 'INT'])).toBe(11);
|
||||
expect(finalAbilityScore(10, 'INT', 'HALF_ELF', ['STR', 'INT'])).toBe(11);
|
||||
expect(finalAbilityScore(10, 'WIS', 'HALF_ELF', ['STR', 'INT'])).toBe(10);
|
||||
expect(finalAbilityScore(10, 'CHA', 'HALF_ELF', { STR: 1, INT: 1 })).toBe(12);
|
||||
expect(finalAbilityScore(10, 'STR', 'HALF_ELF', { STR: 1, INT: 1 })).toBe(11);
|
||||
expect(finalAbilityScore(10, 'INT', 'HALF_ELF', { STR: 1, INT: 1 })).toBe(11);
|
||||
expect(finalAbilityScore(10, 'WIS', 'HALF_ELF', { STR: 1, INT: 1 })).toBe(10);
|
||||
});
|
||||
|
||||
test('a Multiverse species takes the bonuses its player chose', () => {
|
||||
expect(finalAbilityScore(10, 'DEX', 'GOBLIN', { DEX: 2, CON: 1 })).toBe(12);
|
||||
expect(finalAbilityScore(10, 'CON', 'GOBLIN', { DEX: 2, CON: 1 })).toBe(11);
|
||||
expect(finalAbilityScore(10, 'STR', 'GOBLIN', { DEX: 2, CON: 1 })).toBe(10);
|
||||
});
|
||||
|
||||
test('chosen bonuses count only for a species that has some to choose', () => {
|
||||
expect(finalAbilityScore(10, 'STR', 'DWARF', { STR: 2 })).toBe(10);
|
||||
});
|
||||
|
||||
test('no species leaves score untouched', () => {
|
||||
@@ -199,8 +209,7 @@ describe('computeCharacterSheet — full assembly', () => {
|
||||
characterClass: 'BARD',
|
||||
strScore: 10,
|
||||
chaScore: 10,
|
||||
halfElfBonusAbility1: 'STR',
|
||||
halfElfBonusAbility2: 'INT',
|
||||
speciesBonusChoices: { STR: 1, INT: 1 },
|
||||
});
|
||||
expect(sheet.abilities.CHA.final).toBe(12); // fixed +2
|
||||
expect(sheet.abilities.STR.final).toBe(11); // chosen +1
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// The class features that change a number on the sheet, worked out so nobody
|
||||
// has to add them by hand: Unarmored Defense, Draconic Resilience, Fast
|
||||
// Movement, Unarmored Movement, Aura of Protection, Diamond Soul, Slippery
|
||||
// Mind and Jack of All Trades. Everything else a class gives is a line of
|
||||
// text on the sheet (game/data/levelFeatures.js). Pure: no database.
|
||||
//
|
||||
// Each goes by the level in the class that gives it, so a multiclassed
|
||||
// character gets what each of their classes has earned (game/multiclass.js,
|
||||
// classLevels). Several depend on what is worn: armor and shields are items
|
||||
// marked with what kind they are (Item.armorType), and count while equipped.
|
||||
|
||||
const { SUBCLASS_LEVEL } = require('./data/subclasses');
|
||||
|
||||
// A monk's extra speed without armor or a shield, by monk level.
|
||||
function unarmoredMovement(monkLevel) {
|
||||
if (monkLevel < 2) return 0;
|
||||
if (monkLevel < 6) return 10;
|
||||
if (monkLevel < 10) return 15;
|
||||
if (monkLevel < 14) return 20;
|
||||
if (monkLevel < 18) return 25;
|
||||
return 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a character's class features do to the sheet's numbers.
|
||||
*
|
||||
* @param {Array<{characterClass: string, level: number, subclass: string|null}>} classes
|
||||
* @param {object} modifiers - ability modifiers by ability: { STR, DEX, CON, INT, WIS, CHA }
|
||||
* @param {number} profBonus
|
||||
* @param {{worn: 'LIGHT'|'MEDIUM'|'HEAVY'|null, shield: boolean}} armor - what is equipped
|
||||
* @returns {{
|
||||
* acBase: number|null, // what AC starts from in place of 10 + DEX, when that is better
|
||||
* speed: number, // added to walking speed
|
||||
* hp: number, // added to maximum HP
|
||||
* saveBonus: number, // added to every saving throw
|
||||
* saveProficiencies: string[],// saves the character is proficient in on top of their class's two
|
||||
* halfProficiency: number, // added to initiative and to skills they are not proficient in
|
||||
* inForce: Array<{name: string, source: string, text: string}>
|
||||
* }}
|
||||
*/
|
||||
function classFeatureEffects(classes, modifiers, profBonus, armor = { worn: null, shield: false }) {
|
||||
const levelIn = (characterClass) => {
|
||||
const entry = classes.find((c) => c.characterClass === characterClass);
|
||||
return entry ? entry.level : 0;
|
||||
};
|
||||
const unarmored = !armor.worn;
|
||||
const plain = 10 + modifiers.DEX;
|
||||
const inForce = [];
|
||||
const signed = (n) => (n >= 0 ? `+${n}` : `${n}`);
|
||||
|
||||
// Armor Class without armor: the best of the ways the character has of
|
||||
// working it out, and only when it beats 10 + DEX.
|
||||
const acWays = [];
|
||||
if (levelIn('BARBARIAN') >= 1 && unarmored) {
|
||||
acWays.push({ ac: plain + modifiers.CON, name: 'Unarmored Defense', source: 'Barbarian', text: `AC is 10 + Dexterity + Constitution (${signed(modifiers.CON)}) without armor; a shield still adds.` });
|
||||
}
|
||||
if (levelIn('MONK') >= 1 && unarmored && !armor.shield) {
|
||||
acWays.push({ ac: plain + modifiers.WIS, name: 'Unarmored Defense', source: 'Monk', text: `AC is 10 + Dexterity + Wisdom (${signed(modifiers.WIS)}) without armor or a shield.` });
|
||||
}
|
||||
const sorcerer = classes.find((c) => c.characterClass === 'SORCERER');
|
||||
const draconic = sorcerer && sorcerer.subclass === 'DRACONIC' && sorcerer.level >= SUBCLASS_LEVEL.SORCERER ? sorcerer : null;
|
||||
if (draconic && unarmored) {
|
||||
acWays.push({ ac: 13 + modifiers.DEX, name: 'Draconic Resilience', source: 'Draconic Bloodline', text: 'AC is 13 + Dexterity without armor.' });
|
||||
}
|
||||
const bestAc = acWays.sort((a, b) => b.ac - a.ac)[0];
|
||||
const acBase = bestAc && bestAc.ac > plain ? bestAc.ac : null;
|
||||
if (acBase !== null) inForce.push({ name: bestAc.name, source: bestAc.source, text: bestAc.text });
|
||||
|
||||
let hp = 0;
|
||||
if (draconic) {
|
||||
hp += draconic.level;
|
||||
inForce.push({ name: 'Draconic Resilience', source: 'Draconic Bloodline', text: `+1 maximum HP for each sorcerer level (+${draconic.level}).` });
|
||||
}
|
||||
|
||||
let speed = 0;
|
||||
if (levelIn('BARBARIAN') >= 5 && armor.worn !== 'HEAVY') {
|
||||
speed += 10;
|
||||
inForce.push({ name: 'Fast Movement', source: 'Barbarian', text: '+10 ft speed when not in heavy armor.' });
|
||||
}
|
||||
const monkSpeed = unarmored && !armor.shield ? unarmoredMovement(levelIn('MONK')) : 0;
|
||||
if (monkSpeed > 0) {
|
||||
speed += monkSpeed;
|
||||
inForce.push({ name: 'Unarmored Movement', source: 'Monk', text: `+${monkSpeed} ft speed without armor or a shield.` });
|
||||
}
|
||||
|
||||
let saveBonus = 0;
|
||||
if (levelIn('PALADIN') >= 6) {
|
||||
saveBonus = Math.max(1, modifiers.CHA);
|
||||
inForce.push({ name: 'Aura of Protection', source: 'Paladin', text: `+${saveBonus} (Charisma) to every saving throw while conscious. Allies in the aura add it by hand.` });
|
||||
}
|
||||
|
||||
const saveProficiencies = [];
|
||||
if (levelIn('MONK') >= 14) {
|
||||
saveProficiencies.push('STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA');
|
||||
inForce.push({ name: 'Diamond Soul', source: 'Monk', text: 'Proficient in every saving throw.' });
|
||||
}
|
||||
if (levelIn('ROGUE') >= 15) {
|
||||
saveProficiencies.push('WIS');
|
||||
inForce.push({ name: 'Slippery Mind', source: 'Rogue', text: 'Proficient in Wisdom saving throws.' });
|
||||
}
|
||||
|
||||
let halfProficiency = 0;
|
||||
if (levelIn('BARD') >= 2) {
|
||||
halfProficiency = Math.floor(profBonus / 2);
|
||||
inForce.push({ name: 'Jack of All Trades', source: 'Bard', text: `+${halfProficiency} (half proficiency) to initiative and to every skill you are not proficient in.` });
|
||||
}
|
||||
|
||||
return { acBase, speed, hp, saveBonus, saveProficiencies: [...new Set(saveProficiencies)], halfProficiency, inForce };
|
||||
}
|
||||
|
||||
module.exports = { classFeatureEffects, unarmoredMovement };
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -10,31 +10,34 @@
|
||||
const atLeastOne = (n) => Math.max(1, n);
|
||||
|
||||
// Each entry is offered to the classes (and, where set, the subclass) it
|
||||
// names, from `from` level on. `max` gets { level, mods, profBonus }, where
|
||||
// names, from `from` level on (and up to `until`, when it stops being
|
||||
// counted). `max` gets { level, mods, profBonus }, where
|
||||
// `mods` is the ability modifiers by ability. `rest` is the rest that gives
|
||||
// them back: a SHORT one comes back on a long rest too.
|
||||
const RESOURCES = [
|
||||
{ key: 'RAGE', name: 'Rage', classes: ['BARBARIAN'], max: ({ level }) => (level >= 6 ? 4 : level >= 3 ? 3 : 2), rest: 'LONG' },
|
||||
// At level 20 a barbarian rages as often as they like: no counter to keep.
|
||||
{ key: 'RAGE', name: 'Rage', classes: ['BARBARIAN'], max: ({ level }) => (level >= 17 ? 6 : level >= 12 ? 5 : level >= 6 ? 4 : level >= 3 ? 3 : 2), rest: 'LONG', until: 19 },
|
||||
{ key: 'ZEALOUS_PRESENCE', name: 'Zealous Presence', classes: ['BARBARIAN'], subclass: 'ZEALOT', from: 10, max: () => 1, rest: 'LONG' },
|
||||
|
||||
// Font of Inspiration: from level 5 it comes back on a short rest.
|
||||
{ key: 'BARDIC_INSPIRATION', name: 'Bardic Inspiration', classes: ['BARD'], max: ({ mods }) => atLeastOne(mods.CHA), rest: ({ level }) => (level >= 5 ? 'SHORT' : 'LONG') },
|
||||
|
||||
{ key: 'HEALING_HANDS', name: 'Healing Hands', classes: ['CLERIC'], max: () => 1, rest: 'SHORT' },
|
||||
{ key: 'CHANNEL_DIVINITY', name: 'Channel Divinity', classes: ['CLERIC'], from: 2, max: ({ level }) => (level >= 6 ? 2 : 1), rest: 'SHORT' },
|
||||
{ key: 'CHANNEL_DIVINITY', name: 'Channel Divinity', classes: ['CLERIC'], from: 2, max: ({ level }) => (level >= 18 ? 3 : level >= 6 ? 2 : 1), rest: 'SHORT' },
|
||||
{ key: 'CHANNEL_DIVINITY', name: 'Channel Divinity', classes: ['PALADIN'], from: 3, max: () => 1, rest: 'SHORT' },
|
||||
{ key: 'WARDING_FLARE', name: 'Warding Flare', classes: ['CLERIC'], subclass: 'LIGHT', from: 2, max: ({ mods }) => atLeastOne(mods.WIS), rest: 'LONG' },
|
||||
{ key: 'WAR_PRIEST', name: 'War Priest', classes: ['CLERIC'], subclass: 'WAR', from: 2, max: ({ mods }) => atLeastOne(mods.WIS), rest: 'LONG' },
|
||||
|
||||
{ key: 'WILD_SHAPE', name: 'Wild Shape', classes: ['DRUID'], from: 2, max: () => 2, rest: 'SHORT' },
|
||||
// An archdruid (level 20) wild shapes as often as they like.
|
||||
{ key: 'WILD_SHAPE', name: 'Wild Shape', classes: ['DRUID'], from: 2, max: () => 2, rest: 'SHORT', until: 19 },
|
||||
{ key: 'NATURAL_RECOVERY', name: 'Natural Recovery', classes: ['DRUID'], subclass: 'LAND', from: 2, max: () => 1, rest: 'LONG' },
|
||||
{ key: 'STAR_MAP', name: 'Star Map (Guiding Bolt)', classes: ['DRUID'], subclass: 'STARS', from: 2, max: ({ profBonus }) => profBonus, rest: 'LONG' },
|
||||
{ key: 'COSMIC_OMEN', name: 'Cosmic Omen', classes: ['DRUID'], subclass: 'STARS', from: 6, max: ({ profBonus }) => profBonus, rest: 'LONG' },
|
||||
|
||||
{ key: 'SECOND_WIND', name: 'Second Wind', classes: ['FIGHTER'], max: () => 1, rest: 'SHORT' },
|
||||
{ key: 'ACTION_SURGE', name: 'Action Surge', classes: ['FIGHTER'], from: 2, max: () => 1, rest: 'SHORT' },
|
||||
{ key: 'INDOMITABLE', name: 'Indomitable', classes: ['FIGHTER'], from: 9, max: () => 1, rest: 'LONG' },
|
||||
{ key: 'SUPERIORITY_DICE', name: 'Superiority Dice', classes: ['FIGHTER'], subclass: 'BATTLE_MASTER', from: 3, max: ({ level }) => (level >= 7 ? 5 : 4), rest: 'SHORT' },
|
||||
{ key: 'ACTION_SURGE', name: 'Action Surge', classes: ['FIGHTER'], from: 2, max: ({ level }) => (level >= 17 ? 2 : 1), rest: 'SHORT' },
|
||||
{ key: 'INDOMITABLE', name: 'Indomitable', classes: ['FIGHTER'], from: 9, max: ({ level }) => (level >= 17 ? 3 : level >= 13 ? 2 : 1), rest: 'LONG' },
|
||||
{ key: 'SUPERIORITY_DICE', name: 'Superiority Dice', classes: ['FIGHTER'], subclass: 'BATTLE_MASTER', from: 3, max: ({ level }) => (level >= 15 ? 6 : level >= 7 ? 5 : 4), rest: 'SHORT' },
|
||||
{ key: 'FIGHTING_SPIRIT', name: 'Fighting Spirit', classes: ['FIGHTER'], subclass: 'SAMURAI', from: 3, max: () => 3, rest: 'LONG' },
|
||||
|
||||
{ key: 'KI', name: 'Ki', classes: ['MONK'], from: 2, max: ({ level }) => level, rest: 'SHORT' },
|
||||
@@ -42,6 +45,7 @@ const RESOURCES = [
|
||||
|
||||
{ key: 'LAY_ON_HANDS', name: 'Lay on Hands (HP)', classes: ['PALADIN'], max: ({ level }) => 5 * level, rest: 'LONG' },
|
||||
{ key: 'DIVINE_SENSE', name: 'Divine Sense', classes: ['PALADIN'], max: ({ mods }) => atLeastOne(1 + mods.CHA), rest: 'LONG' },
|
||||
{ key: 'CLEANSING_TOUCH', name: 'Cleansing Touch', classes: ['PALADIN'], from: 14, max: ({ mods }) => atLeastOne(mods.CHA), rest: 'LONG' },
|
||||
|
||||
{ key: 'WILD_TALENT', name: 'Wild Talent', classes: ['SORCERER'], max: () => 1, rest: 'LONG' },
|
||||
{ key: 'SORCERY_POINTS', name: 'Sorcery Points', classes: ['SORCERER'], from: 2, max: ({ level }) => level, rest: 'LONG' },
|
||||
@@ -53,6 +57,12 @@ const RESOURCES = [
|
||||
{ key: 'ENTROPIC_WARD', name: 'Entropic Ward', classes: ['WARLOCK'], subclass: 'GREAT_OLD_ONE', from: 6, max: () => 1, rest: 'SHORT' },
|
||||
|
||||
{ key: 'ARCANE_RECOVERY', name: 'Arcane Recovery', classes: ['WIZARD'], max: () => 1, rest: 'LONG' },
|
||||
{ key: 'SIGNATURE_SPELLS', name: 'Signature Spells', classes: ['WIZARD'], from: 20, max: () => 2, rest: 'SHORT' },
|
||||
|
||||
{ key: 'STROKE_OF_LUCK', name: 'Stroke of Luck', classes: ['ROGUE'], from: 20, max: () => 1, rest: 'SHORT' },
|
||||
{ key: 'MYSTIC_ARCANUM', name: 'Mystic Arcanum', classes: ['WARLOCK'], from: 11, max: ({ level }) => (level >= 17 ? 4 : level >= 15 ? 3 : level >= 13 ? 2 : 1), rest: 'LONG' },
|
||||
|
||||
{ key: 'FLASH_OF_GENIUS', name: 'Flash of Genius', classes: ['ARTIFICER'], from: 7, max: ({ mods }) => atLeastOne(mods.INT), rest: 'LONG' },
|
||||
{ key: 'ILLUSORY_SELF', name: 'Illusory Self', classes: ['WIZARD'], subclass: 'ILLUSION', from: 10, max: () => 1, rest: 'SHORT' },
|
||||
];
|
||||
|
||||
@@ -65,7 +75,11 @@ function resourceState({ characterClass, subclass = null, level = 1, resourcesUs
|
||||
const context = { level, mods, profBonus };
|
||||
const tally = resourcesUsed && typeof resourcesUsed === 'object' ? resourcesUsed : {};
|
||||
return RESOURCES.filter(
|
||||
(resource) => resource.classes.includes(characterClass) && level >= (resource.from || 1) && (!resource.subclass || resource.subclass === subclass)
|
||||
(resource) =>
|
||||
resource.classes.includes(characterClass) &&
|
||||
level >= (resource.from || 1) &&
|
||||
level <= (resource.until || Infinity) &&
|
||||
(!resource.subclass || resource.subclass === subclass)
|
||||
).map((resource) => {
|
||||
const max = resource.max(context);
|
||||
const used = Math.min(Math.max(Math.round(Number(tally[resource.key]) || 0), 0), max);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Combat: how an attack or a spell is resolved in a battle. An "action" is
|
||||
// one attack or spell from being declared to its damage landing
|
||||
// (EncounterAction): who it is aimed at, the d20 rolled against each target
|
||||
// (or each target's saving throw), and the damage. Pure: no DB calls. The
|
||||
// random source can be passed in for tests.
|
||||
|
||||
const { parseDice, leadingDice, rollExpression, rollCritical, rollDie } = require('./dice');
|
||||
|
||||
// What an attack roll comes to against a target. A natural 20 is a critical
|
||||
// hit and a natural 1 a miss, whatever the numbers; otherwise the d20 plus the
|
||||
// bonus has to reach the AC (with any area-attack penalty added).
|
||||
function attackOutcome(d20, attackBonus, ac, penalty = 0) {
|
||||
if (d20 === 20) return 'CRIT';
|
||||
if (d20 === 1) return 'FUMBLE';
|
||||
return d20 + attackBonus >= ac + penalty ? 'HIT' : 'MISS';
|
||||
}
|
||||
|
||||
// Whether a saving throw succeeds: the d20 plus the bonus reaches the DC.
|
||||
const saveSucceeds = (d20, saveBonus, dc) => d20 + saveBonus >= dc;
|
||||
|
||||
const lands = (outcome) => outcome === 'HIT' || outcome === 'CRIT';
|
||||
|
||||
// A target as the dice have it (or as the DM has overruled them), with the
|
||||
// damage it takes. `outcome` and `damage` on the target are the DM's
|
||||
// overrides; null leaves it to the dice.
|
||||
// ATTACK: total, outcome HIT | MISS | CRIT | FUMBLE (null until a d20 is in).
|
||||
// SAVE: total, outcome SAVED | FAILED (null until the save is in).
|
||||
// AUTO: outcome HIT.
|
||||
// `finalDamage` is null until it can be known.
|
||||
function settleTarget(action, target) {
|
||||
const { kind, detail } = action;
|
||||
const base = action.damage ? action.damage.total : null;
|
||||
const crit = action.damage && action.damage.crit ? action.damage.crit.total : null;
|
||||
const noDamage = !detail.damageDice && base == null;
|
||||
let total = null;
|
||||
let rolled = null; // what the dice say, before any override
|
||||
if (kind === 'ATTACK') {
|
||||
if (target.d20 != null) {
|
||||
total = target.d20 + (detail.attackBonus || 0);
|
||||
rolled = attackOutcome(target.d20, detail.attackBonus || 0, target.ac ?? 10, target.penalty || 0);
|
||||
}
|
||||
} else if (kind === 'SAVE') {
|
||||
if (target.d20 != null) {
|
||||
total = target.d20 + (target.saveBonus || 0);
|
||||
rolled = saveSucceeds(target.d20, target.saveBonus || 0, detail.saveDC || 10) ? 'SAVED' : 'FAILED';
|
||||
}
|
||||
} else {
|
||||
rolled = 'HIT';
|
||||
}
|
||||
const outcome = target.outcome || rolled;
|
||||
|
||||
let finalDamage = null;
|
||||
if (target.damage != null) finalDamage = target.damage;
|
||||
else if (outcome === 'MISS' || outcome === 'FUMBLE') finalDamage = 0;
|
||||
else if (noDamage) finalDamage = outcome ? 0 : null;
|
||||
else if (outcome && base != null) {
|
||||
if (outcome === 'CRIT') finalDamage = crit != null ? crit : base;
|
||||
else if (outcome === 'SAVED') finalDamage = detail.halfOnSave ? Math.floor(base / 2) : 0;
|
||||
else finalDamage = base;
|
||||
}
|
||||
return { ...target, total, rolled, outcome, overruled: Boolean(target.outcome) && target.outcome !== rolled, finalDamage };
|
||||
}
|
||||
|
||||
// An action with every target settled, and what it is still waiting for:
|
||||
// `needsRolls` (targets with no outcome yet), `needsDamage` (someone is hit
|
||||
// and the damage is not known), `needsCrit` (someone is critically hit and the
|
||||
// critical damage has not been rolled), and `ready` when none of those.
|
||||
function settle(action) {
|
||||
const targets = (action.targets || []).map((target) => settleTarget(action, target));
|
||||
const needsRolls = targets.filter((t) => !t.outcome).length;
|
||||
const hurts = targets.some((t) => t.damage == null && (lands(t.outcome) || t.outcome === 'FAILED' || (t.outcome === 'SAVED' && action.detail.halfOnSave)));
|
||||
const hasDice = Boolean(action.detail.damageDice);
|
||||
const needsDamage = hasDice && hurts && !action.damage;
|
||||
const needsCrit = hasDice && Boolean(action.damage) && !action.damage.crit && !action.damage.entered && targets.some((t) => t.outcome === 'CRIT' && t.damage == null);
|
||||
return { ...action, targets, needsRolls, needsDamage, needsCrit, ready: needsRolls === 0 && !needsDamage && targets.length > 0 };
|
||||
}
|
||||
|
||||
// Rolls an action's damage: its dice plus its bonus, and, when a target is
|
||||
// critically hit, the critical damage by the campaign's rule. Null for an
|
||||
// action with no dice (or dice that can't be read).
|
||||
function rollDamage(detail, { critical = false, critRule = 'DOUBLE_DICE', roll = rollDie } = {}) {
|
||||
const dice = leadingDice(detail.damageDice || '');
|
||||
const parsed = dice ? parseDice(dice) : null;
|
||||
if (!parsed) return null;
|
||||
const withBonus = { dice: parsed.dice, flat: parsed.flat + (detail.damageBonus || 0) };
|
||||
const normal = rollExpression(withBonus, { roll });
|
||||
return {
|
||||
total: Math.max(0, normal.total),
|
||||
dice: normal.dice,
|
||||
modifier: normal.modifier,
|
||||
crit: critical ? critOf(withBonus, critRule, roll) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function critOf(parsed, critRule, roll) {
|
||||
const crit = rollCritical(parsed, critRule, { roll });
|
||||
return { total: Math.max(0, crit.total), dice: crit.dice, modifier: crit.modifier };
|
||||
}
|
||||
|
||||
// The critical damage alone, for an action whose normal damage is already rolled.
|
||||
function rollCritDamage(detail, { critRule = 'DOUBLE_DICE', roll = rollDie } = {}) {
|
||||
const dice = leadingDice(detail.damageDice || '');
|
||||
const parsed = dice ? parseDice(dice) : null;
|
||||
if (!parsed) return null;
|
||||
return critOf({ dice: parsed.dice, flat: parsed.flat + (detail.damageBonus || 0) }, critRule, roll);
|
||||
}
|
||||
|
||||
// Which side a combatant fights on: the party (characters and friendly NPCs),
|
||||
// its enemies (monsters and hostile NPCs), or neither (a neutral NPC).
|
||||
// Takes { kind: 'CHARACTER' | 'MONSTER' | 'NPC', disposition? }.
|
||||
function sideOf(combatant) {
|
||||
if (combatant.kind === 'CHARACTER') return 'PARTY';
|
||||
if (combatant.kind === 'MONSTER') return 'ENEMY';
|
||||
if (combatant.disposition === 'FRIENDLY') return 'PARTY';
|
||||
if (combatant.disposition === 'HOSTILE') return 'ENEMY';
|
||||
return 'NEUTRAL';
|
||||
}
|
||||
|
||||
// Who a combatant acting by itself could go for: everyone standing on the
|
||||
// other side. A neutral has no enemies.
|
||||
function opponentsOf(actor, combatants) {
|
||||
const side = sideOf(actor);
|
||||
if (side === 'NEUTRAL') return [];
|
||||
return combatants.filter((c) => c.id !== actor.id && !c.fallen && sideOf(c) !== 'NEUTRAL' && sideOf(c) !== side);
|
||||
}
|
||||
|
||||
// How likely each opponent is to be picked, relative to the others. Everyone
|
||||
// starts equal; whoever the actor went for last, and whoever has hurt it,
|
||||
// count for more (a creature keeps at what it is fighting), and so, a little,
|
||||
// does someone easier to hit.
|
||||
function targetWeights(candidates, { lastTargetId = null, hurtBy = [] } = {}) {
|
||||
return candidates.map((c) => {
|
||||
let weight = 2;
|
||||
if (c.id === lastTargetId) weight += 3;
|
||||
if (hurtBy.includes(c.id)) weight += 3;
|
||||
weight += Math.max(0, Math.min(2, (16 - (c.ac ?? 12)) / 3));
|
||||
return weight;
|
||||
});
|
||||
}
|
||||
|
||||
// Picks one opponent at random by those weights; null when there is nobody.
|
||||
// `random` gives a number from 0 (inclusive) to 1 (exclusive).
|
||||
function pickTarget(candidates, context = {}, random = Math.random) {
|
||||
if (candidates.length === 0) return null;
|
||||
const weights = targetWeights(candidates, context);
|
||||
let at = random() * weights.reduce((sum, w) => sum + w, 0);
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
at -= weights[i];
|
||||
if (at < 0) return candidates[i];
|
||||
}
|
||||
return candidates[candidates.length - 1];
|
||||
}
|
||||
|
||||
// The attacks a monster or NPC makes on its turn, one entry per attack made
|
||||
// (a multiattack's `count` is spread out): [{ name, attackBonus, damageDice,
|
||||
// damageType }]. A monster's named `attacks` when it has any, otherwise its
|
||||
// single attack; an NPC's single attack. Empty for one with nothing to hit with.
|
||||
function attacksOf({ monster = null, npc = null }) {
|
||||
const named = monster && Array.isArray(monster.attacks) ? monster.attacks.filter((a) => a && a.name) : [];
|
||||
if (named.length > 0) {
|
||||
return named.flatMap((a) =>
|
||||
Array.from({ length: Math.max(1, Math.min(10, Number(a.count) || 1)) }, () => ({
|
||||
name: a.name,
|
||||
attackBonus: Number(a.attackBonus) || 0,
|
||||
damageDice: a.damageDice || null,
|
||||
damageType: a.damageType || null,
|
||||
}))
|
||||
);
|
||||
}
|
||||
const stats = monster || npc;
|
||||
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
|
||||
return [{ name: 'Attack', attackBonus: stats.attackBonus || 0, damageDice: stats.damageDice || null, damageType: null }];
|
||||
}
|
||||
|
||||
// Highest initiative first; ties keep the order combatants were added in.
|
||||
// Anyone who has not rolled yet waits at the bottom.
|
||||
function inTurnOrder(participants) {
|
||||
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
|
||||
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
|
||||
}
|
||||
|
||||
// Whether a combatant may change the weapon in their hand in a battle that
|
||||
// is under way. There are two moments for it: as the battle begins, before
|
||||
// anyone has made a move (round 1, still the first combatant's turn, nothing
|
||||
// landed yet), and on their own turn once their attack has landed, before the
|
||||
// turn passes. `applied` is the battle's landed actions: [{ round,
|
||||
// actorParticipantId }]. `firstId` is whoever went first. `declared` is true
|
||||
// while somebody's attack or spell is declared and still being resolved: that
|
||||
// is a move being made.
|
||||
function weaponSwapOpen({ round, activeId, firstId, participantId, applied = [], declared = false }) {
|
||||
if (activeId == null) return true; // no turn has started
|
||||
const opening = round === 1 && activeId === firstId && applied.length === 0 && !declared;
|
||||
const afterOwnAttack = activeId === participantId && applied.some((a) => a.round === round && a.actorParticipantId === participantId);
|
||||
return opening || afterOwnAttack;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attackOutcome,
|
||||
saveSucceeds,
|
||||
lands,
|
||||
settleTarget,
|
||||
settle,
|
||||
rollDamage,
|
||||
rollCritDamage,
|
||||
sideOf,
|
||||
opponentsOf,
|
||||
targetWeights,
|
||||
pickTarget,
|
||||
attacksOf,
|
||||
inTurnOrder,
|
||||
weaponSwapOpen,
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
const { attackOutcome, saveSucceeds, settle, rollDamage, rollCritDamage, sideOf, opponentsOf, targetWeights, pickTarget, attacksOf } = require('./combat');
|
||||
|
||||
const fixed = (...faces) => {
|
||||
let i = 0;
|
||||
return () => faces[i++ % faces.length];
|
||||
};
|
||||
|
||||
describe('an attack roll', () => {
|
||||
test('hits when the d20 plus the bonus reaches the AC', () => {
|
||||
expect(attackOutcome(10, 5, 15)).toBe('HIT');
|
||||
expect(attackOutcome(9, 5, 15)).toBe('MISS');
|
||||
});
|
||||
|
||||
test('a natural 20 always hits, critically, and a natural 1 always misses', () => {
|
||||
expect(attackOutcome(20, -5, 30)).toBe('CRIT');
|
||||
expect(attackOutcome(1, 20, 5)).toBe('FUMBLE');
|
||||
});
|
||||
|
||||
test('an area attack counts each AC higher', () => {
|
||||
expect(attackOutcome(10, 5, 15, 2)).toBe('MISS');
|
||||
expect(attackOutcome(12, 5, 15, 2)).toBe('HIT');
|
||||
});
|
||||
});
|
||||
|
||||
test('a saving throw succeeds on the DC or more', () => {
|
||||
expect(saveSucceeds(12, 3, 15)).toBe(true);
|
||||
expect(saveSucceeds(11, 3, 15)).toBe(false);
|
||||
});
|
||||
|
||||
describe('settling an action', () => {
|
||||
const attack = (targets, damage = null) => ({ kind: 'ATTACK', detail: { attackBonus: 5, damageDice: '1d8', damageBonus: 3 }, targets, damage });
|
||||
|
||||
test('waits for a d20 for each target, then for the damage', () => {
|
||||
const waiting = settle(attack([{ participantId: 'a', ac: 15, d20: null }]));
|
||||
expect(waiting).toMatchObject({ needsRolls: 1, ready: false });
|
||||
const hit = settle(attack([{ participantId: 'a', ac: 15, d20: 12 }]));
|
||||
expect(hit.targets[0]).toMatchObject({ total: 17, outcome: 'HIT', finalDamage: null });
|
||||
expect(hit).toMatchObject({ needsRolls: 0, needsDamage: true, ready: false });
|
||||
const done = settle(attack([{ participantId: 'a', ac: 15, d20: 12 }], { total: 7 }));
|
||||
expect(done.targets[0].finalDamage).toBe(7);
|
||||
expect(done.ready).toBe(true);
|
||||
});
|
||||
|
||||
test('a miss needs no damage at all', () => {
|
||||
const miss = settle(attack([{ participantId: 'a', ac: 15, d20: 4 }]));
|
||||
expect(miss.targets[0]).toMatchObject({ outcome: 'MISS', finalDamage: 0 });
|
||||
expect(miss).toMatchObject({ needsDamage: false, ready: true });
|
||||
});
|
||||
|
||||
test('a critical hit takes the critical damage, once it is rolled', () => {
|
||||
const crit = settle(attack([{ participantId: 'a', ac: 15, d20: 20 }], { total: 7 }));
|
||||
expect(crit.needsCrit).toBe(true);
|
||||
expect(settle(attack([{ participantId: 'a', ac: 15, d20: 20 }], { total: 7, crit: { total: 13 } })).targets[0].finalDamage).toBe(13);
|
||||
});
|
||||
|
||||
test('the DM can overrule the dice, and set a target’s damage by hand', () => {
|
||||
const overruled = settle(attack([{ participantId: 'a', ac: 15, d20: 4, outcome: 'HIT' }, { participantId: 'b', ac: 12, d20: 15, damage: 2 }], { total: 9 }));
|
||||
expect(overruled.targets[0]).toMatchObject({ rolled: 'MISS', outcome: 'HIT', overruled: true, finalDamage: 9 });
|
||||
expect(overruled.targets[1]).toMatchObject({ outcome: 'HIT', overruled: false, finalDamage: 2 });
|
||||
});
|
||||
|
||||
test('a save spell deals full damage on a failure, half or none on a success', () => {
|
||||
const spell = (halfOnSave) =>
|
||||
settle({
|
||||
kind: 'SAVE',
|
||||
detail: { saveAbility: 'DEX', saveDC: 15, halfOnSave, damageDice: '8d6' },
|
||||
targets: [{ participantId: 'a', saveBonus: 2, d20: 13 }, { participantId: 'b', saveBonus: 2, d20: 12 }, { participantId: 'c', saveBonus: 0, d20: null }],
|
||||
damage: { total: 27 },
|
||||
});
|
||||
const fireball = spell(true);
|
||||
expect(fireball.targets.map((t) => [t.outcome, t.finalDamage])).toEqual([['SAVED', 13], ['FAILED', 27], [null, null]]);
|
||||
expect(fireball).toMatchObject({ needsRolls: 1, ready: false });
|
||||
expect(spell(false).targets[0].finalDamage).toBe(0);
|
||||
});
|
||||
|
||||
test('something that just lands hits everyone it is aimed at', () => {
|
||||
const missile = settle({ kind: 'AUTO', detail: { damageDice: '3d4+3' }, targets: [{ participantId: 'a' }], damage: { total: 10 } });
|
||||
expect(missile.targets[0]).toMatchObject({ outcome: 'HIT', finalDamage: 10 });
|
||||
expect(missile.ready).toBe(true);
|
||||
// With no dice at all (a utility spell) it is ready as it stands.
|
||||
expect(settle({ kind: 'AUTO', detail: {}, targets: [{ participantId: 'a' }], damage: null })).toMatchObject({ ready: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('damage', () => {
|
||||
test('is the dice plus the bonus', () => {
|
||||
expect(rollDamage({ damageDice: '1d8', damageBonus: 3 }, { roll: fixed(5) })).toMatchObject({ total: 8, crit: null });
|
||||
expect(rollDamage({ damageDice: '2d6+1 fire', damageBonus: 0 }, { roll: fixed(2, 3) }).total).toBe(6);
|
||||
expect(rollDamage({ damageDice: 'a nasty bite' })).toBeNull();
|
||||
});
|
||||
|
||||
test('a critical hit doubles the dice, never the bonus', () => {
|
||||
const rolled = rollDamage({ damageDice: '1d8', damageBonus: 3 }, { critical: true, roll: fixed(5, 4, 6) });
|
||||
expect(rolled.total).toBe(8);
|
||||
expect(rolled.crit.total).toBe(4 + 6 + 3);
|
||||
expect(rollCritDamage({ damageDice: '1d8', damageBonus: 3 }, { critRule: 'MAX_PLUS_ROLL', roll: fixed(2) }).total).toBe(8 + 2 + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sides', () => {
|
||||
const aria = { id: 'aria', kind: 'CHARACTER', ac: 16 };
|
||||
const bo = { id: 'bo', kind: 'CHARACTER', ac: 12 };
|
||||
const goblin = { id: 'goblin', kind: 'MONSTER', ac: 13 };
|
||||
const guard = { id: 'guard', kind: 'NPC', disposition: 'FRIENDLY', ac: 14 };
|
||||
const thug = { id: 'thug', kind: 'NPC', disposition: 'HOSTILE', ac: 11 };
|
||||
const baker = { id: 'baker', kind: 'NPC', disposition: 'NEUTRAL', ac: 10 };
|
||||
const everyone = [aria, bo, goblin, guard, thug, baker];
|
||||
|
||||
test('characters and friendly NPCs against monsters and hostile NPCs', () => {
|
||||
expect(everyone.map(sideOf)).toEqual(['PARTY', 'PARTY', 'ENEMY', 'PARTY', 'ENEMY', 'NEUTRAL']);
|
||||
expect(opponentsOf(goblin, everyone).map((c) => c.id)).toEqual(['aria', 'bo', 'guard']);
|
||||
expect(opponentsOf(guard, everyone).map((c) => c.id)).toEqual(['goblin', 'thug']);
|
||||
expect(opponentsOf(baker, everyone)).toEqual([]);
|
||||
});
|
||||
|
||||
test('the fallen are nobody’s target', () => {
|
||||
expect(opponentsOf(goblin, [aria, { ...bo, fallen: true }]).map((c) => c.id)).toEqual(['aria']);
|
||||
});
|
||||
|
||||
test('a creature favours who it fought last, who hurt it, and the easier mark', () => {
|
||||
const [ariaWeight, boWeight] = targetWeights([aria, bo]);
|
||||
expect(boWeight).toBeGreaterThan(ariaWeight); // lower AC
|
||||
const [again] = targetWeights([aria, bo], { lastTargetId: 'aria', hurtBy: ['aria'] });
|
||||
expect(again).toBeGreaterThan(boWeight);
|
||||
});
|
||||
|
||||
test('picks by weight, and nobody when there is nobody', () => {
|
||||
expect(pickTarget([aria, bo], {}, () => 0).id).toBe('aria');
|
||||
expect(pickTarget([aria, bo], {}, () => 0.999).id).toBe('bo');
|
||||
expect(pickTarget([], {})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a creature attacks with', () => {
|
||||
test('its single attack, or each of its named attacks as often as it makes it', () => {
|
||||
expect(attacksOf({ monster: { attackBonus: 4, damageDice: '1d6+2', attacks: [] } })).toEqual([{ name: 'Attack', attackBonus: 4, damageDice: '1d6+2', damageType: null }]);
|
||||
const owlbear = attacksOf({ monster: { attackBonus: 7, damageDice: '2d8+5', attacks: [{ name: 'Claw', attackBonus: 7, damageDice: '2d8+5', damageType: 'slashing', count: 2 }, { name: 'Beak', attackBonus: 7, damageDice: '1d10+5' }] } });
|
||||
expect(owlbear.map((a) => a.name)).toEqual(['Claw', 'Claw', 'Beak']);
|
||||
expect(attacksOf({ npc: { attackBonus: 3, damageDice: '1d6' } })).toHaveLength(1);
|
||||
expect(attacksOf({ npc: { attackBonus: null, damageDice: null } })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing weapons in a battle', () => {
|
||||
const { inTurnOrder, weaponSwapOpen } = require('./combat');
|
||||
const base = { round: 1, activeId: 'a', firstId: 'a', participantId: 'b', applied: [] };
|
||||
|
||||
test('the turn order is by initiative, the unrolled last', () => {
|
||||
const order = inTurnOrder([
|
||||
{ id: '1', initiativeRoll: null, initiative: 0 },
|
||||
{ id: '2', initiativeRoll: 5, initiative: 7 },
|
||||
{ id: '3', initiativeRoll: 12, initiative: 12 },
|
||||
]);
|
||||
expect(order.map((p) => p.id)).toEqual(['3', '2', '1']);
|
||||
});
|
||||
|
||||
test('anyone may as the battle begins, before a move is made', () => {
|
||||
expect(weaponSwapOpen(base)).toBe(true);
|
||||
expect(weaponSwapOpen({ ...base, activeId: null })).toBe(true);
|
||||
// The first turn has passed, or something has landed: the moment is gone.
|
||||
expect(weaponSwapOpen({ ...base, activeId: 'b', participantId: 'c' })).toBe(false);
|
||||
expect(weaponSwapOpen({ ...base, applied: [{ round: 1, actorParticipantId: 'a' }] })).toBe(false);
|
||||
expect(weaponSwapOpen({ ...base, round: 2 })).toBe(false);
|
||||
expect(weaponSwapOpen({ ...base, declared: true })).toBe(false); // an attack is being resolved
|
||||
});
|
||||
|
||||
test('after that, only on their own turn once their attack has landed', () => {
|
||||
const later = { round: 2, activeId: 'b', firstId: 'a', participantId: 'b' };
|
||||
expect(weaponSwapOpen({ ...later, applied: [] })).toBe(false);
|
||||
expect(weaponSwapOpen({ ...later, applied: [{ round: 1, actorParticipantId: 'b' }] })).toBe(false); // last round's
|
||||
expect(weaponSwapOpen({ ...later, applied: [{ round: 2, actorParticipantId: 'b' }] })).toBe(true);
|
||||
// Somebody else's turn, even though they attacked earlier this round.
|
||||
expect(weaponSwapOpen({ ...later, activeId: 'c', applied: [{ round: 2, actorParticipantId: 'b' }] })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,202 @@ const SPECIES_TRAITS = {
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Aggressive', text: 'As a bonus action, move up to your speed toward an enemy you can see.' },
|
||||
],
|
||||
AASIMAR: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Celestial Resistance', text: 'Necrotic and radiant damage against you is halved.' },
|
||||
{ name: 'Healing Hands', text: 'Once per long rest, touch a creature and restore hit points: roll a d4 for each point of your proficiency bonus.' },
|
||||
{ name: 'Light Bearer', text: 'You know the Light cantrip (the DM grants it).' },
|
||||
{ name: 'Celestial Revelation', text: 'From level 3, once per long rest, transform for a minute (bonus action), choosing: spectral wings (fly at your speed), an inner radiance (light, and radiant damage to those near you each turn) or a fearful shroud (enemies nearby make a Charisma save or are frightened). While transformed, one hit each turn deals extra damage equal to your proficiency bonus.' },
|
||||
],
|
||||
GOLIATH: [
|
||||
{ name: 'Giant Ancestry', text: 'Choose a giant with your DM; use its gift your proficiency bonus times per long rest. Cloud: bonus-action teleport 30 ft. Fire: +1d10 fire on a hit. Frost: +1d6 cold on a hit and the target is slowed by 10 ft. Hill: knock a Large or smaller target you hit prone. Stone: reaction, reduce damage you take by 1d12 + Constitution modifier. Storm: reaction, deal 1d8 thunder to a creature that damaged you.' },
|
||||
{ name: 'Large Form', text: 'From level 5, once per long rest, grow Large for 10 minutes (bonus action): advantage on Strength checks and +10 ft speed.' },
|
||||
{ name: 'Powerful Build', text: 'You count as one size larger when working out what you can carry, push, drag or lift.' },
|
||||
],
|
||||
AARAKOCRA: [
|
||||
{ name: 'Flight', text: 'You can fly at your walking speed, but not while wearing medium or heavy armor.' },
|
||||
{ name: 'Talons', text: 'Your unarmed strikes deal 1d6 + Strength modifier slashing damage.' },
|
||||
{ name: 'Wind Caller', text: 'From level 3, cast Gust of Wind once per long rest without a spell slot.' },
|
||||
],
|
||||
BUGBEAR: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
|
||||
{ name: 'Long-Limbed', text: 'On your turn, your melee attacks reach 5 feet further than normal.' },
|
||||
{ name: 'Powerful Build', text: 'You count as one size larger when working out what you can carry, push, drag or lift.' },
|
||||
{ name: 'Sneaky', text: 'You are proficient in Stealth (add the bonus by hand) and can squeeze through spaces made for Small creatures.' },
|
||||
{ name: 'Surprise Attack', text: 'Hit a creature that has not yet taken a turn in the fight: it takes an extra 2d6 damage (once per combat).' },
|
||||
],
|
||||
CENTAUR: [
|
||||
{ name: 'Charge', text: 'Move at least 30 feet straight toward a target and hit it with a melee attack: make a Hooves attack against it as a bonus action.' },
|
||||
{ name: 'Equine Build', text: 'You count as one size larger for carrying, pushing and dragging. Climbing costs 4 extra feet for each foot you climb.' },
|
||||
{ name: 'Hooves', text: 'Your unarmed strikes deal 1d6 + Strength modifier bludgeoning damage.' },
|
||||
{ name: 'Natural Affinity', text: 'You are proficient in one of Animal Handling, Medicine, Nature or Survival (agree it with your DM).' },
|
||||
],
|
||||
CHANGELING: [
|
||||
{ name: 'Shapechanger', text: 'Action: change your appearance and voice (height, features, hair, skin) to look like another humanoid of your size. Your gear does not change.' },
|
||||
{ name: 'Changeling Instincts', text: 'You are proficient in two of Deception, Insight, Intimidation, Performance and Persuasion (agree them with your DM).' },
|
||||
],
|
||||
DEEP_GNOME: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 120 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Gnomish Magic Resistance', text: 'Advantage on Intelligence, Wisdom and Charisma saves against spells.' },
|
||||
{ name: 'Svirfneblin Camouflage', text: 'Your proficiency bonus times per long rest, give yourself advantage on a Stealth check.' },
|
||||
{ name: 'Gift of the Svirfneblin', text: 'From level 3, cast Disguise Self once per long rest; from level 5, Nondetection on yourself once per long rest.' },
|
||||
],
|
||||
DUERGAR: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 120 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Dwarven Resilience', text: 'Advantage on saves against poison, and poison damage against you is halved.' },
|
||||
{ name: 'Psionic Fortitude', text: 'Advantage on saves against being charmed or stunned.' },
|
||||
{ name: 'Duergar Magic', text: 'From level 3, cast Enlarge/Reduce on yourself once per long rest; from level 5, Invisibility on yourself once per long rest.' },
|
||||
],
|
||||
ELADRIN: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
|
||||
{ name: 'Trance', text: 'You do not sleep. Four hours of meditation rests you as well as a full night.' },
|
||||
{ name: 'Keen Senses', text: 'You are proficient in Perception (add the bonus by hand).' },
|
||||
{ name: 'Fey Step', text: 'Your proficiency bonus times per long rest, bonus action: teleport up to 30 feet. From level 3 it also has your season’s effect (agree it with your DM): autumn charms, winter frightens, spring lets you teleport an ally instead, summer burns those nearby.' },
|
||||
],
|
||||
FAIRY: [
|
||||
{ name: 'Flight', text: 'You can fly at your walking speed, but not while wearing medium or heavy armor.' },
|
||||
{ name: 'Fairy Magic', text: 'You know Druidcraft. From level 3 cast Faerie Fire, and from level 5 Enlarge/Reduce, each once per long rest without a slot.' },
|
||||
{ name: 'Small', text: 'You are Small: heavy weapons are hard for you to wield (disadvantage).' },
|
||||
],
|
||||
FIRBOLG: [
|
||||
{ name: 'Firbolg Magic', text: 'Cast Detect Magic and Disguise Self, each once per long rest without a slot.' },
|
||||
{ name: 'Hidden Step', text: 'Your proficiency bonus times per long rest, bonus action: turn invisible until the start of your next turn or until you attack.' },
|
||||
{ name: 'Powerful Build', text: 'You count as one size larger when working out what you can carry, push, drag or lift.' },
|
||||
{ name: 'Speech of Beast and Leaf', text: 'Beasts and plants understand the meaning of your words, and you have advantage on Charisma checks to influence them.' },
|
||||
],
|
||||
AIR_GENASI: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Unending Breath', text: 'You can hold your breath indefinitely.' },
|
||||
{ name: 'Lightning Resistance', text: 'Lightning damage against you is halved.' },
|
||||
{ name: 'Mingle with the Wind', text: 'You know Shocking Grasp. From level 3 cast Feather Fall, and from level 5 Levitate, each once per long rest without a slot.' },
|
||||
],
|
||||
EARTH_GENASI: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Earth Walk', text: 'Difficult terrain made of earth or stone does not slow you.' },
|
||||
{ name: 'Merge with Stone', text: 'You know Blade Ward and can cast it as a bonus action your proficiency bonus times per long rest. From level 5 cast Pass without Trace once per long rest without a slot.' },
|
||||
],
|
||||
FIRE_GENASI: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Fire Resistance', text: 'Fire damage against you is halved.' },
|
||||
{ name: 'Reach to the Blaze', text: 'You know Produce Flame. From level 3 cast Burning Hands, and from level 5 Flame Blade, each once per long rest without a slot.' },
|
||||
],
|
||||
WATER_GENASI: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Acid Resistance', text: 'Acid damage against you is halved.' },
|
||||
{ name: 'Amphibious', text: 'You can breathe air and water, and you have a swimming speed equal to your walking speed.' },
|
||||
{ name: 'Call to the Wave', text: 'You know Acid Splash. From level 3 cast Create or Destroy Water, and from level 5 Water Walk, each once per long rest without a slot.' },
|
||||
],
|
||||
GITHYANKI: [
|
||||
{ name: 'Astral Knowledge', text: 'After each long rest, pick one skill and one weapon or tool to be proficient in until the next (agree them with your DM).' },
|
||||
{ name: 'Githyanki Psionics', text: 'You know Mage Hand (and the hand is invisible). From level 3 cast Jump, and from level 5 Misty Step, each once per long rest without a slot.' },
|
||||
{ name: 'Psychic Resilience', text: 'Psychic damage against you is halved.' },
|
||||
],
|
||||
GITHZERAI: [
|
||||
{ name: 'Mental Discipline', text: 'Advantage on saves against being charmed or frightened.' },
|
||||
{ name: 'Githzerai Psionics', text: 'You know Mage Hand (and the hand is invisible). From level 3 cast Shield, and from level 5 Detect Thoughts, each once per long rest without a slot.' },
|
||||
{ name: 'Psychic Resilience', text: 'Psychic damage against you is halved.' },
|
||||
],
|
||||
GOBLIN: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
|
||||
{ name: 'Fury of the Small', text: 'Your proficiency bonus times per long rest, when you hit a creature larger than you, deal extra damage equal to your proficiency bonus.' },
|
||||
{ name: 'Nimble Escape', text: 'Bonus action: Disengage or Hide.' },
|
||||
{ name: 'Small', text: 'You are Small: heavy weapons are hard for you to wield (disadvantage).' },
|
||||
],
|
||||
HARENGON: [
|
||||
{ name: 'Hare-Trigger', text: 'Add your proficiency bonus to your initiative rolls.' },
|
||||
{ name: 'Leporine Senses', text: 'You are proficient in Perception (add the bonus by hand).' },
|
||||
{ name: 'Lucky Footwork', text: 'Reaction, when you fail a Dexterity save: add a d4 to it, which may turn it into a success.' },
|
||||
{ name: 'Rabbit Hop', text: 'Your proficiency bonus times per long rest, bonus action: jump 5 × your proficiency bonus feet without provoking opportunity attacks.' },
|
||||
],
|
||||
HOBGOBLIN: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
|
||||
{ name: 'Fey Gift', text: 'Your proficiency bonus times per long rest, take the Help action as a bonus action. From level 3 it also gives temporary HP, speed, or makes the target’s next attack against you harder (agree which with your DM).' },
|
||||
{ name: 'Fortune from the Many', text: 'Once per long rest, when you miss an attack or fail a check or save, add +1 for each ally you can see within 30 feet (up to +3).' },
|
||||
],
|
||||
KENKU: [
|
||||
{ name: 'Expert Duplication', text: 'Advantage on checks to copy handwriting or craftwork.' },
|
||||
{ name: 'Kenku Recall', text: 'You are proficient in two skills of your choice. Your proficiency bonus times per long rest, give yourself advantage on a check with a skill you are proficient in.' },
|
||||
{ name: 'Mimicry', text: 'You can copy any sound or voice you have heard; telling it apart takes an Insight check against yours.' },
|
||||
],
|
||||
KOBOLD: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Draconic Cry', text: 'Your proficiency bonus times per long rest, bonus action: until the start of your next turn, you and your allies have advantage on attacks against enemies within 10 feet of you.' },
|
||||
{ name: 'Kobold Legacy', text: 'Choose one with your DM: Craftiness (proficiency in Arcana, Investigation, Medicine, Sleight of Hand or Survival), Defiance (advantage on saves against being frightened), or Draconic Sorcery (one sorcerer cantrip).' },
|
||||
{ name: 'Small', text: 'You are Small: heavy weapons are hard for you to wield (disadvantage).' },
|
||||
],
|
||||
LIZARDFOLK: [
|
||||
{ name: 'Bite', text: 'Your unarmed strikes can be bites: 1d6 + Strength modifier piercing damage.' },
|
||||
{ name: 'Hold Breath', text: 'You can hold your breath for up to 15 minutes, and you swim at your walking speed.' },
|
||||
{ name: 'Hungry Jaws', text: 'Your proficiency bonus times per long rest, bonus action: a Bite attack that, on a hit, gives you temporary HP equal to your proficiency bonus.' },
|
||||
{ name: 'Natural Armor', text: 'Without armor your AC is 13 + your Dexterity modifier (ask your DM to set your armor bonus). A shield still adds.' },
|
||||
{ name: 'Nature’s Intuition', text: 'You are proficient in two of Animal Handling, Medicine, Nature, Perception, Stealth and Survival (agree them with your DM; add the bonus by hand).' },
|
||||
],
|
||||
MINOTAUR: [
|
||||
{ name: 'Horns', text: 'Your unarmed strikes can gore: 1d6 + Strength modifier piercing damage.' },
|
||||
{ name: 'Goring Rush', text: 'Right after you Dash and move at least 20 feet, make one Horns attack as a bonus action.' },
|
||||
{ name: 'Hammering Horns', text: 'After you hit with a melee attack as part of the Attack action, shove the target up to 10 feet away as a bonus action (Strength save to resist).' },
|
||||
{ name: 'Labyrinthine Recall', text: 'You always know which way is north, and you have advantage on checks to navigate or track.' },
|
||||
],
|
||||
SEA_ELF: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
|
||||
{ name: 'Trance', text: 'You do not sleep. Four hours of meditation rests you as well as a full night.' },
|
||||
{ name: 'Child of the Sea', text: 'You breathe air and water, swim at your walking speed, and cold damage against you is halved.' },
|
||||
{ name: 'Friend of the Sea', text: 'You can share simple ideas with beasts that breathe water.' },
|
||||
],
|
||||
SHADAR_KAI: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
|
||||
{ name: 'Trance', text: 'You do not sleep. Four hours of meditation rests you as well as a full night.' },
|
||||
{ name: 'Necrotic Resistance', text: 'Necrotic damage against you is halved.' },
|
||||
{ name: 'Blessing of the Raven Queen', text: 'Your proficiency bonus times per long rest, bonus action: teleport up to 30 feet. From level 3 you also halve all damage against you until the start of your next turn.' },
|
||||
],
|
||||
SHIFTER: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Bestial Instincts', text: 'You are proficient in one of Acrobatics, Athletics, Intimidation or Survival (agree it with your DM).' },
|
||||
{ name: 'Shifting', text: 'Your proficiency bonus times per long rest, bonus action: shift for one minute and gain 2 × your proficiency bonus temporary HP, plus your kind’s benefit (agree it with your DM): Beasthide +1 AC, Longtooth a bonus-action bite, Swiftstride +10 ft speed, Wildhunt advantage on Wisdom checks.' },
|
||||
],
|
||||
TABAXI: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Cat’s Claws', text: 'Your unarmed strikes deal 1d6 + Strength modifier slashing damage, and you climb at your walking speed.' },
|
||||
{ name: 'Cat’s Talent', text: 'You are proficient in Perception and Stealth (add the bonus by hand).' },
|
||||
{ name: 'Feline Agility', text: 'On your turn, double your speed until the end of the turn. You can do it again after a turn in which you do not move.' },
|
||||
],
|
||||
TORTLE: [
|
||||
{ name: 'Claws', text: 'Your unarmed strikes deal 1d6 + Strength modifier slashing damage.' },
|
||||
{ name: 'Hold Breath', text: 'You can hold your breath for up to an hour.' },
|
||||
{ name: 'Natural Armor', text: 'Your shell gives you AC 17, whatever your Dexterity; you cannot wear armor, but a shield still adds (ask your DM to set your armor bonus).' },
|
||||
{ name: 'Shell Defense', text: 'Action: withdraw into your shell: +4 AC and advantage on Strength and Constitution saves, but speed 0, disadvantage on Dexterity saves and no reactions, until you come out (bonus action).' },
|
||||
{ name: 'Nature’s Intuition', text: 'You are proficient in two of Animal Handling, Medicine, Nature, Perception, Stealth and Survival (agree them with your DM; add the bonus by hand).' },
|
||||
],
|
||||
TRITON: [
|
||||
{ name: 'Amphibious', text: 'You can breathe air and water, and you have a swimming speed equal to your walking speed.' },
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Control Air and Water', text: 'Cast Fog Cloud; from level 3 Gust of Wind, and from level 5 Water Walk, each once per long rest without a slot.' },
|
||||
{ name: 'Emissary of the Sea', text: 'You can share simple ideas with beasts, elementals and monstrosities that breathe water.' },
|
||||
{ name: 'Guardian of the Depths', text: 'Cold damage against you is halved.' },
|
||||
],
|
||||
YUAN_TI: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Magic Resistance', text: 'Advantage on saves against spells.' },
|
||||
{ name: 'Poison Resilience', text: 'Advantage on saves against being poisoned, and poison damage against you is halved.' },
|
||||
{ name: 'Serpentine Spellcasting', text: 'You know Poison Spray, and you can cast Animal Friendship on snakes at will. From level 3 cast Suggestion once per long rest without a slot.' },
|
||||
],
|
||||
OWLIN: [
|
||||
{ name: 'Darkvision', text: 'See in dim light within 120 feet as if it were bright, and in darkness as if it were dim.' },
|
||||
{ name: 'Flight', text: 'You can fly at your walking speed, but not while wearing medium or heavy armor.' },
|
||||
{ name: 'Silent Feathers', text: 'You are proficient in Stealth (add the bonus by hand).' },
|
||||
],
|
||||
SATYR: [
|
||||
{ name: 'Ram', text: 'Your unarmed strikes can headbutt: 1d6 + Strength modifier bludgeoning damage.' },
|
||||
{ name: 'Magic Resistance', text: 'Advantage on saves against spells.' },
|
||||
{ name: 'Mirthful Leaps', text: 'Whenever you jump, roll a d8 and add it to the distance in feet.' },
|
||||
{ name: 'Reveler', text: 'You are proficient in Performance and Persuasion (add the bonus by hand).' },
|
||||
],
|
||||
};
|
||||
|
||||
// Granted automatically by class, at level 1.
|
||||
@@ -70,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.' },
|
||||
@@ -110,7 +306,11 @@ 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.' },
|
||||
{ name: 'Spellcasting', text: 'You cast artificer spells through tools: thieves’ tools, artisan’s tools or an infused item serve as your focus.' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -71,6 +71,11 @@ const CLASSES = {
|
||||
saveProficiencies: ['STR', 'DEX'], spellcastingAbility: null,
|
||||
skillChoices: { count: 2, options: ['ACROBATICS', 'ATHLETICS', 'HISTORY', 'INSIGHT', 'RELIGION', 'STEALTH'] },
|
||||
},
|
||||
ARTIFICER: {
|
||||
key: 'ARTIFICER', name: 'Artificer', hitDie: 8, primaryAbility: 'Int',
|
||||
saveProficiencies: ['CON', 'INT'], spellcastingAbility: 'INT',
|
||||
skillChoices: { count: 2, options: ['ARCANA', 'HISTORY', 'INVESTIGATION', 'MEDICINE', 'NATURE', 'PERCEPTION', 'SLEIGHT_OF_HAND'] },
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = { CLASSES };
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// What each class gains at each level after the first: the "Levelling-Up
|
||||
// Checklist" tables of the Hollowcrown book (Rules Cheat Sheet), levels 2 to
|
||||
// 10. These are the features every member of the class gets; what a subclass
|
||||
// Checklist" tables of the Hollowcrown book (Rules Cheat Sheet) for levels 2
|
||||
// to 10, carried on to level 20 in the same spirit. These are the features every member of the class gets; what a subclass
|
||||
// adds is in subclasses.js. Level 1 features are in characterOptions.js and
|
||||
// are written by the builder.
|
||||
//
|
||||
@@ -57,15 +57,27 @@ 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],
|
||||
9: [{ name: 'Brutal Critical', text: 'Roll one extra weapon damage die on a critical hit.' }, rage('Four times', 3)],
|
||||
11: [{ name: 'Relentless Rage', text: 'While raging, when you drop to 0 HP but are not killed outright, make a DC 10 Constitution save to drop to 1 HP instead. The DC rises by 5 each time, back to 10 after a rest.' }],
|
||||
12: [BOON, rage('Five times', 3)],
|
||||
13: [{ name: 'Brutal Critical', text: 'Roll two extra weapon damage dice on a critical hit.', upgrade: true }],
|
||||
15: [{ name: 'Persistent Rage', text: 'Your rage only ends early if you fall unconscious or choose to end it.' }],
|
||||
16: [BOON, rage('Five times', 4)],
|
||||
17: [{ name: 'Brutal Critical', text: 'Roll three extra weapon damage dice on a critical hit.', upgrade: true }, rage('Six times', 4)],
|
||||
18: [{ name: 'Indomitable Might', text: 'If a Strength check comes up lower than your Strength score, use the score instead.' }],
|
||||
19: [BOON],
|
||||
20: [
|
||||
{ name: 'Primal Champion', text: 'Your Strength and Constitution each count 4 higher (to 24 at most): add +2 to those modifiers by hand.' },
|
||||
{ name: 'Rage', text: 'As often as you like, for one minute: +4 melee damage, advantage on Strength checks and saves, and physical damage against you is halved.', upgrade: true },
|
||||
],
|
||||
},
|
||||
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: [
|
||||
@@ -83,6 +95,15 @@ const LEVEL_FEATURES = {
|
||||
{ name: 'Expertise', text: 'Double proficiency on four proficient skills (add by hand).', upgrade: true },
|
||||
{ name: 'Magical Secrets', text: 'Learn two more spells from any class (the DM grants them within the unlock schedule).' },
|
||||
],
|
||||
12: [BOON],
|
||||
13: [{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d10.', upgrade: true }],
|
||||
14: [{ name: 'Magical Secrets', text: 'Learn four spells from any class in all (the DM grants two more within the unlock schedule).', upgrade: true }],
|
||||
15: [bardicInspiration('d12')],
|
||||
16: [BOON],
|
||||
17: [{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d12.', upgrade: true }],
|
||||
18: [{ name: 'Magical Secrets', text: 'Learn six spells from any class in all (the DM grants two more within the unlock schedule).', upgrade: true }],
|
||||
19: [BOON],
|
||||
20: [{ name: 'Superior Inspiration', text: 'When you roll initiative with no Bardic Inspiration left, you get one back.' }],
|
||||
},
|
||||
CLERIC: {
|
||||
2: [
|
||||
@@ -99,6 +120,14 @@ const LEVEL_FEATURES = {
|
||||
{ name: 'Destroy Undead', text: 'Undead of CR 1 or lower that fail against your Turn Undead are destroyed.', upgrade: true },
|
||||
],
|
||||
10: [{ name: 'Divine Intervention', text: 'Once per week, call on your god: roll d100; if you roll your level or lower, the god helps (the DM decides how).' }],
|
||||
11: [{ name: 'Destroy Undead', text: 'Undead of CR 2 or lower that fail against your Turn Undead are destroyed.', upgrade: true }],
|
||||
12: [BOON],
|
||||
14: [{ name: 'Destroy Undead', text: 'Undead of CR 3 or lower that fail against your Turn Undead are destroyed.', upgrade: true }],
|
||||
16: [BOON],
|
||||
17: [{ name: 'Destroy Undead', text: 'Undead of CR 4 or lower that fail against your Turn Undead are destroyed.', upgrade: true }],
|
||||
18: [{ name: 'Channel Divinity', text: 'Three times per short rest, use Turn Undead or your domain’s Channel Divinity.', upgrade: true }],
|
||||
19: [BOON],
|
||||
20: [{ name: 'Divine Intervention', text: 'Once per week, call on your god and it answers: no roll needed (the DM decides how it helps).', upgrade: true }],
|
||||
},
|
||||
DRUID: {
|
||||
2: [
|
||||
@@ -106,6 +135,14 @@ const LEVEL_FEATURES = {
|
||||
],
|
||||
4: [BOON, wildShape('CR 1/2 or less (it may swim, not fly)')],
|
||||
8: [BOON, wildShape('CR 1 or less (it may swim or fly)')],
|
||||
12: [BOON],
|
||||
16: [BOON],
|
||||
18: [
|
||||
{ name: 'Timeless Body', text: 'You age one year for every ten that pass.' },
|
||||
{ name: 'Beast Spells', text: 'You can cast spells while in Wild Shape (anything that needs no costly material).' },
|
||||
],
|
||||
19: [BOON],
|
||||
20: [{ name: 'Archdruid', text: 'Wild Shape as often as you like, and cast druid spells without words, gestures or simple materials.' }],
|
||||
},
|
||||
FIGHTER: {
|
||||
2: [{ name: 'Action Surge', text: 'Once per short rest, take one extra action on your turn.' }],
|
||||
@@ -114,6 +151,17 @@ const LEVEL_FEATURES = {
|
||||
6: [BOON],
|
||||
8: [BOON],
|
||||
9: [{ name: 'Indomitable', text: 'Once per long rest, reroll a failed saving throw.' }],
|
||||
11: [{ name: 'Extra Attack', text: 'Attack three times when you take the Attack action.', upgrade: true }],
|
||||
12: [BOON],
|
||||
13: [{ name: 'Indomitable', text: 'Twice per long rest, reroll a failed saving throw.', upgrade: true }],
|
||||
14: [BOON],
|
||||
16: [BOON],
|
||||
17: [
|
||||
{ name: 'Action Surge', text: 'Twice per short rest (but once per turn), take one extra action on your turn.', upgrade: true },
|
||||
{ name: 'Indomitable', text: 'Three times per long rest, reroll a failed saving throw.', upgrade: true },
|
||||
],
|
||||
19: [BOON],
|
||||
20: [{ name: 'Extra Attack', text: 'Attack four times when you take the Attack action.', upgrade: true }],
|
||||
},
|
||||
MONK: {
|
||||
2: [
|
||||
@@ -121,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.' },
|
||||
@@ -134,15 +182,31 @@ 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 (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 (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.' }],
|
||||
},
|
||||
PALADIN: {
|
||||
2: [
|
||||
@@ -155,9 +219,18 @@ 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.' }],
|
||||
12: [BOON],
|
||||
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 (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],
|
||||
},
|
||||
RANGER: {
|
||||
2: [
|
||||
@@ -178,6 +251,15 @@ const LEVEL_FEATURES = {
|
||||
{ name: 'Hide in Plain Sight', text: 'Spend 1 minute camouflaging: +10 to Stealth while you stay still.' },
|
||||
{ name: 'Natural Explorer', text: 'Choose a third favored terrain with your DM.' },
|
||||
],
|
||||
12: [BOON],
|
||||
14: [
|
||||
{ name: 'Vanish', text: 'Hide as a bonus action, and you can’t be tracked by non-magical means unless you choose to leave a trail.' },
|
||||
{ name: 'Favored Enemy', text: 'Choose a third favored enemy with your DM.', upgrade: true },
|
||||
],
|
||||
16: [BOON],
|
||||
18: [{ name: 'Feral Senses', text: 'Within 30 ft, invisibility gives no advantage against you, and you know where invisible creatures are.' }],
|
||||
19: [BOON],
|
||||
20: [{ name: 'Foe Slayer', text: 'Once per turn, add your Wisdom modifier to the attack or damage roll against a favored enemy.' }],
|
||||
},
|
||||
ROGUE: {
|
||||
2: [{ name: 'Cunning Action', text: 'Bonus action: Dash, Disengage or Hide.' }],
|
||||
@@ -189,6 +271,16 @@ const LEVEL_FEATURES = {
|
||||
8: [BOON],
|
||||
9: [sneakAttack('5d6')],
|
||||
10: [BOON],
|
||||
11: [sneakAttack('6d6'), { name: 'Reliable Talent', text: 'On a check with a skill you are proficient in, treat a d20 roll of 9 or lower as a 10.' }],
|
||||
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 (counted on your sheet).' }],
|
||||
16: [BOON],
|
||||
17: [sneakAttack('9d6')],
|
||||
18: [{ name: 'Elusive', text: 'No attack against you has advantage while you are not incapacitated.' }],
|
||||
19: [BOON, sneakAttack('10d6')],
|
||||
20: [{ name: 'Stroke of Luck', text: 'Once per short rest, turn a missed attack into a hit, or a failed check into a 20.' }],
|
||||
},
|
||||
SORCERER: {
|
||||
2: [{ name: 'Font of Magic', text: 'Sorcery points equal to your level; they come back on a long rest. Bonus action: turn points into a spell slot (2 points = 1st, 3 = 2nd, 5 = 3rd, 6 = 4th, 7 = 5th), or a spell slot into points equal to its level.' }],
|
||||
@@ -196,6 +288,11 @@ const LEVEL_FEATURES = {
|
||||
4: [BOON],
|
||||
8: [BOON],
|
||||
10: [{ name: 'Metamagic', text: 'Choose a third option with your DM.' }],
|
||||
12: [BOON],
|
||||
16: [BOON],
|
||||
17: [{ name: 'Metamagic', text: 'Choose a fourth option with your DM.' }],
|
||||
19: [BOON],
|
||||
20: [{ name: 'Sorcerous Restoration', text: 'Regain 4 sorcery points after each short rest.' }],
|
||||
},
|
||||
WARLOCK: {
|
||||
2: [{ name: 'Eldritch Invocations', text: `Choose two with your DM: ${INVOCATIONS}.` }],
|
||||
@@ -205,15 +302,57 @@ const LEVEL_FEATURES = {
|
||||
7: [invocations('four')],
|
||||
8: [BOON],
|
||||
9: [invocations('five')],
|
||||
11: [{ name: 'Mystic Arcanum', text: 'Choose a 6th-level warlock spell with your DM: cast it once per long rest without a slot. One more spell is added at 13 (7th), 15 (8th) and 17 (9th).' }],
|
||||
12: [BOON, invocations('six')],
|
||||
13: [{ name: 'Mystic Arcanum', text: 'Choose a 7th-level spell too: each arcanum spell is cast once per long rest without a slot.', upgrade: true }],
|
||||
15: [{ name: 'Mystic Arcanum', text: 'Choose an 8th-level spell too: each arcanum spell is cast once per long rest without a slot.', upgrade: true }, invocations('seven')],
|
||||
16: [BOON],
|
||||
17: [{ name: 'Mystic Arcanum', text: 'Choose a 9th-level spell too: each arcanum spell (6th to 9th) is cast once per long rest without a slot.', upgrade: true }],
|
||||
18: [invocations('eight')],
|
||||
19: [BOON],
|
||||
20: [{ name: 'Eldritch Master', text: 'Once per long rest, spend a minute pleading with your patron to regain all your pact slots.' }],
|
||||
},
|
||||
WIZARD: {
|
||||
4: [BOON],
|
||||
8: [BOON],
|
||||
12: [BOON],
|
||||
16: [BOON],
|
||||
18: [{ name: 'Spell Mastery', text: 'Choose a 1st- and a 2nd-level spell in your book with your DM: cast them at their lowest level without a slot.' }],
|
||||
19: [BOON],
|
||||
20: [{ name: 'Signature Spells', text: 'Choose two 3rd-level spells with your DM: each can be cast once per short rest without a slot.' }],
|
||||
},
|
||||
ARTIFICER: {
|
||||
2: [{ name: 'Infuse Item', text: 'After a long rest, infuse up to two items with magic (you know four infusions; choose them with your DM): an enhanced weapon (+1 to hit and damage), enhanced armor (+1 AC), a bag of holding, a glowing focus (+1 spell attacks), and more.' }],
|
||||
3: [{ name: 'The Right Tool for the Job', text: 'With an hour’s work and thieves’ or artisan’s tools, make any set of artisan’s tools you need.' }],
|
||||
4: [BOON],
|
||||
6: [
|
||||
{ name: 'Tool Expertise', text: 'Double your proficiency bonus on any check that uses a tool (add it by hand).' },
|
||||
{ name: 'Infuse Item', text: 'You know six infusions and keep up to three infused items at once.', upgrade: true },
|
||||
],
|
||||
7: [{ name: 'Flash of Genius', text: 'A few times per long rest (your Intelligence modifier, at least once), reaction: add your Intelligence modifier to a check or save made by you or a creature within 30 ft.' }],
|
||||
8: [BOON],
|
||||
10: [
|
||||
{ name: 'Magic Item Adept', text: 'Attune to four magic items, and craft common or uncommon ones in a quarter of the time for half the gold.' },
|
||||
{ name: 'Infuse Item', text: 'You know eight infusions and keep up to four infused items at once.', upgrade: true },
|
||||
],
|
||||
11: [{ name: 'Spell-Storing Item', text: 'After a long rest, store a 1st- or 2nd-level artificer spell in an item. Whoever holds it can cast it from the item, twice your Intelligence modifier times.' }],
|
||||
12: [BOON],
|
||||
14: [
|
||||
{ name: 'Magic Item Savant', text: 'Attune to five magic items, and ignore class, species, spell and level requirements on them.' },
|
||||
{ name: 'Infuse Item', text: 'You know ten infusions and keep up to five infused items at once.', upgrade: true },
|
||||
],
|
||||
16: [BOON],
|
||||
18: [
|
||||
{ name: 'Magic Item Master', text: 'Attune to six magic items.' },
|
||||
{ name: 'Infuse Item', text: 'You know twelve infusions and keep up to six infused items at once.', upgrade: true },
|
||||
],
|
||||
19: [BOON],
|
||||
20: [{ name: 'Soul of Artifice', text: '+1 to every saving throw for each magic item you are attuned to. When you drop to 0 HP, end one of your infusions to drop to 1 instead.' }],
|
||||
},
|
||||
};
|
||||
|
||||
// The class tables above stop here; a higher level still raises the numbers.
|
||||
const MAX_FEATURE_LEVEL = 10;
|
||||
// The class tables above go this far; so does a character.
|
||||
const MAX_FEATURE_LEVEL = 20;
|
||||
|
||||
// From level 5 a damaging cantrip rolls twice the dice the site shows. Written
|
||||
// onto the sheet of every class that casts, so nobody forgets.
|
||||
@@ -246,6 +385,8 @@ const BOONS = [
|
||||
{ key: 'TOUGHENED', name: 'Toughened', max: 1, per: 1, text: (amount) => `+${amount} AC (counted in your Extra AC).` },
|
||||
// The site works max HP out itself, so the extra lives in Temp HP.
|
||||
{ key: 'HARDY', name: 'Hardy', max: 2, per: 5, text: (amount) => `+${amount} maximum HP: gain ${amount} Temp HP after each long rest.` },
|
||||
// Past level 10 a class can have more Boons than the four above allow.
|
||||
{ key: 'RESOLVE', name: 'Resolve', max: 2, per: 1, text: (amount) => `+${amount} to every saving throw (add it to the numbers the site shows).` },
|
||||
];
|
||||
|
||||
module.exports = { LEVEL_FEATURES, MAX_FEATURE_LEVEL, CANTRIP_LEVEL, CANTRIP_FEATURE, BOONS };
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Suggested multiclass routes: well-known ways to combine classes, for a
|
||||
// player choosing where their next level goes. Each route starts from a class
|
||||
// (`from`) and lists its `steps` in order: take `class` up to `toLevel` (that
|
||||
// class's own level). `subclass` is the subclass the route assumes, if any,
|
||||
// and `why` what it buys. The requirements are checked against the character
|
||||
// when shown (game/multiclass.js); none of this is enforced.
|
||||
|
||||
const MULTICLASS_PATHS = [
|
||||
{
|
||||
key: 'HEXADIN',
|
||||
name: 'Hexadin',
|
||||
from: 'PALADIN',
|
||||
steps: [{ class: 'PALADIN', toLevel: 2 }, { class: 'WARLOCK', toLevel: 1, subclass: 'HEXBLADE' }, { class: 'PALADIN', toLevel: 18 }],
|
||||
why: 'Hexblade lets you swing with Charisma, so one ability powers attacks, smites and auras.',
|
||||
},
|
||||
{
|
||||
key: 'SORCADIN',
|
||||
name: 'Sorcadin',
|
||||
from: 'PALADIN',
|
||||
steps: [{ class: 'PALADIN', toLevel: 6 }, { class: 'SORCERER', toLevel: 14 }],
|
||||
why: 'Aura of Protection, then a deep pool of spell slots to pour into Divine Smite.',
|
||||
},
|
||||
{
|
||||
key: 'SORLOCK',
|
||||
name: 'Sorlock',
|
||||
from: 'SORCERER',
|
||||
steps: [{ class: 'SORCERER', toLevel: 2 }, { class: 'WARLOCK', toLevel: 2 }, { class: 'SORCERER', toLevel: 18 }],
|
||||
why: 'Agonizing Eldritch Blast, quickened with Metamagic, and pact slots that come back on a short rest.',
|
||||
},
|
||||
{
|
||||
key: 'GLOOM_ASSASSIN',
|
||||
name: 'Gloom Assassin',
|
||||
from: 'RANGER',
|
||||
steps: [{ class: 'RANGER', toLevel: 5, subclass: 'GLOOM_STALKER' }, { class: 'ROGUE', toLevel: 3, subclass: 'ASSASSIN' }, { class: 'RANGER', toLevel: 12 }],
|
||||
why: 'An enormous first round: extra attacks, advantage and automatic critical hits on the surprised.',
|
||||
},
|
||||
{
|
||||
key: 'FIGHTER_DIP',
|
||||
name: 'Fighter dip for anyone who swings',
|
||||
from: null,
|
||||
steps: [{ class: 'FIGHTER', toLevel: 2 }],
|
||||
why: 'Two levels give a fighting style, Second Wind and Action Surge: one huge turn per rest.',
|
||||
},
|
||||
{
|
||||
key: 'CLERIC_DIP',
|
||||
name: 'Cleric dip for armor and healing',
|
||||
from: 'WIZARD',
|
||||
steps: [{ class: 'CLERIC', toLevel: 1 }, { class: 'WIZARD', toLevel: 19 }],
|
||||
why: 'Armor and shields, healing words and a domain’s gifts on a wizard, for one level.',
|
||||
},
|
||||
{
|
||||
key: 'BLADESINGER_FIGHTER',
|
||||
name: 'Fighting wizard',
|
||||
from: 'FIGHTER',
|
||||
steps: [{ class: 'FIGHTER', toLevel: 2 }, { class: 'WIZARD', toLevel: 18, subclass: 'BLADESINGING' }],
|
||||
why: 'Starts in armor with Action Surge, then grows into a Bladesinger with real spells.',
|
||||
},
|
||||
{
|
||||
key: 'ROGUE_FIGHTER',
|
||||
name: 'Skirmisher',
|
||||
from: 'ROGUE',
|
||||
steps: [{ class: 'ROGUE', toLevel: 1 }, { class: 'FIGHTER', toLevel: 2 }, { class: 'ROGUE', toLevel: 17 }],
|
||||
why: 'Action Surge for a second Sneak Attack chance in a turn, and better armor.',
|
||||
},
|
||||
{
|
||||
key: 'MONK_CLERIC',
|
||||
name: 'Wisdom warrior',
|
||||
from: 'MONK',
|
||||
steps: [{ class: 'MONK', toLevel: 5 }, { class: 'CLERIC', toLevel: 2 }, { class: 'MONK', toLevel: 13 }],
|
||||
why: 'Stunning Strike and Extra Attack first, then Wisdom-powered healing and Channel Divinity.',
|
||||
},
|
||||
{
|
||||
key: 'BARBARIAN_FIGHTER',
|
||||
name: 'Raging champion',
|
||||
from: 'BARBARIAN',
|
||||
steps: [{ class: 'BARBARIAN', toLevel: 5 }, { class: 'FIGHTER', toLevel: 3, subclass: 'CHAMPION' }, { class: 'BARBARIAN', toLevel: 12 }],
|
||||
why: 'Reckless Attack with critical hits on 19–20: advantage makes those crits come often.',
|
||||
},
|
||||
{
|
||||
key: 'DRUID_CLERIC',
|
||||
name: 'Nature priest',
|
||||
from: 'DRUID',
|
||||
steps: [{ class: 'CLERIC', toLevel: 1, subclass: 'LIFE' }, { class: 'DRUID', toLevel: 19 }],
|
||||
why: 'One Life Domain level makes every druid healing spell heal more, and adds heavy armor.',
|
||||
},
|
||||
{
|
||||
key: 'BARD_WARLOCK',
|
||||
name: 'Eldritch bard',
|
||||
from: 'BARD',
|
||||
steps: [{ class: 'BARD', toLevel: 6 }, { class: 'WARLOCK', toLevel: 2 }, { class: 'BARD', toLevel: 12 }],
|
||||
why: 'Eldritch Blast with Agonizing Blast as a dependable attack for a support caster.',
|
||||
},
|
||||
{
|
||||
key: 'ARTIFICER_WIZARD',
|
||||
name: 'Arcane engineer',
|
||||
from: 'ARTIFICER',
|
||||
steps: [{ class: 'ARTIFICER', toLevel: 2 }, { class: 'WIZARD', toLevel: 18 }],
|
||||
why: 'Infusions and a tool focus early, then a wizard’s full spell list, all on Intelligence.',
|
||||
},
|
||||
{
|
||||
key: 'RANGER_DRUID',
|
||||
name: 'Wild warden',
|
||||
from: 'RANGER',
|
||||
steps: [{ class: 'RANGER', toLevel: 5 }, { class: 'DRUID', toLevel: 2 }, { class: 'RANGER', toLevel: 13 }],
|
||||
why: 'Extra Attack and a ranger’s toolkit, plus Wild Shape for scouting and a second health bar.',
|
||||
},
|
||||
{
|
||||
key: 'WARLOCK_PALADIN',
|
||||
name: 'Blade pact knight',
|
||||
from: 'WARLOCK',
|
||||
steps: [{ class: 'WARLOCK', toLevel: 3, subclass: 'HEXBLADE' }, { class: 'PALADIN', toLevel: 2 }, { class: 'WARLOCK', toLevel: 15 }],
|
||||
why: 'A pact weapon swung with Charisma, and short-rest slots to burn on Divine Smite.',
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = { MULTICLASS_PATHS };
|
||||
@@ -1,23 +1,76 @@
|
||||
// Generated from /seed-data/species.json — keep in sync by hand if that file changes.
|
||||
// See docs/GAME_RULES_REFERENCE.md "Species Ability Score Increases & Speed".
|
||||
//
|
||||
// `abilityBonuses` are fixed. `flexible` is a choice the player makes in the
|
||||
// builder on top of them: one of the `patterns` (+2 and +1, or +1 three
|
||||
// times...), each to a different ability, never one in `exclude`. The choice
|
||||
// is kept on the character as speciesBonusChoices, e.g. { STR: 2, WIS: 1 }.
|
||||
|
||||
const SPECIES = {
|
||||
HUMAN: { key: 'HUMAN', name: 'Human', abilityBonuses: { STR: 1, DEX: 1, CON: 1, INT: 1, WIS: 1, CHA: 1 }, speed: 30, notes: null },
|
||||
HALF_ELF: {
|
||||
key: 'HALF_ELF',
|
||||
name: 'Half-Elf',
|
||||
abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 2 },
|
||||
speed: 30,
|
||||
notes: "Fixed +2 CHA plus a flexible +1/+1 to two other abilities of the player's choice. The builder UI must prompt for this choice; do not hardcode a default.",
|
||||
},
|
||||
ELF: { key: 'ELF', name: 'Elf', abilityBonuses: { STR: 0, DEX: 2, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, notes: null },
|
||||
GNOME: { key: 'GNOME', name: 'Gnome', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 2, WIS: 0, CHA: 0 }, speed: 25, notes: null },
|
||||
DWARF: { key: 'DWARF', name: 'Dwarf', abilityBonuses: { STR: 0, DEX: 0, CON: 2, INT: 0, WIS: 0, CHA: 0 }, speed: 25, notes: null },
|
||||
HALFLING: { key: 'HALFLING', name: 'Halfling', abilityBonuses: { STR: 0, DEX: 2, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 25, notes: null },
|
||||
DRAGONBORN: { key: 'DRAGONBORN', name: 'Dragonborn', abilityBonuses: { STR: 2, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 1 }, speed: 30, notes: null },
|
||||
TIEFLING: { key: 'TIEFLING', name: 'Tiefling', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 1, WIS: 0, CHA: 2 }, speed: 30, notes: null },
|
||||
HALF_ORC: { key: 'HALF_ORC', name: 'Half-Orc', abilityBonuses: { STR: 2, DEX: 0, CON: 1, INT: 0, WIS: 0, CHA: 0 }, speed: 30, notes: null },
|
||||
ORC: { key: 'ORC', name: 'Orc', abilityBonuses: { STR: 2, DEX: 0, CON: 1, INT: 0, WIS: 0, CHA: 0 }, speed: 30, notes: null },
|
||||
HUMAN: { key: 'HUMAN', name: 'Human', abilityBonuses: { STR: 1, DEX: 1, CON: 1, INT: 1, WIS: 1, CHA: 1 }, speed: 30 },
|
||||
HALF_ELF: { key: 'HALF_ELF', name: 'Half-Elf', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 2 }, speed: 30, flexible: { patterns: [[1, 1]], exclude: ['CHA'] } },
|
||||
ELF: { key: 'ELF', name: 'Elf', abilityBonuses: { STR: 0, DEX: 2, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30 },
|
||||
GNOME: { key: 'GNOME', name: 'Gnome', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 2, WIS: 0, CHA: 0 }, speed: 25 },
|
||||
DWARF: { key: 'DWARF', name: 'Dwarf', abilityBonuses: { STR: 0, DEX: 0, CON: 2, INT: 0, WIS: 0, CHA: 0 }, speed: 25 },
|
||||
HALFLING: { key: 'HALFLING', name: 'Halfling', abilityBonuses: { STR: 0, DEX: 2, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 25 },
|
||||
DRAGONBORN: { key: 'DRAGONBORN', name: 'Dragonborn', abilityBonuses: { STR: 2, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 1 }, speed: 30 },
|
||||
TIEFLING: { key: 'TIEFLING', name: 'Tiefling', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 1, WIS: 0, CHA: 2 }, speed: 30 },
|
||||
HALF_ORC: { key: 'HALF_ORC', name: 'Half-Orc', abilityBonuses: { STR: 2, DEX: 0, CON: 1, INT: 0, WIS: 0, CHA: 0 }, speed: 30 },
|
||||
ORC: { key: 'ORC', name: 'Orc', abilityBonuses: { STR: 2, DEX: 0, CON: 1, INT: 0, WIS: 0, CHA: 0 }, speed: 30 },
|
||||
AASIMAR: { key: 'AASIMAR', name: 'Aasimar', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
GOLIATH: { key: 'GOLIATH', name: 'Goliath', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 35, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
AARAKOCRA: { key: 'AARAKOCRA', name: 'Aarakocra', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
BUGBEAR: { key: 'BUGBEAR', name: 'Bugbear', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
CENTAUR: { key: 'CENTAUR', name: 'Centaur', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 40, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
CHANGELING: { key: 'CHANGELING', name: 'Changeling', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
DEEP_GNOME: { key: 'DEEP_GNOME', name: 'Deep Gnome', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
DUERGAR: { key: 'DUERGAR', name: 'Duergar', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
ELADRIN: { key: 'ELADRIN', name: 'Eladrin', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
FAIRY: { key: 'FAIRY', name: 'Fairy', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
FIRBOLG: { key: 'FIRBOLG', name: 'Firbolg', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
AIR_GENASI: { key: 'AIR_GENASI', name: 'Air Genasi', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 35, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
EARTH_GENASI: { key: 'EARTH_GENASI', name: 'Earth Genasi', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
FIRE_GENASI: { key: 'FIRE_GENASI', name: 'Fire Genasi', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
WATER_GENASI: { key: 'WATER_GENASI', name: 'Water Genasi', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
GITHYANKI: { key: 'GITHYANKI', name: 'Githyanki', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
GITHZERAI: { key: 'GITHZERAI', name: 'Githzerai', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
GOBLIN: { key: 'GOBLIN', name: 'Goblin', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
HARENGON: { key: 'HARENGON', name: 'Harengon', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
HOBGOBLIN: { key: 'HOBGOBLIN', name: 'Hobgoblin', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
KENKU: { key: 'KENKU', name: 'Kenku', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
KOBOLD: { key: 'KOBOLD', name: 'Kobold', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
LIZARDFOLK: { key: 'LIZARDFOLK', name: 'Lizardfolk', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
MINOTAUR: { key: 'MINOTAUR', name: 'Minotaur', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
SEA_ELF: { key: 'SEA_ELF', name: 'Sea Elf', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
SHADAR_KAI: { key: 'SHADAR_KAI', name: 'Shadar-kai', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
SHIFTER: { key: 'SHIFTER', name: 'Shifter', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
TABAXI: { key: 'TABAXI', name: 'Tabaxi', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
TORTLE: { key: 'TORTLE', name: 'Tortle', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
TRITON: { key: 'TRITON', name: 'Triton', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
YUAN_TI: { key: 'YUAN_TI', name: 'Yuan-ti', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
OWLIN: { key: 'OWLIN', name: 'Owlin', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
SATYR: { key: 'SATYR', name: 'Satyr', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 35, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
|
||||
};
|
||||
|
||||
module.exports = { SPECIES };
|
||||
const ABILITIES = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
|
||||
// What is wrong with a player's flexible bonuses for a species, or null when
|
||||
// they are fine. A species without a choice to make takes none.
|
||||
function speciesBonusProblem(speciesKey, choices) {
|
||||
const species = SPECIES[speciesKey];
|
||||
if (!species) return 'That is not a species.';
|
||||
const entries = Object.entries(choices || {}).filter(([, amount]) => amount);
|
||||
if (!species.flexible) return entries.length ? `${species.name} has no bonuses to choose.` : null;
|
||||
if (entries.some(([ability]) => !ABILITIES.includes(ability))) return 'Bonuses go to the six abilities.';
|
||||
const excluded = species.flexible.exclude || [];
|
||||
if (entries.some(([ability]) => excluded.includes(ability))) return `${species.name} cannot put a chosen bonus on ${excluded.join(' or ')}.`;
|
||||
const amounts = entries.map(([, amount]) => amount).sort((a, b) => b - a).join(',');
|
||||
const fits = species.flexible.patterns.some((pattern) => [...pattern].sort((a, b) => b - a).join(',') === amounts);
|
||||
if (!fits) {
|
||||
const options = species.flexible.patterns.map((pattern) => pattern.map((n) => `+${n}`).join('/')).join(' or ');
|
||||
return `${species.name} chooses ${options}, each to a different ability.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { SPECIES, speciesBonusProblem };
|
||||
|
||||
@@ -50,6 +50,55 @@ const HALF_CASTER_SLOTS = [
|
||||
[4, 3, 3, 3, 2],
|
||||
];
|
||||
|
||||
// Artificer: a half caster that starts at level 1, rounding up.
|
||||
const ARTIFICER_SLOTS = [
|
||||
[2],
|
||||
[2],
|
||||
[3],
|
||||
[3],
|
||||
[4, 2],
|
||||
[4, 2],
|
||||
[4, 3],
|
||||
[4, 3],
|
||||
[4, 3, 2],
|
||||
[4, 3, 2],
|
||||
[4, 3, 3],
|
||||
[4, 3, 3],
|
||||
[4, 3, 3, 1],
|
||||
[4, 3, 3, 1],
|
||||
[4, 3, 3, 2],
|
||||
[4, 3, 3, 2],
|
||||
[4, 3, 3, 3, 1],
|
||||
[4, 3, 3, 3, 1],
|
||||
[4, 3, 3, 3, 2],
|
||||
[4, 3, 3, 3, 2],
|
||||
];
|
||||
|
||||
// Eldritch Knight, Arcane Trickster: a third of a full caster's pace, from
|
||||
// level 3 (the subclass is what casts, not the class).
|
||||
const THIRD_CASTER_SLOTS = [
|
||||
[],
|
||||
[],
|
||||
[2],
|
||||
[3],
|
||||
[3],
|
||||
[3],
|
||||
[4, 2],
|
||||
[4, 2],
|
||||
[4, 2],
|
||||
[4, 3],
|
||||
[4, 3],
|
||||
[4, 3],
|
||||
[4, 3, 2],
|
||||
[4, 3, 2],
|
||||
[4, 3, 2],
|
||||
[4, 3, 3],
|
||||
[4, 3, 3],
|
||||
[4, 3, 3],
|
||||
[4, 3, 3, 1],
|
||||
[4, 3, 3, 1],
|
||||
];
|
||||
|
||||
// Warlock: a few slots, all of one slot level. [how many, which level].
|
||||
const PACT_SLOTS = [
|
||||
[1, 1],
|
||||
@@ -84,9 +133,26 @@ const CASTER_KIND = {
|
||||
WIZARD: 'FULL',
|
||||
PALADIN: 'HALF',
|
||||
RANGER: 'HALF',
|
||||
ARTIFICER: 'HALF',
|
||||
WARLOCK: 'PACT',
|
||||
};
|
||||
|
||||
// A class whose slots follow its own table rather than its kind's.
|
||||
const CLASS_SLOT_TABLES = { ARTIFICER: ARTIFICER_SLOTS };
|
||||
|
||||
// Subclasses that cast in a class that otherwise does not, with Intelligence.
|
||||
const SUBCLASS_CASTER_KIND = { ELDRITCH_KNIGHT: 'THIRD', ARCANE_TRICKSTER: 'THIRD' };
|
||||
|
||||
const MAX_SLOT_LEVEL = 9;
|
||||
|
||||
module.exports = { FULL_CASTER_SLOTS, HALF_CASTER_SLOTS, PACT_SLOTS, CASTER_KIND, MAX_SLOT_LEVEL };
|
||||
module.exports = {
|
||||
FULL_CASTER_SLOTS,
|
||||
HALF_CASTER_SLOTS,
|
||||
ARTIFICER_SLOTS,
|
||||
THIRD_CASTER_SLOTS,
|
||||
PACT_SLOTS,
|
||||
CASTER_KIND,
|
||||
CLASS_SLOT_TABLES,
|
||||
SUBCLASS_CASTER_KIND,
|
||||
MAX_SLOT_LEVEL,
|
||||
};
|
||||
|
||||
@@ -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,501 +0,0 @@
|
||||
// Subclasses: the path a character takes within their class. Three per class.
|
||||
// The first of each is the path every character of that class followed before
|
||||
// subclasses could be chosen (the Champion fighter, the Life cleric...); its
|
||||
// lines are word for word what the class tables used to write, so sheets
|
||||
// written back then still match.
|
||||
//
|
||||
// `features` are by character level, in the same shape as LEVEL_FEATURES
|
||||
// (levelFeatures.js): each becomes a line of the special abilities, and an
|
||||
// `upgrade` rewrites the line that already has its name. Like the class
|
||||
// tables they stop at level 10, and none of it feeds the calculation engine.
|
||||
|
||||
// The level at which a class chooses.
|
||||
const SUBCLASS_LEVEL = {
|
||||
BARBARIAN: 3,
|
||||
BARD: 3,
|
||||
CLERIC: 2,
|
||||
DRUID: 2,
|
||||
FIGHTER: 3,
|
||||
MONK: 3,
|
||||
PALADIN: 3,
|
||||
RANGER: 3,
|
||||
ROGUE: 3,
|
||||
SORCERER: 3,
|
||||
WARLOCK: 3,
|
||||
WIZARD: 2,
|
||||
};
|
||||
|
||||
const SUBCLASSES = {
|
||||
BARBARIAN: [
|
||||
{
|
||||
key: 'BERSERKER',
|
||||
name: 'Path of the Berserker',
|
||||
summary: 'Rage as pure fury: extra attacks now, exhaustion after.',
|
||||
features: {
|
||||
3: [{ name: 'Frenzy', text: 'Path of the Berserker. While raging you may make one extra melee attack as a bonus action each turn; when the rage ends, gain one level of exhaustion.' }],
|
||||
6: [{ name: 'Mindless Rage', text: 'You can’t be charmed or frightened while raging.' }],
|
||||
10: [{ name: 'Intimidating Presence', text: 'Action: one creature within 30 ft makes a Wisdom save (DC 8 + proficiency bonus + Charisma modifier) or is frightened of you until the end of your next turn.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'TOTEM_WARRIOR',
|
||||
name: 'Path of the Totem Warrior',
|
||||
summary: 'A spirit animal walks with you and lends its strength.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Spirit Seeker', text: 'Path of the Totem Warrior. You can speak with beasts and sense what they sense, as a ritual with your DM.' },
|
||||
{ name: 'Totem Spirit', text: 'Choose one with your DM. Bear: while raging, all damage but psychic is halved. Eagle: while raging, opportunity attacks against you have disadvantage and you can Dash as a bonus action. Wolf: while raging, allies have advantage on melee attacks against enemies next to you.' },
|
||||
],
|
||||
6: [{ name: 'Aspect of the Beast', text: 'Choose one with your DM. Bear: carry twice as much, advantage on Strength checks to push, pull or lift. Eagle: see clearly up to a mile, no penalty in dim light. Wolf: track at a fast pace and move stealthily at a normal one.' }],
|
||||
10: [{ name: 'Spirit Walker', text: 'As a ritual, ask the spirits of a place for what they know (the DM answers as a nature spirit would).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ZEALOT',
|
||||
name: 'Path of the Zealot',
|
||||
summary: 'A god’s fury in your blows, and death has trouble keeping you.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Divine Fury', text: 'Path of the Zealot. While raging, the first creature you hit each turn takes an extra 1d6 + half your level radiant or necrotic damage.' },
|
||||
{ name: 'Warrior of the Gods', text: 'Spells that bring you back from the dead cost nothing to cast on you.' },
|
||||
],
|
||||
6: [{ name: 'Fanatical Focus', text: 'Once per rage, reroll a saving throw you just failed.' }],
|
||||
10: [{ name: 'Zealous Presence', text: 'Once per long rest, bonus action: up to ten allies within 60 ft have advantage on attacks and saves until your next turn.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
BARD: [
|
||||
{
|
||||
key: 'LORE',
|
||||
name: 'College of Lore',
|
||||
summary: 'Knows a bit of everything, and a word to trip anyone up.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Cutting Words', text: 'College of Lore. Reaction: spend a Bardic Inspiration to subtract the die from an enemy’s attack, check or damage roll.' },
|
||||
{ name: 'Bonus Proficiencies', text: 'Treat three extra skills of your choice as proficient (add your proficiency bonus by hand).' },
|
||||
],
|
||||
6: [{ name: 'Additional Magical Secrets', text: 'Learn two spells from any class list (the DM grants them within the unlock schedule).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'VALOR',
|
||||
name: 'College of Valor',
|
||||
summary: 'A skald in the front line, inspiring by example.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Combat Inspiration', text: 'College of Valor. A creature with your Bardic Inspiration may add the die to a weapon damage roll, or to its AC against one attack as a reaction.' },
|
||||
{ name: 'Battle Training', text: 'You fight in medium armor and with a shield (the DM raises Extra AC to match what you wear).' },
|
||||
],
|
||||
6: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SWORDS',
|
||||
name: 'College of Swords',
|
||||
summary: 'A blade dancer whose performance is the fight itself.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Fighting Style', text: 'College of Swords. Choose one with your DM: +2 damage with a one-handed weapon and nothing in the other hand, or add your ability modifier to the damage of a second weapon (added by hand).' },
|
||||
{ name: 'Blade Flourish', text: 'When you attack on your turn, +10 ft speed that turn. On a hit, spend a Bardic Inspiration and add the die to the damage, and choose: add it to your AC until your next turn, hit a second creature next to you for the die’s roll, or push the target 5 ft + the roll and follow it.' },
|
||||
],
|
||||
6: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
CLERIC: [
|
||||
{
|
||||
key: 'LIFE',
|
||||
name: 'Life Domain',
|
||||
summary: 'The healer: more from every cure, and healing in a burst.',
|
||||
features: {
|
||||
2: [{ name: 'Preserve Life', text: 'Channel Divinity. Action: share out healing of 5 × your level among creatures within 30 ft (up to half their max HP each).' }],
|
||||
6: [{ name: 'Blessed Healer', text: 'When you heal someone else with a spell, you heal yourself 2 + the spell’s level.' }],
|
||||
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 radiant damage.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'LIGHT',
|
||||
name: 'Light Domain',
|
||||
summary: 'Fire and radiance: blinds attackers and burns the dark away.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Warding Flare', text: 'Light Domain. Reaction, a few times per day (your Wisdom modifier, at least once): a creature within 30 ft that attacks you has disadvantage on that attack.' },
|
||||
{ name: 'Radiance of the Dawn', text: 'Channel Divinity. Action: magical darkness within 30 ft is dispelled, and each enemy there takes 2d10 + your level radiant damage (Constitution save for half).' },
|
||||
],
|
||||
6: [{ name: 'Improved Flare', text: 'Use Warding Flare when a creature attacks someone else within 30 ft of you.' }],
|
||||
8: [{ name: 'Potent Spellcasting', text: 'Add your Wisdom modifier to the damage of your cleric cantrips.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'WAR',
|
||||
name: 'War Domain',
|
||||
summary: 'A soldier of the faith: extra strikes and blows that land.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'War Priest', text: 'War Domain. A few times per day (your Wisdom modifier, at least once), after you take the Attack action, make one weapon attack as a bonus action.' },
|
||||
{ name: 'Guided Strike', text: 'Channel Divinity. When you make an attack roll, add +10 to it after seeing the roll.' },
|
||||
],
|
||||
6: [{ name: 'War God’s Blessing', text: 'Channel Divinity. Reaction: give +10 to an attack roll made by a creature within 30 ft.' }],
|
||||
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 damage of the weapon’s type.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
DRUID: [
|
||||
{
|
||||
key: 'LAND',
|
||||
name: 'Circle of the Land',
|
||||
summary: 'A keeper of old magic, at home in the wild places.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Natural Recovery', text: 'Once per day on a short rest, recover spent spell slots totalling up to half your level (rounded up).' },
|
||||
{ name: 'Bonus Cantrip', text: 'Learn one extra cantrip (the DM grants it).' },
|
||||
],
|
||||
6: [{ name: 'Land’s Stride', text: 'Ordinary difficult terrain and plants do not slow you; advantage on saves against magical plants.' }],
|
||||
10: [{ name: 'Nature’s Ward', text: 'Immune to poison and disease; you can’t be charmed or frightened by fey or elementals.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'MOON',
|
||||
name: 'Circle of the Moon',
|
||||
summary: 'A shapeshifter who fights as the beast.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Combat Wild Shape', text: 'Circle of the Moon. Use Wild Shape as a bonus action. While in beast form, bonus action: spend a spell slot to heal 1d8 per slot level.' },
|
||||
{ name: 'Circle Forms', text: 'Your Wild Shape can be a beast of up to CR 1; from level 6, up to CR equal to a third of your level (rounded down).' },
|
||||
],
|
||||
6: [{ name: 'Primal Strike', text: 'Your attacks in beast form count as magical.' }],
|
||||
10: [{ name: 'Elemental Wild Shape', text: 'Spend both uses of Wild Shape at once to become an air, earth, fire or water elemental.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'STARS',
|
||||
name: 'Circle of Stars',
|
||||
summary: 'Reads the night sky and wears its constellations.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Star Map', text: 'Circle of Stars. You know the Guidance cantrip, and cast Guiding Bolt without a slot a number of times per day equal to your proficiency bonus (the DM grants both).' },
|
||||
{ name: 'Starry Form', text: 'Bonus action: spend a Wild Shape use to take a starry form for 10 minutes. Choose Archer (bonus action: ranged spell attack, 60 ft, 1d8 + Wisdom modifier radiant), Chalice (when you cast a healing spell, another creature within 30 ft heals 1d8 + Wisdom modifier) or Dragon (treat a roll of 9 or lower as 10 on Intelligence and Wisdom checks and on concentration saves).' },
|
||||
],
|
||||
6: [{ name: 'Cosmic Omen', text: 'After a long rest roll a die. Even (Weal): reaction, add 1d6 to a creature’s attack, check or save. Odd (Woe): reaction, subtract 1d6. Usable a number of times per day equal to your proficiency bonus, on creatures within 30 ft.' }],
|
||||
10: [{ name: 'Twinkling Constellations', text: 'Archer and Chalice use 2d8. In Dragon form you can fly 20 ft. At the start of each turn in Starry Form you may change constellation.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
FIGHTER: [
|
||||
{
|
||||
key: 'CHAMPION',
|
||||
name: 'Champion',
|
||||
summary: 'Simple and deadly: more critical hits, more athletic.',
|
||||
features: {
|
||||
3: [{ name: 'Improved Critical', text: 'Your weapon attacks score a critical hit on a roll of 19 or 20.' }],
|
||||
7: [{ name: 'Remarkable Athlete', text: 'Add half your proficiency bonus to Strength, Dexterity and Constitution checks you are not proficient in; long jump + Strength modifier feet.' }],
|
||||
10: [{ name: 'Additional Fighting Style', text: 'Choose a second option from your Fighting Style list. (If it is +1 AC in armor, the DM raises Extra AC by 1.)' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'BATTLE_MASTER',
|
||||
name: 'Battle Master',
|
||||
summary: 'A tactician who turns each swing into a manoeuvre.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Combat Superiority', text: 'Battle Master. Four superiority dice (d8), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier.' },
|
||||
{ name: 'Manoeuvres', text: 'Choose three with your DM: Trip Attack (add the die to damage; Strength save or prone), Disarming Attack (add the die; Strength save or it drops what it holds), Pushing Attack (add the die; Strength save or pushed 15 ft), Precision Attack (add the die to an attack roll), Riposte (reaction when a creature misses you: attack it and add the die), Parry (reaction: reduce melee damage by the die + Dexterity modifier), Rally (bonus action: an ally gains the die + Charisma modifier Temp HP), Menacing Attack (add the die; Wisdom save or frightened until your next turn).' },
|
||||
],
|
||||
7: [
|
||||
{ name: 'Know Your Enemy', text: 'After a minute watching a creature, the DM tells you whether it is your equal, better or worse in two of: Strength, Dexterity, Constitution, AC, current HP, level.' },
|
||||
{ name: 'Combat Superiority', text: 'Battle Master. Five superiority dice (d8), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier.', upgrade: true },
|
||||
{ name: 'Manoeuvres', text: 'Choose two more with your DM (five in all) from the list on your level 3 line.' },
|
||||
],
|
||||
10: [{ name: 'Combat Superiority', text: 'Battle Master. Five superiority dice (d10), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier. Choose two more manoeuvres with your DM (seven in all).', upgrade: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SAMURAI',
|
||||
name: 'Samurai',
|
||||
summary: 'Unbreakable resolve: strikes with advantage and shrugs off blows.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Fighting Spirit', text: 'Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 5 Temp HP.' },
|
||||
{ name: 'Bonus Proficiency', text: 'Treat one of History, Insight, Performance or Persuasion as proficient (add your proficiency bonus by hand).' },
|
||||
],
|
||||
7: [{ name: 'Elegant Courtier', text: 'Add your Wisdom modifier to Persuasion checks, and treat Wisdom saves as proficient (both added by hand).' }],
|
||||
10: [
|
||||
{ name: 'Tireless Spirit', text: 'If you roll initiative with no uses of Fighting Spirit left, you regain one.' },
|
||||
{ name: 'Fighting Spirit', text: 'Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 10 Temp HP.', upgrade: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
MONK: [
|
||||
{
|
||||
key: 'OPEN_HAND',
|
||||
name: 'Way of the Open Hand',
|
||||
summary: 'Pure martial arts: knocks down, pushes away, heals itself.',
|
||||
features: {
|
||||
3: [{ name: 'Open Hand Technique', text: 'Flurry hits can knock a target prone (Dexterity save), push it 15 ft (Strength save) or stop its reactions.' }],
|
||||
6: [{ name: 'Wholeness of Body', text: 'Once per long rest, heal 3 × your level as an action.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SHADOW',
|
||||
name: 'Way of Shadow',
|
||||
summary: 'A ninja: steps between shadows and strikes unseen.',
|
||||
features: {
|
||||
3: [{ name: 'Shadow Arts', text: 'Way of Shadow. 2 ki: cast Darkness, Darkvision, Pass without Trace or Silence without a slot. You can also make a small illusion at will.' }],
|
||||
6: [{ name: 'Shadow Step', text: 'Bonus action, while in dim light or darkness: teleport up to 60 ft to another dim or dark spot you can see, and have advantage on your first melee attack this turn.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'FOUR_ELEMENTS',
|
||||
name: 'Way of the Four Elements',
|
||||
summary: 'Bends fire, water, earth and air with ki.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Elemental Attunement', text: 'Way of the Four Elements. Action: a harmless trick of fire, water, earth or air within 30 ft (light or snuff a flame, chill or warm, shape a little earth or mist).' },
|
||||
{ name: 'Elemental Disciplines', text: 'Choose one with your DM: Fangs of the Fire Snake (1 ki: your unarmed reach is 10 ft this turn and hits deal fire damage; +1 ki for +1d10 fire), Fist of Unbroken Air (2 ki: one creature within 30 ft takes 3d10 bludgeoning, is pushed 20 ft and knocked prone; Strength save for half and no push), Water Whip (2 ki: one creature within 30 ft takes 3d10 bludgeoning and is pulled 25 ft or knocked prone; Dexterity save for half), Shape the Flowing River (1 ki: reshape or freeze water or ice in a 30 ft cube).' },
|
||||
],
|
||||
6: [{ name: 'Elemental Disciplines', text: 'Choose one more with your DM (two in all). New at this level: Gong of the Summit (3 ki: cast Shatter), Clench of the North Wind (3 ki: cast Hold Person).' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
PALADIN: [
|
||||
{
|
||||
key: 'DEVOTION',
|
||||
name: 'Oath of Devotion',
|
||||
summary: 'The shining knight: honesty, courage, a blessed blade.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Sacred Weapon', text: 'Oath of Devotion, Channel Divinity. Add your Charisma modifier to attack rolls with your weapon for 1 minute; it sheds light.' },
|
||||
{ name: 'Turn the Unholy', text: 'Channel Divinity. Fiends and undead within 30 ft make a Wisdom save or flee for 1 minute.' },
|
||||
],
|
||||
7: [{ name: 'Aura of Devotion', text: 'You and allies within 10 ft can’t be charmed.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ANCIENTS',
|
||||
name: 'Oath of the Ancients',
|
||||
summary: 'A green knight who guards life, light and laughter.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Nature’s Wrath', text: 'Oath of the Ancients, Channel Divinity. Spectral vines restrain a creature within 10 ft (Strength or Dexterity save; it repeats the save at the end of each of its turns).' },
|
||||
{ name: 'Turn the Faithless', text: 'Channel Divinity. Fey and fiends within 30 ft make a Wisdom save or flee for 1 minute.' },
|
||||
],
|
||||
7: [{ name: 'Aura of Warding', text: 'You and allies within 10 ft take half damage from spells.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'VENGEANCE',
|
||||
name: 'Oath of Vengeance',
|
||||
summary: 'A relentless hunter of one sworn enemy at a time.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Vow of Enmity', text: 'Oath of Vengeance, Channel Divinity. Bonus action: for 1 minute you have advantage on attack rolls against one creature within 10 ft.' },
|
||||
{ name: 'Abjure Enemy', text: 'Channel Divinity. One creature within 60 ft makes a Wisdom save or is frightened and cannot move for 1 minute; on a success its speed is halved.' },
|
||||
],
|
||||
7: [{ name: 'Relentless Avenger', text: 'When you hit with an opportunity attack, move up to half your speed straight afterwards without provoking attacks.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
RANGER: [
|
||||
{
|
||||
key: 'HUNTER',
|
||||
name: 'Hunter',
|
||||
summary: 'Brings down the biggest thing in the room.',
|
||||
features: {
|
||||
3: [{ name: 'Colossus Slayer', text: 'Hunter’s Prey. Once per turn, +1d8 damage to a creature that is already below its max HP.' }],
|
||||
7: [{ name: 'Escape the Horde', text: 'Defensive Tactics. Opportunity attacks against you have disadvantage.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'BEAST_MASTER',
|
||||
name: 'Beast Master',
|
||||
summary: 'Fights beside an animal companion.',
|
||||
features: {
|
||||
3: [{ name: 'Ranger’s Companion', text: 'Beast Master. A beast of CR 1/4 or lower fights beside you (the DM adds it to encounters as a monster). It acts on your turn: it can move freely, and attacks, Dashes, Disengages or Helps when you use your action to tell it to. Add your proficiency bonus to its AC, attacks and damage. If it dies, bond with another over 8 hours.' }],
|
||||
7: [{ name: 'Exceptional Training', text: 'On a turn your companion does not attack, it can Dash, Disengage or Help as your bonus action. Its attacks count as magical.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'GLOOM_STALKER',
|
||||
name: 'Gloom Stalker',
|
||||
summary: 'An ambusher who owns the first round and the dark.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Dread Ambusher', text: 'Gloom Stalker. Add your Wisdom modifier to initiative (ask your DM to count it). On your first turn of a fight: +10 ft speed and one extra weapon attack, which deals +1d8 damage on a hit.' },
|
||||
{ name: 'Umbral Sight', text: 'You see in darkness to 60 ft, and creatures that rely on darkvision cannot see you in the dark.' },
|
||||
],
|
||||
7: [{ name: 'Iron Mind', text: 'Treat Wisdom saves as proficient (add your proficiency bonus by hand).' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
ROGUE: [
|
||||
{
|
||||
key: 'THIEF',
|
||||
name: 'Thief',
|
||||
summary: 'Quick fingers, rooftops and anything that can be used.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Fast Hands', text: 'Bonus action: Sleight of Hand, pick a lock, disarm a trap or Use an Object.' },
|
||||
{ name: 'Second-Story Work', text: 'Climbing costs no extra movement; running jumps + Dexterity modifier feet.' },
|
||||
],
|
||||
9: [{ name: 'Supreme Sneak', text: 'Advantage on Stealth if you move no more than half your speed.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ASSASSIN',
|
||||
name: 'Assassin',
|
||||
summary: 'Strikes first, and the first strike is the worst.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Assassinate', text: 'Assassin. Advantage on attacks against any creature that has not yet taken a turn in this fight, and any hit on a surprised creature is a critical hit.' },
|
||||
{ name: 'Tools of the Trade', text: 'You are practised with disguises and poisons: advantage on checks to make or use them.' },
|
||||
],
|
||||
9: [{ name: 'Infiltration Expertise', text: 'With a week and 25 gold you build a false identity with papers and a history; nobody doubts it without an obvious reason.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SWASHBUCKLER',
|
||||
name: 'Swashbuckler',
|
||||
summary: 'A duellist: in, strike, out, and a smile.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Fancy Footwork', text: 'Swashbuckler. A creature you made a melee attack against this turn cannot make opportunity attacks against you until the turn ends.' },
|
||||
{ name: 'Rakish Audacity', text: 'Add your Charisma modifier to initiative (ask your DM to count it). You can Sneak Attack a creature with nobody else next to you or it, without needing advantage.' },
|
||||
],
|
||||
9: [{ name: 'Panache', text: 'Action: Persuasion against a creature’s Insight. A hostile one has disadvantage on attacks against anyone but you for 1 minute; any other is charmed by you for 1 minute.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
SORCERER: [
|
||||
{
|
||||
key: 'DRACONIC',
|
||||
name: 'Draconic Bloodline',
|
||||
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).' }],
|
||||
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.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'WILD_MAGIC',
|
||||
name: 'Wild Magic',
|
||||
summary: 'Raw chaos: luck you can bend, and surprises you cannot.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Wild Magic Surge', text: 'Wild Magic. After you cast a sorcerer spell of 1st level or higher, the DM may have you roll a d20: on a 1, something strange happens (the DM decides, or rolls on a table of their own).' },
|
||||
{ name: 'Tides of Chaos', text: 'Once per long rest, gain advantage on one attack, check or save. The DM may give it back by calling for a Wild Magic Surge.' },
|
||||
],
|
||||
6: [{ name: 'Bend Luck', text: 'Reaction, 2 sorcery points: add or subtract 1d4 from another creature’s attack, check or save.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'STORM',
|
||||
name: 'Storm Sorcery',
|
||||
summary: 'Wind and lightning: never stands still for long.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Tempestuous Magic', text: 'Storm Sorcery. Bonus action, just before or after casting a spell of 1st level or higher: fly 10 ft without provoking opportunity attacks.' },
|
||||
{ name: 'Wind Speaker', text: 'You speak the tongue of air, water, earth and fire elementals.' },
|
||||
],
|
||||
6: [
|
||||
{ name: 'Heart of the Storm', text: 'You resist lightning and thunder damage. When you start casting a lightning or thunder spell of 1st level or higher, creatures of your choice within 10 ft take half your level in lightning or thunder damage.' },
|
||||
{ name: 'Storm Guide', text: 'Action: stop rain falling within 20 ft of you. Bonus action: choose which way the wind blows within 100 ft until your next turn.' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
WARLOCK: [
|
||||
{
|
||||
key: 'FIEND',
|
||||
name: 'The Fiend',
|
||||
summary: 'A pact with something from the hells: power fed by the fallen.',
|
||||
features: {
|
||||
3: [{ name: 'Dark One’s Blessing', text: 'The Fiend. When you bring a hostile creature to 0 HP, gain Temp HP equal to your Charisma modifier + your level (the DM adds it).' }],
|
||||
6: [{ name: 'Patron’s Luck', text: 'Once per short rest, add a d10 to an ability check or saving throw after seeing the roll.' }],
|
||||
10: [{ name: 'Patron’s Resilience', text: 'After each rest, choose one damage type: you resist it until you choose another.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ARCHFEY',
|
||||
name: 'The Archfey',
|
||||
summary: 'A fey lord’s favourite: glamour, vanishing and beguilement.',
|
||||
features: {
|
||||
3: [{ name: 'Fey Presence', text: 'The Archfey. Once per short rest, action: each creature in a 10 ft cube from you makes a Wisdom save or is charmed or frightened (your choice) until the end of your next turn.' }],
|
||||
6: [{ name: 'Misty Escape', text: 'Once per short rest, reaction when you take damage: turn invisible and teleport up to 60 ft. You stay invisible until your next turn, or until you attack or cast.' }],
|
||||
10: [{ name: 'Beguiling Defenses', text: 'You can’t be charmed. When a creature tries, turn it back: it makes a Wisdom save or is charmed by you for 1 minute.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'GREAT_OLD_ONE',
|
||||
name: 'The Great Old One',
|
||||
summary: 'Something vast and unknowable whispers through you.',
|
||||
features: {
|
||||
3: [{ name: 'Awakened Mind', text: 'The Great Old One. Speak mind to mind with any creature you can see within 30 ft; it needs no shared language, only to know one.' }],
|
||||
6: [{ name: 'Entropic Ward', text: 'Once per short rest, reaction: an attack against you has disadvantage. If it misses, your next attack against that creature has advantage.' }],
|
||||
10: [{ name: 'Thought Shield', text: 'Nobody reads your thoughts unless you allow it. You resist psychic damage, and whoever deals it to you takes the same amount.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
WIZARD: [
|
||||
{
|
||||
key: 'EVOCATION',
|
||||
name: 'School of Evocation',
|
||||
summary: 'Blasting, with friends left standing in the middle of it.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Evocation Savant', text: 'School of Evocation. Copying evocation spells costs half the time and gold.' },
|
||||
{ name: 'Sculpt Spells', text: 'When you cast an evocation spell that hits an area, choose 1 + the spell’s level creatures to automatically save and take no damage.' },
|
||||
],
|
||||
6: [{ name: 'Potent Cantrip', text: 'Creatures that save against your damaging cantrips still take half damage.' }],
|
||||
10: [{ name: 'Empowered Evocation', text: 'Add your Intelligence modifier to the damage of your evocation spells.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ABJURATION',
|
||||
name: 'School of Abjuration',
|
||||
summary: 'Wards and counters: a shield of magic that takes the hits.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Abjuration Savant', text: 'School of Abjuration. Copying abjuration spells costs half the time and gold.' },
|
||||
{ name: 'Arcane Ward', text: 'When you cast an abjuration spell of 1st level or higher, a ward appears with HP of twice your level + your Intelligence modifier; it takes damage before you do (the DM tracks it as Temp HP). It lasts until a long rest; later abjuration spells restore twice their level to it.' },
|
||||
],
|
||||
6: [{ name: 'Projected Ward', text: 'Reaction: when a creature within 30 ft takes damage, your Arcane Ward takes it instead.' }],
|
||||
10: [{ name: 'Improved Abjuration', text: 'Add your proficiency bonus to ability checks made as part of an abjuration spell (Counterspell, Dispel Magic).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ILLUSION',
|
||||
name: 'School of Illusion',
|
||||
summary: 'Tricks of light and sound that hold up to a second look.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Illusion Savant', text: 'School of Illusion. Copying illusion spells costs half the time and gold.' },
|
||||
{ name: 'Improved Minor Illusion', text: 'You know Minor Illusion (the DM grants it), and it makes a sound and an image with one casting.' },
|
||||
],
|
||||
6: [{ name: 'Malleable Illusions', text: 'Action: change what an illusion of yours looks like while it lasts, if it lasts a minute or more.' }],
|
||||
10: [{ name: 'Illusory Self', text: 'Once per short rest, reaction when a creature attacks you: a copy of you takes the blow and the attack misses.' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function subclassesOf(characterClass) {
|
||||
return SUBCLASSES[characterClass] || [];
|
||||
}
|
||||
|
||||
// One subclass of a class by its key; null when the class has no such subclass.
|
||||
function findSubclass(characterClass, key) {
|
||||
return subclassesOf(characterClass).find((subclass) => subclass.key === key) || null;
|
||||
}
|
||||
|
||||
function subclassName(characterClass, key) {
|
||||
const subclass = findSubclass(characterClass, key);
|
||||
return subclass ? subclass.name : null;
|
||||
}
|
||||
|
||||
// The path a class followed before subclasses could be chosen.
|
||||
function defaultSubclass(characterClass) {
|
||||
return subclassesOf(characterClass)[0] || null;
|
||||
}
|
||||
|
||||
module.exports = { SUBCLASS_LEVEL, SUBCLASSES, subclassesOf, findSubclass, subclassName, defaultSubclass };
|
||||
@@ -0,0 +1,70 @@
|
||||
// Artificer subclasses: the specialists. The shape is described in index.js.
|
||||
|
||||
const ARTIFICER = [
|
||||
{
|
||||
key: 'ALCHEMIST',
|
||||
name: 'Alchemist',
|
||||
source: 'Eberron: Rising from the Last War',
|
||||
edition: '2014',
|
||||
summary: 'Brews elixirs and bolsters healing and acid with chemistry.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Experimental Elixir', text: 'Alchemist. After a long rest, brew an elixir (roll a d6: healing, swiftness, resilience, boldness, flight or transformation); spend a spell slot to brew more and choose.' },
|
||||
{ name: 'Tool Proficiency', text: 'You are proficient with alchemist’s supplies.' },
|
||||
],
|
||||
5: [{ name: 'Alchemical Savant', text: 'Add your Intelligence modifier to one roll of acid, fire, necrotic or poison damage, or of healing, from your spells.' }],
|
||||
9: [{ name: 'Restorative Reagents', text: 'Your elixirs give 2d6 + Intelligence modifier Temp HP, and a few times per long rest you cast Lesser Restoration without a slot.' }],
|
||||
15: [{ name: 'Chemical Mastery', text: 'Acid and poison damage against you is halved, and you are immune to being poisoned. Once per long rest each, cast Greater Restoration and Heal without a slot.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ARMORER',
|
||||
name: 'Armorer',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Wears a suit of arcane power armor: guardian or infiltrator.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Arcane Armor', text: 'Armorer. Your armor becomes your focus, covers your whole body, and you are proficient with heavy armor.' },
|
||||
{ name: 'Armor Model', text: 'Choose with your DM, changeable after a rest. Guardian: thunder gauntlets (1d8 thunder; the target has disadvantage against others) and a defensive field (bonus action, Temp HP equal to your level). Infiltrator: a lightning launcher (1d6 lightning, +1d6 once per turn), +5 ft speed and advantage on Stealth.' },
|
||||
],
|
||||
5: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
|
||||
9: [{ name: 'Armor Modifications', text: 'Your armor counts as four items for your infusions (helmet, chest, boots, weapon), and you can keep two more infused items.' }],
|
||||
15: [{ name: 'Perfected Armor', text: 'Guardian: reaction, pull a Huge or smaller creature 30 ft and attack it. Infiltrator: lightning hits make the target glow and give advantage on the next attack against it, plus 1d6 lightning.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ARTILLERIST',
|
||||
name: 'Artillerist',
|
||||
source: 'Eberron: Rising from the Last War',
|
||||
edition: '2014',
|
||||
summary: 'Builds an eldritch cannon that blasts, burns or shields.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Eldritch Cannon', text: 'Artillerist. Once per long rest (or by spending a spell slot), action: make a Small or Tiny cannon (the DM adds it). Bonus action: it fires a Flamethrower (2d8 fire cone), a Force Ballista (2d8 force and a push) or a Protector (1d8 + Intelligence modifier Temp HP to allies nearby).' },
|
||||
{ name: 'Tool Proficiency', text: 'You are proficient with woodcarver’s tools.' },
|
||||
],
|
||||
5: [{ name: 'Arcane Firearm', text: 'Carve a wand, staff or rod into an arcane firearm: your artificer spells cast through it add 1d8 to one damage roll.' }],
|
||||
9: [{ name: 'Explosive Cannon', text: 'Your cannon deals 1d8 more, and you can detonate it (3d8 force to creatures within 20 ft, Dexterity save for half).' }],
|
||||
15: [{ name: 'Fortified Position', text: 'You can have two cannons at once, and you and allies within 10 ft of a cannon have half cover.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'BATTLE_SMITH',
|
||||
name: 'Battle Smith',
|
||||
source: 'Eberron: Rising from the Last War',
|
||||
edition: '2014',
|
||||
summary: 'Fights with a steel defender at your side and a magic weapon in hand.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Battle Ready', text: 'Battle Smith. Proficient with martial weapons, and you can use Intelligence for attack and damage with magic weapons.' },
|
||||
{ name: 'Steel Defender', text: 'A mechanical companion fights beside you (the DM adds it): it acts on your turn, can repair itself, and uses its reaction to give an attacker disadvantage against someone near it. Bonus action: command it to attack.' },
|
||||
],
|
||||
5: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
|
||||
9: [{ name: 'Arcane Jolt', text: 'A few times per long rest (Intelligence modifier), when you or your defender hit: +2d6 force, or heal a creature within 30 ft 2d6.' }],
|
||||
15: [{ name: 'Improved Defender', text: 'Arcane Jolt deals or heals 4d6; your defender gains +2 AC, and when it deflects an attack the attacker takes 1d4 + Intelligence modifier force.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = ARTIFICER;
|
||||
@@ -0,0 +1,165 @@
|
||||
// Barbarian subclasses: the primal paths. The shape is described in index.js.
|
||||
|
||||
const BARBARIAN = [
|
||||
{
|
||||
key: 'BERSERKER',
|
||||
name: 'Path of the Berserker',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Rage as pure fury: extra attacks now, exhaustion after.',
|
||||
features: {
|
||||
3: [{ name: 'Frenzy', text: 'Path of the Berserker. While raging you may make one extra melee attack as a bonus action each turn; when the rage ends, gain one level of exhaustion.' }],
|
||||
6: [{ name: 'Mindless Rage', text: 'You can’t be charmed or frightened while raging.' }],
|
||||
10: [{ name: 'Intimidating Presence', text: 'Action: one creature within 30 ft makes a Wisdom save (DC 8 + proficiency bonus + Charisma modifier) or is frightened of you until the end of your next turn.' }],
|
||||
14: [{ name: 'Retaliation', text: 'Reaction: when a creature within 5 ft damages you, make a melee weapon attack against it.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'TOTEM_WARRIOR',
|
||||
name: 'Path of the Totem Warrior',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A spirit animal walks with you and lends its strength.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Spirit Seeker', text: 'Path of the Totem Warrior. You can speak with beasts and sense what they sense, as a ritual with your DM.' },
|
||||
{ name: 'Totem Spirit', text: 'Choose one with your DM. Bear: while raging, all damage but psychic is halved. Eagle: while raging, opportunity attacks against you have disadvantage and you can Dash as a bonus action. Wolf: while raging, allies have advantage on melee attacks against enemies next to you.' },
|
||||
],
|
||||
6: [{ name: 'Aspect of the Beast', text: 'Choose one with your DM. Bear: carry twice as much, advantage on Strength checks to push, pull or lift. Eagle: see clearly up to a mile, no penalty in dim light. Wolf: track at a fast pace and move stealthily at a normal one.' }],
|
||||
10: [{ name: 'Spirit Walker', text: 'As a ritual, ask the spirits of a place for what they know (the DM answers as a nature spirit would).' }],
|
||||
14: [{ name: 'Totemic Attunement', text: 'Choose one with your DM. Bear: while raging, enemies next to you have disadvantage on attacks against anyone else. Eagle: while raging, fly at your speed for the turn. Wolf: while raging, a melee hit can knock a Large or smaller creature prone as a bonus action.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ZEALOT',
|
||||
name: 'Path of the Zealot',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A god’s fury in your blows, and death has trouble keeping you.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Divine Fury', text: 'Path of the Zealot. While raging, the first creature you hit each turn takes an extra 1d6 + half your level radiant or necrotic damage.' },
|
||||
{ name: 'Warrior of the Gods', text: 'Spells that bring you back from the dead cost nothing to cast on you.' },
|
||||
],
|
||||
6: [{ name: 'Fanatical Focus', text: 'Once per rage, reroll a saving throw you just failed.' }],
|
||||
10: [{ name: 'Zealous Presence', text: 'Once per long rest, bonus action: up to ten allies within 60 ft have advantage on attacks and saves until your next turn.' }],
|
||||
14: [{ name: 'Rage Beyond Death', text: 'While raging, dropping to 0 HP does not knock you out. You still make death saves; you die only when the rage ends with you at 0 HP.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ANCESTRAL_GUARDIAN',
|
||||
name: 'Path of the Ancestral Guardian',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Ancestor spirits shield your friends from whatever you hit.',
|
||||
features: {
|
||||
3: [{ name: 'Ancestral Protectors', text: 'Path of the Ancestral Guardian. While raging, the first creature you hit each turn has disadvantage on attacks against anyone but you, and anyone else it hits takes only half damage, until your next turn.' }],
|
||||
6: [{ name: 'Spirit Shield', text: 'While raging, reaction: when a creature within 30 ft takes damage, reduce it by 2d6 (3d6 from level 10, 4d6 from level 14).' }],
|
||||
10: [{ name: 'Consult the Spirits', text: 'Once per short rest, cast Augury or Clairvoyance without a slot, the spirits doing the seeing.' }],
|
||||
14: [{ name: 'Vengeful Ancestors', text: 'When Spirit Shield reduces damage, the attacker takes force damage equal to the amount reduced.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'BATTLERAGER',
|
||||
name: 'Path of the Battlerager',
|
||||
source: 'Sword Coast Adventurer’s Guide',
|
||||
edition: '2014',
|
||||
summary: 'Spiked armor and a reckless charge into the middle of everything.',
|
||||
features: {
|
||||
3: [{ name: 'Battlerager Armor', text: 'Path of the Battlerager. In spiked armor while raging: bonus action, a spike attack (1d4 + Strength piercing); and when you grapple a creature it takes 3 piercing damage.' }],
|
||||
6: [{ name: 'Reckless Abandon', text: 'When you attack recklessly while raging, gain Temp HP equal to your Constitution modifier.' }],
|
||||
10: [{ name: 'Battlerager Charge', text: 'While raging, you can Dash as a bonus action.' }],
|
||||
14: [{ name: 'Spiked Retribution', text: 'While raging in spiked armor, a creature within 5 ft that hits you with a melee attack takes 3 piercing damage.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'BEAST',
|
||||
name: 'Path of the Beast',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Your rage brings out claws, a bite or a lashing tail.',
|
||||
features: {
|
||||
3: [{ name: 'Form of the Beast', text: 'Path of the Beast. Each time you rage, choose a natural weapon: Bite (1d8; once per turn heal your proficiency bonus when below half HP), Claws (1d6 each; one extra claw attack when you attack with one) or Tail (1d8, 10 ft reach; reaction +1d8 to your AC against a hit).' }],
|
||||
6: [{ name: 'Bestial Soul', text: 'Your natural weapons count as magical. After a long rest choose: swim speed and water breathing, climb speed, or extra jump distance.' }],
|
||||
10: [{ name: 'Infectious Fury', text: 'A few times per long rest (proficiency bonus), when your natural weapon hits: the target makes a Wisdom save or uses its reaction to attack someone else, or takes 2d12 psychic damage.' }],
|
||||
14: [{ name: 'Call the Hunt', text: 'A few times per long rest (proficiency bonus), when you rage: up to your Constitution modifier allies gain 1d6 extra damage once per turn, and you gain 5 Temp HP for each.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'GIANT',
|
||||
name: 'Path of the Giant',
|
||||
source: 'Bigby Presents: Glory of the Giants',
|
||||
edition: '2014',
|
||||
summary: 'Grow huge in your rage, and hurl weapons and foes alike.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Giant’s Power', text: 'Path of the Giant. You speak Giant and know Druidcraft or Thaumaturgy.' },
|
||||
{ name: 'Giant’s Havoc', text: 'While raging your reach grows by 5 ft, and you can grow Large if there is room.' },
|
||||
],
|
||||
6: [{ name: 'Elemental Cleaver', text: 'While raging, your weapon carries acid, cold, fire, lightning or thunder: +1d6 of that damage, and it can be thrown 20/60 ft and flies back.' }],
|
||||
10: [{ name: 'Mighty Impel', text: 'Bonus action while raging: throw a Medium or smaller creature within reach up to 30 ft (Strength save if unwilling); it lands prone and takes 1d8 bludgeoning.' }],
|
||||
14: [{ name: 'Demiurgic Colossus', text: 'Your rage reach grows by 10 ft, you can grow Large or Huge, Mighty Impel throws Large creatures, and Elemental Cleaver deals 2d6.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'STORM_HERALD',
|
||||
name: 'Path of the Storm Herald',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A storm of desert, sea or tundra rages around you.',
|
||||
features: {
|
||||
3: [{ name: 'Storm Aura', text: 'Path of the Storm Herald. While raging, a 10 ft aura (choose with your DM). Desert: each turn, others in it take fire damage (2 at level 3, rising). Sea: each turn, one creature in it takes 1d6 lightning (Dexterity save for half). Tundra: each turn, you and allies in it gain Temp HP (2 at level 3, rising).' }],
|
||||
6: [{ name: 'Storm Soul', text: 'Desert: fire resistance. Sea: lightning resistance, breathe underwater, swim speed. Tundra: cold resistance, and you can freeze a little water.' }],
|
||||
10: [{ name: 'Shielding Storm', text: 'Allies in your Storm Aura gain the resistance your Storm Soul gives.' }],
|
||||
14: [{ name: 'Raging Storm', text: 'Desert: reaction, a creature in your aura that hits you takes fire damage of half your level. Sea: reaction, a creature you hit makes a Strength save or is knocked prone. Tundra: each turn, one creature in your aura makes a Strength save or has speed 0.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'WILD_MAGIC_BARBARIAN',
|
||||
name: 'Path of Wild Magic',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Raw magic erupts every time you rage.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Magic Awareness', text: 'Path of Wild Magic. A few times per long rest (proficiency bonus), action: sense spells and magic items within 60 ft.' },
|
||||
{ name: 'Wild Surge', text: 'When you rage, roll a d8 for a wild effect (the DM reads it): necrotic tendrils, a teleport, an exploding spirit, a magic weapon, a retaliating aura, a light that blinds, flowers and vines, or a bolt of light.' },
|
||||
],
|
||||
6: [{ name: 'Bolstering Magic', text: 'A few times per long rest (proficiency bonus), action: a creature you touch adds a d3 to attacks and checks for 10 minutes, or regains a spent spell slot of up to a d3’s level.' }],
|
||||
10: [{ name: 'Unstable Backlash', text: 'While raging, reaction when you take damage or fail a save: roll a new Wild Surge in place of the current one.' }],
|
||||
14: [{ name: 'Controlled Surge', text: 'When you roll a Wild Surge, roll twice and choose (any doubles, choose any effect).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'WILD_HEART',
|
||||
name: 'Path of the Wild Heart',
|
||||
source: 'Player’s Handbook (2024)',
|
||||
edition: '2024',
|
||||
replaces: 'TOTEM_WARRIOR',
|
||||
summary: 'The 2024 totem path: a beast’s spirit rages with you.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Animal Speaker', text: 'Path of the Wild Heart. Cast Beast Sense and Speak with Animals as rituals.' },
|
||||
{ name: 'Rage of the Wilds', text: 'Each time you rage, choose. Bear: all damage but force, necrotic, psychic and radiant is halved. Eagle: Dash and Disengage as one bonus action when you rage and on later turns. Wolf: allies have advantage on attacks against enemies next to you.' },
|
||||
],
|
||||
6: [{ name: 'Aspect of the Wilds', text: 'After each long rest choose: Owl (darkvision 60 ft, or 60 ft more), Panther (climb speed), or Salmon (swim speed).' }],
|
||||
10: [{ name: 'Nature Speaker', text: 'Cast Commune with Nature as a ritual.' }],
|
||||
14: [{ name: 'Power of the Wilds', text: 'Each time you rage, choose. Falcon: fly at your speed. Lion: enemies next to you have disadvantage on attacks against anyone else. Ram: a melee hit can knock a Large or smaller creature prone.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'WORLD_TREE',
|
||||
name: 'Path of the World Tree',
|
||||
source: 'Player’s Handbook (2024)',
|
||||
edition: '2024',
|
||||
summary: 'The cosmic tree’s roots and branches move with you and your allies.',
|
||||
features: {
|
||||
3: [{ name: 'Vitality of the Tree', text: 'Path of the World Tree. When you rage, gain Temp HP equal to your level. While raging, at the start of each turn give an ally within 10 ft Temp HP: d6s equal to your rage damage bonus.' }],
|
||||
6: [{ name: 'Branches of the Tree', text: 'While raging, reaction: a creature within 30 ft makes a Strength save or is teleported next to you, and its speed is 0 until the turn ends.' }],
|
||||
10: [{ name: 'Battering Roots', text: 'Your reach with Heavy or Versatile melee weapons grows by 10 ft, and a hit can push the target 15 ft or knock it prone.' }],
|
||||
14: [{ name: 'Travel Along the Tree', text: 'When you rage, and as a bonus action while raging, teleport up to 60 ft. Once per rage, teleport up to 150 ft and take up to six willing creatures within 10 ft with you.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = BARBARIAN;
|
||||
@@ -0,0 +1,147 @@
|
||||
// Bard subclasses: the colleges. The shape is described in index.js.
|
||||
|
||||
const BARD = [
|
||||
{
|
||||
key: 'LORE',
|
||||
name: 'College of Lore',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Knows a bit of everything, and a word to trip anyone up.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Cutting Words', text: 'College of Lore. Reaction: spend a Bardic Inspiration to subtract the die from an enemy’s attack, check or damage roll.' },
|
||||
{ name: 'Bonus Proficiencies', text: 'Treat three extra skills of your choice as proficient (add your proficiency bonus by hand).' },
|
||||
],
|
||||
6: [{ name: 'Additional Magical Secrets', text: 'Learn two spells from any class list (the DM grants them within the unlock schedule).' }],
|
||||
14: [{ name: 'Peerless Skill', text: 'When you make an ability check, spend a Bardic Inspiration and add the die to it.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'VALOR',
|
||||
name: 'College of Valor',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A skald in the front line, inspiring by example.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Combat Inspiration', text: 'College of Valor. A creature with your Bardic Inspiration may add the die to a weapon damage roll, or to its AC against one attack as a reaction.' },
|
||||
{ name: 'Battle Training', text: 'You fight in medium armor and with a shield (the DM raises Extra AC to match what you wear).' },
|
||||
],
|
||||
6: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
|
||||
14: [{ name: 'Battle Magic', text: 'When you cast a bard spell with your action, make one weapon attack as a bonus action.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SWORDS',
|
||||
name: 'College of Swords',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A blade dancer whose performance is the fight itself.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Fighting Style', text: 'College of Swords. Choose one with your DM: +2 damage with a one-handed weapon and nothing in the other hand, or add your ability modifier to the damage of a second weapon (added by hand).' },
|
||||
{ name: 'Blade Flourish', text: 'When you attack on your turn, +10 ft speed that turn. On a hit, spend a Bardic Inspiration and add the die to the damage, and choose: add it to your AC until your next turn, hit a second creature next to you for the die’s roll, or push the target 5 ft + the roll and follow it.' },
|
||||
],
|
||||
6: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
|
||||
14: [{ name: 'Master’s Flourish', text: 'Use a d6 for a Blade Flourish instead of spending a Bardic Inspiration.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'CREATION',
|
||||
name: 'College of Creation',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Sings things into being, and makes objects dance.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Mote of Potential', text: 'College of Creation. Your Bardic Inspiration comes with a mote: on an ability check roll the die twice and keep either; on an attack, a thunderclap hits creatures near the target for the die’s roll; on a save, gain Temp HP of the roll + your Charisma modifier.' },
|
||||
{ name: 'Performance of Creation', text: 'Once per long rest (or by spending a 2nd-level slot), action: create a non-magical object worth up to 20 × your level gold, for a few hours.' },
|
||||
],
|
||||
6: [{ name: 'Animating Performance', text: 'Once per long rest (or by spending a 3rd-level slot), action: animate a Large or smaller object as a Dancing Item that fights for you for an hour (the DM adds it as a monster).' }],
|
||||
14: [{ name: 'Creative Crescendo', text: 'Performance of Creation makes several objects at once (your Charisma modifier), and with no limit on their value.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'DANCE',
|
||||
name: 'College of Dance',
|
||||
source: 'Player’s Handbook (2024)',
|
||||
edition: '2024',
|
||||
summary: 'A whirling dancer who fights unarmored and moves the whole party.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Dazzling Footwork', text: 'College of Dance. Without armor or a shield: AC is 10 + Dexterity + Charisma (ask your DM to set it), advantage on Performance for dancing, unarmed strikes use Dexterity and deal your Bardic Inspiration die + Dexterity bludgeoning, and when you give Bardic Inspiration you may make an unarmed strike as part of it.' },
|
||||
],
|
||||
6: [
|
||||
{ name: 'Inspiring Movement', text: 'Reaction when an enemy ends its turn within 5 ft: spend a Bardic Inspiration to move half your speed, and one ally within 30 ft can do the same, without provoking.' },
|
||||
{ name: 'Tandem Footwork', text: 'When you roll initiative, spend a Bardic Inspiration: you and allies within 30 ft add the die to initiative.' },
|
||||
],
|
||||
14: [{ name: 'Leading Evasion', text: 'On a Dexterity save for half damage, take none on a success and half on a failure; allies within 5 ft making the same save get the same.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ELOQUENCE',
|
||||
name: 'College of Eloquence',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'The master orator: words that never fall flat.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Silver Tongue', text: 'College of Eloquence. Treat a Persuasion or Deception roll of 9 or lower as a 10.' },
|
||||
{ name: 'Unsettling Words', text: 'Bonus action: spend a Bardic Inspiration; a creature within 60 ft subtracts the die from its next save.' },
|
||||
],
|
||||
6: [
|
||||
{ name: 'Unfailing Inspiration', text: 'A creature that adds your Bardic Inspiration and still fails keeps the die.' },
|
||||
{ name: 'Universal Speech', text: 'Once per long rest, up to your Charisma modifier creatures understand you for an hour, whatever language you speak.' },
|
||||
],
|
||||
14: [{ name: 'Infectious Inspiration', text: 'A few times per long rest (Charisma modifier), reaction when a creature succeeds with your Bardic Inspiration: give a free Bardic Inspiration to another creature.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'GLAMOUR',
|
||||
name: 'College of Glamour',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Fey beauty that charms crowds and commands attention.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Mantle of Inspiration', text: 'College of Glamour. Bonus action, spend a Bardic Inspiration: up to your Charisma modifier creatures within 60 ft gain Temp HP (5, rising with level) and can move their speed without provoking.' },
|
||||
{ name: 'Enthralling Performance', text: 'After performing for a minute, up to your Charisma modifier listeners make a Wisdom save or are charmed by you for an hour.' },
|
||||
],
|
||||
6: [{ name: 'Mantle of Majesty', text: 'Once per long rest, bonus action: for a minute, cast Command as a bonus action each turn without a slot; a creature you charmed fails it automatically.' }],
|
||||
14: [{ name: 'Unbreakable Majesty', text: 'Once per short rest, bonus action for a minute: a creature that attacks you makes a Charisma save or must pick another target.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SPIRITS',
|
||||
name: 'College of Spirits',
|
||||
source: 'Van Richten’s Guide to Ravenloft',
|
||||
edition: '2014',
|
||||
summary: 'A storyteller who channels the tales of the dead.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Guiding Whispers', text: 'College of Spirits. You know Guidance, and it reaches 60 ft.' },
|
||||
{ name: 'Spiritual Focus', text: 'A candle, crystal ball or skull serves as your focus; from level 6, add a d6 to the damage or healing of your bard spells through it.' },
|
||||
{ name: 'Tales from Beyond', text: 'Bonus action, spend a Bardic Inspiration: roll it to learn a tale (the DM reads the effect). Bonus action later: tell it to a creature within 30 ft for its effect.' },
|
||||
],
|
||||
6: [{ name: 'Spirit Session', text: 'With an hour-long ritual, learn a divination or necromancy spell from any class for the day.' }],
|
||||
14: [{ name: 'Mystical Connection', text: 'When you roll a tale, roll twice and choose.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'WHISPERS',
|
||||
name: 'College of Whispers',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Secrets, fear and poison words: a bard who frightens.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Psychic Blades', text: 'College of Whispers. When you hit with a weapon, spend a Bardic Inspiration: +2d6 psychic damage (3d6 at level 5, 5d6 at 10, 8d6 at 15), once per turn.' },
|
||||
{ name: 'Words of Terror', text: 'Once per short rest, after a minute alone talking with a creature: Wisdom save or frightened of you or someone you name for an hour.' },
|
||||
],
|
||||
6: [{ name: 'Mantle of Whispers', text: 'Once per short rest, reaction when a humanoid dies within 30 ft: take its shadow and wear its appearance for an hour, knowing what it knew of its life.' }],
|
||||
14: [{ name: 'Shadow Lore', text: 'Once per long rest, action: whisper to a creature; it makes a Wisdom save or is charmed by you for 8 hours, believing you know its darkest secret.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = BARD;
|
||||
@@ -0,0 +1,232 @@
|
||||
// Cleric subclasses: the divine domains. The shape is described in index.js.
|
||||
// A domain’s first-level gifts arrive with the choice, at level 2.
|
||||
|
||||
const CLERIC = [
|
||||
{
|
||||
key: 'LIFE',
|
||||
name: 'Life Domain',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'The healer: more from every cure, and healing in a burst.',
|
||||
features: {
|
||||
2: [{ name: 'Preserve Life', text: 'Channel Divinity. Action: share out healing of 5 × your level among creatures within 30 ft (up to half their max HP each).' }],
|
||||
6: [{ name: 'Blessed Healer', text: 'When you heal someone else with a spell, you heal yourself 2 + the spell’s level.' }],
|
||||
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 radiant damage.' }],
|
||||
14: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +2d8 radiant damage.', upgrade: true }],
|
||||
17: [{ name: 'Supreme Healing', text: 'Your healing spells heal the most their dice can roll, instead of rolling.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'LIGHT',
|
||||
name: 'Light Domain',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Fire and radiance: blinds attackers and burns the dark away.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Warding Flare', text: 'Light Domain. Reaction, a few times per day (your Wisdom modifier, at least once): a creature within 30 ft that attacks you has disadvantage on that attack.' },
|
||||
{ name: 'Radiance of the Dawn', text: 'Channel Divinity. Action: magical darkness within 30 ft is dispelled, and each enemy there takes 2d10 + your level radiant damage (Constitution save for half).' },
|
||||
],
|
||||
6: [{ name: 'Improved Flare', text: 'Use Warding Flare when a creature attacks someone else within 30 ft of you.' }],
|
||||
8: [{ name: 'Potent Spellcasting', text: 'Add your Wisdom modifier to the damage of your cleric cantrips.' }],
|
||||
17: [{ name: 'Corona of Light', text: 'Action: shine like the sun for a minute (60 ft bright light); enemies in it have disadvantage on saves against fire and radiant spells.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'WAR',
|
||||
name: 'War Domain',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A soldier of the faith: extra strikes and blows that land.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'War Priest', text: 'War Domain. A few times per day (your Wisdom modifier, at least once), after you take the Attack action, make one weapon attack as a bonus action.' },
|
||||
{ name: 'Guided Strike', text: 'Channel Divinity. When you make an attack roll, add +10 to it after seeing the roll.' },
|
||||
],
|
||||
6: [{ name: 'War God’s Blessing', text: 'Channel Divinity. Reaction: give +10 to an attack roll made by a creature within 30 ft.' }],
|
||||
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 damage of the weapon’s type.' }],
|
||||
14: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +2d8 damage of the weapon’s type.', upgrade: true }],
|
||||
17: [{ name: 'Avatar of Battle', text: 'Bludgeoning, piercing and slashing damage from non-magical attacks against you is halved.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ARCANA',
|
||||
name: 'Arcana Domain',
|
||||
source: 'Sword Coast Adventurer’s Guide',
|
||||
edition: '2014',
|
||||
summary: 'A scholar of the god of magic, at home with wizard spells.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Arcane Initiate', text: 'Arcana Domain. Treat Arcana as proficient (add the bonus by hand) and learn two wizard cantrips (the DM grants them).' },
|
||||
{ name: 'Arcane Abjuration', text: 'Channel Divinity. Action: a celestial, elemental, fey or fiend within 30 ft makes a Wisdom save or is turned for a minute; from level 5, weak ones are banished.' },
|
||||
],
|
||||
6: [{ name: 'Spell Breaker', text: 'When you heal an ally with a spell, also end one spell on them of a level no higher than the slot you used.' }],
|
||||
8: [{ name: 'Potent Spellcasting', text: 'Add your Wisdom modifier to the damage of your cleric cantrips.' }],
|
||||
17: [{ name: 'Arcane Mastery', text: 'Add one wizard spell of each of 6th, 7th, 8th and 9th level to your domain spells (the DM grants them).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'DEATH',
|
||||
name: 'Death Domain',
|
||||
source: 'Dungeon Master’s Guide',
|
||||
edition: '2014',
|
||||
summary: 'A priest of endings: necrotic magic that ignores resistance.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Reaper', text: 'Death Domain. Learn a necromancy cantrip; when you cast one that targets one creature, you may target two within 5 ft of each other.' },
|
||||
{ name: 'Touch of Death', text: 'Channel Divinity. When you hit with a melee attack, deal an extra 5 + 2 × your level necrotic damage.' },
|
||||
],
|
||||
6: [{ name: 'Inescapable Destruction', text: 'Your necrotic damage ignores resistance to necrotic.' }],
|
||||
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 necrotic damage (2d8 from level 14).' }],
|
||||
17: [{ name: 'Improved Reaper', text: 'When you cast a necromancy spell of 1st to 5th level that targets one creature, you may target a second within 5 ft of the first.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'FORGE',
|
||||
name: 'Forge Domain',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A smith-priest in heavy armor, blessing weapons and walking through fire.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Blessing of the Forge', text: 'Forge Domain. After a long rest, touch a weapon or suit of armor: it gets +1 to attack and damage, or +1 AC, until your next long rest.' },
|
||||
{ name: 'Artisan’s Blessing', text: 'Channel Divinity. An hour’s ritual creates a metal object worth up to 100 gold.' },
|
||||
],
|
||||
6: [{ name: 'Soul of the Forge', text: 'Fire damage against you is halved, and you gain +1 AC in heavy armor (the DM raises Extra AC by 1).' }],
|
||||
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 fire damage (2d8 from level 14).' }],
|
||||
17: [{ name: 'Saint of Forge and Fire', text: 'Immune to fire, and in heavy armor non-magical weapon damage against you is halved.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'GRAVE',
|
||||
name: 'Grave Domain',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Guards the line between life and death, and pulls friends back from it.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Circle of Mortality', text: 'Grave Domain. Healing spells on a creature at 0 HP heal their maximum. You know Spare the Dying, at 30 ft, as a bonus action.' },
|
||||
{ name: 'Eyes of the Grave', text: 'A few times per long rest (Wisdom modifier), action: sense undead within 60 ft.' },
|
||||
{ name: 'Path to the Grave', text: 'Channel Divinity. Action: curse a creature within 30 ft; the next attack to hit it deals double damage.' },
|
||||
],
|
||||
6: [{ name: 'Sentinel at Death’s Door', text: 'A few times per long rest (Wisdom modifier), reaction: turn a critical hit on a creature within 30 ft into a normal hit.' }],
|
||||
8: [{ name: 'Potent Spellcasting', text: 'Add your Wisdom modifier to the damage of your cleric cantrips.' }],
|
||||
17: [{ name: 'Keeper of Souls', text: 'Once per round, when an enemy dies within 60 ft, you or an ally within 60 ft regains HP equal to its number of Hit Dice.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'KNOWLEDGE',
|
||||
name: 'Knowledge Domain',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A seeker of secrets who reads minds and past events.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Blessings of Knowledge', text: 'Knowledge Domain. Two extra languages, and expertise in two of Arcana, History, Nature or Religion (add double proficiency by hand).' },
|
||||
{ name: 'Knowledge of the Ages', text: 'Channel Divinity. For 10 minutes, be proficient with a skill or tool of your choice.' },
|
||||
],
|
||||
6: [{ name: 'Read Thoughts', text: 'Channel Divinity. Read the surface thoughts of a creature within 60 ft (Wisdom save); you may then cast Suggestion on it without a slot.' }],
|
||||
8: [{ name: 'Potent Spellcasting', text: 'Add your Wisdom modifier to the damage of your cleric cantrips.' }],
|
||||
17: [{ name: 'Visions of the Past', text: 'After a minute of meditation, see recent events around an object you hold or the place you are in.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'NATURE',
|
||||
name: 'Nature Domain',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A green priest who charms beasts and commands plants.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Acolyte of Nature', text: 'Nature Domain. Learn a druid cantrip, and treat one of Animal Handling, Nature or Survival as proficient.' },
|
||||
{ name: 'Charm Animals and Plants', text: 'Channel Divinity. Action: beasts and plant creatures within 30 ft make a Wisdom save or are charmed by you for a minute.' },
|
||||
],
|
||||
6: [{ name: 'Dampen Elements', text: 'Reaction: give yourself or a creature within 30 ft resistance to one instance of acid, cold, fire, lightning or thunder damage.' }],
|
||||
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 cold, fire or lightning damage (2d8 from level 14).' }],
|
||||
17: [{ name: 'Master of Nature', text: 'Bonus action: command the creatures you have charmed with Charm Animals and Plants.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ORDER',
|
||||
name: 'Order Domain',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'A herald of law whose spells make allies strike.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Voice of Authority', text: 'Order Domain. When you cast a spell of 1st level or higher on an ally, they may use their reaction to make one weapon attack.' },
|
||||
{ name: 'Order’s Demand', text: 'Channel Divinity. Action: creatures of your choice within 30 ft make a Wisdom save or are charmed until your next turn and drop what they hold.' },
|
||||
],
|
||||
6: [{ name: 'Embodiment of the Law', text: 'A few times per long rest (Wisdom modifier), cast an enchantment spell of 1st level or higher as a bonus action.' }],
|
||||
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 psychic damage (2d8 from level 14).' }],
|
||||
17: [{ name: 'Order’s Wrath', text: 'Once per turn, a creature you hit with Divine Strike is cursed: the next ally to hit it before your next turn deals +2d8 psychic.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'PEACE',
|
||||
name: 'Peace Domain',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Binds friends together so they protect one another.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Emboldening Bond', text: 'Peace Domain. A few times per long rest (proficiency bonus), bond up to your proficiency bonus creatures for 10 minutes: once per turn each may add a d4 to an attack, check or save while within 30 ft of another.' },
|
||||
{ name: 'Balm of Peace', text: 'Channel Divinity. Action: move up to your speed without provoking; each creature you pass within 5 ft of heals 2d6 + your Wisdom modifier.' },
|
||||
],
|
||||
6: [{ name: 'Protective Bond', text: 'When a bonded creature is about to take damage, another within 30 ft can teleport next to it and take the damage instead.' }],
|
||||
8: [{ name: 'Potent Spellcasting', text: 'Add your Wisdom modifier to the damage of your cleric cantrips.' }],
|
||||
17: [{ name: 'Expansive Bond', text: 'Your bonds reach 60 ft, and a creature taking damage for another resists it.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'TEMPEST',
|
||||
name: 'Tempest Domain',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Thunder and lightning at the god’s command.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Wrath of the Storm', text: 'Tempest Domain. A few times per long rest (Wisdom modifier), reaction when a creature within 5 ft hits you: it takes 2d8 lightning or thunder (Dexterity save for half).' },
|
||||
{ name: 'Destructive Wrath', text: 'Channel Divinity. When you roll lightning or thunder damage, deal the maximum instead.' },
|
||||
],
|
||||
6: [{ name: 'Thunderbolt Strike', text: 'When you deal lightning damage to a Large or smaller creature, push it up to 10 ft away.' }],
|
||||
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 thunder damage (2d8 from level 14).' }],
|
||||
17: [{ name: 'Stormborn', text: 'You can fly at your walking speed whenever you are outdoors and not underground.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'TRICKERY',
|
||||
name: 'Trickery Domain',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A god of mischief’s agent: illusions, duplicates and blessings of stealth.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Blessing of the Trickster', text: 'Trickery Domain. Action: give a creature advantage on Stealth checks for an hour.' },
|
||||
{ name: 'Invoke Duplicity', text: 'Channel Divinity. Create an illusory double of yourself within 30 ft for a minute; you can cast spells as if from its space, and have advantage on attacks against creatures next to it.' },
|
||||
],
|
||||
6: [{ name: 'Cloak of Shadows', text: 'Channel Divinity. Action: turn invisible until the end of your next turn.' }],
|
||||
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 poison damage (2d8 from level 14).' }],
|
||||
17: [{ name: 'Improved Duplicity', text: 'Invoke Duplicity makes up to four doubles, and you can move them all at once.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'TWILIGHT',
|
||||
name: 'Twilight Domain',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'A guardian of the night who keeps fear away and friends topped up.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Eyes of Night', text: 'Twilight Domain. You see in darkness to 300 ft, and can share it with allies for an hour.' },
|
||||
{ name: 'Vigilant Blessing', text: 'Action: a creature has advantage on its next initiative roll.' },
|
||||
{ name: 'Twilight Sanctuary', text: 'Channel Divinity. Action: a 30 ft sphere of dim light around you for a minute; each turn, a creature in it may gain 1d6 + your level Temp HP or end being charmed or frightened.' },
|
||||
],
|
||||
6: [{ name: 'Steps of Night', text: 'A few times per long rest (proficiency bonus), bonus action in dim light or darkness: fly at your speed for a minute.' }],
|
||||
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 radiant damage (2d8 from level 14).' }],
|
||||
17: [{ name: 'Twilight Shroud', text: 'You and allies in your Twilight Sanctuary have half cover.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = CLERIC;
|
||||
@@ -0,0 +1,125 @@
|
||||
// Druid subclasses: the circles. The shape is described in index.js.
|
||||
|
||||
const DRUID = [
|
||||
{
|
||||
key: 'LAND',
|
||||
name: 'Circle of the Land',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A keeper of old magic, at home in the wild places.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Natural Recovery', text: 'Once per day on a short rest, recover spent spell slots totalling up to half your level (rounded up).' },
|
||||
{ name: 'Bonus Cantrip', text: 'Learn one extra cantrip (the DM grants it).' },
|
||||
],
|
||||
6: [{ name: 'Land’s Stride', text: 'Ordinary difficult terrain and plants do not slow you; advantage on saves against magical plants.' }],
|
||||
10: [{ name: 'Nature’s Ward', text: 'Immune to poison and disease; you can’t be charmed or frightened by fey or elementals.' }],
|
||||
14: [{ name: 'Nature’s Sanctuary', text: 'Beasts and plant creatures must make a Wisdom save to attack you; on a failure they pick another target.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'MOON',
|
||||
name: 'Circle of the Moon',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A shapeshifter who fights as the beast.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Combat Wild Shape', text: 'Circle of the Moon. Use Wild Shape as a bonus action. While in beast form, bonus action: spend a spell slot to heal 1d8 per slot level.' },
|
||||
{ name: 'Circle Forms', text: 'Your Wild Shape can be a beast of up to CR 1; from level 6, up to CR equal to a third of your level (rounded down).' },
|
||||
],
|
||||
6: [{ name: 'Primal Strike', text: 'Your attacks in beast form count as magical.' }],
|
||||
10: [{ name: 'Elemental Wild Shape', text: 'Spend both uses of Wild Shape at once to become an air, earth, fire or water elemental.' }],
|
||||
14: [{ name: 'Thousand Forms', text: 'Cast Alter Self at will.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'STARS',
|
||||
name: 'Circle of Stars',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Reads the night sky and wears its constellations.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Star Map', text: 'Circle of Stars. You know the Guidance cantrip, and cast Guiding Bolt without a slot a number of times per day equal to your proficiency bonus (the DM grants both).' },
|
||||
{ name: 'Starry Form', text: 'Bonus action: spend a Wild Shape use to take a starry form for 10 minutes. Choose Archer (bonus action: ranged spell attack, 60 ft, 1d8 + Wisdom modifier radiant), Chalice (when you cast a healing spell, another creature within 30 ft heals 1d8 + Wisdom modifier) or Dragon (treat a roll of 9 or lower as 10 on Intelligence and Wisdom checks and on concentration saves).' },
|
||||
],
|
||||
6: [{ name: 'Cosmic Omen', text: 'After a long rest roll a die. Even (Weal): reaction, add 1d6 to a creature’s attack, check or save. Odd (Woe): reaction, subtract 1d6. Usable a number of times per day equal to your proficiency bonus, on creatures within 30 ft.' }],
|
||||
10: [{ name: 'Twinkling Constellations', text: 'Archer and Chalice use 2d8. In Dragon form you can fly 20 ft. At the start of each turn in Starry Form you may change constellation.' }],
|
||||
14: [{ name: 'Full of Stars', text: 'In Starry Form, bludgeoning, piercing and slashing damage against you is halved.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'DREAMS',
|
||||
name: 'Circle of Dreams',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Fey-touched healer who opens safe paths through the night.',
|
||||
features: {
|
||||
2: [{ name: 'Balm of the Summer Court', text: 'Circle of Dreams. A pool of d6s equal to your level per long rest. Bonus action: spend up to half your level of them to heal a creature within 120 ft, who also gains 1 Temp HP per die.' }],
|
||||
6: [{ name: 'Hearth of Moonlight and Shadow', text: 'During a rest, a 30 ft sphere around you gives +5 to Stealth and Perception checks and hides your camp’s light.' }],
|
||||
10: [{ name: 'Hidden Paths', text: 'A few times per long rest (Wisdom modifier), bonus action: teleport 60 ft, or action: teleport a willing creature you touch 30 ft.' }],
|
||||
14: [{ name: 'Walker in Dreams', text: 'Once per long rest, right after a short rest: cast Dream, Scrying or Teleportation Circle without a slot.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SEA',
|
||||
name: 'Circle of the Sea',
|
||||
source: 'Player’s Handbook (2024)',
|
||||
edition: '2024',
|
||||
summary: 'Wears the ocean’s fury as a stinging, storm-wrapped aura.',
|
||||
features: {
|
||||
2: [{ name: 'Wrath of the Sea', text: 'Circle of the Sea. Bonus action, spend a Wild Shape use: a 5 ft emanation of ocean spray for 10 minutes. Each turn, one creature in it takes cold damage (d6s equal to your Wisdom modifier, Constitution save for half) and may be pushed 15 ft.' }],
|
||||
6: [{ name: 'Aquatic Affinity', text: 'Your Wrath of the Sea grows to 10 ft, and you gain a swim speed equal to your walking speed.' }],
|
||||
10: [{ name: 'Stormborn', text: 'While Wrath of the Sea is active you fly at your speed and resist cold, lightning and thunder damage.' }],
|
||||
14: [{ name: 'Oceanic Gift', text: 'You can put your Wrath of the Sea around an ally instead, or around both of you by spending two uses.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SHEPHERD',
|
||||
name: 'Circle of the Shepherd',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A summoner whose spirits and beasts are stronger than anyone’s.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Speech of the Woods', text: 'Circle of the Shepherd. You speak Sylvan and can cast Speak with Animals at will.' },
|
||||
{ name: 'Spirit Totem', text: 'Once per short rest, bonus action: a spirit aura of 30 ft for a minute. Bear: allies gain 5 + your level Temp HP and advantage on Strength checks. Hawk: reaction, give an ally advantage on an attack. Unicorn: advantage on checks to detect creatures, and your healing spells heal everyone in it your level more.' },
|
||||
],
|
||||
6: [{ name: 'Mighty Summoner', text: 'Beasts and fey you summon have 2 extra HP per Hit Die, and their attacks count as magical.' }],
|
||||
10: [{ name: 'Guardian Spirit', text: 'Creatures you summon that end a turn in your Spirit Totem heal half your level.' }],
|
||||
14: [{ name: 'Faithful Summons', text: 'Once per long rest, when you drop to 0 HP or are incapacitated: four CR 2 beasts appear to protect you (as Conjure Animals).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SPORES',
|
||||
name: 'Circle of Spores',
|
||||
source: 'Guildmasters’ Guide to Ravnica',
|
||||
edition: '2014',
|
||||
summary: 'Rot and fungus: poison the living and raise the fallen.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Halo of Spores', text: 'Circle of Spores. Reaction: a creature within 10 ft takes 1d4 necrotic (1d6 at level 6, 1d8 at 10, 1d10 at 14; Constitution save negates).' },
|
||||
{ name: 'Symbiotic Entity', text: 'Action, spend a Wild Shape use: gain 4 × your level Temp HP for 10 minutes; while they last your Halo of Spores dice double and your melee hits deal +1d6 necrotic.' },
|
||||
],
|
||||
6: [{ name: 'Fungal Infestation', text: 'A few times per long rest (Wisdom modifier), reaction when a beast or humanoid dies within 10 ft: it rises as a 1 HP zombie under your command for an hour.' }],
|
||||
10: [{ name: 'Spreading Spores', text: 'Bonus action while Symbiotic Entity is active: hurl your spores into a 10 ft cube up to 30 ft away, which deals your Halo damage to anyone who enters or starts there.' }],
|
||||
14: [{ name: 'Fungal Body', text: 'You can’t be blinded, deafened, frightened or poisoned, and critical hits against you are normal hits unless you are incapacitated.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'WILDFIRE',
|
||||
name: 'Circle of Wildfire',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'A fire spirit companion that burns and renews in equal measure.',
|
||||
features: {
|
||||
2: [{ name: 'Summon Wildfire Spirit', text: 'Circle of Wildfire. Action, spend a Wild Shape use: a wildfire spirit appears for an hour, dealing 2d6 fire to creatures within 10 ft of it as it arrives (the DM adds it as a monster). It can teleport allies and fight beside you.' }],
|
||||
6: [{ name: 'Enhanced Bond', text: 'While your spirit is summoned, your fire spells and healing spells add +1d8, and spells can come from the spirit’s position.' }],
|
||||
10: [{ name: 'Cauterizing Flames', text: 'A few times per long rest (proficiency bonus), when a creature dies within 30 ft of you or your spirit: a flame appears there; anyone entering it can be healed or burned 2d10 + your Wisdom modifier, your choice.' }],
|
||||
14: [{ name: 'Blazing Revival', text: 'Once per long rest, when you drop to 0 HP with your spirit within 120 ft: it vanishes and you regain half your HP and stand up.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = DRUID;
|
||||
@@ -0,0 +1,183 @@
|
||||
// Fighter subclasses: the martial archetypes. The shape is described in index.js.
|
||||
|
||||
const FIGHTER = [
|
||||
{
|
||||
key: 'CHAMPION',
|
||||
name: 'Champion',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Simple and deadly: more critical hits, more athletic.',
|
||||
features: {
|
||||
3: [{ name: 'Improved Critical', text: 'Your weapon attacks score a critical hit on a roll of 19 or 20.' }],
|
||||
7: [{ name: 'Remarkable Athlete', text: 'Add half your proficiency bonus to Strength, Dexterity and Constitution checks you are not proficient in; long jump + Strength modifier feet.' }],
|
||||
10: [{ name: 'Additional Fighting Style', text: 'Choose a second option from your Fighting Style list. (If it is +1 AC in armor, the DM raises Extra AC by 1.)' }],
|
||||
15: [{ name: 'Superior Critical', text: 'Your weapon attacks score a critical hit on a roll of 18 to 20.' }],
|
||||
18: [{ name: 'Survivor', text: 'At the start of each turn, if you are below half HP but above 0, regain 5 + your Constitution modifier HP.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'BATTLE_MASTER',
|
||||
name: 'Battle Master',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A tactician who turns each swing into a manoeuvre.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Combat Superiority', text: 'Battle Master. Four superiority dice (d8), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier.' },
|
||||
{ name: 'Manoeuvres', text: 'Choose three with your DM: Trip Attack (add the die to damage; Strength save or prone), Disarming Attack (add the die; Strength save or it drops what it holds), Pushing Attack (add the die; Strength save or pushed 15 ft), Precision Attack (add the die to an attack roll), Riposte (reaction when a creature misses you: attack it and add the die), Parry (reaction: reduce melee damage by the die + Dexterity modifier), Rally (bonus action: an ally gains the die + Charisma modifier Temp HP), Menacing Attack (add the die; Wisdom save or frightened until your next turn).' },
|
||||
],
|
||||
7: [
|
||||
{ name: 'Know Your Enemy', text: 'After a minute watching a creature, the DM tells you whether it is your equal, better or worse in two of: Strength, Dexterity, Constitution, AC, current HP, level.' },
|
||||
{ name: 'Combat Superiority', text: 'Battle Master. Five superiority dice (d8), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier.', upgrade: true },
|
||||
{ name: 'Manoeuvres', text: 'Choose two more with your DM (five in all) from the list on your level 3 line.' },
|
||||
],
|
||||
10: [{ name: 'Combat Superiority', text: 'Battle Master. Five superiority dice (d10), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier. Choose two more manoeuvres with your DM (seven in all).', upgrade: true }],
|
||||
15: [
|
||||
{ name: 'Relentless', text: 'When you roll initiative with no superiority dice left, regain one.' },
|
||||
{ name: 'Combat Superiority', text: 'Battle Master. Six superiority dice (d10), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier. Choose two more manoeuvres with your DM (nine in all).', upgrade: true },
|
||||
],
|
||||
18: [{ name: 'Combat Superiority', text: 'Battle Master. Six superiority dice (d12), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier. Nine manoeuvres in all.', upgrade: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SAMURAI',
|
||||
name: 'Samurai',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Unbreakable resolve: strikes with advantage and shrugs off blows.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Fighting Spirit', text: 'Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 5 Temp HP.' },
|
||||
{ name: 'Bonus Proficiency', text: 'Treat one of History, Insight, Performance or Persuasion as proficient (add your proficiency bonus by hand).' },
|
||||
],
|
||||
7: [{ name: 'Elegant Courtier', text: 'Add your Wisdom modifier to Persuasion checks, and treat Wisdom saves as proficient (both added by hand).' }],
|
||||
10: [
|
||||
{ name: 'Tireless Spirit', text: 'If you roll initiative with no uses of Fighting Spirit left, you regain one.' },
|
||||
{ name: 'Fighting Spirit', text: 'Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 10 Temp HP.', upgrade: true },
|
||||
],
|
||||
15: [
|
||||
{ name: 'Rapid Strike', text: 'Once per turn, when you have advantage on an attack, give it up to make one extra attack.' },
|
||||
{ name: 'Fighting Spirit', text: 'Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 15 Temp HP.', upgrade: true },
|
||||
],
|
||||
18: [{ name: 'Strength Before Death', text: 'Once per long rest, when you drop to 0 HP: take a full extra turn right away before you fall.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ARCANE_ARCHER',
|
||||
name: 'Arcane Archer',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Elven archery that wraps each arrow in a spell.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Arcane Archer Lore', text: 'Arcane Archer. Treat Arcana or Nature as proficient, and learn Prestidigitation or Druidcraft.' },
|
||||
{ name: 'Arcane Shot', text: 'Twice per short rest, when you hit with a bow: add an arcane effect (choose two to know, more later): Banishing, Beguiling, Bursting (2d6 force to all within 10 ft), Enfeebling, Grasping, Piercing (a 30 ft line), Seeking (finds a hidden target), Shadow (blinds).' },
|
||||
],
|
||||
7: [
|
||||
{ name: 'Magic Arrow', text: 'Your arrows count as magical.' },
|
||||
{ name: 'Curving Shot', text: 'Bonus action when you miss with a magic arrow: reroll the attack against another target within 60 ft of the first.' },
|
||||
],
|
||||
10: [{ name: 'Arcane Shot', text: 'Learn one more Arcane Shot option (three in all); damage from shots grows to 4d6 at level 18.' }],
|
||||
15: [{ name: 'Ever-Ready Shot', text: 'When you roll initiative with no Arcane Shots left, regain one.' }],
|
||||
18: [{ name: 'Arcane Shot', text: 'Your Arcane Shot effects deal their improved damage.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'CAVALIER',
|
||||
name: 'Cavalier',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A mounted guardian who holds the line and punishes anyone who ignores them.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Born to the Saddle', text: 'Cavalier. Advantage on saves against falling off your mount; mounting or dismounting costs 5 ft of movement.' },
|
||||
{ name: 'Unwavering Mark', text: 'When you hit a creature, mark it until your next turn: it has disadvantage on attacks against anyone else, and if it damages someone else you may attack it as a bonus action with advantage, adding half your level to the damage.' },
|
||||
],
|
||||
7: [{ name: 'Warding Maneuver', text: 'A few times per long rest (Constitution modifier), reaction: add 1d8 to an adjacent ally’s AC against an attack; if it still hits, it takes half damage.' }],
|
||||
10: [{ name: 'Hold the Line', text: 'Creatures provoke opportunity attacks from you when they move 5 ft or more within your reach; a hit stops them.' }],
|
||||
15: [{ name: 'Ferocious Charger', text: 'Once per turn, after moving 10 ft straight before a hit, the target makes a Strength save or is knocked prone.' }],
|
||||
18: [{ name: 'Vigilant Defender', text: 'You get a special reaction on every other creature’s turn, used only for opportunity attacks.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ECHO_KNIGHT',
|
||||
name: 'Echo Knight',
|
||||
source: 'Explorer’s Guide to Wildemount',
|
||||
edition: '2014',
|
||||
summary: 'Fights from two places at once with an echo of a possible self.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Manifest Echo', text: 'Echo Knight. Bonus action: a translucent echo of you appears within 15 ft (AC 14 + proficiency bonus, 1 HP). You can attack from its space, swap places with it (15 ft of movement), and it moves 30 ft as a bonus action.' },
|
||||
{ name: 'Unleash Incarnation', text: 'A few times per long rest (Constitution modifier), when you take the Attack action: make one extra attack from your echo’s position.' },
|
||||
],
|
||||
7: [{ name: 'Echo Avatar', text: 'Action: see and hear through your echo for 10 minutes; it can travel up to 1,000 ft away.' }],
|
||||
10: [{ name: 'Shadow Martyr', text: 'Once per short rest, reaction: your echo teleports in front of an ally and takes the attack instead.' }],
|
||||
15: [{ name: 'Reclaim Potential', text: 'A few times per long rest (Constitution modifier), when your echo is destroyed: gain 2d6 + Constitution modifier Temp HP.' }],
|
||||
18: [{ name: 'Legion of One', text: 'You can have two echoes at once, and you regain a use of Unleash Incarnation when you roll initiative with none.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ELDRITCH_KNIGHT',
|
||||
name: 'Eldritch Knight',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A warrior who weaves wizard spells between sword strokes.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Spellcasting', text: 'Eldritch Knight. You cast wizard spells (mostly abjuration and evocation) with Intelligence, as a third caster: the DM grants spells within the unlock schedule.' },
|
||||
{ name: 'Weapon Bond', text: 'Bond with up to two weapons: they can’t be disarmed from you, and you can summon one to your hand as a bonus action.' },
|
||||
],
|
||||
7: [{ name: 'War Magic', text: 'When you cast a cantrip with your action, make one weapon attack as a bonus action.' }],
|
||||
10: [{ name: 'Eldritch Strike', text: 'A creature you hit with a weapon has disadvantage on its next save against a spell you cast before the end of your next turn.' }],
|
||||
15: [{ name: 'Arcane Charge', text: 'When you use Action Surge, teleport up to 30 ft.' }],
|
||||
18: [{ name: 'Improved War Magic', text: 'When you cast a spell with your action, make one weapon attack as a bonus action.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'PSI_WARRIOR',
|
||||
name: 'Psi Warrior',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Telekinetic force behind every blow and shield.',
|
||||
features: {
|
||||
3: [{ name: 'Psionic Power', text: 'Psi Warrior. Psionic dice (twice your proficiency bonus, d6, growing to d12), back on a long rest. Protective Field: reaction, reduce damage to someone within 30 ft by a die + Intelligence modifier. Psionic Strike: +a die + Intelligence modifier force on a hit. Telekinetic Movement: move an object or ally 30 ft.' }],
|
||||
7: [{ name: 'Telekinetic Adept', text: 'Psi-Powered Leap: bonus action, fly twice your speed this turn. Telekinetic Thrust: when you use Psionic Strike, the target makes a Strength save or is pushed 10 ft or knocked prone.' }],
|
||||
10: [{ name: 'Guarded Mind', text: 'Psychic damage against you is halved; spend a psionic die to end being charmed or frightened.' }],
|
||||
15: [{ name: 'Bulwark of Force', text: 'Once per long rest, bonus action: up to your Intelligence modifier creatures gain half cover for a minute.' }],
|
||||
18: [{ name: 'Telekinetic Master', text: 'Once per long rest, cast Telekinesis without a slot; while concentrating on it, make one weapon attack as a bonus action each turn.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'PURPLE_DRAGON_KNIGHT',
|
||||
name: 'Purple Dragon Knight (Banneret)',
|
||||
source: 'Sword Coast Adventurer’s Guide',
|
||||
edition: '2014',
|
||||
summary: 'A battlefield leader whose second winds heal the whole squad.',
|
||||
features: {
|
||||
3: [{ name: 'Rallying Cry', text: 'Purple Dragon Knight. When you use Second Wind, up to three allies within 60 ft regain HP equal to your level.' }],
|
||||
7: [{ name: 'Royal Envoy', text: 'Treat Persuasion as proficient, and double your proficiency bonus on it (add by hand).' }],
|
||||
10: [{ name: 'Inspiring Surge', text: 'When you use Action Surge, one ally within 60 ft can make a weapon attack with its reaction.' }],
|
||||
15: [{ name: 'Bulwark', text: 'When you use Indomitable on an Intelligence, Wisdom or Charisma save, an ally within 60 ft who failed the same save can reroll too.' }],
|
||||
18: [{ name: 'Inspiring Surge', text: 'When you use Action Surge, two allies within 60 ft can each make a weapon attack with their reaction.', upgrade: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'RUNE_KNIGHT',
|
||||
name: 'Rune Knight',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Giant runes carved into your gear, and the strength to grow giant-sized.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Rune Carver', text: 'Rune Knight. Learn two runes (Cloud, Fire, Frost, Stone, Hill, Storm; more later) and inscribe them on your gear. Each has a passive gift and a once-per-short-rest power.' },
|
||||
{ name: 'Giant’s Might', text: 'A few times per long rest (proficiency bonus), bonus action for a minute: grow Large, advantage on Strength checks and saves, and +1d6 damage once per turn.' },
|
||||
],
|
||||
7: [{ name: 'Runic Shield', text: 'A few times per long rest (proficiency bonus), reaction: force an attacker to reroll its attack against an ally within 60 ft.' }],
|
||||
10: [{ name: 'Great Stature', text: 'You grow taller, and Giant’s Might deals 1d8 extra damage.' }],
|
||||
15: [{ name: 'Master of Runes', text: 'Each rune’s power can be used twice per short rest.' }],
|
||||
18: [{ name: 'Runic Juggernaut', text: 'Giant’s Might deals 1d10, lets you grow Huge, and adds 5 ft of reach.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = FIGHTER;
|
||||
@@ -0,0 +1,84 @@
|
||||
// Subclasses: the path a character takes within their class, one file per
|
||||
// class. Every official subclass is here (2014 books, and the 2024 Player’s
|
||||
// Handbook where it brought a new one), as listed at
|
||||
// dungeonsanddragonsfan.com/all-dnd-5e-classes-subclasses-complete-guide.
|
||||
//
|
||||
// Each entry is { key, name, source, edition, replaces?, summary, features }.
|
||||
// - `key` is unique across all classes; it is what Character.subclass holds.
|
||||
// - `source` is the book, `edition` '2014' or '2024'.
|
||||
// - `replaces` names the 2014 subclass a 2024 one updates (Wild Heart for the
|
||||
// Totem Warrior). A character on the old one can switch to the new.
|
||||
// - `features` are by character level, in the same shape as LEVEL_FEATURES
|
||||
// (levelFeatures.js): each becomes a line of the special abilities, and an
|
||||
// `upgrade` rewrites the line that already has its name. A subclass's gifts
|
||||
// from before its class chooses (a cleric domain's 1st-level ones) arrive
|
||||
// with the choice. None of it feeds the calculation engine.
|
||||
//
|
||||
// The first subclass of each class is the path every character of that class
|
||||
// followed before subclasses could be chosen (the Champion fighter, the Life
|
||||
// cleric...), and the one a campaign without subclasses gives everybody. Its
|
||||
// lines are word for word what the class tables used to write, so sheets
|
||||
// written back then still match.
|
||||
|
||||
// The level at which a class chooses.
|
||||
const SUBCLASS_LEVEL = {
|
||||
ARTIFICER: 3,
|
||||
BARBARIAN: 3,
|
||||
BARD: 3,
|
||||
CLERIC: 2,
|
||||
DRUID: 2,
|
||||
FIGHTER: 3,
|
||||
MONK: 3,
|
||||
PALADIN: 3,
|
||||
RANGER: 3,
|
||||
ROGUE: 3,
|
||||
SORCERER: 3,
|
||||
WARLOCK: 3,
|
||||
WIZARD: 2,
|
||||
};
|
||||
|
||||
const SUBCLASSES = {
|
||||
ARTIFICER: require('./artificer'),
|
||||
BARBARIAN: require('./barbarian'),
|
||||
BARD: require('./bard'),
|
||||
CLERIC: require('./cleric'),
|
||||
DRUID: require('./druid'),
|
||||
FIGHTER: require('./fighter'),
|
||||
MONK: require('./monk'),
|
||||
PALADIN: require('./paladin'),
|
||||
RANGER: require('./ranger'),
|
||||
ROGUE: require('./rogue'),
|
||||
SORCERER: require('./sorcerer'),
|
||||
WARLOCK: require('./warlock'),
|
||||
WIZARD: require('./wizard'),
|
||||
};
|
||||
|
||||
function subclassesOf(characterClass) {
|
||||
return SUBCLASSES[characterClass] || [];
|
||||
}
|
||||
|
||||
// One subclass of a class by its key; null when the class has no such subclass.
|
||||
function findSubclass(characterClass, key) {
|
||||
return subclassesOf(characterClass).find((subclass) => subclass.key === key) || null;
|
||||
}
|
||||
|
||||
function subclassName(characterClass, key) {
|
||||
const subclass = findSubclass(characterClass, key);
|
||||
return subclass ? subclass.name : null;
|
||||
}
|
||||
|
||||
// The path a class followed before subclasses could be chosen.
|
||||
function defaultSubclass(characterClass) {
|
||||
return subclassesOf(characterClass)[0] || null;
|
||||
}
|
||||
|
||||
// The 2024 subclass that updates this one, if there is one.
|
||||
function newerVersionOf(characterClass, key) {
|
||||
return subclassesOf(characterClass).find((subclass) => subclass.replaces === key) || null;
|
||||
}
|
||||
|
||||
// What a list of subclasses to choose from shows of each: everything but the
|
||||
// features.
|
||||
const subclassSummary = ({ key, name, summary, source, edition, replaces = null }) => ({ key, name, summary, source, edition, replaces });
|
||||
|
||||
module.exports = { SUBCLASS_LEVEL, SUBCLASSES, subclassesOf, findSubclass, subclassName, defaultSubclass, newerVersionOf, subclassSummary };
|
||||
@@ -0,0 +1,171 @@
|
||||
// Monk subclasses: the monastic traditions. The shape is described in index.js.
|
||||
|
||||
const MONK = [
|
||||
{
|
||||
key: 'OPEN_HAND',
|
||||
name: 'Way of the Open Hand',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Pure martial arts: knocks down, pushes away, heals itself.',
|
||||
features: {
|
||||
3: [{ name: 'Open Hand Technique', text: 'Flurry hits can knock a target prone (Dexterity save), push it 15 ft (Strength save) or stop its reactions.' }],
|
||||
6: [{ name: 'Wholeness of Body', text: 'Once per long rest, heal 3 × your level as an action.' }],
|
||||
11: [{ name: 'Tranquility', text: 'After a long rest you are under Sanctuary until your next long rest or until you attack or cast on someone.' }],
|
||||
17: [{ name: 'Quivering Palm', text: '3 ki on an unarmed hit: set up vibrations. Later, as an action, end them: the target makes a Constitution save or drops to 0 HP; on a success it takes 10d10 necrotic.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SHADOW',
|
||||
name: 'Way of Shadow',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A ninja: steps between shadows and strikes unseen.',
|
||||
features: {
|
||||
3: [{ name: 'Shadow Arts', text: 'Way of Shadow. 2 ki: cast Darkness, Darkvision, Pass without Trace or Silence without a slot. You can also make a small illusion at will.' }],
|
||||
6: [{ name: 'Shadow Step', text: 'Bonus action, while in dim light or darkness: teleport up to 60 ft to another dim or dark spot you can see, and have advantage on your first melee attack this turn.' }],
|
||||
11: [{ name: 'Cloak of Shadows', text: 'Action, in dim light or darkness: turn invisible until you attack, cast or step into bright light.' }],
|
||||
17: [{ name: 'Opportunist', text: 'Reaction: when a creature within 5 ft is hit by someone else’s attack, make a melee attack against it.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'FOUR_ELEMENTS',
|
||||
name: 'Way of the Four Elements',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Bends fire, water, earth and air with ki.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Elemental Attunement', text: 'Way of the Four Elements. Action: a harmless trick of fire, water, earth or air within 30 ft (light or snuff a flame, chill or warm, shape a little earth or mist).' },
|
||||
{ name: 'Elemental Disciplines', text: 'Choose one with your DM: Fangs of the Fire Snake (1 ki: your unarmed reach is 10 ft this turn and hits deal fire damage; +1 ki for +1d10 fire), Fist of Unbroken Air (2 ki: one creature within 30 ft takes 3d10 bludgeoning, is pushed 20 ft and knocked prone; Strength save for half and no push), Water Whip (2 ki: one creature within 30 ft takes 3d10 bludgeoning and is pulled 25 ft or knocked prone; Dexterity save for half), Shape the Flowing River (1 ki: reshape or freeze water or ice in a 30 ft cube).' },
|
||||
],
|
||||
6: [{ name: 'Elemental Disciplines', text: 'Choose one more with your DM (two in all). New at this level: Gong of the Summit (3 ki: cast Shatter), Clench of the North Wind (3 ki: cast Hold Person).' }],
|
||||
11: [{ name: 'Elemental Disciplines', text: 'Choose one more with your DM (three in all). New at this level: Flames of the Phoenix (4 ki: cast Fireball), Eternal Mountain Defense (5 ki: cast Stoneskin on yourself).', upgrade: true }],
|
||||
17: [{ name: 'Elemental Disciplines', text: 'Choose one more with your DM (four in all). New at this level: Breath of Winter (6 ki: cast Cone of Cold), River of Hungry Flame (5 ki: cast Wall of Fire), Wave of Rolling Earth (6 ki: cast Wall of Stone).', upgrade: true }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ASCENDANT_DRAGON',
|
||||
name: 'Way of the Ascendant Dragon',
|
||||
source: 'Fizban’s Treasury of Dragons',
|
||||
edition: '2014',
|
||||
summary: 'Draconic breath and wings, channelled through ki.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Draconic Disciple', text: 'Way of the Ascendant Dragon. Your unarmed strikes can deal acid, cold, fire, lightning or poison damage; you can reroll a failed Intimidation or Persuasion check once per long rest.' },
|
||||
{ name: 'Breath of the Dragon', text: 'Replace one attack with a 20 ft cone or 30 ft line of draconic breath: two martial arts dice of your element (Dexterity save for half). Free a few times per long rest (proficiency bonus), then 2 ki.' },
|
||||
],
|
||||
6: [{ name: 'Wings Unfurled', text: 'When you use Step of the Wind, sprout wings and fly until the end of the turn (a few times per long rest).' }],
|
||||
11: [{ name: 'Aspect of the Wyrm', text: 'Bonus action, 3 ki: a 10 ft aura of frightful presence or elemental resistance for a minute.' }],
|
||||
17: [{ name: 'Ascendant Aspect', text: 'Blindsight 10 ft; Breath of the Dragon uses three dice; when you hit with the breath, deal extra damage to creatures in your aura.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ASTRAL_SELF',
|
||||
name: 'Way of the Astral Self',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Summons spectral arms, then a whole astral body, to fight.',
|
||||
features: {
|
||||
3: [{ name: 'Arms of the Astral Self', text: 'Way of the Astral Self. Bonus action, 1 ki: spectral arms for 10 minutes. Wisdom replaces Strength for checks and saves, your reach is 10 ft, and arm strikes deal force using Dexterity or Wisdom.' }],
|
||||
6: [{ name: 'Visage of the Astral Self', text: 'Bonus action, 1 ki: an astral mask for 10 minutes: darkvision 120 ft, advantage on Insight and Intimidation, and you can speak to one creature alone.' }],
|
||||
11: [{ name: 'Body of the Astral Self', text: 'With arms and visage summoned: reaction, reduce acid, cold, fire, force, lightning or thunder damage by 1d10 + Wisdom modifier; once per turn an arm hit deals +1 martial arts die.' }],
|
||||
17: [{ name: 'Awakened Astral Self', text: 'Bonus action, 5 ki: your whole astral self appears for 10 minutes: +2 AC, and three arm attacks when you take the Attack action.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'DRUNKEN_MASTER',
|
||||
name: 'Way of the Drunken Master',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Sways, staggers and somehow never gets hit.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Bonus Proficiencies', text: 'Way of the Drunken Master. Treat Performance as proficient, and you are proficient with brewer’s supplies.' },
|
||||
{ name: 'Drunken Technique', text: 'When you use Flurry of Blows, you also gain Disengage and +10 ft speed this turn.' },
|
||||
],
|
||||
6: [{ name: 'Tipsy Sway', text: 'Leap to Your Feet: standing up costs only 5 ft. Redirect Attack: reaction, 1 ki, when a creature misses you, turn its attack onto another creature next to you.' }],
|
||||
11: [{ name: 'Drunkard’s Luck', text: '2 ki: cancel disadvantage on a check, attack or save.' }],
|
||||
17: [{ name: 'Intoxicated Frenzy', text: 'When you use Flurry of Blows, make up to three extra attacks, each against a different creature.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'KENSEI',
|
||||
name: 'Way of the Kensei',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A weapon master who makes chosen weapons part of their martial arts.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Path of the Kensei', text: 'Way of the Kensei. Choose two kensei weapons with your DM (one melee, one ranged): they count as monk weapons. Agile Parry: after an unarmed strike, +2 AC while holding a kensei weapon. Kensei’s Shot: bonus action, +1d4 to ranged kensei weapon damage this turn.' },
|
||||
],
|
||||
6: [
|
||||
{ name: 'One with the Blade', text: 'Your kensei weapons count as magical.' },
|
||||
{ name: 'Deft Strike', text: 'Once per turn, 1 ki on a kensei weapon hit: add a martial arts die to the damage.' },
|
||||
],
|
||||
11: [{ name: 'Sharpen the Blade', text: 'Bonus action, up to 3 ki: a kensei weapon gains that much to attack and damage for a minute.' }],
|
||||
17: [{ name: 'Unerring Accuracy', text: 'Once per turn, reroll a missed monk weapon attack.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'LONG_DEATH',
|
||||
name: 'Way of the Long Death',
|
||||
source: 'Sword Coast Adventurer’s Guide',
|
||||
edition: '2014',
|
||||
summary: 'Studies death itself: feeds on the dying and refuses to fall.',
|
||||
features: {
|
||||
3: [{ name: 'Touch of Death', text: 'Way of the Long Death. When you reduce a creature within 5 ft to 0 HP, gain Temp HP equal to your Wisdom modifier + your level.' }],
|
||||
6: [{ name: 'Hour of Reaping', text: 'Action: creatures within 30 ft that can see you make a Wisdom save or are frightened of you until your next turn.' }],
|
||||
11: [{ name: 'Mastery of Death', text: 'When you drop to 0 HP, spend 1 ki to drop to 1 HP instead.' }],
|
||||
17: [{ name: 'Touch of the Long Death', text: 'Action, 1 to 10 ki: touch a creature; it takes 2d10 necrotic per ki (Constitution save for half).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'MERCY',
|
||||
name: 'Way of Mercy',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'A masked healer whose touch mends friends and harms foes.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Implements of Mercy', text: 'Way of Mercy. Treat Insight and Medicine as proficient, and you are proficient with an herbalism kit.' },
|
||||
{ name: 'Hand of Healing', text: '1 ki (or in place of one Flurry strike): a creature you touch heals a martial arts die + Wisdom modifier.' },
|
||||
{ name: 'Hand of Harm', text: 'Once per turn, 1 ki on an unarmed hit: +a martial arts die + Wisdom modifier necrotic.' },
|
||||
],
|
||||
6: [{ name: 'Physician’s Touch', text: 'Hand of Healing also ends a disease or a condition (blinded, deafened, paralyzed, poisoned, stunned); Hand of Harm also poisons the target until your next turn.' }],
|
||||
11: [{ name: 'Flurry of Healing and Harm', text: 'Each Flurry of Blows strike can use Hand of Healing for free, and once per turn Hand of Harm for free.' }],
|
||||
17: [{ name: 'Hand of Ultimate Mercy', text: 'Once per long rest, 5 ki: return to life a creature dead no more than 24 hours, with 4d10 + Wisdom modifier HP.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SUN_SOUL',
|
||||
name: 'Way of the Sun Soul',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Hurls bolts of radiant ki from range.',
|
||||
features: {
|
||||
3: [{ name: 'Radiant Sun Bolt', text: 'Way of the Sun Soul. A ranged spell attack (30 ft) using Dexterity: a martial arts die + Dexterity radiant. It can replace your attacks, and Flurry can be bolts for 1 ki.' }],
|
||||
6: [{ name: 'Searing Arc Strike', text: 'After the Attack action, 2 ki (+1 per extra level): cast Burning Hands as a bonus action.' }],
|
||||
11: [{ name: 'Searing Sunburst', text: 'Action: a 20 ft burst of radiance within 150 ft: 2d6 radiant (Constitution save negates), +2d6 per 1 ki spent (up to 3).' }],
|
||||
17: [{ name: 'Sun Shield', text: 'Shed bright light at will; when a creature hits you with a melee attack, it takes 5 + Wisdom modifier radiant.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'WARRIOR_OF_ELEMENTS',
|
||||
name: 'Warrior of the Elements',
|
||||
source: 'Player’s Handbook (2024)',
|
||||
edition: '2024',
|
||||
replaces: 'FOUR_ELEMENTS',
|
||||
summary: 'The 2024 elemental monk: elemental reach and bursts, simpler to run.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Elemental Attunement', text: 'Warrior of the Elements. 1 ki: for 10 minutes your unarmed reach grows 10 ft, your strikes can deal acid, cold, fire, lightning or thunder, and a hit can push or pull the target 10 ft (Strength save).' },
|
||||
{ name: 'Manipulate Elements', text: 'You know Elementalism.' },
|
||||
],
|
||||
6: [{ name: 'Elemental Burst', text: 'Action, 2 ki: a 20 ft sphere within 120 ft; three martial arts dice of an element (Dexterity save for half).' }],
|
||||
11: [{ name: 'Stride of the Elements', text: 'While attuned, you have a fly and swim speed equal to your walking speed.' }],
|
||||
17: [{ name: 'Elemental Epitome', text: 'While attuned: resistance to one element, Step of the Wind adds 20 ft and damages those you pass, and once per turn a strike deals +a martial arts die.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = MONK;
|
||||
@@ -0,0 +1,150 @@
|
||||
// Paladin subclasses: the sacred oaths. The shape is described in index.js.
|
||||
|
||||
const PALADIN = [
|
||||
{
|
||||
key: 'DEVOTION',
|
||||
name: 'Oath of Devotion',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'The shining knight: honesty, courage, a blessed blade.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Sacred Weapon', text: 'Oath of Devotion, Channel Divinity. Add your Charisma modifier to attack rolls with your weapon for 1 minute; it sheds light.' },
|
||||
{ name: 'Turn the Unholy', text: 'Channel Divinity. Fiends and undead within 30 ft make a Wisdom save or flee for 1 minute.' },
|
||||
],
|
||||
7: [{ name: 'Aura of Devotion', text: 'You and allies within 10 ft can’t be charmed.' }],
|
||||
15: [{ name: 'Purity of Spirit', text: 'You are always under Protection from Evil and Good.' }],
|
||||
20: [{ name: 'Holy Nimbus', text: 'Once per long rest, action for a minute: bright light 30 ft; enemies starting a turn in it take 10 radiant, and you have advantage on saves against fiend and undead spells.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ANCIENTS',
|
||||
name: 'Oath of the Ancients',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A green knight who guards life, light and laughter.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Nature’s Wrath', text: 'Oath of the Ancients, Channel Divinity. Spectral vines restrain a creature within 10 ft (Strength or Dexterity save; it repeats the save at the end of each of its turns).' },
|
||||
{ name: 'Turn the Faithless', text: 'Channel Divinity. Fey and fiends within 30 ft make a Wisdom save or flee for 1 minute.' },
|
||||
],
|
||||
7: [{ name: 'Aura of Warding', text: 'You and allies within 10 ft take half damage from spells.' }],
|
||||
15: [{ name: 'Undying Sentinel', text: 'Once per long rest, when you drop to 0 HP and are not killed, drop to 1 HP instead. You no longer suffer the frailty of old age.' }],
|
||||
20: [{ name: 'Elder Champion', text: 'Once per long rest, action for a minute: regain 10 HP each turn, cast paladin spells as a bonus action, and enemies within 10 ft have disadvantage on saves against your spells and Channel Divinity.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'VENGEANCE',
|
||||
name: 'Oath of Vengeance',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A relentless hunter of one sworn enemy at a time.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Vow of Enmity', text: 'Oath of Vengeance, Channel Divinity. Bonus action: for 1 minute you have advantage on attack rolls against one creature within 10 ft.' },
|
||||
{ name: 'Abjure Enemy', text: 'Channel Divinity. One creature within 60 ft makes a Wisdom save or is frightened and cannot move for 1 minute; on a success its speed is halved.' },
|
||||
],
|
||||
7: [{ name: 'Relentless Avenger', text: 'When you hit with an opportunity attack, move up to half your speed straight afterwards without provoking attacks.' }],
|
||||
15: [{ name: 'Soul of Vengeance', text: 'Reaction: when the target of your Vow of Enmity attacks, make a melee attack against it.' }],
|
||||
20: [{ name: 'Avenging Angel', text: 'Once per long rest, action for an hour: wings (fly 60 ft) and a 30 ft aura; enemies entering it make a Wisdom save or are frightened, and attacks against them have advantage.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'CONQUEST',
|
||||
name: 'Oath of Conquest',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Rules through fear: enemies who can’t run and can’t win.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Conquering Presence', text: 'Oath of Conquest, Channel Divinity. Action: creatures of your choice within 30 ft make a Wisdom save or are frightened for a minute.' },
|
||||
{ name: 'Guided Strike', text: 'Channel Divinity. Add +10 to an attack roll after seeing it.' },
|
||||
],
|
||||
7: [{ name: 'Aura of Conquest', text: 'A creature frightened of you within 10 ft has speed 0 and takes psychic damage of half your level when it starts its turn there.' }],
|
||||
15: [{ name: 'Scornful Rebuke', text: 'A creature that hits you takes psychic damage equal to your Charisma modifier.' }],
|
||||
20: [{ name: 'Invincible Conqueror', text: 'Once per long rest, action for a minute: all damage against you is halved, you attack one extra time with the Attack action, and you crit on 19–20.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'CROWN',
|
||||
name: 'Oath of the Crown',
|
||||
source: 'Sword Coast Adventurer’s Guide',
|
||||
edition: '2014',
|
||||
summary: 'A loyal protector who takes the blows meant for others.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Champion Challenge', text: 'Oath of the Crown, Channel Divinity. Bonus action: creatures of your choice within 30 ft make a Wisdom save or can’t willingly move more than 30 ft away from you.' },
|
||||
{ name: 'Turn the Tide', text: 'Channel Divinity. Bonus action: each creature of your choice within 30 ft below half HP heals 1d6 + your Charisma modifier.' },
|
||||
],
|
||||
7: [{ name: 'Divine Allegiance', text: 'Reaction: when a creature within 5 ft takes damage, take it yourself instead.' }],
|
||||
15: [{ name: 'Unyielding Spirit', text: 'Advantage on saves against being paralyzed or stunned.' }],
|
||||
20: [{ name: 'Exalted Champion', text: 'Once per long rest, action for an hour: non-magical weapon damage against you is halved, and you and allies within 30 ft have advantage on death saves and Wisdom saves.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'GLORY',
|
||||
name: 'Oath of Glory',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'A hero of legend: athletic feats and inspiring smites.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Peerless Athlete', text: 'Oath of Glory, Channel Divinity. Bonus action for 10 minutes: advantage on Athletics and Acrobatics, carry twice as much, and jump 10 ft further.' },
|
||||
{ name: 'Inspiring Smite', text: 'Channel Divinity. Right after a Divine Smite: share 2d8 + your level Temp HP among creatures within 30 ft.' },
|
||||
],
|
||||
7: [{ name: 'Aura of Alacrity', text: '+10 ft speed; allies who start their turn within 5 ft of you gain +10 ft too.' }],
|
||||
15: [{ name: 'Glorious Defense', text: 'A few times per long rest (Charisma modifier), reaction: add your Charisma modifier to the AC of someone within 10 ft against an attack; if it misses, you may attack the attacker.' }],
|
||||
20: [{ name: 'Living Legend', text: 'Once per long rest, bonus action for a minute: advantage on Charisma checks, once per turn turn a miss into a hit, and reroll a failed save.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'OATHBREAKER',
|
||||
name: 'Oathbreaker',
|
||||
source: 'Dungeon Master’s Guide',
|
||||
edition: '2014',
|
||||
summary: 'A fallen paladin who commands the undead and feeds on fear.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Control Undead', text: 'Oathbreaker, Channel Divinity. Action: an undead within 30 ft makes a Wisdom save or obeys you for 24 hours.' },
|
||||
{ name: 'Dreadful Aspect', text: 'Channel Divinity. Action: creatures of your choice within 30 ft make a Wisdom save or are frightened for a minute.' },
|
||||
],
|
||||
7: [{ name: 'Aura of Hate', text: 'You, and fiends and undead within 10 ft, add your Charisma modifier to melee weapon damage.' }],
|
||||
15: [{ name: 'Supernatural Resistance', text: 'Non-magical bludgeoning, piercing and slashing damage against you is halved.' }],
|
||||
20: [{ name: 'Dread Lord', text: 'Once per long rest, action for a minute: a 30 ft aura of gloom; enemies starting in it take 4d10 psychic, and you can attack with shadows as a bonus action (3d10 necrotic).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'REDEMPTION',
|
||||
name: 'Oath of Redemption',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Prefers peace, and makes violence hurt the one who started it.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Emissary of Peace', text: 'Oath of Redemption, Channel Divinity. +5 to Persuasion checks for 10 minutes.' },
|
||||
{ name: 'Rebuke the Violent', text: 'Channel Divinity. Reaction: when an attacker damages someone within 30 ft, it takes radiant damage equal to the damage it dealt (Wisdom save for half).' },
|
||||
],
|
||||
7: [{ name: 'Aura of the Guardian', text: 'Reaction: when a creature within 10 ft takes damage, take it yourself instead.' }],
|
||||
15: [{ name: 'Protective Spirit', text: 'At the end of each turn, if below half HP, heal 1d6 + half your level.' }],
|
||||
20: [{ name: 'Emissary of Redemption', text: 'All damage against you is halved, and a creature that damages you takes radiant damage equal to half of it (until you attack or cast on it).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'WATCHERS',
|
||||
name: 'Oath of the Watchers',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Guards the world from extraplanar intruders, alert to every threat.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Watcher’s Will', text: 'Oath of the Watchers, Channel Divinity. Up to your Charisma modifier creatures within 30 ft have advantage on Intelligence, Wisdom and Charisma saves for a minute.' },
|
||||
{ name: 'Abjure the Extraplanar', text: 'Channel Divinity. Aberrations, celestials, elementals, fey and fiends within 30 ft make a Wisdom save or are turned for a minute.' },
|
||||
],
|
||||
7: [{ name: 'Aura of the Sentinel', text: 'You and allies within 10 ft add your proficiency bonus to initiative.' }],
|
||||
15: [{ name: 'Vigilant Rebuke', text: 'Reaction: when someone within 30 ft succeeds on an Intelligence, Wisdom or Charisma save, the creature that forced it takes 2d8 + Charisma modifier force.' }],
|
||||
20: [{ name: 'Mortal Bulwark', text: 'Once per long rest, bonus action for a minute: truesight 120 ft, advantage on attacks against extraplanar creatures, and a hit can banish one (Charisma save).' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = PALADIN;
|
||||
@@ -0,0 +1,128 @@
|
||||
// Ranger subclasses: the ranger conclaves. The shape is described in index.js.
|
||||
|
||||
const RANGER = [
|
||||
{
|
||||
key: 'HUNTER',
|
||||
name: 'Hunter',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Brings down the biggest thing in the room.',
|
||||
features: {
|
||||
3: [{ name: 'Colossus Slayer', text: 'Hunter’s Prey. Once per turn, +1d8 damage to a creature that is already below its max HP.' }],
|
||||
7: [{ name: 'Escape the Horde', text: 'Defensive Tactics. Opportunity attacks against you have disadvantage.' }],
|
||||
11: [{ name: 'Volley', text: 'Multiattack. Action: make a ranged attack against every creature within 10 ft of a point you can see in range.' }],
|
||||
15: [{ name: 'Uncanny Dodge', text: 'Superior Hunter’s Defense. Reaction: halve the damage of an attack that hits you.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'BEAST_MASTER',
|
||||
name: 'Beast Master',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Fights beside an animal companion.',
|
||||
features: {
|
||||
3: [{ name: 'Ranger’s Companion', text: 'Beast Master. A beast of CR 1/4 or lower fights beside you (the DM adds it to encounters as a monster). It acts on your turn: it can move freely, and attacks, Dashes, Disengages or Helps when you use your action to tell it to. Add your proficiency bonus to its AC, attacks and damage. If it dies, bond with another over 8 hours.' }],
|
||||
7: [{ name: 'Exceptional Training', text: 'On a turn your companion does not attack, it can Dash, Disengage or Help as your bonus action. Its attacks count as magical.' }],
|
||||
11: [{ name: 'Bestial Fury', text: 'When you command your companion to attack, it can attack twice.' }],
|
||||
15: [{ name: 'Share Spells', text: 'When you cast a spell targeting yourself, it can also affect your companion if within 30 ft.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'GLOOM_STALKER',
|
||||
name: 'Gloom Stalker',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'An ambusher who owns the first round and the dark.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Dread Ambusher', text: 'Gloom Stalker. Add your Wisdom modifier to initiative (ask your DM to count it). On your first turn of a fight: +10 ft speed and one extra weapon attack, which deals +1d8 damage on a hit.' },
|
||||
{ name: 'Umbral Sight', text: 'You see in darkness to 60 ft, and creatures that rely on darkvision cannot see you in the dark.' },
|
||||
],
|
||||
7: [{ name: 'Iron Mind', text: 'Treat Wisdom saves as proficient (add your proficiency bonus by hand).' }],
|
||||
11: [{ name: 'Stalker’s Flurry', text: 'Once per turn, when you miss with a weapon attack, make another weapon attack.' }],
|
||||
15: [{ name: 'Shadowy Dodge', text: 'Reaction, when an attack without advantage targets you: give it disadvantage.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'DRAKEWARDEN',
|
||||
name: 'Drakewarden',
|
||||
source: 'Fizban’s Treasury of Dragons',
|
||||
edition: '2014',
|
||||
summary: 'Raises a drake that grows into a mount and a breath weapon.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Draconic Gift', text: 'Drakewarden. You speak Draconic and know Thaumaturgy.' },
|
||||
{ name: 'Drake Companion', text: 'Action: summon a drake (acid, cold, fire, lightning or poison) that fights beside you (the DM adds it as a monster). It acts on your turn and attacks when you command it with a bonus action; its bite deals an extra 1d6 of its element.' },
|
||||
],
|
||||
7: [{ name: 'Bond of Fang and Scale', text: 'Your drake grows Medium; you can ride it if you are Small, it can fly, you resist its element, and its bite counts as magical and deals +1d6 more.' }],
|
||||
11: [{ name: 'Drake’s Breath', text: 'Once per long rest (or by spending a 3rd-level slot), action: you or the drake exhale a 30 ft cone: 8d6 of its element (Dexterity save for half; 10d6 at level 15).' }],
|
||||
15: [{ name: 'Perfected Bond', text: 'Your drake grows Large and can carry you in flight; its bite deals +1d6 more; reaction, halve damage it takes or you take while near it.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'FEY_WANDERER',
|
||||
name: 'Fey Wanderer',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Fey charm and psychic strikes: the bewitching ranger.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Dreadful Strikes', text: 'Fey Wanderer. Once per turn per creature, a weapon hit deals +1d4 psychic (1d6 from level 11).' },
|
||||
{ name: 'Otherworldly Glamour', text: 'Add your Wisdom modifier to Charisma checks, and treat one of Deception, Performance or Persuasion as proficient.' },
|
||||
],
|
||||
7: [{ name: 'Beguiling Twist', text: 'Advantage on saves against being charmed or frightened. Reaction when someone near you resists one: another creature makes a Wisdom save or is charmed or frightened for a minute.' }],
|
||||
11: [{ name: 'Fey Reinforcements', text: 'Cast Summon Fey once per long rest without a slot or material, and you may skip concentration (lasting a minute).' }],
|
||||
15: [{ name: 'Misty Wanderer', text: 'A few times per long rest (Wisdom modifier), cast Misty Step without a slot, bringing one willing creature within 5 ft along.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'HORIZON_WALKER',
|
||||
name: 'Horizon Walker',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Guards the borders between worlds, stepping through space itself.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Detect Portal', text: 'Horizon Walker. Once per short rest, sense the distance and direction of the nearest planar portal within 1 mile.' },
|
||||
{ name: 'Planar Warrior', text: 'Bonus action: your next hit on a creature within 30 ft this turn deals all force damage and +1d8 (2d8 from level 11).' },
|
||||
],
|
||||
7: [{ name: 'Ethereal Step', text: 'Once per short rest, bonus action: step into the Ethereal Plane until the end of the turn.' }],
|
||||
11: [{ name: 'Distant Strike', text: 'When you take the Attack action, teleport 10 ft before each attack; hitting two different creatures gives one extra attack against a third.' }],
|
||||
15: [{ name: 'Spectral Defense', text: 'Reaction: halve the damage of an attack that hits you.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'MONSTER_SLAYER',
|
||||
name: 'Monster Slayer',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A specialist in one dangerous quarry at a time.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Hunter’s Sense', text: 'Monster Slayer. A few times per long rest (Wisdom modifier), action: learn a creature’s immunities, resistances and vulnerabilities.' },
|
||||
{ name: 'Slayer’s Prey', text: 'Bonus action: mark a creature within 60 ft; the first hit on it each turn deals +1d6.' },
|
||||
],
|
||||
7: [{ name: 'Supernatural Defense', text: 'When your prey forces you to save or tries to escape your grapple, add 1d6.' }],
|
||||
11: [{ name: 'Magic-User’s Nemesis', text: 'Once per short rest, reaction: a creature casting a spell or teleporting within 60 ft makes a Wisdom save or the attempt fails.' }],
|
||||
15: [{ name: 'Slayer’s Counter', text: 'Reaction when your prey forces a save: attack it; on a hit you succeed on the save automatically.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SWARMKEEPER',
|
||||
name: 'Swarmkeeper',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'A cloud of spirit insects or birds moves foes and lifts you.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Gathered Swarm', text: 'Swarmkeeper. Once per turn after a hit, your swarm either deals +1d6 piercing, pushes the target 15 ft (Strength save), or moves you 5 ft.' },
|
||||
{ name: 'Swarmkeeper Magic', text: 'You know Mage Hand, and gain extra spells from your swarm as you level.' },
|
||||
],
|
||||
7: [{ name: 'Writhing Tide', text: 'A few times per long rest (proficiency bonus), bonus action: your swarm lifts you to fly 10 ft for a minute.' }],
|
||||
11: [{ name: 'Mighty Swarm', text: 'Gathered Swarm deals 1d8, a pushed creature can also be knocked prone, and moving yourself gives you half cover.' }],
|
||||
15: [{ name: 'Swarming Dispersal', text: 'A few times per long rest (proficiency bonus), reaction when you take damage: halve it and teleport 30 ft.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = RANGER;
|
||||
@@ -0,0 +1,151 @@
|
||||
// Rogue subclasses: the roguish archetypes. The shape is described in index.js.
|
||||
|
||||
const ROGUE = [
|
||||
{
|
||||
key: 'THIEF',
|
||||
name: 'Thief',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Quick fingers, rooftops and anything that can be used.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Fast Hands', text: 'Bonus action: Sleight of Hand, pick a lock, disarm a trap or Use an Object.' },
|
||||
{ name: 'Second-Story Work', text: 'Climbing costs no extra movement; running jumps + Dexterity modifier feet.' },
|
||||
],
|
||||
9: [{ name: 'Supreme Sneak', text: 'Advantage on Stealth if you move no more than half your speed.' }],
|
||||
13: [{ name: 'Use Magic Device', text: 'Ignore class, species and level requirements on magic items.' }],
|
||||
17: [{ name: 'Thief’s Reflexes', text: 'In the first round of a fight you take two turns: one at your initiative, one at your initiative − 10.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ASSASSIN',
|
||||
name: 'Assassin',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Strikes first, and the first strike is the worst.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Assassinate', text: 'Assassin. Advantage on attacks against any creature that has not yet taken a turn in this fight, and any hit on a surprised creature is a critical hit.' },
|
||||
{ name: 'Tools of the Trade', text: 'You are practised with disguises and poisons: advantage on checks to make or use them.' },
|
||||
],
|
||||
9: [{ name: 'Infiltration Expertise', text: 'With a week and 25 gold you build a false identity with papers and a history; nobody doubts it without an obvious reason.' }],
|
||||
13: [{ name: 'Impostor', text: 'After three hours of study, mimic a person’s speech, writing and manner perfectly; advantage on checks to avoid being found out.' }],
|
||||
17: [{ name: 'Death Strike', text: 'When you hit a surprised creature, it makes a Constitution save or takes double damage.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SWASHBUCKLER',
|
||||
name: 'Swashbuckler',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A duellist: in, strike, out, and a smile.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Fancy Footwork', text: 'Swashbuckler. A creature you made a melee attack against this turn cannot make opportunity attacks against you until the turn ends.' },
|
||||
{ name: 'Rakish Audacity', text: 'Add your Charisma modifier to initiative (ask your DM to count it). You can Sneak Attack a creature with nobody else next to you or it, without needing advantage.' },
|
||||
],
|
||||
9: [{ name: 'Panache', text: 'Action: Persuasion against a creature’s Insight. A hostile one has disadvantage on attacks against anyone but you for 1 minute; any other is charmed by you for 1 minute.' }],
|
||||
13: [{ name: 'Elegant Maneuver', text: 'Bonus action: advantage on your next Acrobatics or Athletics check this turn.' }],
|
||||
17: [{ name: 'Master Duelist', text: 'Once per short rest, reroll a missed attack with advantage.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ARCANE_TRICKSTER',
|
||||
name: 'Arcane Trickster',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A thief with wizard tricks and an invisible hand.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Spellcasting', text: 'Arcane Trickster. You cast wizard spells (mostly enchantment and illusion) with Intelligence, as a third caster: the DM grants spells within the unlock schedule.' },
|
||||
{ name: 'Mage Hand Legerdemain', text: 'Your Mage Hand is invisible, and can pick locks, disarm traps and pick pockets (bonus action to control it).' },
|
||||
],
|
||||
9: [{ name: 'Magical Ambush', text: 'When you cast a spell while hidden, the target has disadvantage on its save.' }],
|
||||
13: [{ name: 'Versatile Trickster', text: 'Bonus action: your Mage Hand distracts a creature within 5 ft of it, giving you advantage on attacks against it this turn.' }],
|
||||
17: [{ name: 'Spell Thief', text: 'Once per long rest, reaction when a spell targets you: the caster makes a save; on a failure the spell fails and you can cast it for 8 hours.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'INQUISITIVE',
|
||||
name: 'Inquisitive',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A detective who reads lies and finds the weak spot.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Ear for Deceit', text: 'Inquisitive. Treat an Insight roll of 7 or lower as an 8 when sensing lies.' },
|
||||
{ name: 'Eye for Detail', text: 'Bonus action: Perception to spot a hidden creature or object, or Investigation to uncover clues.' },
|
||||
{ name: 'Insightful Fighting', text: 'Bonus action: Insight against a creature’s Deception; on a success you can Sneak Attack it without advantage for a minute.' },
|
||||
],
|
||||
9: [{ name: 'Steady Eye', text: 'Advantage on Perception and Investigation if you move no more than half your speed.' }],
|
||||
13: [{ name: 'Unerring Eye', text: 'A few times per long rest (Wisdom modifier), action: sense illusions, shapechangers and other magical deceptions within 30 ft.' }],
|
||||
17: [{ name: 'Eye for Weakness', text: 'Sneak Attack deals +3d6 against a creature you have read with Insightful Fighting.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'MASTERMIND',
|
||||
name: 'Mastermind',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A schemer who helps from afar and reads everyone.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Master of Intrigue', text: 'Mastermind. Proficient with disguise and forgery kits and one gaming set; two extra languages; mimic accents perfectly.' },
|
||||
{ name: 'Master of Tactics', text: 'Help as a bonus action, and at a range of 30 ft.' },
|
||||
],
|
||||
9: [{ name: 'Insightful Manipulator', text: 'After a minute of watching or talking, learn how a creature compares to you in two of: Intelligence, Wisdom, Charisma, class levels.' }],
|
||||
13: [{ name: 'Misdirection', text: 'Reaction: when you are attacked while a creature gives you cover, the attack targets that creature instead.' }],
|
||||
17: [{ name: 'Soul of Deceit', text: 'Your thoughts can’t be read unless you allow it, you can show false thoughts, and magic can’t compel you to tell the truth.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'PHANTOM',
|
||||
name: 'Phantom',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Walks with the dead and borrows their skills.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Whispers of the Dead', text: 'Phantom. After a rest, gain proficiency in one skill or tool of your choice until your next rest.' },
|
||||
{ name: 'Wails from the Grave', text: 'A few times per long rest (proficiency bonus), right after Sneak Attack: a second creature within 30 ft takes half your Sneak Attack dice as necrotic.' },
|
||||
],
|
||||
9: [{ name: 'Tokens of the Departed', text: 'Reaction when a creature dies within 30 ft: make a soul trinket. While you carry one you have advantage on death and Constitution saves, and can spend it on a free Wails from the Grave.' }],
|
||||
13: [{ name: 'Ghost Walk', text: 'Bonus action: become spectral for 10 minutes: fly 10 ft, pass through objects, and attacks against you have disadvantage.' }],
|
||||
17: [{ name: 'Death’s Friend', text: 'Wails from the Grave hits both creatures with necrotic damage, and you always have a soul trinket after a long rest.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SCOUT',
|
||||
name: 'Scout',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A skirmisher of the wilds, always first to move.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Skirmisher', text: 'Scout. Reaction when an enemy ends its turn within 5 ft: move half your speed without provoking.' },
|
||||
{ name: 'Survivalist', text: 'Treat Nature and Survival as proficient, with double your proficiency bonus (add by hand).' },
|
||||
],
|
||||
9: [{ name: 'Superior Mobility', text: '+10 ft to your walking speed (and climb or swim speeds).' }],
|
||||
13: [{ name: 'Ambush Master', text: 'Advantage on initiative; in the first round, the first creature you hit is easier for everyone to hit until your next turn.' }],
|
||||
17: [{ name: 'Sudden Strike', text: 'After the Attack action, make one more attack as a bonus action; it can Sneak Attack even if you already have, against a different target.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SOULKNIFE',
|
||||
name: 'Soulknife',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Psychic blades conjured from the mind, and telepathy to match.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Psionic Power', text: 'Soulknife. Psionic dice (twice your proficiency bonus, d6, growing to d12), back on a long rest. Psi-Bolstered Knack: add a die to a failed proficient check. Psychic Whispers: link minds with allies for hours.' },
|
||||
{ name: 'Psychic Blades', text: 'Manifest blades: 1d6 psychic (finesse, thrown 60 ft), and a second blade as a bonus action for 1d4.' },
|
||||
],
|
||||
9: [{ name: 'Soul Blades', text: 'Homing Strikes: add a psionic die to a missed blade attack. Psychic Teleportation: bonus action, throw a blade and teleport up to 10 × the die’s roll in feet.' }],
|
||||
13: [{ name: 'Psychic Veil', text: 'Once per long rest (or by spending a psionic die), action: invisible for an hour, until you damage or force a save.' }],
|
||||
17: [{ name: 'Rend Mind', text: 'Once per long rest (or by spending three psionic dice), when your blade Sneak Attacks: the target makes a Wisdom save or is stunned for a minute.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = ROGUE;
|
||||
@@ -0,0 +1,138 @@
|
||||
// Sorcerer subclasses: the sorcerous origins. The shape is described in index.js.
|
||||
// An origin’s first-level gifts arrive with the choice, at level 3.
|
||||
|
||||
const SORCERER = [
|
||||
{
|
||||
key: 'DRACONIC',
|
||||
name: 'Draconic Bloodline',
|
||||
source: 'Player’s Handbook',
|
||||
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: 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.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'WILD_MAGIC',
|
||||
name: 'Wild Magic',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Raw chaos: luck you can bend, and surprises you cannot.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Wild Magic Surge', text: 'Wild Magic. After you cast a sorcerer spell of 1st level or higher, the DM may have you roll a d20: on a 1, something strange happens (the DM decides, or rolls on a table of their own).' },
|
||||
{ name: 'Tides of Chaos', text: 'Once per long rest, gain advantage on one attack, check or save. The DM may give it back by calling for a Wild Magic Surge.' },
|
||||
],
|
||||
6: [{ name: 'Bend Luck', text: 'Reaction, 2 sorcery points: add or subtract 1d4 from another creature’s attack, check or save.' }],
|
||||
14: [{ name: 'Controlled Chaos', text: 'When you roll on the wild magic table, roll twice and use either.' }],
|
||||
18: [{ name: 'Spell Bombardment', text: 'Once per turn, when a spell’s damage dice roll their highest on a die, roll that die again and add it.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'STORM',
|
||||
name: 'Storm Sorcery',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Wind and lightning: never stands still for long.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Tempestuous Magic', text: 'Storm Sorcery. Bonus action, just before or after casting a spell of 1st level or higher: fly 10 ft without provoking opportunity attacks.' },
|
||||
{ name: 'Wind Speaker', text: 'You speak the tongue of air, water, earth and fire elementals.' },
|
||||
],
|
||||
6: [
|
||||
{ name: 'Heart of the Storm', text: 'You resist lightning and thunder damage. When you start casting a lightning or thunder spell of 1st level or higher, creatures of your choice within 10 ft take half your level in lightning or thunder damage.' },
|
||||
{ name: 'Storm Guide', text: 'Action: stop rain falling within 20 ft of you. Bonus action: choose which way the wind blows within 100 ft until your next turn.' },
|
||||
],
|
||||
14: [{ name: 'Storm’s Fury', text: 'Reaction when a creature hits you in melee: it takes lightning damage equal to your level and is pushed up to 20 ft (Strength save negates the push).' }],
|
||||
18: [{ name: 'Wind Soul', text: 'Immune to lightning and thunder; fly 60 ft. Once per short rest, give up to three creatures a 30 ft fly speed for an hour.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ABERRANT_MIND',
|
||||
name: 'Aberrant Mind',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'A psionic mind touched by the Far Realm.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Psionic Spells', text: 'Aberrant Mind. Learn psionic spells as you level (Arms of Hadar, Dissonant Whispers, Mind Sliver and more; the DM grants them).' },
|
||||
{ name: 'Telepathic Speech', text: 'Bonus action: speak mind to mind with a creature within 30 ft for minutes equal to your level.' },
|
||||
],
|
||||
6: [
|
||||
{ name: 'Psionic Sorcery', text: 'Cast a psionic spell with sorcery points equal to its level instead of a slot, with no words or gestures.' },
|
||||
{ name: 'Psychic Defenses', text: 'Psychic damage against you is halved; advantage on saves against being charmed or frightened.' },
|
||||
],
|
||||
14: [{ name: 'Revelation in Flesh', text: 'Bonus action, 1+ sorcery points, for 10 minutes: see invisible things, fly, breathe and swim underwater, or squeeze through tiny gaps.' }],
|
||||
18: [{ name: 'Warping Implosion', text: 'Once per long rest (or 5 sorcery points), action: teleport 120 ft; creatures within 30 ft of where you were take 3d10 force and are pulled in (Strength save for half).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'CLOCKWORK_SOUL',
|
||||
name: 'Clockwork Soul',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'The cosmic order of Mechanus balances luck and shields friends.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Clockwork Magic', text: 'Clockwork Soul. Learn order-themed spells as you level (Alarm, Protection from Evil and Good, Aid…; the DM grants them).' },
|
||||
{ name: 'Restore Balance', text: 'A few times per long rest (proficiency bonus), reaction: cancel advantage or disadvantage on a roll within 60 ft.' },
|
||||
],
|
||||
6: [{ name: 'Bastion of Law', text: 'Action, 1 to 5 sorcery points: a ward of that many d8s on a creature within 30 ft, which it spends to reduce damage.' }],
|
||||
14: [{ name: 'Trance of Order', text: 'Once per long rest (or 5 sorcery points), bonus action for a minute: attacks against you lack advantage, and your attack rolls, checks and saves of 9 or lower count as 10.' }],
|
||||
18: [{ name: 'Clockwork Cavalcade', text: 'Once per long rest (or 7 sorcery points), action: a 30 ft cube of spirits restores 100 HP among those inside, repairs objects and ends spells of 6th level or lower.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'DIVINE_SOUL',
|
||||
name: 'Divine Soul',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A god’s spark: sorcery with the cleric’s spell list.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Divine Magic', text: 'Divine Soul. You can learn cleric spells as sorcerer spells, and gain one affinity spell (Cure Wounds, Inflict Wounds, Bless, Bane or Protection from Evil and Good).' },
|
||||
{ name: 'Favored by the Gods', text: 'Once per short rest, add 2d4 to a failed save or missed attack.' },
|
||||
],
|
||||
6: [{ name: 'Empowered Healing', text: '1 sorcery point: reroll healing dice for you or an ally within 5 ft.' }],
|
||||
14: [{ name: 'Otherworldly Wings', text: 'Bonus action: sprout feathered or bat-like wings and fly 30 ft until dismissed.' }],
|
||||
18: [{ name: 'Unearthly Recovery', text: 'Once per long rest, bonus action while below half HP: regain HP equal to half your maximum.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'LUNAR_SORCERY',
|
||||
name: 'Lunar Sorcery',
|
||||
source: 'Dragonlance: Shadow of the Dragon Queen',
|
||||
edition: '2014',
|
||||
summary: 'Draws power from the moon’s phases: full, new and crescent.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Lunar Embodiment', text: 'Lunar Sorcery. Learn moon spells as you level, and choose a phase after each long rest (full, new or crescent); once per long rest cast that phase’s 1st-level spell without a slot.' },
|
||||
{ name: 'Moon Fire', text: 'You know Sacred Flame, and can target two creatures within 5 ft of each other with it.' },
|
||||
],
|
||||
6: [{ name: 'Lunar Boons', text: 'A few times per long rest (proficiency bonus), reduce a spell’s sorcery-point cost for the current phase’s school by 1.' }],
|
||||
14: [{ name: 'Waxing and Waning', text: 'Bonus action, 1 sorcery point: change your phase.' }],
|
||||
18: [{ name: 'Lunar Phenomenon', text: 'Once per long rest, bonus action: unleash your phase (full: blinding light; new: dark cloud and invisibility; crescent: teleport and resistance).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SHADOW_MAGIC',
|
||||
name: 'Shadow Magic',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Shadowfell-touched: darkness you can see through and a hound of ill omen.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Eyes of the Dark', text: 'Shadow Magic. Darkvision 120 ft; cast Darkness with 2 sorcery points and see through it.' },
|
||||
{ name: 'Strength of the Grave', text: 'Once per long rest, when you drop to 0 HP (not from radiant or a crit), make a Charisma save to drop to 1 HP instead.' },
|
||||
],
|
||||
6: [{ name: 'Hound of Ill Omen', text: 'Bonus action, 3 sorcery points: a shadow hound hunts a creature within 120 ft (the DM adds it); while it is near, the target has disadvantage on saves against your spells.' }],
|
||||
14: [{ name: 'Shadow Walk', text: 'Bonus action, in dim light or darkness: teleport up to 120 ft to another dim or dark spot.' }],
|
||||
18: [{ name: 'Umbral Form', text: 'Bonus action, 6 sorcery points, for a minute: become shadow, resisting all damage but force and radiant, and move through creatures and objects.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = SORCERER;
|
||||
@@ -0,0 +1,140 @@
|
||||
// Warlock subclasses: the otherworldly patrons. The shape is described in index.js.
|
||||
// A patron’s first-level gifts arrive with the choice, at level 3.
|
||||
|
||||
const WARLOCK = [
|
||||
{
|
||||
key: 'FIEND',
|
||||
name: 'The Fiend',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A pact with something from the hells: power fed by the fallen.',
|
||||
features: {
|
||||
3: [{ name: 'Dark One’s Blessing', text: 'The Fiend. When you bring a hostile creature to 0 HP, gain Temp HP equal to your Charisma modifier + your level (the DM adds it).' }],
|
||||
6: [{ name: 'Patron’s Luck', text: 'Once per short rest, add a d10 to an ability check or saving throw after seeing the roll.' }],
|
||||
10: [{ name: 'Patron’s Resilience', text: 'After each rest, choose one damage type: you resist it until you choose another.' }],
|
||||
14: [{ name: 'Hurl Through Hell', text: 'Once per long rest, when you hit a creature: it vanishes into the lower planes until your next turn and takes 10d10 psychic (unless it is a fiend).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ARCHFEY',
|
||||
name: 'The Archfey',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'A fey lord’s favourite: glamour, vanishing and beguilement.',
|
||||
features: {
|
||||
3: [{ name: 'Fey Presence', text: 'The Archfey. Once per short rest, action: each creature in a 10 ft cube from you makes a Wisdom save or is charmed or frightened (your choice) until the end of your next turn.' }],
|
||||
6: [{ name: 'Misty Escape', text: 'Once per short rest, reaction when you take damage: turn invisible and teleport up to 60 ft. You stay invisible until your next turn, or until you attack or cast.' }],
|
||||
10: [{ name: 'Beguiling Defenses', text: 'You can’t be charmed. When a creature tries, turn it back: it makes a Wisdom save or is charmed by you for 1 minute.' }],
|
||||
14: [{ name: 'Dark Delirium', text: 'Once per short rest, action: a creature within 60 ft makes a Wisdom save or is lost in an illusory realm for a minute, charmed or frightened by you.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'GREAT_OLD_ONE',
|
||||
name: 'The Great Old One',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Something vast and unknowable whispers through you.',
|
||||
features: {
|
||||
3: [{ name: 'Awakened Mind', text: 'The Great Old One. Speak mind to mind with any creature you can see within 30 ft; it needs no shared language, only to know one.' }],
|
||||
6: [{ name: 'Entropic Ward', text: 'Once per short rest, reaction: an attack against you has disadvantage. If it misses, your next attack against that creature has advantage.' }],
|
||||
10: [{ name: 'Thought Shield', text: 'Nobody reads your thoughts unless you allow it. You resist psychic damage, and whoever deals it to you takes the same amount.' }],
|
||||
14: [{ name: 'Create Thrall', text: 'Action: touch an incapacitated humanoid; it is charmed by you until Remove Curse is cast, and you can speak to it telepathically on the same plane.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'CELESTIAL',
|
||||
name: 'The Celestial',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A patron of light: the warlock who heals.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Bonus Cantrips', text: 'The Celestial. You know Light and Sacred Flame.' },
|
||||
{ name: 'Healing Light', text: 'A pool of d6s equal to 1 + your level per long rest. Bonus action: heal a creature within 60 ft with up to your Charisma modifier of them.' },
|
||||
],
|
||||
6: [{ name: 'Radiant Soul', text: 'Radiant damage against you is halved; add your Charisma modifier to one radiant or fire damage roll of your spells.' }],
|
||||
10: [{ name: 'Celestial Resilience', text: 'After a rest, you gain your level + Charisma modifier Temp HP, and up to five allies gain half your level + Charisma modifier.' }],
|
||||
14: [{ name: 'Searing Vengeance', text: 'Once per long rest, when you would make a death save: stand up with half your HP, and enemies within 30 ft take 2d8 + Charisma modifier radiant and are blinded until the turn ends.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'FATHOMLESS',
|
||||
name: 'The Fathomless',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'A patron of the deep sea, with a spectral tentacle at your call.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Tentacle of the Deeps', text: 'The Fathomless. A few times per long rest (proficiency bonus), bonus action: a 10 ft spectral tentacle within 60 ft for a minute; it strikes for 1d8 cold and slows by 10 ft.' },
|
||||
{ name: 'Gift of the Sea', text: 'Swim speed 40 ft, and you can breathe underwater.' },
|
||||
],
|
||||
6: [
|
||||
{ name: 'Oceanic Soul', text: 'Cold damage against you is halved; you can speak with anything underwater.' },
|
||||
{ name: 'Guardian Coil', text: 'Reaction: your tentacle reduces damage to a creature within 10 ft of it by 1d8 (2d8 from level 10).' },
|
||||
],
|
||||
10: [{ name: 'Grasping Tentacles', text: 'Learn Evard’s Black Tentacles and cast it once per long rest without a slot; while casting it you gain Temp HP equal to your level, and damage cannot break your concentration on it.' }],
|
||||
14: [{ name: 'Fathomless Plunge', text: 'Once per short rest, action: you and up to five willing creatures within 30 ft teleport to a body of water you have seen within 1 mile.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'GENIE',
|
||||
name: 'The Genie',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'A noble genie’s vassal, with a bottle to hide in.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Genie’s Vessel', text: 'The Genie. A tiny vessel serves as your focus. Bottled Respite: hide inside it for hours. Genie’s Wrath: once per turn a hit deals extra damage equal to your proficiency bonus, of your genie’s element.' },
|
||||
],
|
||||
6: [{ name: 'Elemental Gift', text: 'Resist your genie’s element, and a few times per long rest (proficiency bonus) fly 30 ft for 10 minutes as a bonus action.' }],
|
||||
10: [{ name: 'Sanctuary Vessel', text: 'Up to five willing creatures can join you in your vessel, and those who rest there for 10 minutes gain the benefits of a short rest plus extra healing.' }],
|
||||
14: [{ name: 'Limited Wish', text: 'Once every 1d4 long rests, action: ask your patron for a 6th-level or lower spell from any list, cast without material.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'HEXBLADE',
|
||||
name: 'The Hexblade',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'A sentient weapon from the Shadowfell: the warlock who melees.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Hexblade’s Curse', text: 'The Hexblade. Once per short rest, bonus action: curse a creature within 30 ft for a minute: +proficiency bonus damage against it, crit on 19–20, and you heal your level + Charisma modifier if it dies.' },
|
||||
{ name: 'Hex Warrior', text: 'Proficient with medium armor, shields and martial weapons; one-handed (later pact) weapons can use Charisma for attack and damage.' },
|
||||
],
|
||||
6: [{ name: 'Accursed Specter', text: 'Once per long rest, when you slay a humanoid: its spirit rises as a specter under your command until your next long rest.' }],
|
||||
10: [{ name: 'Armor of Hexes', text: 'When the target of your Hexblade’s Curse hits you, roll a d6: on 4 or higher, it misses.' }],
|
||||
14: [{ name: 'Master of Hexes', text: 'When the cursed creature dies, move the curse to another creature within 30 ft (no healing from that move).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'UNDEAD',
|
||||
name: 'The Undead',
|
||||
source: 'Van Richten’s Guide to Ravenloft',
|
||||
edition: '2014',
|
||||
summary: 'A deathless patron: a terrifying form and necrotic power.',
|
||||
features: {
|
||||
3: [{ name: 'Form of Dread', text: 'The Undead. A few times per long rest (proficiency bonus), bonus action for a minute: gain 1d10 + your level Temp HP, you can’t be frightened, and once per turn a hit can frighten the target (Wisdom save).' }],
|
||||
6: [{ name: 'Grave Touched', text: 'You need no food, water or air. Once per turn, a hit can become necrotic; in Form of Dread it deals one extra die.' }],
|
||||
10: [{ name: 'Necrotic Husk', text: 'Necrotic damage against you is halved (immune in Form of Dread). Once per long rest, when you drop to 0 HP, drop to 1 and blast creatures within 30 ft with necrotic damage.' }],
|
||||
14: [{ name: 'Spirit Projection', text: 'Once per long rest, action: project your spirit for an hour; it can fly, pass through objects, and resist damage.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'UNDYING',
|
||||
name: 'The Undying',
|
||||
source: 'Sword Coast Adventurer’s Guide',
|
||||
edition: '2014',
|
||||
summary: 'A patron who cheated death, and teaches you to.',
|
||||
features: {
|
||||
3: [
|
||||
{ name: 'Among the Dead', text: 'The Undying. You know Spare the Dying; advantage on saves against disease; undead must make a Wisdom save to attack you.' },
|
||||
],
|
||||
6: [{ name: 'Defy Death', text: 'Once per long rest, when you succeed on a death save or stabilize someone with Spare the Dying, heal 1d8 + Constitution modifier.' }],
|
||||
10: [{ name: 'Undying Nature', text: 'You can hold your breath indefinitely, need no food, water or sleep, and age a tenth as fast.' }],
|
||||
14: [{ name: 'Indestructible Life', text: 'Once per short rest, bonus action: heal 1d8 + your level and reattach a severed body part.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = WARLOCK;
|
||||
@@ -0,0 +1,213 @@
|
||||
// Wizard subclasses: the arcane traditions. The shape is described in index.js.
|
||||
|
||||
const WIZARD = [
|
||||
{
|
||||
key: 'EVOCATION',
|
||||
name: 'School of Evocation',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Blasting, with friends left standing in the middle of it.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Evocation Savant', text: 'School of Evocation. Copying evocation spells costs half the time and gold.' },
|
||||
{ name: 'Sculpt Spells', text: 'When you cast an evocation spell that hits an area, choose 1 + the spell’s level creatures to automatically save and take no damage.' },
|
||||
],
|
||||
6: [{ name: 'Potent Cantrip', text: 'Creatures that save against your damaging cantrips still take half damage.' }],
|
||||
10: [{ name: 'Empowered Evocation', text: 'Add your Intelligence modifier to the damage of your evocation spells.' }],
|
||||
14: [{ name: 'Overchannel', text: 'When you cast a damaging spell of 1st to 5th level, deal maximum damage. Doing it again before a long rest costs you necrotic damage that grows each time.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ABJURATION',
|
||||
name: 'School of Abjuration',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Wards and counters: a shield of magic that takes the hits.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Abjuration Savant', text: 'School of Abjuration. Copying abjuration spells costs half the time and gold.' },
|
||||
{ name: 'Arcane Ward', text: 'When you cast an abjuration spell of 1st level or higher, a ward appears with HP of twice your level + your Intelligence modifier; it takes damage before you do (the DM tracks it as Temp HP). It lasts until a long rest; later abjuration spells restore twice their level to it.' },
|
||||
],
|
||||
6: [{ name: 'Projected Ward', text: 'Reaction: when a creature within 30 ft takes damage, your Arcane Ward takes it instead.' }],
|
||||
10: [{ name: 'Improved Abjuration', text: 'Add your proficiency bonus to ability checks made as part of an abjuration spell (Counterspell, Dispel Magic).' }],
|
||||
14: [{ name: 'Spell Resistance', text: 'Advantage on saves against spells, and spell damage against you is halved.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ILLUSION',
|
||||
name: 'School of Illusion',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Tricks of light and sound that hold up to a second look.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Illusion Savant', text: 'School of Illusion. Copying illusion spells costs half the time and gold.' },
|
||||
{ name: 'Improved Minor Illusion', text: 'You know Minor Illusion (the DM grants it), and it makes a sound and an image with one casting.' },
|
||||
],
|
||||
6: [{ name: 'Malleable Illusions', text: 'Action: change what an illusion of yours looks like while it lasts, if it lasts a minute or more.' }],
|
||||
10: [{ name: 'Illusory Self', text: 'Once per short rest, reaction when a creature attacks you: a copy of you takes the blow and the attack misses.' }],
|
||||
14: [{ name: 'Illusory Reality', text: 'Bonus action, when you cast an illusion of 1st level or higher: make one inanimate, harmless object in it real for a minute.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'BLADESINGING',
|
||||
name: 'Bladesinging',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'An elven sword-dance: a wizard who fights up close.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Training in War and Song', text: 'Bladesinging. Proficient with light armor and one one-handed melee weapon, and treat Performance as proficient.' },
|
||||
{ name: 'Bladesong', text: 'A few times per long rest (proficiency bonus), bonus action for a minute (in light or no armor): +Intelligence modifier to AC and concentration saves, +10 ft speed, advantage on Acrobatics.' },
|
||||
],
|
||||
6: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action; one attack can be a cantrip instead.' }],
|
||||
10: [{ name: 'Song of Defense', text: 'Reaction while in Bladesong: spend a spell slot to reduce damage by five times its level.' }],
|
||||
14: [{ name: 'Song of Victory', text: 'While in Bladesong, add your Intelligence modifier to melee weapon damage.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'CHRONURGY',
|
||||
name: 'Chronurgy Magic',
|
||||
source: 'Explorer’s Guide to Wildemount',
|
||||
edition: '2014',
|
||||
summary: 'Bends time: rerolls fate and freezes foes.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Chronal Shift', text: 'Chronurgy Magic. Twice per long rest, reaction: force a creature within 30 ft to reroll an attack, check or save.' },
|
||||
{ name: 'Temporal Awareness', text: 'Add your Intelligence modifier to initiative.' },
|
||||
],
|
||||
6: [{ name: 'Momentary Stasis', text: 'A few times per long rest (Intelligence modifier), action: a Large or smaller creature within 60 ft makes a Constitution save or is frozen in time until the end of your next turn.' }],
|
||||
10: [{ name: 'Arcane Abeyance', text: 'Once per short rest, cast a spell of 4th level or lower into a bead; anyone can release it later as an action.' }],
|
||||
14: [{ name: 'Convergent Future', text: 'Reaction: decide that an attack, check or save within 60 ft succeeds or fails; you gain a level of exhaustion each time.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'CONJURATION',
|
||||
name: 'School of Conjuration',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Summons, teleports and makes objects out of nothing.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Conjuration Savant', text: 'School of Conjuration. Copying conjuration spells costs half the time and gold.' },
|
||||
{ name: 'Minor Conjuration', text: 'Action: conjure a non-magical object up to 3 ft across and 10 lb, for an hour.' },
|
||||
],
|
||||
6: [{ name: 'Benign Transposition', text: 'Action: teleport 30 ft, or swap places with a willing creature; again after a long rest or casting a conjuration spell.' }],
|
||||
10: [{ name: 'Focused Conjuration', text: 'Damage can’t break your concentration on a conjuration spell.' }],
|
||||
14: [{ name: 'Durable Summons', text: 'Creatures you summon gain 30 Temp HP.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'DIVINATION',
|
||||
name: 'School of Divination',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Sees the future: replaces rolls with dice foretold at dawn.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Divination Savant', text: 'School of Divination. Copying divination spells costs half the time and gold.' },
|
||||
{ name: 'Portent', text: 'After a long rest roll two d20s and note them. You can swap any attack, check or save you can see for one of those rolls, before it is made.' },
|
||||
],
|
||||
6: [{ name: 'Expert Divination', text: 'Casting a divination spell of 2nd level or higher gives back a spell slot of a lower level (up to 5th).' }],
|
||||
10: [{ name: 'The Third Eye', text: 'Action, until your next rest: darkvision, see into the Ethereal, read any language, or see invisible things within 10 ft.' }],
|
||||
14: [{ name: 'Greater Portent', text: 'Roll three d20s for Portent instead of two.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'ENCHANTMENT',
|
||||
name: 'School of Enchantment',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Charm and compulsion: makes enemies forget, freeze or fight each other.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Enchantment Savant', text: 'School of Enchantment. Copying enchantment spells costs half the time and gold.' },
|
||||
{ name: 'Hypnotic Gaze', text: 'Action: a creature within 5 ft makes a Wisdom save or is charmed and incapacitated until the end of your next turn; you can extend it each turn.' },
|
||||
],
|
||||
6: [{ name: 'Instinctive Charm', text: 'Reaction when attacked: the attacker makes a Wisdom save or must attack someone else nearby (or waste the attack).' }],
|
||||
10: [{ name: 'Split Enchantment', text: 'An enchantment spell that targets one creature can target a second.' }],
|
||||
14: [{ name: 'Alter Memories', text: 'A creature you charm doesn’t know it was charmed, and can be made to forget up to a few hours of what happened (Intelligence save).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'GRAVITURGY',
|
||||
name: 'Graviturgy Magic',
|
||||
source: 'Explorer’s Guide to Wildemount',
|
||||
edition: '2014',
|
||||
summary: 'Commands gravity: makes things heavy, light, or pulled together.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Adjust Density', text: 'Graviturgy Magic. Action: halve or double the weight of a Large or smaller creature or object within 30 ft for a minute, changing its speed and Strength rolls.' },
|
||||
],
|
||||
6: [{ name: 'Gravity Well', text: 'When you cast a spell on a creature, you can move it 5 ft to a free space.' }],
|
||||
10: [{ name: 'Violent Attraction', text: 'A few times per long rest (Intelligence modifier), reaction: +1d10 damage to a weapon hit within 60 ft, or +2d10 to a creature’s fall damage.' }],
|
||||
14: [{ name: 'Event Horizon', text: 'Once per long rest, action for a minute: hostile creatures within 30 ft take 2d10 force each turn and have speed 0 (Strength save for half and partial speed).' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'NECROMANCY',
|
||||
name: 'School of Necromancy',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Drains life to heal itself, and raises stronger undead.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Necromancy Savant', text: 'School of Necromancy. Copying necromancy spells costs half the time and gold.' },
|
||||
{ name: 'Grim Harvest', text: 'Once per turn, when your spell kills a creature, heal twice the spell’s level (three times for necromancy).' },
|
||||
],
|
||||
6: [{ name: 'Undead Thralls', text: 'Animate Dead raises one extra corpse, and your undead have extra HP (your level) and add your proficiency bonus to damage.' }],
|
||||
10: [{ name: 'Inured to Undeath', text: 'Necrotic damage against you is halved, and your maximum HP can’t be reduced.' }],
|
||||
14: [{ name: 'Command Undead', text: 'Action: an undead within 60 ft makes a Charisma save or becomes friendly and obeys you.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'SCRIBES',
|
||||
name: 'Order of Scribes',
|
||||
source: 'Tasha’s Cauldron of Everything',
|
||||
edition: '2014',
|
||||
summary: 'Your spellbook is alive, and rewrites your spells’ damage.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Wizardly Quill', text: 'Order of Scribes. A magic quill: copy spells in 2 minutes per level, and erase writing.' },
|
||||
{ name: 'Awakened Spellbook', text: 'Your book is your focus. When you cast with a slot, change the spell’s damage type to another from your book. Once per long rest, cast a ritual at normal speed.' },
|
||||
],
|
||||
6: [{ name: 'Manifest Mind', text: 'Bonus action: a spectral mind within 60 ft; see and hear through it, and cast spells from it a few times per long rest (proficiency bonus).' }],
|
||||
10: [{ name: 'Master Scrivener', text: 'After a long rest, make a scroll of a 1st- or 2nd-level spell from your book; cast from it, it counts one level higher.' }],
|
||||
14: [{ name: 'One with the Word', text: 'Advantage on Arcana. Once per long rest, reaction when you take damage with your mind manifested: take none, and instead lose spells from your book worth 3d6 spell levels until the book is restored.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'TRANSMUTATION',
|
||||
name: 'School of Transmutation',
|
||||
source: 'Player’s Handbook',
|
||||
edition: '2014',
|
||||
summary: 'Changes matter: a philosopher’s stone and shapechanging.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Transmutation Savant', text: 'School of Transmutation. Copying transmutation spells costs half the time and gold.' },
|
||||
{ name: 'Minor Alchemy', text: 'Spend 10 minutes per cubic foot to turn wood, stone, iron, copper or silver into another of them for an hour.' },
|
||||
],
|
||||
6: [{ name: 'Transmuter’s Stone', text: 'Make a stone that grants one benefit to whoever holds it: darkvision, +10 ft speed, Constitution save proficiency, or resistance to one element.' }],
|
||||
10: [{ name: 'Shapechanger', text: 'Cast Polymorph on yourself once per short rest without a slot, as a beast of CR 1 or lower.' }],
|
||||
14: [{ name: 'Master Transmuter', text: 'Destroy your stone for a great effect: transmute an object, cure all ailments, cast Raise Dead without a slot, or restore youth.' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'WAR_MAGIC',
|
||||
name: 'War Magic',
|
||||
source: 'Xanathar’s Guide to Everything',
|
||||
edition: '2014',
|
||||
summary: 'Battle wizardry: deflects attacks and surges with power.',
|
||||
features: {
|
||||
2: [
|
||||
{ name: 'Arcane Deflection', text: 'War Magic. Reaction when hit or failing a save: +2 AC or +4 to the save; you can then cast only cantrips until your next turn.' },
|
||||
{ name: 'Tactical Wit', text: 'Add your Intelligence modifier to initiative.' },
|
||||
],
|
||||
6: [{ name: 'Power Surge', text: 'Store surges (up to your Intelligence modifier): spend one to add half your level as force damage to a spell; you gain one when you end a spell with Dispel Magic or Counterspell.' }],
|
||||
10: [{ name: 'Durable Magic', text: '+2 to AC and saves while concentrating on a spell.' }],
|
||||
14: [{ name: 'Deflecting Shroud', text: 'When you use Arcane Deflection, up to three creatures within 60 ft take force damage of half your level.' }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
module.exports = WIZARD;
|
||||
@@ -0,0 +1,105 @@
|
||||
// Dice: reading an expression ("2d6+3"), rolling it, and checking dice that
|
||||
// were rolled at the table and typed in. Used by the table's dice roller
|
||||
// (POST /campaigns/:id/rolls) and by attacks in an encounter (game/combat.js).
|
||||
// Pure but for the random source, which can be passed in for tests.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const rollDie = (sides) => crypto.randomInt(1, sides + 1);
|
||||
|
||||
const MAX_DICE = 100;
|
||||
const MAX_SIDES = 1000;
|
||||
|
||||
// Reads the dice expressions the app writes: "1d8", "2d6", "1d6+1d8",
|
||||
// "2d4+2", "1d8 - 1". Returns { dice: [{ count, sides, sign }], flat }, or null
|
||||
// for anything else (free text is allowed where a DM types dice) and for
|
||||
// something too big to be a real roll. Mirrors parseDice in the frontend's
|
||||
// lib/diceRoller.ts.
|
||||
function parseDice(expression) {
|
||||
const terms = String(expression || '')
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[−–]/g, '-')
|
||||
.toLowerCase()
|
||||
.replace(/-/g, '+-')
|
||||
.split('+')
|
||||
.filter(Boolean);
|
||||
if (terms.length === 0) return null;
|
||||
const parsed = { dice: [], flat: 0 };
|
||||
let total = 0;
|
||||
for (const term of terms) {
|
||||
const die = /^(-?)(\d*)d(\d+)$/.exec(term);
|
||||
if (die) {
|
||||
const count = Number(die[2]) || 1;
|
||||
const sides = Number(die[3]);
|
||||
total += count;
|
||||
if (total > MAX_DICE || sides < 2 || sides > MAX_SIDES) return null;
|
||||
parsed.dice.push({ count, sides, sign: die[1] ? -1 : 1 });
|
||||
} else if (/^-?\d+$/.test(term)) {
|
||||
parsed.flat += Number(term);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parsed.dice.length > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
// The dice at the start of a line of text that says more: "2d8+3" from
|
||||
// "2d8+3 fire". Null when it does not start with dice.
|
||||
function leadingDice(text) {
|
||||
const match = /^\s*((?:\d*d\d+|\d+)(?:\s*[+\-−–]\s*(?:\d*d\d+|\d+))*)/i.exec(String(text || ''));
|
||||
return match && parseDice(match[1]) ? match[1].replace(/\s+/g, '') : null;
|
||||
}
|
||||
|
||||
// Every die of a parsed expression, one entry each, in order: [{ sides, sign }].
|
||||
function diceOf(parsed) {
|
||||
return parsed.dice.flatMap((term) => Array.from({ length: term.count }, () => ({ sides: term.sides, sign: term.sign })));
|
||||
}
|
||||
|
||||
// Whether `values` could be what these dice showed: one whole number per die,
|
||||
// each on its die.
|
||||
function validValues(dice, values) {
|
||||
return Array.isArray(values) && values.length === dice.length && dice.every((die, i) => Number.isInteger(values[i]) && values[i] >= 1 && values[i] <= die.sides);
|
||||
}
|
||||
|
||||
// Rolls a parsed expression, or takes `values` (dice rolled at the table,
|
||||
// already checked with validValues) in place of rolling. Returns
|
||||
// { dice: [{ sides, value, kept, sign }], modifier, total }.
|
||||
function rollExpression(parsed, { values = null, roll = rollDie } = {}) {
|
||||
const dice = diceOf(parsed).map((die, i) => ({ sides: die.sides, value: values ? values[i] : roll(die.sides), kept: true, sign: die.sign }));
|
||||
return { dice, modifier: parsed.flat, total: dice.reduce((sum, die) => sum + die.sign * die.value, 0) + parsed.flat };
|
||||
}
|
||||
|
||||
const D20_MODES = ['NORMAL', 'ADVANTAGE', 'DISADVANTAGE'];
|
||||
|
||||
// How many d20s a mode rolls: two with advantage or disadvantage.
|
||||
const d20Count = (mode) => (mode === 'NORMAL' ? 1 : 2);
|
||||
|
||||
// A d20 plus a modifier, as the mode has it: one die, or two with the higher
|
||||
// (advantage) or lower (disadvantage) kept. `values` as in rollExpression.
|
||||
// Returns { dice, modifier, total, natural }: `natural` is the d20 that counted.
|
||||
function rollD20(modifier = 0, mode = 'NORMAL', { values = null, roll = rollDie } = {}) {
|
||||
const rolled = Array.from({ length: d20Count(mode) }, (_, i) => (values ? values[i] : roll(20)));
|
||||
const natural = mode === 'ADVANTAGE' ? Math.max(...rolled) : mode === 'DISADVANTAGE' ? Math.min(...rolled) : rolled[0];
|
||||
// Of two equal dice only the first is marked as the one kept.
|
||||
const keptIndex = rolled.indexOf(natural);
|
||||
return {
|
||||
dice: rolled.map((value, i) => ({ sides: 20, value, kept: i === keptIndex, sign: 1 })),
|
||||
modifier,
|
||||
total: natural + modifier,
|
||||
natural,
|
||||
};
|
||||
}
|
||||
|
||||
// What a critical hit does to damage dice (Campaign.critRule). DOUBLE_DICE:
|
||||
// every die is rolled twice. MAX_PLUS_ROLL: every die counts at its maximum,
|
||||
// and is rolled once more on top. Takes and returns a parsed expression's
|
||||
// roll; the flat modifier is never doubled.
|
||||
function rollCritical(parsed, critRule = 'DOUBLE_DICE', { roll = rollDie } = {}) {
|
||||
const base = diceOf(parsed);
|
||||
const first = base.map((die) => ({ sides: die.sides, value: critRule === 'MAX_PLUS_ROLL' ? die.sides : roll(die.sides), kept: true, sign: die.sign, maxed: critRule === 'MAX_PLUS_ROLL' }));
|
||||
const second = base.map((die) => ({ sides: die.sides, value: roll(die.sides), kept: true, sign: die.sign }));
|
||||
const dice = [...first, ...second];
|
||||
return { dice, modifier: parsed.flat, total: dice.reduce((sum, die) => sum + die.sign * die.value, 0) + parsed.flat };
|
||||
}
|
||||
|
||||
module.exports = { rollDie, parseDice, leadingDice, diceOf, validValues, rollExpression, rollD20, d20Count, rollCritical, D20_MODES, MAX_DICE };
|
||||
@@ -0,0 +1,77 @@
|
||||
const { parseDice, leadingDice, diceOf, validValues, rollExpression, rollD20, rollCritical } = require('./dice');
|
||||
|
||||
// A die that always shows the same face, or the faces given in turn.
|
||||
const fixed = (...faces) => {
|
||||
let i = 0;
|
||||
return () => faces[i++ % faces.length];
|
||||
};
|
||||
|
||||
describe('parseDice', () => {
|
||||
test('reads the expressions the app writes', () => {
|
||||
expect(parseDice('2d6+3')).toEqual({ dice: [{ count: 2, sides: 6, sign: 1 }], flat: 3 });
|
||||
expect(parseDice('1d6 + 1d8 − 1')).toEqual({ dice: [{ count: 1, sides: 6, sign: 1 }, { count: 1, sides: 8, sign: 1 }], flat: -1 });
|
||||
expect(parseDice('d20')).toEqual({ dice: [{ count: 1, sides: 20, sign: 1 }], flat: 0 });
|
||||
});
|
||||
|
||||
test('refuses free text, bare numbers and absurd rolls', () => {
|
||||
expect(parseDice('a big bite')).toBeNull();
|
||||
expect(parseDice('7')).toBeNull();
|
||||
expect(parseDice('101d6')).toBeNull();
|
||||
expect(parseDice('60d6+60d6')).toBeNull();
|
||||
expect(parseDice('1d1')).toBeNull();
|
||||
});
|
||||
|
||||
test('leadingDice takes the dice off the front of a line', () => {
|
||||
expect(leadingDice('2d8+3 fire')).toBe('2d8+3');
|
||||
expect(leadingDice('fire, 2d8')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rolling', () => {
|
||||
test('rolls every die and adds the flat part', () => {
|
||||
const roll = rollExpression(parseDice('2d6+1d4+2'), { roll: fixed(3, 5, 4) });
|
||||
expect(roll.dice.map((d) => [d.sides, d.value])).toEqual([[6, 3], [6, 5], [4, 4]]);
|
||||
expect(roll).toMatchObject({ modifier: 2, total: 14 });
|
||||
});
|
||||
|
||||
test('a subtracted die takes away', () => {
|
||||
expect(rollExpression(parseDice('1d8-1d4'), { roll: fixed(6, 2) }).total).toBe(4);
|
||||
});
|
||||
|
||||
test('dice rolled at the table are checked against each die, then used as they are', () => {
|
||||
const dice = diceOf(parseDice('1d8+1d6'));
|
||||
expect(validValues(dice, [8, 6])).toBe(true);
|
||||
expect(validValues(dice, [9, 1])).toBe(false);
|
||||
expect(validValues(dice, [3])).toBe(false);
|
||||
expect(validValues(dice, [3, 2.5])).toBe(false);
|
||||
expect(rollExpression(parseDice('1d8+1d6+1'), { values: [8, 6] }).total).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a d20', () => {
|
||||
test('rolls one die and adds the modifier', () => {
|
||||
expect(rollD20(5, 'NORMAL', { roll: fixed(12) })).toMatchObject({ total: 17, natural: 12, dice: [{ sides: 20, value: 12, kept: true }] });
|
||||
});
|
||||
|
||||
test('keeps the higher with advantage and the lower with disadvantage', () => {
|
||||
const adv = rollD20(2, 'ADVANTAGE', { roll: fixed(7, 15) });
|
||||
expect(adv).toMatchObject({ natural: 15, total: 17 });
|
||||
expect(adv.dice.map((d) => d.kept)).toEqual([false, true]);
|
||||
expect(rollD20(0, 'DISADVANTAGE', { roll: fixed(7, 15) }).natural).toBe(7);
|
||||
expect(rollD20(0, 'ADVANTAGE', { values: [20, 20] }).dice.map((d) => d.kept)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a critical hit', () => {
|
||||
test('rolls every damage die twice, and the flat part once', () => {
|
||||
const crit = rollCritical(parseDice('1d8+3'), 'DOUBLE_DICE', { roll: fixed(5, 7) });
|
||||
expect(crit.dice.map((d) => d.value)).toEqual([5, 7]);
|
||||
expect(crit.total).toBe(15);
|
||||
});
|
||||
|
||||
test('or takes the dice at their most and rolls them once more', () => {
|
||||
const crit = rollCritical(parseDice('2d6+1'), 'MAX_PLUS_ROLL', { roll: fixed(2, 3) });
|
||||
expect(crit.dice.map((d) => d.value)).toEqual([6, 6, 2, 3]);
|
||||
expect(crit.total).toBe(18);
|
||||
});
|
||||
});
|
||||
+70
-17
@@ -7,6 +7,7 @@ const { SUBCLASS_LEVEL, subclassesOf, findSubclass, defaultSubclass } = require(
|
||||
const { CLASS_FEATURES } = require('./data/characterOptions');
|
||||
const { CASTER_KIND } = require('./data/spellSlots');
|
||||
const { getClassSpellcastingAbility, maxSpellLevel, scaledDamageDice, MAX_GEAR_LEVEL } = require('./calculations');
|
||||
const { classLevels, classOptions, joiningFeatures } = require('./multiclass');
|
||||
|
||||
// A choice that cannot be made: the message is for whoever made it.
|
||||
class LevelUpError extends Error {}
|
||||
@@ -63,7 +64,7 @@ function owesSubclass(characterClass, level) {
|
||||
|
||||
const subclassOwedFeature = (characterClass) => ({
|
||||
name: SUBCLASS_OWED,
|
||||
text: `Choose one with your DM: ${subclassesOf(characterClass).map((subclass) => subclass.name).join(', ')}.`,
|
||||
text: `Choose one of the ${subclassesOf(characterClass).length} with your DM (the level-up checklist lists them all).`,
|
||||
});
|
||||
|
||||
// Everything a subclass has given by `level`, in the order it came.
|
||||
@@ -211,25 +212,42 @@ function subclassByDefault(characterClass, level, currentKey) {
|
||||
}
|
||||
|
||||
// The checklist for a character's next level. Takes the character with its
|
||||
// weapons loaded (each with its pool entry). `subclasses` is the campaign's
|
||||
// setting: off, there is no subclass to pick, and the class's first one is
|
||||
// taken without asking (`subclassTaken`), its features listed with the rest.
|
||||
function levelUpPlan(character, { subclasses = true } = {}) {
|
||||
// weapons and class levels loaded (each weapon with its pool entry).
|
||||
// `subclasses` is the campaign's setting: off, there is no subclass to pick,
|
||||
// and the class's first one is taken without asking (`subclassTaken`), its
|
||||
// features listed with the rest. `multiclass` (the campaign's setting) lists
|
||||
// the classes the level could go to (`classChoice`, by the final ability
|
||||
// `scores`), and `toClass` is the one it goes to: the character's first class
|
||||
// unless another is named. Features, Boons, spells and the subclass all go by
|
||||
// that class's own level.
|
||||
function levelUpPlan(character, { subclasses = true, multiclass = false, toClass = null, scores = {} } = {}) {
|
||||
const toLevel = character.level + 1;
|
||||
const taken = subclasses ? null : subclassByDefault(character.characterClass, toLevel, character.subclass);
|
||||
const entries = [
|
||||
...featuresAt(character.characterClass, toLevel, taken ? null : character.subclass),
|
||||
...(taken ? subclassFeaturesThrough(findSubclass(character.characterClass, taken), toLevel) : []),
|
||||
];
|
||||
const characterClass = toClass || character.characterClass;
|
||||
const entry = classLevels(character).find((e) => e.characterClass === characterClass) || { characterClass, level: 0, subclass: null };
|
||||
const classLevel = entry.level + 1;
|
||||
const joining = entry.level === 0;
|
||||
const taken = subclasses || joining ? null : subclassByDefault(characterClass, classLevel, entry.subclass);
|
||||
const entries = joining
|
||||
? joiningFeatures(characterClass)
|
||||
: [
|
||||
...featuresAt(characterClass, classLevel, taken ? null : entry.subclass),
|
||||
...(taken ? subclassFeaturesThrough(findSubclass(characterClass, taken), classLevel) : []),
|
||||
];
|
||||
return {
|
||||
fromLevel: character.level,
|
||||
toLevel,
|
||||
// The class taking the level, and its own level once it has.
|
||||
characterClass,
|
||||
classLevel,
|
||||
// True when this is the character's first level in this class.
|
||||
joining,
|
||||
classChoice: multiclass ? { options: classOptions(character, scores) } : null,
|
||||
// Set when a subclass has to be picked on the way to this level.
|
||||
subclass: subclasses ? subclassChoice(character.characterClass, toLevel, character.subclass) : null,
|
||||
subclass: subclasses ? subclassChoice(characterClass, classLevel, entry.subclass) : null,
|
||||
subclassTaken: taken,
|
||||
features: entries.filter((entry) => !entry.boon).map(({ name, text, upgrade }) => ({ name, text, upgrade: Boolean(upgrade) })),
|
||||
boon: entries.some((entry) => entry.boon) ? boonOptions(character.specialAbilities) : null,
|
||||
spells: spellsGainedAt(character.characterClass, toLevel),
|
||||
features: entries.filter((e) => !e.boon).map(({ name, text, upgrade }) => ({ name, text, upgrade: Boolean(upgrade) })),
|
||||
boon: entries.some((e) => e.boon) ? boonOptions(character.specialAbilities) : null,
|
||||
spells: spellsGainedAt(characterClass, classLevel),
|
||||
// The free upgrade: one weapon of standard growth goes up a level, never
|
||||
// past its holder.
|
||||
weapons: (character.weapons || [])
|
||||
@@ -242,9 +260,44 @@ function levelUpPlan(character, { subclasses = true } = {}) {
|
||||
damageDice: scaledDamageDice(cw.weapon.damageDice, cw.level, cw.weapon.damageScaling, cw.weapon.minLevel),
|
||||
nextDamageDice: scaledDamageDice(cw.weapon.damageDice, cw.level + 1, cw.weapon.damageScaling, cw.weapon.minLevel),
|
||||
})),
|
||||
// The class tables stop at level 10; past it only the numbers move.
|
||||
beyondTables: toLevel > MAX_FEATURE_LEVEL,
|
||||
// The class tables stop at level 20; past it only the numbers move.
|
||||
beyondTables: classLevel > MAX_FEATURE_LEVEL,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { LevelUpError, featuresAt, owesSubclass, subclassChoice, subclassByDefault, switchSubclass, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
|
||||
// What taking a planned level does to a character's special abilities (see
|
||||
// advance): a first level in a new class writes what that class grants at
|
||||
// level 1 and teaches; any other goes through advance at the class's own level.
|
||||
function advanceIn(specialAbilities, plan, boonKey, { key = null, chosen = false } = {}) {
|
||||
if (plan.joining) return { specialAbilities: addFeatures(specialAbilities, joiningFeatures(plan.characterClass)), armorBonus: 0, tempHp: 0 };
|
||||
return advance(specialAbilities, plan.characterClass, plan.classLevel, boonKey, { key, chosen });
|
||||
}
|
||||
|
||||
// The road from level 1 to 20 for a class (and subclass): what every level
|
||||
// gives, as the progression timeline shows it. Level 1 is what the builder
|
||||
// writes; a level that grants a Boon says so. `fromSubclass` marks the
|
||||
// subclass's own lines.
|
||||
function progression(characterClass, subclassKey = null) {
|
||||
const subclass = findSubclass(characterClass, subclassKey);
|
||||
const levels = [];
|
||||
for (let level = 1; level <= MAX_FEATURE_LEVEL; level++) {
|
||||
const own = level === 1 ? CLASS_FEATURES[characterClass] || [] : featuresAt(characterClass, level);
|
||||
const fromSubclass = subclass ? subclass.features[level] || [] : [];
|
||||
const slots = maxSpellLevel(characterClass, level);
|
||||
levels.push({
|
||||
level,
|
||||
features: [
|
||||
...own.filter((entry) => !entry.boon).map(({ name, text }) => ({ name, text, fromSubclass: false })),
|
||||
...fromSubclass.map(({ name, text }) => ({ name, text, fromSubclass: true })),
|
||||
],
|
||||
boon: own.some((entry) => entry.boon),
|
||||
// The level a subclass is chosen, when none is yet.
|
||||
choosesSubclass: !subclass && subclassesOf(characterClass).length > 0 && level === SUBCLASS_LEVEL[characterClass],
|
||||
// Set when this level opens up a new level of spells.
|
||||
unlocksSpellLevel: slots > (level === 1 ? 0 : maxSpellLevel(characterClass, level - 1)) ? slots : null,
|
||||
});
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
module.exports = { LevelUpError, progression, advanceIn, featuresAt, owesSubclass, subclassChoice, subclassByDefault, switchSubclass, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { featuresAt, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan, switchSubclass, LevelUpError } = require('./levelUp');
|
||||
const { featuresAt, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan, switchSubclass, progression, LevelUpError } = require('./levelUp');
|
||||
const { SUBCLASSES, SUBCLASS_LEVEL } = require('./data/subclasses');
|
||||
const { LEVEL_FEATURES, BOONS } = require('./data/levelFeatures');
|
||||
const { CLASSES } = require('./data/classes');
|
||||
@@ -34,8 +34,11 @@ describe('the class tables', () => {
|
||||
expect(featuresAt('FIGHTER', 5).map((f) => f.name)).toEqual(['Extra Attack']);
|
||||
});
|
||||
|
||||
test('nothing is written past level 10', () => {
|
||||
expect(featuresAt('FIGHTER', 11)).toEqual([]);
|
||||
test('the tables go on to level 20', () => {
|
||||
expect(featuresAt('FIGHTER', 11).map((f) => f.name)).toEqual(['Extra Attack']);
|
||||
expect(featuresAt('FIGHTER', 20).map((f) => f.name)).toEqual(['Extra Attack']);
|
||||
expect(featuresAt('ROGUE', 19).map((f) => f.boon || f.name)).toEqual([true, 'Sneak Attack']);
|
||||
expect(featuresAt('ARTIFICER', 7).map((f) => f.name)).toEqual(['Flash of Genius']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,7 +98,7 @@ describe('advance', () => {
|
||||
|
||||
test('with no Boon picked the line says one is owed', () => {
|
||||
const { specialAbilities, armorBonus } = advance(null, 'WIZARD', 4, undefined, { key: 'EVOCATION' });
|
||||
expect(specialAbilities).toBe('Boon (level 4) — Choose one with your DM: Weapon Mastery, Arcane Focus, Toughened, Hardy.');
|
||||
expect(specialAbilities).toBe('Boon (level 4) — Choose one with your DM: Weapon Mastery, Arcane Focus, Toughened, Hardy, Resolve.');
|
||||
expect(armorBonus).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -119,7 +122,7 @@ describe('levelUpPlan', () => {
|
||||
test('lists the features, the Boon and the weapons that can take the free upgrade', () => {
|
||||
const plan = levelUpPlan({ level: 3, characterClass: 'FIGHTER', specialAbilities: null, weapons: [held(), held({ weaponId: 'p', weapon: { name: 'Dawnblade', damageDice: '1d8', damageScaling: 2, minLevel: 1 } }), held({ weaponId: 'top', level: 4 })] });
|
||||
expect(plan).toMatchObject({ fromLevel: 3, toLevel: 4, features: [], spells: null, beyondTables: false });
|
||||
expect(plan.boon).toHaveLength(4);
|
||||
expect(plan.boon).toHaveLength(5);
|
||||
// Only standard growth, and never past the character's new level.
|
||||
expect(plan.weapons).toEqual([{ weaponId: 'w', name: 'Longsword', equipped: true, level: 1, damageDice: '1d8', nextDamageDice: '1d10' }]);
|
||||
});
|
||||
@@ -135,7 +138,7 @@ describe('levelUpPlan', () => {
|
||||
const fighter = { characterClass: 'FIGHTER', specialAbilities: null, weapons: [] };
|
||||
const plan = levelUpPlan({ ...fighter, level: 2 });
|
||||
expect(plan.subclass.level).toBe(3);
|
||||
expect(plan.subclass.options.map((o) => o.key)).toEqual(['CHAMPION', 'BATTLE_MASTER', 'SAMURAI']);
|
||||
expect(plan.subclass.options.map((o) => o.key)).toEqual(['CHAMPION', 'BATTLE_MASTER', 'SAMURAI', 'ARCANE_ARCHER', 'CAVALIER', 'ECHO_KNIGHT', 'ELDRITCH_KNIGHT', 'PSI_WARRIOR', 'PURPLE_DRAGON_KNIGHT', 'RUNE_KNIGHT']);
|
||||
expect(plan.subclass.options[0].features.map((f) => f.name)).toEqual(['Improved Critical']);
|
||||
// A wizard chooses a level earlier.
|
||||
expect(levelUpPlan({ ...fighter, characterClass: 'WIZARD', level: 1 }).subclass.level).toBe(2);
|
||||
@@ -176,21 +179,33 @@ describe('a campaign without subclasses', () => {
|
||||
});
|
||||
|
||||
describe('subclasses', () => {
|
||||
test('every class has three, each with a summary and features from its choice level to 10', () => {
|
||||
test('every class has its full catalogue, each with a summary, a book and features from its choice level up to 20', () => {
|
||||
const keys = new Set();
|
||||
for (const cls of Object.keys(CLASSES)) {
|
||||
expect(SUBCLASSES[cls]).toHaveLength(3);
|
||||
expect(SUBCLASSES[cls].length).toBeGreaterThanOrEqual(4);
|
||||
for (const subclass of SUBCLASSES[cls]) {
|
||||
expect(subclass.key && subclass.name && subclass.summary).toBeTruthy();
|
||||
expect(subclass.key && subclass.name && subclass.summary && subclass.source).toBeTruthy();
|
||||
expect(['2014', '2024']).toContain(subclass.edition);
|
||||
if (subclass.replaces) expect(SUBCLASSES[cls].map((s) => s.key)).toContain(subclass.replaces);
|
||||
const levels = Object.keys(subclass.features).map(Number);
|
||||
expect(Math.min(...levels)).toBe(SUBCLASS_LEVEL[cls]);
|
||||
expect(Math.max(...levels)).toBeLessThanOrEqual(10);
|
||||
expect(Math.max(...levels)).toBeLessThanOrEqual(20);
|
||||
expect(Math.max(...levels)).toBeGreaterThan(10); // nobody stops at the old tables
|
||||
for (const f of Object.values(subclass.features).flat()) {
|
||||
expect(f.name && f.text).toBeTruthy();
|
||||
expect(f.name).not.toMatch(/—|–|--/); // the dash is what ends a name on the sheet
|
||||
}
|
||||
// Keys are unique across classes: a multiclass character holds one per class.
|
||||
expect(keys.has(subclass.key)).toBe(false);
|
||||
keys.add(subclass.key);
|
||||
}
|
||||
expect(new Set(SUBCLASSES[cls].map((s) => s.key)).size).toBe(3);
|
||||
}
|
||||
expect(keys.size).toBe(123);
|
||||
});
|
||||
|
||||
test('the first subclass of each class is still the one it always followed', () => {
|
||||
const firsts = Object.fromEntries(Object.entries(SUBCLASSES).map(([cls, list]) => [cls, list[0].key]));
|
||||
expect(firsts).toMatchObject({ BARBARIAN: 'BERSERKER', BARD: 'LORE', CLERIC: 'LIFE', DRUID: 'LAND', FIGHTER: 'CHAMPION', MONK: 'OPEN_HAND', PALADIN: 'DEVOTION', RANGER: 'HUNTER', ROGUE: 'THIEF', SORCERER: 'DRACONIC', WARLOCK: 'FIEND', WIZARD: 'EVOCATION' });
|
||||
});
|
||||
|
||||
test('a subclass adds its features to the class\'s at each level', () => {
|
||||
@@ -203,7 +218,7 @@ describe('subclasses', () => {
|
||||
test('without one, from the level the class chooses at, a line says a subclass is owed', () => {
|
||||
expect(advance(null, 'FIGHTER', 2).specialAbilities).toBe('Action Surge — Once per short rest, take one extra action on your turn.');
|
||||
const owed = advance(null, 'FIGHTER', 3).specialAbilities;
|
||||
expect(owed).toBe('Subclass — Choose one with your DM: Champion, Battle Master, Samurai.');
|
||||
expect(owed).toBe('Subclass — Choose one of the 10 with your DM (the level-up checklist lists them all).');
|
||||
// Said once, however many levels go by.
|
||||
expect(advance(owed, 'FIGHTER', 5).specialAbilities.split('\n').filter((l) => l.startsWith('Subclass — '))).toHaveLength(1);
|
||||
});
|
||||
@@ -232,3 +247,21 @@ describe('subclasses', () => {
|
||||
expect(switchSubclass(valor, 'BARD', 6, 'VALOR', 'LORE')).not.toContain('Combat Inspiration');
|
||||
});
|
||||
});
|
||||
|
||||
describe('progression', () => {
|
||||
test('runs from level 1 to 20, with the subclass marked and the Boons and spell levels shown', () => {
|
||||
const road = progression('FIGHTER', 'CHAMPION');
|
||||
expect(road).toHaveLength(20);
|
||||
expect(road[0].features.map((f) => f.name)).toEqual(['Second Wind', 'Fighting Style']);
|
||||
expect(road[2].features).toEqual([expect.objectContaining({ name: 'Improved Critical', fromSubclass: true })]);
|
||||
expect(road[3]).toMatchObject({ boon: true, features: [] });
|
||||
expect(road[17].features.map((f) => f.name)).toEqual(['Survivor']);
|
||||
});
|
||||
|
||||
test('without a subclass, says where one is chosen; for a caster, where spell levels open', () => {
|
||||
const road = progression('WIZARD');
|
||||
expect(road[1].choosesSubclass).toBe(true);
|
||||
expect(road.map((l) => l.unlocksSpellLevel).filter(Boolean)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
||||
expect(progression('ARTIFICER')[0].unlocksSpellLevel).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// Multiclassing (the 2014 Player’s Handbook rules): a character takes a level
|
||||
// in another class instead of the one they have. Their levels add up to their
|
||||
// character level; hit dice, hit points, class features and counters go by
|
||||
// each class's own level, and spell slots by the levels of every class that
|
||||
// casts. Pure: no DB calls.
|
||||
//
|
||||
// A character's first class is Character.characterClass (with its subclass);
|
||||
// the others are CharacterClassLevel rows. The first class's own level is the
|
||||
// character level less the others'.
|
||||
|
||||
const { CLASSES } = require('./data/classes');
|
||||
const { FULL_CASTER_SLOTS, PACT_SLOTS } = require('./data/spellSlots');
|
||||
const { CLASS_FEATURES } = require('./data/characterOptions');
|
||||
const { resourceState } = require('./classResources');
|
||||
const calculations = require('./calculations');
|
||||
|
||||
// What a class asks of the scores before anyone can take a level in it (or
|
||||
// leave it for another). Each entry is a list of alternatives; every ability
|
||||
// in an alternative must be 13 or more.
|
||||
const MULTICLASS_PREREQS = {
|
||||
ARTIFICER: [['INT']],
|
||||
BARBARIAN: [['STR']],
|
||||
BARD: [['CHA']],
|
||||
CLERIC: [['WIS']],
|
||||
DRUID: [['WIS']],
|
||||
FIGHTER: [['STR'], ['DEX']],
|
||||
MONK: [['DEX', 'WIS']],
|
||||
PALADIN: [['STR', 'CHA']],
|
||||
RANGER: [['DEX', 'WIS']],
|
||||
ROGUE: [['DEX']],
|
||||
SORCERER: [['CHA']],
|
||||
WARLOCK: [['CHA']],
|
||||
WIZARD: [['INT']],
|
||||
};
|
||||
const PREREQ_SCORE = 13;
|
||||
|
||||
const ABILITY_NAMES = { STR: 'Strength', DEX: 'Dexterity', CON: 'Constitution', INT: 'Intelligence', WIS: 'Wisdom', CHA: 'Charisma' };
|
||||
|
||||
// "Strength 13 or Dexterity 13", "Dexterity 13 and Wisdom 13".
|
||||
function prereqText(characterClass) {
|
||||
return (MULTICLASS_PREREQS[characterClass] || [])
|
||||
.map((all) => all.map((ability) => `${ABILITY_NAMES[ability]} ${PREREQ_SCORE}`).join(' and '))
|
||||
.join(' or ');
|
||||
}
|
||||
|
||||
// Whether final scores ({ STR: 14, ... }) meet a class's requirement.
|
||||
function meetsPrereq(characterClass, scores) {
|
||||
return (MULTICLASS_PREREQS[characterClass] || []).some((all) => all.every((ability) => (scores[ability] || 0) >= PREREQ_SCORE));
|
||||
}
|
||||
|
||||
// What a class teaches someone who comes to it from another (the 2014
|
||||
// multiclassing table), as a line for the special abilities.
|
||||
const MULTICLASS_PROFICIENCIES = {
|
||||
ARTIFICER: 'light and medium armor, shields, thieves’ tools and tinker’s tools',
|
||||
BARBARIAN: 'shields, simple and martial weapons',
|
||||
BARD: 'light armor, one skill of your choice and one musical instrument',
|
||||
CLERIC: 'light and medium armor and shields',
|
||||
DRUID: 'light and medium armor and shields (no metal)',
|
||||
FIGHTER: 'light and medium armor, shields, simple and martial weapons',
|
||||
MONK: 'simple weapons and shortswords',
|
||||
PALADIN: 'light and medium armor, shields, simple and martial weapons',
|
||||
RANGER: 'light and medium armor, shields, simple and martial weapons, and one skill from the ranger’s list',
|
||||
ROGUE: 'light armor, one skill from the rogue’s list, and thieves’ tools',
|
||||
SORCERER: 'nothing new: a sorcerer’s magic is in the blood',
|
||||
WARLOCK: 'light armor and simple weapons',
|
||||
WIZARD: 'nothing new: a wizard’s training is the spellbook',
|
||||
};
|
||||
|
||||
// The lines a character's first level in another class writes: what the class
|
||||
// grants at level 1 (multiclassing does not bring its saves or skills), and
|
||||
// what it teaches.
|
||||
function joiningFeatures(characterClass) {
|
||||
return [
|
||||
...(CLASS_FEATURES[characterClass] || []),
|
||||
{ name: `${CLASSES[characterClass].name} Training`, text: `Multiclassing into ${CLASSES[characterClass].name} teaches ${MULTICLASS_PROFICIENCIES[characterClass]}.` },
|
||||
];
|
||||
}
|
||||
|
||||
// Every class a character has levels in, the first first:
|
||||
// [{ characterClass, level, subclass, first }]. Takes a character with its
|
||||
// classLevels (an empty list or none: a single class).
|
||||
function classLevels(character) {
|
||||
const others = [...(character.classLevels || [])]
|
||||
.filter((row) => row.level > 0 && row.characterClass !== character.characterClass)
|
||||
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
|
||||
const elsewhere = others.reduce((sum, row) => sum + row.level, 0);
|
||||
return [
|
||||
{ characterClass: character.characterClass, level: Math.max(1, (character.level || 1) - elsewhere), subclass: character.subclass || null, first: true },
|
||||
...others.map((row) => ({ characterClass: row.characterClass, level: row.level, subclass: row.subclass || null, first: false })),
|
||||
];
|
||||
}
|
||||
|
||||
// The classes a character could put their next level in: those they have
|
||||
// (always), and those whose requirement they and every class they have meet.
|
||||
// `scores` are the final ability scores.
|
||||
function classOptions(character, scores) {
|
||||
const have = classLevels(character);
|
||||
const leaving = have.filter((entry) => !meetsPrereq(entry.characterClass, scores));
|
||||
return Object.keys(CLASSES).map((characterClass) => {
|
||||
const current = have.find((entry) => entry.characterClass === characterClass);
|
||||
if (current) return { characterClass, level: current.level, eligible: true, problems: [] };
|
||||
const problems = [];
|
||||
if (!meetsPrereq(characterClass, scores)) problems.push(`${CLASSES[characterClass].name} needs ${prereqText(characterClass)}.`);
|
||||
for (const entry of leaving) problems.push(`Leaving ${CLASSES[entry.characterClass].name} for another class needs ${prereqText(entry.characterClass)}.`);
|
||||
return { characterClass, level: 0, eligible: problems.length === 0, problems };
|
||||
});
|
||||
}
|
||||
|
||||
// The character as it would be with one more level in `characterClass`: the
|
||||
// level and the class rows moved on. For working out the sheet afterwards.
|
||||
function withLevelIn(character, characterClass, subclass = undefined) {
|
||||
const next = { ...character, level: character.level + 1 };
|
||||
if (characterClass === character.characterClass) {
|
||||
if (subclass !== undefined) next.subclass = subclass;
|
||||
return next;
|
||||
}
|
||||
const rows = [...(character.classLevels || [])];
|
||||
const at = rows.findIndex((row) => row.characterClass === characterClass);
|
||||
if (at >= 0) rows[at] = { ...rows[at], level: rows[at].level + 1, ...(subclass !== undefined ? { subclass } : {}) };
|
||||
else rows.push({ characterClass, level: 1, subclass: subclass ?? null, createdAt: new Date() });
|
||||
next.classLevels = rows;
|
||||
return next;
|
||||
}
|
||||
|
||||
// Hit points: the first class's die in full at level 1, then each level the
|
||||
// average of its own class's die (rounded up), plus Constitution every level.
|
||||
function multiclassHpMax(classes, conMod) {
|
||||
let total = 0;
|
||||
classes.forEach((entry, i) => {
|
||||
const die = calculations.getClassHitDieSize(entry.characterClass);
|
||||
const average = Math.floor(die / 2) + 1;
|
||||
const levels = entry.level;
|
||||
total += i === 0 ? die + conMod + (levels - 1) * (average + conMod) : levels * (average + conMod);
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
// Hit Dice: one per level, of each class's own size. Spent ones are counted
|
||||
// together; `size` is the first class's, the one rests roll.
|
||||
function multiclassHitDice(classes, used = 0) {
|
||||
const count = classes.reduce((sum, entry) => sum + entry.level, 0);
|
||||
const spent = Math.min(Math.max(Number(used) || 0, 0), count);
|
||||
return {
|
||||
size: calculations.getClassHitDieSize(classes[0].characterClass),
|
||||
count,
|
||||
used: spent,
|
||||
remaining: count - spent,
|
||||
pools: classes.map((entry) => ({ characterClass: entry.characterClass, size: calculations.getClassHitDieSize(entry.characterClass), count: entry.level })),
|
||||
};
|
||||
}
|
||||
|
||||
// How many levels a class adds to the shared caster level: all of a full
|
||||
// caster's, half a half caster's (an artificer's rounded up), a third of a
|
||||
// casting subclass's, and none of a warlock's (pact slots stand apart).
|
||||
function casterLevelOf(entry) {
|
||||
const kind = calculations.casterKind(entry.characterClass, entry.subclass);
|
||||
if (kind === 'FULL') return entry.level;
|
||||
if (kind === 'HALF') return entry.characterClass === 'ARTIFICER' ? Math.ceil(entry.level / 2) : Math.floor(entry.level / 2);
|
||||
if (kind === 'THIRD') return Math.floor(entry.level / 3);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// The spell slots of a multiclassed character (pact slots aside): a single
|
||||
// casting class keeps its own table; two or more add up to a caster level on
|
||||
// the full caster table. A character whose only casting is a warlock's has
|
||||
// those pact slots. Null for none.
|
||||
function multiclassSpellSlots(classes) {
|
||||
const casters = classes.filter((entry) => {
|
||||
const kind = calculations.casterKind(entry.characterClass, entry.subclass);
|
||||
return kind && kind !== 'PACT';
|
||||
});
|
||||
if (casters.length === 0) {
|
||||
const warlock = classes.find((entry) => calculations.casterKind(entry.characterClass) === 'PACT');
|
||||
return warlock ? calculations.spellSlotsFor('WARLOCK', warlock.level) : null;
|
||||
}
|
||||
if (casters.length === 1) return calculations.spellSlotsFor(casters[0].characterClass, casters[0].level, casters[0].subclass);
|
||||
const casterLevel = casters.reduce((sum, entry) => sum + casterLevelOf(entry), 0);
|
||||
if (casterLevel < 1) return null;
|
||||
return { kind: 'FULL', restoresOn: 'LONG', max: [...FULL_CASTER_SLOTS[Math.min(casterLevel, FULL_CASTER_SLOTS.length) - 1]] };
|
||||
}
|
||||
|
||||
// A multiclassed warlock's pact slots, kept apart from the others' (they come
|
||||
// back on a short rest): { count, slotLevel }, or null when the warlock's are
|
||||
// the only slots (they are then the sheet's spell slots) or there is none.
|
||||
function pactSlotsOf(classes) {
|
||||
const warlock = classes.find((entry) => calculations.casterKind(entry.characterClass) === 'PACT');
|
||||
if (!warlock) return null;
|
||||
const others = classes.some((entry) => {
|
||||
const kind = calculations.casterKind(entry.characterClass, entry.subclass);
|
||||
return kind && kind !== 'PACT';
|
||||
});
|
||||
if (!others) return null;
|
||||
const [count, slotLevel] = PACT_SLOTS[Math.min(warlock.level, PACT_SLOTS.length) - 1];
|
||||
return { count, slotLevel };
|
||||
}
|
||||
|
||||
// The class counters of every class, each by its own class level. Where two
|
||||
// classes give the same counter (Channel Divinity), the larger one counts.
|
||||
function multiclassResources(classes, resourcesUsed, abilities, profBonus) {
|
||||
const byKey = new Map();
|
||||
for (const entry of classes) {
|
||||
for (const resource of resourceState({ ...entry, resourcesUsed }, abilities, profBonus)) {
|
||||
const had = byKey.get(resource.key);
|
||||
if (!had || resource.max > had.max) byKey.set(resource.key, resource);
|
||||
}
|
||||
}
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
// Added to (not replacing) the exports object: calculations.js holds it from
|
||||
// before this file has finished loading.
|
||||
Object.assign(module.exports, {
|
||||
MULTICLASS_PREREQS,
|
||||
PREREQ_SCORE,
|
||||
prereqText,
|
||||
meetsPrereq,
|
||||
joiningFeatures,
|
||||
classLevels,
|
||||
classOptions,
|
||||
withLevelIn,
|
||||
multiclassHpMax,
|
||||
multiclassHitDice,
|
||||
casterLevelOf,
|
||||
multiclassSpellSlots,
|
||||
pactSlotsOf,
|
||||
multiclassResources,
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
const {
|
||||
prereqText,
|
||||
meetsPrereq,
|
||||
classLevels,
|
||||
classOptions,
|
||||
withLevelIn,
|
||||
multiclassHpMax,
|
||||
multiclassSpellSlots,
|
||||
pactSlotsOf,
|
||||
casterLevelOf,
|
||||
} = require('./multiclass');
|
||||
const { computeCharacterSheet, spellSlotsFor } = require('./calculations');
|
||||
const { levelUpPlan, advanceIn } = require('./levelUp');
|
||||
const { MULTICLASS_PATHS } = require('./data/multiclassPaths');
|
||||
const { findSubclass } = require('./data/subclasses');
|
||||
|
||||
const scores = (over = {}) => ({ STR: 10, DEX: 10, CON: 10, INT: 10, WIS: 10, CHA: 10, ...over });
|
||||
|
||||
describe('requirements', () => {
|
||||
test('say what a class needs, with either-or where there is a choice', () => {
|
||||
expect(prereqText('FIGHTER')).toBe('Strength 13 or Dexterity 13');
|
||||
expect(prereqText('MONK')).toBe('Dexterity 13 and Wisdom 13');
|
||||
});
|
||||
|
||||
test('are met by final scores of 13 or more', () => {
|
||||
expect(meetsPrereq('FIGHTER', scores({ DEX: 13 }))).toBe(true);
|
||||
expect(meetsPrereq('PALADIN', scores({ STR: 15, CHA: 12 }))).toBe(false);
|
||||
});
|
||||
|
||||
test('apply to the class left as well as the one joined', () => {
|
||||
const wizard = { characterClass: 'WIZARD', level: 3, classLevels: [] };
|
||||
const options = classOptions(wizard, scores({ CHA: 14 })); // INT only 10: can't leave wizard
|
||||
const warlock = options.find((o) => o.characterClass === 'WARLOCK');
|
||||
expect(warlock.eligible).toBe(false);
|
||||
expect(warlock.problems.join(' ')).toMatch(/Leaving Wizard/);
|
||||
expect(options.find((o) => o.characterClass === 'WIZARD')).toMatchObject({ eligible: true, level: 3 });
|
||||
expect(classOptions(wizard, scores({ INT: 15, CHA: 14 })).find((o) => o.characterClass === 'WARLOCK').eligible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('class levels', () => {
|
||||
test('the first class has the levels the others leave', () => {
|
||||
const character = { characterClass: 'PALADIN', subclass: 'DEVOTION', level: 5, classLevels: [{ characterClass: 'WARLOCK', level: 2, subclass: null }] };
|
||||
expect(classLevels(character)).toEqual([
|
||||
{ characterClass: 'PALADIN', level: 3, subclass: 'DEVOTION', first: true },
|
||||
{ characterClass: 'WARLOCK', level: 2, subclass: null, first: false },
|
||||
]);
|
||||
expect(classLevels({ characterClass: 'BARD', level: 4 })).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a level in a class moves the character and that class on', () => {
|
||||
const character = { characterClass: 'FIGHTER', level: 2, classLevels: [] };
|
||||
const next = withLevelIn(character, 'ROGUE');
|
||||
expect(next.level).toBe(3);
|
||||
expect(classLevels(next).map((e) => [e.characterClass, e.level])).toEqual([['FIGHTER', 2], ['ROGUE', 1]]);
|
||||
expect(classLevels(withLevelIn(next, 'FIGHTER')).map((e) => e.level)).toEqual([3, 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hit points', () => {
|
||||
test('the first class’s die in full, then each level’s own class average', () => {
|
||||
// Fighter 1 (10) + Wizard 2 (4 + 4), Constitution +1 at each of 3 levels.
|
||||
expect(multiclassHpMax([{ characterClass: 'FIGHTER', level: 1 }, { characterClass: 'WIZARD', level: 2 }], 1)).toBe(10 + 8 + 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spell slots', () => {
|
||||
test('one casting class keeps its own table', () => {
|
||||
expect(multiclassSpellSlots([{ characterClass: 'FIGHTER', level: 5 }, { characterClass: 'WIZARD', level: 3 }]).max).toEqual([4, 2]);
|
||||
});
|
||||
|
||||
test('two casting classes add up to a caster level on the full table', () => {
|
||||
// Paladin 6 (3) + Sorcerer 4 (4) = caster level 7.
|
||||
expect(multiclassSpellSlots([{ characterClass: 'PALADIN', level: 6 }, { characterClass: 'SORCERER', level: 4 }]).max).toEqual([4, 3, 3, 1]);
|
||||
expect(casterLevelOf({ characterClass: 'ARTIFICER', level: 3 })).toBe(2); // rounds up
|
||||
expect(casterLevelOf({ characterClass: 'FIGHTER', level: 6, subclass: 'ELDRITCH_KNIGHT' })).toBe(2);
|
||||
});
|
||||
|
||||
test('a warlock’s pact slots stand apart', () => {
|
||||
const classes = [{ characterClass: 'SORCERER', level: 5 }, { characterClass: 'WARLOCK', level: 3 }];
|
||||
expect(multiclassSpellSlots(classes).max).toEqual([4, 3, 2]);
|
||||
expect(pactSlotsOf(classes)).toEqual({ count: 2, slotLevel: 2 });
|
||||
expect(multiclassSpellSlots([{ characterClass: 'FIGHTER', level: 4 }, { characterClass: 'WARLOCK', level: 2 }]).kind).toBe('PACT');
|
||||
});
|
||||
|
||||
test('the Eldritch Knight and Arcane Trickster cast from level 3', () => {
|
||||
expect(spellSlotsFor('FIGHTER', 3, 'ELDRITCH_KNIGHT').max).toEqual([2]);
|
||||
expect(spellSlotsFor('ROGUE', 7, 'ARCANE_TRICKSTER').max).toEqual([4, 2]);
|
||||
expect(spellSlotsFor('FIGHTER', 3, 'CHAMPION')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the sheet', () => {
|
||||
const base = { strScore: 16, dexScore: 12, conScore: 14, intScore: 10, wisScore: 10, chaScore: 14, species: 'HUMAN' };
|
||||
|
||||
test('a multiclassed character adds up hit dice, hit points, slots and counters by class', () => {
|
||||
const sheet = computeCharacterSheet({ ...base, characterClass: 'PALADIN', level: 5, classLevels: [{ characterClass: 'SORCERER', level: 2 }] });
|
||||
expect(sheet.hitDice).toMatchObject({ count: 5, pools: [{ characterClass: 'PALADIN', size: 10, count: 3 }, { characterClass: 'SORCERER', size: 6, count: 2 }] });
|
||||
expect(sheet.hp.max).toBe(10 + 2 * 6 + 2 * 4 + 5 * 2); // CON 15 (14 + 1): +2
|
||||
expect(sheet.spellSlots.levels.map((l) => l.max)).toEqual([4, 2]); // caster level 1 + 2 = 3
|
||||
expect(sheet.spellcastingByClass.map((c) => c.characterClass)).toEqual(['PALADIN', 'SORCERER']);
|
||||
expect(sheet.resources.find((r) => r.key === 'SORCERY_POINTS').max).toBe(2);
|
||||
expect(sheet.resources.find((r) => r.key === 'LAY_ON_HANDS').max).toBe(15); // 5 × paladin level 3
|
||||
});
|
||||
|
||||
test('a single class is worked out as before', () => {
|
||||
const sheet = computeCharacterSheet({ ...base, characterClass: 'FIGHTER', level: 3, subclass: 'ELDRITCH_KNIGHT' });
|
||||
expect(sheet.hitDice.pools).toBeUndefined();
|
||||
expect(sheet.spellcasting.ability).toBe('INT');
|
||||
expect(sheet.spellSlots.levels).toEqual([{ level: 1, max: 2, used: 0, remaining: 2 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('levelling up into another class', () => {
|
||||
const fighter = { characterClass: 'FIGHTER', level: 4, specialAbilities: null, weapons: [], classLevels: [] };
|
||||
|
||||
test('plans the new class at its own level 1, with what it grants and teaches', () => {
|
||||
const plan = levelUpPlan(fighter, { multiclass: true, toClass: 'ROGUE', scores: scores({ STR: 15, DEX: 14 }) });
|
||||
expect(plan).toMatchObject({ characterClass: 'ROGUE', classLevel: 1, joining: true, toLevel: 5, boon: null, subclass: null });
|
||||
expect(plan.features.map((f) => f.name)).toEqual(['Sneak Attack', 'Thieves’ Cant', 'Rogue Training']);
|
||||
expect(plan.classChoice.options.find((o) => o.characterClass === 'ROGUE').eligible).toBe(true);
|
||||
const { specialAbilities } = advanceIn(null, plan, undefined);
|
||||
expect(specialAbilities).toMatch(/^Rogue Training — /m);
|
||||
});
|
||||
|
||||
test('a class’s Boons and subclass go by its own level', () => {
|
||||
const multi = { ...fighter, level: 5, classLevels: [{ characterClass: 'ROGUE', level: 2 }] };
|
||||
const plan = levelUpPlan(multi, { multiclass: true, toClass: 'ROGUE' });
|
||||
expect(plan).toMatchObject({ classLevel: 3, joining: false });
|
||||
expect(plan.subclass.options.map((o) => o.key)).toContain('ARCANE_TRICKSTER');
|
||||
expect(levelUpPlan(multi, { multiclass: true }).characterClass).toBe('FIGHTER');
|
||||
});
|
||||
|
||||
test('without the setting there is no class to choose', () => {
|
||||
expect(levelUpPlan(fighter).classChoice).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('every suggested route names real classes and subclasses', () => {
|
||||
for (const path of MULTICLASS_PATHS) {
|
||||
expect(path.steps.length).toBeGreaterThan(0);
|
||||
for (const step of path.steps) {
|
||||
if (step.subclass) expect(findSubclass(step.class, step.subclass)).not.toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -76,3 +76,12 @@ describe('the sheet', () => {
|
||||
expect(computeCharacterSheet({ characterClass: 'ROGUE', level: 3 }).spellSlots).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the artificer', () => {
|
||||
test('is a half caster from level 1, rounding up', () => {
|
||||
expect(spellSlotsFor('ARTIFICER', 1)).toEqual({ kind: 'HALF', restoresOn: 'LONG', max: [2] });
|
||||
expect(spellSlotsFor('ARTIFICER', 5).max).toEqual([4, 2]);
|
||||
expect(maxSpellLevel('ARTIFICER', 20)).toBe(5);
|
||||
expect(spellSlotsFor('PALADIN', 1).max).toEqual([]); // a paladin still waits for level 2
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,22 @@ const STORY_PICKS = {
|
||||
appearance: { build: ['MUSCULAR'], height: ['TALL'], hair: ['RED'], eyes: ['GREEN'], complexion: ['WEATHERED'], features: ['SCAR'], style: ['CLOAK'] },
|
||||
};
|
||||
|
||||
// The body that picks a species, with the bonus choices it needs: a Half-Elf
|
||||
// by its older two picks, any other species with a choice by +2/+1.
|
||||
const FLEXIBLE_SPECIES = new Set(Object.entries(require('../game/data/species').SPECIES).filter(([, s]) => s.flexible).map(([key]) => key));
|
||||
function speciesPick(species) {
|
||||
if (species === 'HALF_ELF') return { species, halfElfBonusAbilities: ['STR', 'INT'] };
|
||||
if (FLEXIBLE_SPECIES.has(species)) return { species, bonusChoices: { STR: 2, DEX: 1 } };
|
||||
return { species };
|
||||
}
|
||||
|
||||
// Chooses a class, then (where the campaign asks) leaves the subclass for later.
|
||||
async function chooseClass(characterId, auth, characterClass) {
|
||||
const res = await request(app).post(`/characters/${characterId}/build/choose`).set(auth).send({ characterClass });
|
||||
if (res.body.buildStage !== 'CHOOSING_SUBCLASS') return res;
|
||||
return request(app).post(`/characters/${characterId}/build/choose`).set(auth).send({ subclass: null });
|
||||
}
|
||||
|
||||
// Walks a character from the special-abilities step through to equipment.
|
||||
async function storySteps(characterId, auth) {
|
||||
const choose = (body) => request(app).post(`/characters/${characterId}/build/choose`).set(auth).send(body);
|
||||
@@ -150,7 +166,7 @@ describe('campaigns + characters + build flow', () => {
|
||||
let chooseRes = await request(app)
|
||||
.post(`/characters/${characterId}/build/choose`)
|
||||
.set(auth)
|
||||
.send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
|
||||
.send(speciesPick(species));
|
||||
expect(chooseRes.status).toBe(200);
|
||||
expect(chooseRes.body.buildStage).toBe('ROLLING_CLASS');
|
||||
|
||||
@@ -162,6 +178,11 @@ describe('campaigns + characters + build flow', () => {
|
||||
const characterClass = rollRes.body.buildState.classChoices.options[0];
|
||||
chooseRes = await request(app).post(`/characters/${characterId}/build/choose`).set(auth).send({ characterClass });
|
||||
expect(chooseRes.status).toBe(200);
|
||||
expect(chooseRes.body.buildStage).toBe('CHOOSING_SUBCLASS');
|
||||
|
||||
// Step 3: the subclass, which can wait until the class chooses
|
||||
chooseRes = await request(app).post(`/characters/${characterId}/build/choose`).set(auth).send({ subclass: null });
|
||||
expect(chooseRes.status).toBe(200);
|
||||
// skipMotivationRoll was true, so motivation is skipped entirely.
|
||||
expect(chooseRes.body.buildStage).toBe('ASSIGNING_SCORES');
|
||||
|
||||
@@ -261,13 +282,13 @@ async function buildCharacter(campaignId, auth, name, { stopBeforeEquipment = fa
|
||||
await request(app)
|
||||
.post(`/characters/${characterId}/build/choose`)
|
||||
.set(auth)
|
||||
.send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
|
||||
.send(speciesPick(species));
|
||||
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
|
||||
while (rollRes.body.buildState.classChoices.type === 'reroll') {
|
||||
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
|
||||
}
|
||||
const characterClass = rollRes.body.buildState.classChoices.options[0];
|
||||
await request(app).post(`/characters/${characterId}/build/choose`).set(auth).send({ characterClass });
|
||||
await chooseClass(characterId, auth, characterClass);
|
||||
await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
|
||||
await request(app)
|
||||
.post(`/characters/${characterId}/build/choose`)
|
||||
@@ -1136,7 +1157,7 @@ describe('dice mode', () => {
|
||||
expect(modeRes.status).toBe(200);
|
||||
expect(modeRes.body.diceMode).toBe('PHYSICAL');
|
||||
|
||||
const tooHigh = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth).send({ value: 7 });
|
||||
const tooHigh = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth).send({ value: 9 });
|
||||
expect(tooHigh.status).toBe(400);
|
||||
|
||||
const validRoll = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth).send({ value: 3 });
|
||||
@@ -1150,7 +1171,7 @@ describe('dice mode', () => {
|
||||
// Physical ability scores: six sets of four d6, lowest die dropped.
|
||||
await request(app).post(`/characters/${characterId}/build/choose`).set(dmAuth).send({ species: validRoll.body.buildState.speciesChoices.options[0] });
|
||||
await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth).send({ value: 1 });
|
||||
await request(app).post(`/characters/${characterId}/build/choose`).set(dmAuth).send({ characterClass: 'FIGHTER' });
|
||||
await chooseClass(characterId, dmAuth, 'FIGHTER');
|
||||
const sets = [[6, 6, 5, 1], [5, 5, 4, 2], [4, 4, 4, 1], [3, 3, 4, 2], [6, 2, 2, 1], [5, 4, 3, 3]];
|
||||
const badSets = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth).send({ abilitySets: [[7, 1, 1, 1], ...sets.slice(1)] });
|
||||
expect(badSets.status).toBe(400);
|
||||
@@ -1171,17 +1192,18 @@ describe('story steps: special abilities, background and appearance', () => {
|
||||
expect(options.talentPicks).toBe(2);
|
||||
expect(options.background.map((g) => g.key)).toEqual(['upbringing', 'home', 'turningPoint', 'personality', 'flaw']);
|
||||
expect(options.appearance.map((g) => g.key)).toEqual(['build', 'height', 'hair', 'eyes', 'complexion', 'features', 'style']);
|
||||
expect(Object.keys(options.speciesTraits)).toHaveLength(10);
|
||||
expect(Object.keys(options.classFeatures)).toHaveLength(12);
|
||||
expect(Object.keys(options.speciesTraits)).toHaveLength(43);
|
||||
expect(Object.keys(options.classFeatures)).toHaveLength(13);
|
||||
|
||||
const id = (await request(app).post(`/campaigns/${campaign.id}/characters`).set(playerAuth).send({ name: 'Storied' })).body.character.id;
|
||||
const b = `/characters/${id}/build`;
|
||||
const post = (path, body) => request(app).post(`${b}/${path}`).set(playerAuth).send(body);
|
||||
await request(app).patch(`${b}/dice-mode`).set(playerAuth).send({ diceMode: 'PHYSICAL' });
|
||||
await post('roll', { value: 3 });
|
||||
await post('roll', { value: 2 });
|
||||
await post('choose', { species: 'DWARF' });
|
||||
await post('roll', { value: 1 });
|
||||
await post('choose', { characterClass: 'BARBARIAN' });
|
||||
await post('choose', { subclass: null });
|
||||
await post('roll', { abilitySets: [[6, 6, 5, 1], [5, 5, 4, 2], [4, 4, 4, 1], [3, 3, 4, 2], [6, 2, 2, 1], [5, 4, 3, 3]] });
|
||||
await post('choose', { assignments: { STR: 0, DEX: 1, CON: 2, INT: 3, WIS: 4, CHA: 5 } });
|
||||
expect((await post('choose', {})).body.buildStage).toBe('CHOOSING_ABILITIES');
|
||||
@@ -1246,7 +1268,7 @@ describe('story steps: special abilities, background and appearance', () => {
|
||||
describe('going back in the builder', () => {
|
||||
itIfDb('returns to earlier steps with the same rolls, lets the choice change, and never re-rolls', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('back');
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ skipMotivationRoll: false });
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ skipMotivationRoll: false, subclassesEnabled: false });
|
||||
const id = (await request(app).post(`/campaigns/${campaign.id}/characters`).set(playerAuth).send({ name: 'Undecided' })).body.character.id;
|
||||
const b = `/characters/${id}/build`;
|
||||
const post = (path, body, auth = playerAuth) => request(app).post(`${b}/${path}`).set(auth).send(body);
|
||||
@@ -1255,15 +1277,15 @@ describe('going back in the builder', () => {
|
||||
expect((await post('back')).status).toBe(400); // nothing before the first step
|
||||
await request(app).patch(`${b}/dice-mode`).set(playerAuth).send({ diceMode: 'PHYSICAL' });
|
||||
|
||||
// Species: a 3 offers Dwarf or Halfling.
|
||||
await post('roll', { value: 3 });
|
||||
// Species: a 2 offers the dwarves, gnomes and halflings.
|
||||
await post('roll', { value: 2 });
|
||||
expect((await post('roll', { value: 6 })).status).toBe(400); // one roll only
|
||||
expect(stage(await post('choose', { species: 'DWARF' }))).toBe('ROLLING_CLASS');
|
||||
|
||||
// Back before rolling for class: same species roll, and the pick can change.
|
||||
const backToSpecies = await post('back');
|
||||
expect(stage(backToSpecies)).toBe('ROLLING_SPECIES');
|
||||
expect(backToSpecies.body.buildState).toMatchObject({ speciesRoll: 3, species: 'DWARF' });
|
||||
expect(backToSpecies.body.buildState).toMatchObject({ speciesRoll: 2, species: 'DWARF' });
|
||||
expect((await post('roll', { value: 6 })).status).toBe(400); // still no re-roll
|
||||
expect((await post('choose', { species: 'ELF' })).status).toBe(400); // not offered by the roll
|
||||
await post('choose', { species: 'HALFLING' });
|
||||
@@ -1358,6 +1380,7 @@ describe('going back in the builder', () => {
|
||||
expect((await back()).body.buildStage).toBe('CHOOSING_ABILITIES');
|
||||
expect((await back()).body.buildStage).toBe('ASSIGNING_PROFICIENCIES');
|
||||
expect((await back()).body.buildStage).toBe('ASSIGNING_SCORES');
|
||||
expect((await back()).body.buildStage).toBe('CHOOSING_SUBCLASS');
|
||||
expect((await back()).body.buildStage).toBe('ROLLING_CLASS');
|
||||
expect((await back()).body.buildStage).toBe('ROLLING_SPECIES');
|
||||
expect((await back()).status).toBe(400);
|
||||
@@ -1378,14 +1401,14 @@ describe('auto-generated skill proficiencies', () => {
|
||||
await request(app)
|
||||
.post(`/characters/${characterId}/build/choose`)
|
||||
.set(dmAuth)
|
||||
.send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
|
||||
.send(speciesPick(species));
|
||||
|
||||
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
|
||||
while (rollRes.body.buildState.classChoices.type === 'reroll') {
|
||||
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
|
||||
}
|
||||
const characterClass = rollRes.body.buildState.classChoices.options[0];
|
||||
await request(app).post(`/characters/${characterId}/build/choose`).set(dmAuth).send({ characterClass });
|
||||
await chooseClass(characterId, dmAuth, characterClass);
|
||||
|
||||
await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
|
||||
const scoresRes = await request(app)
|
||||
@@ -1590,13 +1613,13 @@ describe('encounter combat info', () => {
|
||||
await request(app)
|
||||
.post(`/characters/${characterId}/build/choose`)
|
||||
.set(dmAuth)
|
||||
.send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
|
||||
.send(speciesPick(species));
|
||||
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
|
||||
while (rollRes.body.buildState.classChoices.type === 'reroll') {
|
||||
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
|
||||
}
|
||||
const characterClass = rollRes.body.buildState.classChoices.options[0];
|
||||
await request(app).post(`/characters/${characterId}/build/choose`).set(dmAuth).send({ characterClass });
|
||||
await chooseClass(characterId, dmAuth, characterClass);
|
||||
await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
|
||||
await request(app)
|
||||
.post(`/characters/${characterId}/build/choose`)
|
||||
@@ -2615,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 = {
|
||||
@@ -3038,7 +3061,8 @@ describe('levelling up', () => {
|
||||
|
||||
// Level 3 is where a fighter picks a subclass, which has to be one of theirs.
|
||||
const choice = (await request(app).get(`/characters/${characterId}/level-up`).set(dmAuth)).body.plan.subclass;
|
||||
expect(choice.options.map((o) => o.key)).toEqual(['CHAMPION', 'BATTLE_MASTER', 'SAMURAI']);
|
||||
expect(choice.options.map((o) => o.key).slice(0, 3)).toEqual(['CHAMPION', 'BATTLE_MASTER', 'SAMURAI']);
|
||||
expect(choice.options).toHaveLength(10);
|
||||
expect((await levelUp(dmAuth)).status).toBe(400);
|
||||
expect((await levelUp(dmAuth, { subclass: 'LIFE' })).status).toBe(400);
|
||||
const champion = (await levelUp(dmAuth, { subclass: 'CHAMPION' })).body.character;
|
||||
@@ -3117,7 +3141,7 @@ describe('levelling up', () => {
|
||||
expect(lines[0]).toMatch(/^Sneak Attack — .*2d6/);
|
||||
expect(lines).toContainEqual(expect.stringMatching(/^Cunning Action — /));
|
||||
expect(lines).toContainEqual(expect.stringMatching(/^Boon \(level 4\) — Choose one/));
|
||||
expect(lines).toContainEqual('Subclass — Choose one with your DM: Thief, Assassin, Swashbuckler.');
|
||||
expect(lines).toContainEqual('Subclass — Choose one of the 9 with your DM (the level-up checklist lists them all).');
|
||||
|
||||
// Going down takes nothing away, and coming back up adds nothing twice.
|
||||
await patch({ level: 1 });
|
||||
@@ -3134,10 +3158,13 @@ describe('levelling up', () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('subclass');
|
||||
const characterId = await buildAs(campaign.id, playerAuth, 'Sly', 'ROGUE');
|
||||
const patch = (auth, body) => request(app).patch(`/characters/${characterId}`).set(auth).send(body);
|
||||
const names = (res) => res.body.character.specialAbilities.split('\n').map((line) => line.split(' — ')[0]);
|
||||
const names = (res) => (res.body.character.specialAbilities || '').split('\n').map((line) => line.split(' — ')[0]);
|
||||
|
||||
// Too early, not theirs, and not the player's to set.
|
||||
expect((await patch(dmAuth, { subclass: 'THIEF' })).status).toBe(400);
|
||||
// Early is fine (the builder does it, and nothing is written yet); not theirs, or set by the player, is not.
|
||||
const early = await patch(dmAuth, { subclass: 'THIEF' });
|
||||
expect(early.status).toBe(200);
|
||||
expect(names(early)).not.toContain('Fast Hands');
|
||||
await patch(dmAuth, { subclass: null });
|
||||
await patch(dmAuth, { level: 3 });
|
||||
expect((await patch(dmAuth, { subclass: 'CHAMPION' })).status).toBe(400);
|
||||
expect((await patch(playerAuth, { subclass: 'THIEF' })).status).toBe(403);
|
||||
@@ -3497,10 +3524,10 @@ describe('campaign settings', () => {
|
||||
async function toScores(campaignId, auth) {
|
||||
const id = (await request(app).post(`/campaigns/${campaignId}/characters`).set(auth).send({ name: 'Scorer' })).body.character.id;
|
||||
const species = (await request(app).post(`/characters/${id}/build/roll`).set(auth)).body.buildState.speciesChoices.options[0];
|
||||
await request(app).post(`/characters/${id}/build/choose`).set(auth).send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
|
||||
await request(app).post(`/characters/${id}/build/choose`).set(auth).send(speciesPick(species));
|
||||
let classRoll = await request(app).post(`/characters/${id}/build/roll`).set(auth);
|
||||
while (classRoll.body.buildState.classChoices.type === 'reroll') classRoll = await request(app).post(`/characters/${id}/build/roll`).set(auth);
|
||||
await request(app).post(`/characters/${id}/build/choose`).set(auth).send({ characterClass: classRoll.body.buildState.classChoices.options[0] });
|
||||
await chooseClass(id, auth, classRoll.body.buildState.classChoices.options[0]);
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -3540,3 +3567,684 @@ describe('campaign settings', () => {
|
||||
expect(res.body.error).toMatch(/experience/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('choosing a subclass in the builder', () => {
|
||||
itIfDb('comes right after the class, can be suggested by a die, and starts giving at the class\'s level', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('buildsub');
|
||||
const options = (await request(app).get('/characters/build/options').set(playerAuth)).body;
|
||||
expect(options.subclasses.FIGHTER.map((s) => s.key)).toContain('ECHO_KNIGHT');
|
||||
expect(options.subclasses.FIGHTER[0]).toMatchObject({ key: 'CHAMPION', source: 'Player’s Handbook', edition: '2014' });
|
||||
expect(options.subclassLevels).toMatchObject({ FIGHTER: 3, WIZARD: 2, ARTIFICER: 3 });
|
||||
|
||||
const id = (await request(app).post(`/campaigns/${campaign.id}/characters`).set(playerAuth).send({ name: 'Pathfinder' })).body.character.id;
|
||||
const post = (path, body) => request(app).post(`/characters/${id}/build/${path}`).set(playerAuth).send(body);
|
||||
await request(app).patch(`/characters/${id}/build/dice-mode`).set(playerAuth).send({ diceMode: 'PHYSICAL' });
|
||||
await post('roll', { value: 3 });
|
||||
await post('choose', { species: 'DRAGONBORN' });
|
||||
await post('roll', { value: 1 });
|
||||
expect((await post('choose', { characterClass: 'FIGHTER' })).body.buildStage).toBe('CHOOSING_SUBCLASS');
|
||||
|
||||
// The die has a face per subclass, and only suggests.
|
||||
expect((await post('roll', { value: 11 })).status).toBe(400);
|
||||
const suggested = await post('roll', { value: 6 });
|
||||
expect(suggested.body.buildState).toMatchObject({ subclassRoll: 6, subclassSuggestion: 'ECHO_KNIGHT' });
|
||||
expect((await post('roll', { value: 2 })).status).toBe(400);
|
||||
expect((await post('choose', { subclass: 'LIFE' })).status).toBe(400); // a cleric's
|
||||
const chosen = await post('choose', { subclass: 'RUNE_KNIGHT' });
|
||||
expect(chosen.body.buildStage).toBe('ASSIGNING_SCORES'); // skipMotivationRoll
|
||||
|
||||
// Back to the class and a change of class lets the subclass go.
|
||||
expect((await post('back')).body.buildStage).toBe('CHOOSING_SUBCLASS');
|
||||
expect((await post('back')).body.buildStage).toBe('ROLLING_CLASS');
|
||||
const barbarian = await post('choose', { characterClass: 'BARBARIAN' });
|
||||
expect(barbarian.body.buildState.subclass).toBeUndefined();
|
||||
await post('back');
|
||||
await post('choose', { characterClass: 'FIGHTER' });
|
||||
await post('choose', { subclass: 'RUNE_KNIGHT' });
|
||||
|
||||
await post('roll', { abilitySets: [[6, 6, 5, 1], [5, 5, 4, 2], [4, 4, 4, 1], [3, 3, 4, 2], [6, 2, 2, 1], [5, 4, 3, 3]] });
|
||||
await post('choose', { assignments: { STR: 0, DEX: 1, CON: 2, INT: 3, WIS: 4, CHA: 5 } });
|
||||
await post('choose', {});
|
||||
await storySteps(id, playerAuth);
|
||||
await post('choose', { weaponIds: [], itemIds: [], spellIds: [] });
|
||||
const character = (await post('finalize')).body.character;
|
||||
expect(character).toMatchObject({ subclass: 'RUNE_KNIGHT', subclassName: 'Rune Knight', subclassLevel: 3 });
|
||||
expect(character.specialAbilities).not.toMatch(/Rune Carver/); // not yet: level 1
|
||||
|
||||
// At level 3 its gifts arrive, with nothing left to ask.
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const plan = (await request(app).get(`/characters/${id}/level-up`).set(dmAuth)).body.plan;
|
||||
expect(plan.subclass).toBeNull();
|
||||
await request(app).post(`/characters/${id}/level-up`).set(dmAuth).send({});
|
||||
}
|
||||
const atThree = (await request(app).get(`/characters/${id}`).set(playerAuth)).body.character;
|
||||
expect(atThree.level).toBe(3);
|
||||
expect(atThree.specialAbilities).toMatch(/Rune Carver/);
|
||||
expect(atThree.specialAbilities).not.toMatch(/^Subclass —/m);
|
||||
});
|
||||
|
||||
itIfDb('a 2014 subclass offers its 2024 version', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('buildsubnew');
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Totem', 'BARBARIAN', 3);
|
||||
await request(app).patch(`/characters/${id}`).set(dmAuth).send({ subclass: 'TOTEM_WARRIOR' });
|
||||
const character = (await request(app).get(`/characters/${id}`).set(playerAuth)).body.character;
|
||||
expect(character.subclassUpgrade).toMatchObject({ key: 'WILD_HEART', edition: '2024', replaces: 'TOTEM_WARRIOR' });
|
||||
expect(character.subclassOptions.length).toBe(11);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reference', () => {
|
||||
itIfDb('gives the road from level 1 to 20 for a class and subclass', async () => {
|
||||
const { playerAuth } = await setupCampaign('reference');
|
||||
const res = await request(app).get('/reference/progression?class=BARBARIAN&subclass=WORLD_TREE').set(playerAuth);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.levels).toHaveLength(20);
|
||||
expect(res.body.levels[2].features.map((f) => f.name)).toContain('Vitality of the Tree');
|
||||
expect((await request(app).get('/reference/progression?class=BARBARIAN&subclass=LIFE').set(playerAuth)).status).toBe(400);
|
||||
expect((await request(app).get('/reference/progression?class=BARBARIAN')).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiclassing', () => {
|
||||
itIfDb('takes a level in another class only where the campaign allows it and the scores meet it', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('multiclass');
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Duo', 'FIGHTER', 3);
|
||||
await prisma.character.update({ where: { id }, data: { strScore: 15, dexScore: 14, intScore: 8 } });
|
||||
const plan = (cls) => request(app).get(`/characters/${id}/level-up${cls ? `?class=${cls}` : ''}`).set(dmAuth);
|
||||
const levelUp = (body) => request(app).post(`/characters/${id}/level-up`).set(dmAuth).send(body);
|
||||
|
||||
expect((await plan('ROGUE')).status).toBe(400); // the campaign doesn't allow it
|
||||
expect((await plan()).body.plan.classChoice).toBeNull();
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ multiclassEnabled: true });
|
||||
|
||||
const wizard = await plan('WIZARD');
|
||||
expect(wizard.status).toBe(400);
|
||||
expect(wizard.body.error).toMatch(/Intelligence 13/);
|
||||
const rogue = (await plan('ROGUE')).body.plan;
|
||||
expect(rogue).toMatchObject({ characterClass: 'ROGUE', classLevel: 1, joining: true, toLevel: 4 });
|
||||
expect(rogue.classChoice.options.find((o) => o.characterClass === 'ROGUE').eligible).toBe(true);
|
||||
|
||||
const res = await levelUp({ characterClass: 'ROGUE' });
|
||||
expect(res.status).toBe(200);
|
||||
const character = res.body.character;
|
||||
expect(character.level).toBe(4);
|
||||
expect(character.classes).toEqual([
|
||||
{ characterClass: 'FIGHTER', level: 3, subclass: null, subclassName: null },
|
||||
{ characterClass: 'ROGUE', level: 1, subclass: null, subclassName: null },
|
||||
]);
|
||||
expect(character.specialAbilities).toMatch(/Rogue Training — /);
|
||||
expect(character.sheet.hitDice.pools.map((p) => p.size)).toEqual([10, 8]);
|
||||
|
||||
// The rogue's own level 3 asks for its subclass; the fighter's level goes on as before.
|
||||
await levelUp({ characterClass: 'ROGUE' });
|
||||
const third = (await plan('ROGUE')).body.plan;
|
||||
expect(third).toMatchObject({ classLevel: 3, joining: false });
|
||||
expect((await levelUp({ characterClass: 'ROGUE' })).status).toBe(400); // subclass needed
|
||||
const tricked = (await levelUp({ characterClass: 'ROGUE', subclass: 'ARCANE_TRICKSTER' })).body.character;
|
||||
expect(tricked.classes[1]).toMatchObject({ characterClass: 'ROGUE', level: 3, subclass: 'ARCANE_TRICKSTER' });
|
||||
expect(tricked.sheet.spellSlots.levels[0].max).toBe(2); // a third caster at rogue level 3
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters.find((c) => c.id === id);
|
||||
expect(listed.classes.map((c) => `${c.characterClass} ${c.level}`)).toEqual(['FIGHTER 3', 'ROGUE 3']);
|
||||
});
|
||||
|
||||
itIfDb('the reference lists each class’s requirement and the suggested routes', async () => {
|
||||
const { playerAuth } = await setupCampaign('multiref');
|
||||
const res = await request(app).get('/reference/multiclass').set(playerAuth);
|
||||
expect(res.body.prereqs.MONK.text).toBe('Dexterity 13 and Wisdom 13');
|
||||
expect(res.body.paths.map((p) => p.key)).toContain('SORLOCK');
|
||||
});
|
||||
});
|
||||
|
||||
describe('approved level-ups', () => {
|
||||
itIfDb('the DM approves for many at once; each player then takes their own level, once', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('approve');
|
||||
const ariaId = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
|
||||
const boId = await buildAs(campaign.id, dmAuth, 'Bo', 'WIZARD', 1);
|
||||
const approve = (auth, body) => request(app).post(`/campaigns/${campaign.id}/level-up`).set(auth).send(body);
|
||||
const levelUp = (id, auth, body = {}) => request(app).post(`/characters/${id}/level-up`).set(auth).send(body);
|
||||
|
||||
// Not the player's to take until it is approved, and not theirs to approve.
|
||||
expect((await request(app).get(`/characters/${ariaId}/level-up`).set(playerAuth)).status).toBe(403);
|
||||
expect((await levelUp(ariaId, playerAuth)).status).toBe(403);
|
||||
expect((await approve(playerAuth, { characterIds: [ariaId] })).status).toBe(403);
|
||||
|
||||
const approved = await approve(dmAuth, { characterIds: [ariaId, boId] });
|
||||
expect(approved.status).toBe(200);
|
||||
expect(approved.body.results.map((r) => [r.name, r.levelUpsPending, r.skipped])).toEqual([['Aria', 1, null], ['Bo', 1, null]]);
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters;
|
||||
expect(listed.find((c) => c.id === ariaId).levelUpsPending).toBe(1);
|
||||
|
||||
// The player sees the plan and takes the level; a second try has nothing approved.
|
||||
const plan = await request(app).get(`/characters/${ariaId}/level-up`).set(playerAuth);
|
||||
expect(plan.status).toBe(200);
|
||||
expect(plan.body.plan).toMatchObject({ toLevel: 2, hp: { method: 'AVERAGE', die: 10, average: 6 } });
|
||||
const taken = await levelUp(ariaId, playerAuth);
|
||||
expect(taken.status).toBe(200);
|
||||
expect(taken.body.character).toMatchObject({ level: 2, levelUpsPending: 0 });
|
||||
expect(taken.body.hpRolled).toBeNull();
|
||||
expect((await levelUp(ariaId, playerAuth)).status).toBe(403);
|
||||
// Bo is the DM's own character; another player cannot take that level.
|
||||
expect((await levelUp(boId, playerAuth)).status).toBe(403);
|
||||
|
||||
// An approval can be taken back, and stops at the top level.
|
||||
const revoked = await approve(dmAuth, { characterIds: [boId, ariaId], revoke: true });
|
||||
expect(revoked.body.results.find((r) => r.name === 'Bo')).toMatchObject({ levelUpsPending: 0, skipped: null });
|
||||
expect(revoked.body.results.find((r) => r.name === 'Aria').skipped).toMatch(/no level up waiting/);
|
||||
await prisma.character.update({ where: { id: boId }, data: { level: 20 } });
|
||||
expect((await approve(dmAuth, { characterIds: [boId] })).body.results[0].skipped).toMatch(/level 20/);
|
||||
});
|
||||
|
||||
itIfDb('an approved level is no longer shown as due', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('approvedue');
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
|
||||
await request(app).patch(`/characters/${id}`).set(dmAuth).send({ xp: 300 });
|
||||
const read = async () => (await request(app).get(`/characters/${id}`).set(playerAuth)).body.character;
|
||||
expect((await read()).xpProgress.levelUpDue).toBe(true);
|
||||
await request(app).post(`/campaigns/${campaign.id}/level-up`).set(dmAuth).send({ characterIds: [id] });
|
||||
expect(await read()).toMatchObject({ levelUpsPending: 1, xpProgress: { levelUpDue: false } });
|
||||
});
|
||||
|
||||
itIfDb('where the campaign rolls hit points, the die is rolled here or typed in from the table', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('hproll');
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ hpOnLevelUp: 'ROLL', diceMode: 'PHYSICAL' });
|
||||
const start = (await request(app).get(`/characters/${id}`).set(dmAuth)).body.character.sheet;
|
||||
const before = start.hp.max;
|
||||
const con = start.abilities.CON.modifier; // whatever the species made of it
|
||||
const levelUp = (body) => request(app).post(`/characters/${id}/level-up`).set(dmAuth).send(body);
|
||||
expect((await request(app).get(`/characters/${id}/level-up`).set(dmAuth)).body.plan.hp).toEqual({ method: 'ROLL', die: 10, average: 6, physical: true });
|
||||
|
||||
expect((await levelUp({})).status).toBe(400); // the roll has to be entered
|
||||
expect((await levelUp({ hpRoll: 11 })).status).toBe(400); // not on a d10
|
||||
const low = await levelUp({ hpRoll: 2 });
|
||||
expect(low.body.hpRolled).toBe(2);
|
||||
// 2 from the die plus Constitution, where the average would have given 6.
|
||||
expect(low.body.character.sheet.hp.max).toBe(before + 2 + con);
|
||||
|
||||
// Digital dice: the server rolls, and the sheet follows whatever came up.
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ diceMode: 'DIGITAL' });
|
||||
const rolled = await levelUp({ hpRoll: 10, subclass: 'CHAMPION' }); // a typed value is ignored
|
||||
expect(rolled.body.hpRolled).toBeGreaterThanOrEqual(1);
|
||||
expect(rolled.body.hpRolled).toBeLessThanOrEqual(10);
|
||||
expect(rolled.body.character.sheet.hp.max).toBe(before + 2 + con + rolled.body.hpRolled + con);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a player picking spells on a level up', () => {
|
||||
itIfDb('is offered the campaign spells they can cast, and held to the level’s allowance', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('playerspells');
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Mira', 'WIZARD', 1);
|
||||
const spell = async (name, level) => (await request(app).post(`/campaigns/${campaign.id}/spells`).set(dmAuth).send({ name, level })).body.spell.id;
|
||||
const [a, b, c, high] = [await spell('Alarm', 1), await spell('Shield', 1), await spell('Sleep', 1), await spell('Fireball', 3)];
|
||||
await request(app).post(`/campaigns/${campaign.id}/level-up`).set(dmAuth).send({ characterIds: [id] });
|
||||
|
||||
const plan = (await request(app).get(`/characters/${id}/level-up`).set(playerAuth)).body.plan;
|
||||
expect(plan.spells).toMatchObject({ newSpells: 2, maxSpellLevel: 1 });
|
||||
expect(plan.spellOptions.map((s) => s.name)).toEqual(['Alarm', 'Shield', 'Sleep']); // not Fireball: too high
|
||||
|
||||
const levelUp = (spellIds) => request(app).post(`/characters/${id}/level-up`).set(playerAuth).send({ subclass: 'EVOCATION', spellIds });
|
||||
expect((await levelUp([a, b, c])).status).toBe(400); // one too many
|
||||
expect((await levelUp([a, high])).status).toBe(400); // out of reach
|
||||
const done = await levelUp([a, b]);
|
||||
expect(done.status).toBe(200);
|
||||
expect(done.body.character.spells.map((s) => s.spell.name).sort()).toEqual(['Alarm', 'Shield']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dice rolls', () => {
|
||||
itIfDb('are rolled by the server, kept, and shown to whoever may see them', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('rolls');
|
||||
const roll = (auth, body) => request(app).post(`/campaigns/${campaign.id}/rolls`).set(auth).send(body);
|
||||
const seen = async (auth) => (await request(app).get(`/campaigns/${campaign.id}/rolls`).set(auth)).body.rolls;
|
||||
|
||||
const check = await roll(playerAuth, { kind: 'check', label: 'Aria: Stealth', modifier: 5, mode: 'ADVANTAGE' });
|
||||
expect(check.status).toBe(201);
|
||||
const r = check.body.roll;
|
||||
expect(r).toMatchObject({ label: 'Aria: Stealth', notation: '1d20+5', mode: 'ADVANTAGE', physical: false, hidden: false, rollerName: 'Player' });
|
||||
expect(r.dice).toHaveLength(2);
|
||||
expect(r.natural).toBe(Math.max(...r.dice.map((d) => d.value)));
|
||||
expect(r.total).toBe(r.natural + 5);
|
||||
|
||||
const damage = (await roll(playerAuth, { kind: 'dice', expression: '2d6 + 3' })).body.roll;
|
||||
expect(damage.notation).toBe('2d6+3');
|
||||
expect(damage.total).toBe(damage.dice[0].value + damage.dice[1].value + 3);
|
||||
expect((await roll(playerAuth, { kind: 'dice', expression: 'a bite' })).status).toBe(400);
|
||||
expect((await roll(playerAuth, { kind: 'dice' })).status).toBe(400);
|
||||
|
||||
// A player cannot roll behind the screen; the DM can, and only the DM sees it.
|
||||
expect((await roll(playerAuth, { kind: 'check', hidden: true })).body.roll.hidden).toBe(false);
|
||||
const secret = (await roll(dmAuth, { kind: 'check', label: 'Goblin: Stealth', hidden: true })).body.roll;
|
||||
expect(secret.hidden).toBe(true);
|
||||
expect((await seen(dmAuth)).map((x) => x.id)).toContain(secret.id);
|
||||
expect((await seen(playerAuth)).map((x) => x.id)).not.toContain(secret.id);
|
||||
expect((await seen(dmAuth))[0].id).toBe(secret.id); // newest first
|
||||
|
||||
// With rolls kept to oneself, a player sees only their own; the DM still sees all.
|
||||
const open = (await roll(dmAuth, { kind: 'check', label: 'In the open' })).body.roll;
|
||||
expect((await seen(playerAuth)).map((x) => x.id)).toContain(open.id);
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ rollVisibility: 'OWN' });
|
||||
expect((await seen(playerAuth)).every((x) => x.rollerName === 'Player')).toBe(true);
|
||||
expect((await seen(dmAuth)).length).toBeGreaterThan((await seen(playerAuth)).length);
|
||||
});
|
||||
|
||||
itIfDb('follow the campaign\u2019s dice: typed in and checked with physical dice, never typed in with digital', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('rollsmode');
|
||||
const roll = (body) => request(app).post(`/campaigns/${campaign.id}/rolls`).set(playerAuth).send(body);
|
||||
const set = (diceMode) => request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ diceMode });
|
||||
|
||||
// Both: either way.
|
||||
expect((await roll({ kind: 'check', values: [14], modifier: 2 })).body.roll).toMatchObject({ total: 16, natural: 14, physical: true });
|
||||
expect((await roll({ kind: 'check', values: [21] })).status).toBe(400);
|
||||
expect((await roll({ kind: 'check', mode: 'DISADVANTAGE', values: [14] })).status).toBe(400); // two d20s
|
||||
expect((await roll({ kind: 'check', mode: 'DISADVANTAGE', values: [14, 6] })).body.roll.natural).toBe(6);
|
||||
expect((await roll({ kind: 'dice', expression: '1d8+1d6+1', values: [8, 6] })).body.roll.total).toBe(15);
|
||||
expect((await roll({ kind: 'dice', expression: '1d8+1d6', values: [8, 7] })).status).toBe(400);
|
||||
|
||||
await set('PHYSICAL');
|
||||
expect((await roll({ kind: 'check' })).status).toBe(400);
|
||||
expect((await roll({ kind: 'check', values: [3] })).status).toBe(201);
|
||||
await set('DIGITAL');
|
||||
expect((await roll({ kind: 'check', values: [20] })).status).toBe(400);
|
||||
expect((await roll({ kind: 'check' })).status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('battle actions', () => {
|
||||
// A battle with a fighter (a longsword in hand), a wizard and two goblins,
|
||||
// the fighter on turn. Everyone's numbers are set, so the sums can be checked.
|
||||
async function battle(prefix, settings = {}) {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign(prefix);
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 3);
|
||||
const mageId = await buildAs(campaign.id, dmAuth, 'Mira', 'WIZARD', 5);
|
||||
await prisma.character.updateMany({ where: { id: { in: [heroId, mageId] } }, data: { species: 'HUMAN', strScore: 15, dexScore: 13, intScore: 15, speciesBonusChoices: {} } });
|
||||
const sword = (await request(app).post(`${c}/weapons`).set(dmAuth).send({ name: 'Longsword', damageDice: '1d8', damageType: 'slashing', attackAbility: 'STR', properties: [] })).body.weapon;
|
||||
await request(app).post(`/characters/${heroId}/weapons`).set(dmAuth).send({ weaponId: sword.id });
|
||||
await request(app).patch(`/characters/${heroId}/weapons/${sword.id}`).set(playerAuth).send({ equipped: true });
|
||||
await request(app).post(`${c}/spells/from-library`).set(dmAuth).send({ key: 'fireball' });
|
||||
await request(app).post(`${c}/spells/from-library`).set(dmAuth).send({ key: 'fire-bolt' });
|
||||
for (const spell of (await request(app).get(`${c}/spells`).set(dmAuth)).body.spells) {
|
||||
await request(app).post(`/characters/${mageId}/spells`).set(dmAuth).send({ spellId: spell.id });
|
||||
}
|
||||
const goblin = (await request(app).post(`${c}/monsters/from-library`).set(dmAuth).send({ key: 'goblin' })).body.monster;
|
||||
const e = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Ambush' })).body.encounter.id}`;
|
||||
const add = async (body) => (await request(app).post(`${e}/participants`).set(dmAuth).send(body)).body.participant;
|
||||
const hero = await add({ characterId: heroId, initiativeRoll: 20 });
|
||||
const mage = await add({ characterId: mageId, initiativeRoll: 15 });
|
||||
const one = await add({ monsterId: goblin.id, displayName: 'Goblin 1', initiativeRoll: 10 });
|
||||
const two = await add({ monsterId: goblin.id, displayName: 'Goblin 2', initiativeRoll: 5 });
|
||||
if (Object.keys(settings).length > 0) await request(app).patch(c).set(dmAuth).send(settings);
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
|
||||
const hp = async (id) => (await view(dmAuth)).participants.find((p) => p.id === id).currentHp;
|
||||
return { dmAuth, playerAuth, campaign, e, hero, mage, one, two, goblin, heroId, mageId, view, hp };
|
||||
}
|
||||
|
||||
itIfDb('a player declares an attack on their turn and enters the d20; the DM sees hit or miss, and applies it', async () => {
|
||||
const { dmAuth, playerAuth, e, one, two, mage, view, hp } = await battle('act');
|
||||
const declare = (auth, body) => request(app).post(`${e}/actions`).set(auth).send(body);
|
||||
const patch = (auth, id, body) => request(app).patch(`${e}/actions/${id}`).set(auth).send(body);
|
||||
const apply = (auth, id) => request(app).post(`${e}/actions/${id}/apply`).set(auth).send({});
|
||||
|
||||
const info = (await view(playerAuth)).participants.find((p) => p.displayName === 'Aria').characterCombatInfo;
|
||||
const sword = info.weapons[0];
|
||||
// Strength 16 (15, +1 human): +3, and proficiency +2.
|
||||
expect(sword).toMatchObject({ name: 'Longsword', attackBonus: 5, damageBonus: 3 });
|
||||
|
||||
// No target marked: nothing to aim at. A player has no "basic" attack.
|
||||
expect((await declare(playerAuth, { source: { type: 'weapon', characterWeaponId: sword.characterWeaponId } })).status).toBe(400);
|
||||
await request(app).put(`${e}/targets`).set(playerAuth).send({ participantIds: [one.id] });
|
||||
expect((await declare(playerAuth, { source: { type: 'basic' } })).status).toBe(403);
|
||||
|
||||
const declared = await declare(playerAuth, { source: { type: 'weapon', characterWeaponId: sword.characterWeaponId } });
|
||||
expect(declared.status).toBe(201);
|
||||
const actionId = declared.body.actionId;
|
||||
let action = (await view(dmAuth)).actions[0];
|
||||
expect(action).toMatchObject({ id: actionId, kind: 'ATTACK', name: 'Longsword', actorName: 'Aria', needsRolls: 1, ready: false });
|
||||
expect(action.targets[0]).toMatchObject({ name: 'Goblin 1', ac: 15, d20: null, outcome: null });
|
||||
// The player sees their attack, but not the goblin's AC.
|
||||
expect((await view(playerAuth)).actions[0].targets[0].ac).toBeNull();
|
||||
|
||||
// 9 + 5 = 14 against AC 15: a miss. Then the DM overrules it, and takes that back.
|
||||
expect((await patch(playerAuth, actionId, { rolls: [{ participantId: one.id, d20: 21 }] })).status).toBe(400);
|
||||
action = (await patch(playerAuth, actionId, { rolls: [{ participantId: one.id, d20: 9 }] })).body.encounter.actions[0];
|
||||
expect(action.targets[0]).toMatchObject({ d20: 9, total: 14, outcome: 'MISS', finalDamage: 0 });
|
||||
expect(action.ready).toBe(true);
|
||||
expect((await patch(playerAuth, actionId, { outcomes: [{ participantId: one.id, outcome: 'HIT' }] })).status).toBe(403);
|
||||
action = (await patch(dmAuth, actionId, { outcomes: [{ participantId: one.id, outcome: 'HIT' }] })).body.encounter.actions[0];
|
||||
expect(action.targets[0]).toMatchObject({ rolled: 'MISS', outcome: 'HIT', overruled: true });
|
||||
expect(action).toMatchObject({ needsDamage: true, ready: false });
|
||||
await patch(dmAuth, actionId, { outcomes: [{ participantId: one.id, outcome: null }] });
|
||||
|
||||
// 10 + 5 = 15: a hit. The damage is typed in, and only the DM lets it land.
|
||||
action = (await patch(playerAuth, actionId, { rolls: [{ participantId: one.id, d20: 10 }] })).body.encounter.actions[0];
|
||||
expect(action.targets[0]).toMatchObject({ total: 15, outcome: 'HIT' });
|
||||
expect((await apply(dmAuth, actionId)).status).toBe(400); // no damage yet
|
||||
action = (await patch(playerAuth, actionId, { damage: 4 })).body.encounter.actions[0];
|
||||
expect(action.targets[0].finalDamage).toBe(4);
|
||||
expect((await apply(playerAuth, actionId)).status).toBe(403);
|
||||
const before = await hp(one.id);
|
||||
const applied = await apply(dmAuth, actionId);
|
||||
expect(applied.status).toBe(200);
|
||||
expect(await hp(one.id)).toBe(before - 4);
|
||||
expect(applied.body.encounter.actions).toEqual([]);
|
||||
expect(applied.body.encounter.recentActions[0]).toMatchObject({ id: actionId, status: 'APPLIED' });
|
||||
expect((await apply(dmAuth, actionId)).status).toBe(400); // once
|
||||
|
||||
// The server can roll: the d20, then the damage where it hits. A natural 20 doubles the dice.
|
||||
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [two.id] });
|
||||
const second = (await declare(playerAuth, { source: { type: 'weapon', characterWeaponId: sword.characterWeaponId } })).body.actionId;
|
||||
action = (await patch(playerAuth, second, { rollRemaining: true })).body.encounter.actions[0];
|
||||
expect(action.targets[0].d20).toBeGreaterThanOrEqual(1);
|
||||
action = (await patch(dmAuth, second, { rolls: [{ participantId: two.id, d20: 20 }], rollDamage: true })).body.encounter.actions[0];
|
||||
expect(action.targets[0].outcome).toBe('CRIT');
|
||||
expect(action.damage.crit.dice).toHaveLength(2); // 1d8 rolled twice
|
||||
expect(action.targets[0].finalDamage).toBe(action.damage.crit.total);
|
||||
// The DM can set what a target takes by hand, and call the whole thing off.
|
||||
action = (await patch(dmAuth, second, { targetDamage: [{ participantId: two.id, damage: 1 }] })).body.encounter.actions[0];
|
||||
expect(action.targets[0].finalDamage).toBe(1);
|
||||
expect((await patch(dmAuth, second, { discard: true })).body.encounter.actions).toEqual([]);
|
||||
|
||||
// It is not the wizard's turn (and she is the DM's character): the player cannot act for her.
|
||||
expect((await declare(playerAuth, { actorParticipantId: mage.id, source: { type: 'basic' } })).status).toBe(403);
|
||||
});
|
||||
|
||||
itIfDb('a save spell asks each target for a save against the caster\u2019s DC, halves on a success, and takes its slot', async () => {
|
||||
const { dmAuth, playerAuth, e, one, two, mageId, view, hp } = await battle('actspell');
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth); // the wizard's turn
|
||||
const mira = (await view(dmAuth)).participants.find((p) => p.displayName === 'Mira').characterCombatInfo;
|
||||
const fireball = mira.spells.find((s) => s.name === 'Fireball');
|
||||
expect(fireball).toMatchObject({ saveAbility: 'DEX', halfOnSave: true, attackRoll: false });
|
||||
expect(mira.spells.find((s) => s.name === 'Fire Bolt')).toMatchObject({ attackRoll: true, saveAbility: null });
|
||||
const dc = mira.spellcasting.saveDC; // 8 + 3 proficiency + 3 Intelligence
|
||||
expect(dc).toBe(14);
|
||||
|
||||
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [one.id, two.id] });
|
||||
const actionId = (await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: fireball.characterSpellId } })).body.actionId;
|
||||
let action = (await view(dmAuth)).actions[0];
|
||||
// A goblin's Dexterity is 14: +2 to the save.
|
||||
expect(action).toMatchObject({ kind: 'SAVE', name: 'Fireball', needsRolls: 2 });
|
||||
expect(action.detail).toMatchObject({ saveAbility: 'DEX', saveDC: 14, halfOnSave: true, slotLevel: 3 });
|
||||
expect(action.targets.map((t) => t.saveBonus)).toEqual([2, 2]);
|
||||
|
||||
const patch = (body) => request(app).patch(`${e}/actions/${actionId}`).set(dmAuth).send(body);
|
||||
// 12 + 2 = 14 saves; 11 + 2 = 13 fails. 20 damage: 10 and 20.
|
||||
action = (await patch({ rolls: [{ participantId: one.id, d20: 12 }, { participantId: two.id, d20: 11 }], damage: 20 })).body.encounter.actions[0];
|
||||
expect(action.targets.map((t) => [t.outcome, t.finalDamage])).toEqual([['SAVED', 10], ['FAILED', 20]]);
|
||||
expect(action.ready).toBe(true);
|
||||
// A player cannot enter a monster's save.
|
||||
expect((await request(app).patch(`${e}/actions/${actionId}`).set(playerAuth).send({ rolls: [{ participantId: one.id, d20: 1 }] })).status).toBe(403);
|
||||
|
||||
const slotsBefore = (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels.find((l) => l.level === 3).remaining;
|
||||
const hpOne = await hp(one.id);
|
||||
const applied = await request(app).post(`${e}/actions/${actionId}/apply`).set(dmAuth).send({});
|
||||
expect(applied.status).toBe(200);
|
||||
expect(await hp(one.id)).toBe(Math.max(0, hpOne - 10));
|
||||
expect(await hp(two.id)).toBe(0); // 7 HP, 20 damage
|
||||
const slotsAfter = (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels.find((l) => l.level === 3).remaining;
|
||||
expect(slotsAfter).toBe(slotsBefore - 1);
|
||||
|
||||
// A cantrip takes no slot, however often it is cast. Spells are listed cantrips first.
|
||||
expect(mira.spells.map((s) => [s.name, s.level])).toEqual([['Fire Bolt', 0], ['Fireball', 3]]);
|
||||
const used = async () => (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels.map((l) => l.used);
|
||||
const before = await used();
|
||||
await request(app).put(`${e}/targets`).set(dmAuth).send({ participantIds: [one.id] });
|
||||
const bolt = mira.spells.find((s) => s.name === 'Fire Bolt');
|
||||
const boltId = (await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: bolt.characterSpellId } })).body.actionId;
|
||||
action = (await request(app).patch(`${e}/actions/${boltId}`).set(dmAuth).send({ rolls: [{ participantId: one.id, d20: 19 }], damage: 3 })).body.encounter.actions[0];
|
||||
expect(action).toMatchObject({ kind: 'ATTACK', name: 'Fire Bolt' });
|
||||
expect(action.detail.slotLevel).toBeNull();
|
||||
const cast = await request(app).post(`${e}/actions/${boltId}/apply`).set(dmAuth).send({});
|
||||
expect(cast.status).toBe(200);
|
||||
expect(cast.body.slotNote).toBeNull();
|
||||
expect(await used()).toEqual(before);
|
||||
|
||||
// With her last 3rd-level slot gone, Fireball cannot be declared; the cantrip still can.
|
||||
await request(app).post(`/characters/${mageId}/spell-slots`).set(dmAuth).send({ action: 'set', level: 3, used: 2 });
|
||||
const dry = await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: fireball.characterSpellId } });
|
||||
expect(dry.status).toBe(400);
|
||||
expect(dry.body.error).toMatch(/no spell slot of level 3 or higher left/);
|
||||
expect((await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: bolt.characterSpellId } })).status).toBe(201);
|
||||
|
||||
// Every slot she has spent, at every level: the cantrip is still hers to cast, and lands.
|
||||
const all = (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels;
|
||||
for (const slot of all) await request(app).post(`/characters/${mageId}/spell-slots`).set(dmAuth).send({ action: 'set', level: slot.level, used: slot.max });
|
||||
const empty = (await view(dmAuth)).participants.find((p) => p.displayName === 'Mira').characterCombatInfo.spellSlots.levels;
|
||||
expect(empty.every((l) => l.remaining === 0)).toBe(true);
|
||||
const again = await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: bolt.characterSpellId } });
|
||||
expect(again.status).toBe(201);
|
||||
await request(app).patch(`${e}/actions/${again.body.actionId}`).set(dmAuth).send({ rolls: [{ participantId: one.id, d20: 1 }] });
|
||||
expect((await request(app).post(`${e}/actions/${again.body.actionId}/apply`).set(dmAuth).send({})).status).toBe(200);
|
||||
});
|
||||
|
||||
itIfDb('a weapon is changed as the battle begins or on your own turn after attacking, and not in between', async () => {
|
||||
const { dmAuth, playerAuth, campaign, e, one, heroId } = await battle('actswap');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const bow = (await request(app).post(`${c}/weapons`).set(dmAuth).send({ name: 'Shortbow', damageDice: '1d6', damageType: 'piercing', attackAbility: 'DEX', properties: [] })).body.weapon;
|
||||
await request(app).post(`/characters/${heroId}/weapons`).set(dmAuth).send({ weaponId: bow.id });
|
||||
await request(app).patch(c).set(dmAuth).send({ maxEquippedWeapons: 2 });
|
||||
const held = async () => (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character.weapons;
|
||||
const sword = (await held()).find((w) => w.weapon.name === 'Longsword');
|
||||
const equip = (auth, weaponId, equipped) => request(app).patch(`/characters/${heroId}/weapons/${weaponId}`).set(auth).send({ equipped });
|
||||
|
||||
// The battle has begun and nobody has made a move: the bow can come out, and go back.
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(200);
|
||||
expect((await equip(playerAuth, bow.id, false)).status).toBe(200);
|
||||
|
||||
// Aria attacks. While the attack is still being resolved nothing changes hands...
|
||||
await request(app).put(`${e}/targets`).set(playerAuth).send({ participantIds: [one.id] });
|
||||
const actionId = (await request(app).post(`${e}/actions`).set(playerAuth).send({ source: { type: 'weapon', characterWeaponId: sword.id } })).body.actionId;
|
||||
await request(app).patch(`${e}/actions/${actionId}`).set(playerAuth).send({ rolls: [{ participantId: one.id, d20: 2 }] });
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(400);
|
||||
await request(app).post(`${e}/actions/${actionId}/apply`).set(dmAuth).send({});
|
||||
// After her attack, before the next turn: she may swap.
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(200);
|
||||
|
||||
// The wizard's turn: Aria's hands stay as they are, either way. The DM can still change them.
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
const refused = await equip(playerAuth, bow.id, false);
|
||||
expect(refused.status).toBe(400);
|
||||
expect(refused.body.error).toMatch(/is in a battle \(Ambush\).*own turn once their attack has landed/);
|
||||
expect((await equip(playerAuth, sword.weaponId, false)).status).toBe(400);
|
||||
expect((await equip(dmAuth, bow.id, false)).status).toBe(200);
|
||||
// Other gear is not held to it.
|
||||
expect((await held()).find((w) => w.weapon.name === 'Shortbow').equipped).toBe(false);
|
||||
|
||||
// Round the table to Aria again: not before she has attacked this turn.
|
||||
for (let i = 0; i < 3; i += 1) await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
expect((await request(app).get(e).set(dmAuth)).body.encounter.currentRound).toBe(2);
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(400);
|
||||
|
||||
// Once the battle is over, gear is changed freely again.
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'COMPLETE' });
|
||||
expect((await equip(playerAuth, bow.id, true)).status).toBe(200);
|
||||
});
|
||||
|
||||
itIfDb('with auto-roll on, a monster\u2019s turn opens with its attack aimed and rolled, kept from the players until the DM accepts it', async () => {
|
||||
const { dmAuth, playerAuth, e, hero, mage, one, view } = await battle('actauto', { autoRollMonsters: true });
|
||||
// Aria, then Mira, then Goblin 1.
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
expect((await view(dmAuth)).actions).toEqual([]); // a character's turn: nothing declared for them
|
||||
const turn = (await request(app).post(`${e}/next-turn`).set(dmAuth)).body.encounter;
|
||||
expect(turn.activeParticipantId).toBe(one.id);
|
||||
expect(turn.actions).toHaveLength(1);
|
||||
const action = turn.actions[0];
|
||||
expect(action).toMatchObject({ actorName: 'Goblin 1', kind: 'ATTACK', auto: true, hidden: true, needsRolls: 0 });
|
||||
// It goes for someone on the other side, with the d20 already rolled, and damage where it hits.
|
||||
expect([hero.id, mage.id]).toContain(action.targets[0].participantId);
|
||||
expect(action.targets[0].d20).toBeGreaterThanOrEqual(1);
|
||||
expect(action.targets[0].total).toBe(action.targets[0].d20 + 4); // a goblin's +4
|
||||
if (['HIT', 'CRIT'].includes(action.targets[0].outcome)) expect(action.damage.total).toBeGreaterThan(0);
|
||||
expect(action.ready).toBe(true);
|
||||
// The players see nothing of it yet.
|
||||
expect((await view(playerAuth)).actions).toEqual([]);
|
||||
|
||||
// The DM changes its mind about the target: the same d20, against the other's AC.
|
||||
const other = action.targets[0].participantId === hero.id ? mage.id : hero.id;
|
||||
const moved = (await request(app).patch(`${e}/actions/${action.id}`).set(dmAuth).send({ targetIds: [other] })).body.encounter.actions[0];
|
||||
expect(moved.targets[0]).toMatchObject({ participantId: other, d20: action.targets[0].d20 });
|
||||
|
||||
// The DM makes sure it hits for 3, and accepts: the damage lands and everyone sees what happened.
|
||||
await request(app).patch(`${e}/actions/${action.id}`).set(dmAuth).send({ outcomes: [{ participantId: other, outcome: 'HIT' }], targetDamage: [{ participantId: other, damage: 3 }] });
|
||||
const before = (await view(dmAuth)).participants.find((p) => p.id === other).currentHp;
|
||||
const applied = (await request(app).post(`${e}/actions/${action.id}/apply`).set(dmAuth).send({})).body.encounter;
|
||||
expect(applied.participants.find((p) => p.id === other).currentHp).toBe(before - 3);
|
||||
expect((await view(playerAuth)).recentActions[0]).toMatchObject({ actorName: 'Goblin 1', status: 'APPLIED', hidden: false });
|
||||
|
||||
// Passing the turn clears anything left unresolved, and the next goblin declares its own.
|
||||
const next = (await request(app).post(`${e}/next-turn`).set(dmAuth)).body.encounter;
|
||||
expect(next.actions.every((a) => a.actorName === 'Goblin 2')).toBe(true);
|
||||
});
|
||||
|
||||
itIfDb('without the setting nothing is declared for a monster, and the DM can spell out an attack of their own', async () => {
|
||||
const { dmAuth, e, hero, one, view } = await battle('actmanual');
|
||||
await request(app).post(`${e}/next-turn`).set(dmAuth);
|
||||
const turn = (await request(app).post(`${e}/next-turn`).set(dmAuth)).body.encounter;
|
||||
expect(turn.activeParticipantId).toBe(one.id);
|
||||
expect(turn.actions).toEqual([]);
|
||||
|
||||
// The goblin's own attack, aimed by hand.
|
||||
const basic = await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'basic' }, targetIds: [hero.id] });
|
||||
expect(basic.status).toBe(201);
|
||||
expect((await view(dmAuth)).actions[0]).toMatchObject({ kind: 'ATTACK', actorName: 'Goblin 1', detail: { attackBonus: 4 } });
|
||||
|
||||
// A trap, spelled out: Dexterity save DC 13, 2d6, half on a success. It replaces the unfinished attack.
|
||||
const trap = await request(app)
|
||||
.post(`${e}/actions`)
|
||||
.set(dmAuth)
|
||||
.send({ source: { type: 'custom', name: 'Dart trap', kind: 'SAVE', saveAbility: 'DEX', saveDC: 13, halfOnSave: true, damageDice: '2d6' }, targetIds: [hero.id] });
|
||||
expect(trap.status).toBe(201);
|
||||
const actions = (await view(dmAuth)).actions;
|
||||
expect(actions).toHaveLength(1);
|
||||
expect(actions[0]).toMatchObject({ kind: 'SAVE', name: 'Dart trap', detail: { saveDC: 13 } });
|
||||
const rolled = (await request(app).patch(`${e}/actions/${actions[0].id}`).set(dmAuth).send({ rollRemaining: true, rollDamage: true })).body.encounter.actions[0];
|
||||
expect(rolled.ready).toBe(true);
|
||||
expect(rolled.damage.dice).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('free roam', () => {
|
||||
itIfDb('whoever the DM adds wanders between its places, and shops where they stand', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('roam');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const allyId = await buildCharacter(campaign.id, dmAuth, 'Bo');
|
||||
await request(app).post(`/characters/${heroId}/gold`).set(dmAuth).send({ amount: 50, reason: 'Purse' });
|
||||
const place = async (name) => (await request(app).post(`${c}/locations`).set(dmAuth).send({ name, description: `${name}.` })).body.location;
|
||||
const [market, inn, docks] = [await place('Market'), await place('Inn'), await place('Docks')];
|
||||
const rope = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope' })).body.item;
|
||||
const ale = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Ale' })).body.item;
|
||||
|
||||
// A shop at the market, and an innkeeper found at the inn who sells ale.
|
||||
expect((await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, itemId: rope.id, price: 2 })).status).toBe(201);
|
||||
expect((await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, npcId: 'x', itemId: rope.id, price: 2 })).status).toBe(400);
|
||||
const keeper = (await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Mara', role: 'Innkeeper', dmNotes: 'Waters the ale.', locationId: inn.id })).body.npc;
|
||||
expect((await request(app).post(`${c}/npcs`).set(dmAuth).send({ name: 'Nobody', locationId: 'nowhere' })).status).toBe(400);
|
||||
await request(app).post(`${c}/wares`).set(dmAuth).send({ npcId: keeper.id, itemId: ale.id, price: 1 });
|
||||
|
||||
const created = await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'A day in town', mode: 'FREE_ROAM', placeIds: [market.id, inn.id] });
|
||||
expect(created.status).toBe(201);
|
||||
const e = `/encounters/${created.body.encounter.id}`;
|
||||
const view = async (auth) => (await request(app).get(e).set(auth)).body.encounter;
|
||||
|
||||
// Nobody is in it until the DM adds them: the party may be split up.
|
||||
expect((await view(dmAuth)).participants).toEqual([]);
|
||||
expect((await request(app).get(e).set(playerAuth)).status).toBe(404);
|
||||
for (const characterId of [heroId, allyId]) await request(app).post(`${e}/participants`).set(dmAuth).send({ characterId });
|
||||
let scene = await view(playerAuth);
|
||||
expect(scene.mode).toBe('FREE_ROAM');
|
||||
expect(scene.participants.map((p) => p.displayName).sort()).toEqual(['Aria', 'Bo']);
|
||||
expect(scene.places.map((p) => p.name)).toEqual(['Market', 'Inn']);
|
||||
expect(scene.places[0].wares[0].item.name).toBe('Rope');
|
||||
expect(scene.places[1].npcs[0]).toMatchObject({ name: 'Mara', role: 'Innkeeper' });
|
||||
expect(scene.places[1].npcs[0].dmNotes).toBeUndefined(); // the DM's alone
|
||||
expect(scene.places[1].npcs[0].wares[0].item.name).toBe('Ale');
|
||||
expect((await view(dmAuth)).places[1].npcs[0].dmNotes).toBe('Waters the ale.');
|
||||
|
||||
const visit = (auth, characterId, locationId) => request(app).post(`${e}/visit`).set(auth).send({ characterId, locationId });
|
||||
const buy = (auth, wareId, characterId) => request(app).post(`${e}/wares/${wareId}/buy`).set(auth).send({ characterId });
|
||||
const ropeWare = scene.places[0].wares[0].id;
|
||||
const aleWare = scene.places[1].npcs[0].wares[0].id;
|
||||
|
||||
// Nothing moves, and nothing is sold, before the scene begins.
|
||||
expect((await visit(playerAuth, heroId, market.id)).status).toBe(400);
|
||||
await request(app).patch(e).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect((await buy(playerAuth, ropeWare, heroId)).status).toBe(400); // not at the market yet
|
||||
expect((await visit(playerAuth, heroId, docks.id)).status).toBe(400); // not one of the scene's places
|
||||
expect((await visit(playerAuth, allyId, market.id)).status).toBe(403); // not their character
|
||||
|
||||
scene = (await visit(playerAuth, heroId, market.id)).body.encounter;
|
||||
expect(scene.places[0].here.map((h) => h.name)).toEqual(['Aria']);
|
||||
expect((await buy(playerAuth, ropeWare, heroId)).status).toBe(201);
|
||||
// The innkeeper is at the inn: go there to buy her ale.
|
||||
const tooFar = await buy(playerAuth, aleWare, heroId);
|
||||
expect(tooFar.status).toBe(400);
|
||||
expect(tooFar.body.error).toMatch(/go to Inn first/);
|
||||
await visit(playerAuth, heroId, inn.id);
|
||||
expect((await buy(playerAuth, aleWare, heroId)).status).toBe(201);
|
||||
const hero = (await request(app).get(`/characters/${heroId}`).set(playerAuth)).body.character;
|
||||
expect(hero.inventoryItems.map((i) => i.item.name).sort()).toEqual(['Ale', 'Rope']);
|
||||
expect(hero.currentLocation.name).toBe('Inn');
|
||||
// The DM can send anyone, and opens another place while the party roams.
|
||||
expect((await visit(dmAuth, allyId, market.id)).status).toBe(200);
|
||||
expect((await request(app).put(`${e}/places`).set(playerAuth).send({ locationIds: [] })).status).toBe(403);
|
||||
const more = (await request(app).put(`${e}/places`).set(dmAuth).send({ locationIds: [market.id, inn.id, docks.id] })).body.encounter;
|
||||
expect(more.places.map((p) => p.name)).toEqual(['Market', 'Inn', 'Docks']);
|
||||
// Having been there, the player now knows the place for good.
|
||||
const known = (await request(app).get(`${c}/locations`).set(playerAuth)).body.locations.map((l) => l.name).sort();
|
||||
expect(known).toEqual(['Inn', 'Market']);
|
||||
});
|
||||
|
||||
itIfDb('a shop at a place does not sell in a quest, and only a free-roam scene has places', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('roamnot');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const market = (await request(app).post(`${c}/locations`).set(dmAuth).send({ name: 'Market' })).body.location;
|
||||
const rope = (await request(app).post(`${c}/items`).set(dmAuth).send({ name: 'Rope' })).body.item;
|
||||
const ware = (await request(app).post(`${c}/wares`).set(dmAuth).send({ locationId: market.id, itemId: rope.id, price: 1 })).body.ware;
|
||||
const quest = `/encounters/${(await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Errand', mode: 'QUEST' })).body.encounter.id}`;
|
||||
await request(app).post(`${quest}/participants`).set(dmAuth).send({ characterId: heroId });
|
||||
await request(app).patch(quest).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
expect((await request(app).post(`${quest}/wares/${ware.id}/buy`).set(playerAuth).send({ characterId: heroId })).status).toBe(404);
|
||||
expect((await request(app).post(`${quest}/visit`).set(playerAuth).send({ characterId: heroId, locationId: market.id })).status).toBe(400);
|
||||
expect((await request(app).get(quest).set(playerAuth)).body.encounter.places).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('class features on the sheet', () => {
|
||||
itIfDb('a barbarian’s Unarmored Defense and Fast Movement go by the armor that is equipped', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('unarmored');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Korg', 'BARBARIAN', 5);
|
||||
await prisma.character.update({ where: { id }, data: { species: 'HUMAN', dexScore: 13, conScore: 15, speciesBonusChoices: {}, armorBonus: 0, speedOverride: null } });
|
||||
const sheet = async () => (await request(app).get(`/characters/${id}`).set(playerAuth)).body.character.sheet;
|
||||
|
||||
// Dexterity 14 (+2), Constitution 16 (+3): 10 + 2 + 3. A human's 30 ft, and 10 more.
|
||||
let s = await sheet();
|
||||
expect(s).toMatchObject({ armorClass: 15, speed: 40 });
|
||||
expect(s.features.map((f) => f.name)).toEqual(['Unarmored Defense', 'Fast Movement']);
|
||||
|
||||
// The library's armor comes marked. Hide (+2): 10 + 2 + 2, and no Unarmored Defense.
|
||||
const hide = (await request(app).post(`${c}/items/from-library`).set(dmAuth).send({ key: 'hide-armor' })).body.item;
|
||||
expect(hide.armorType).toBe('MEDIUM');
|
||||
await request(app).post(`/characters/${id}/inventory`).set(dmAuth).send({ itemId: hide.id });
|
||||
expect((await sheet()).armorClass).toBe(15); // carried, not worn
|
||||
await request(app).patch(`/characters/${id}/inventory/${hide.id}`).set(playerAuth).send({ equipped: true });
|
||||
s = await sheet();
|
||||
expect(s).toMatchObject({ armorClass: 14, speed: 40 });
|
||||
expect(s.features.map((f) => f.name)).toEqual(['Fast Movement']);
|
||||
|
||||
// The DM says what kind of thing an item is. Heavy: the extra speed goes, 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@ const { deathSaveInfo, lifeState } = require('../utils/hp');
|
||||
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');
|
||||
|
||||
@@ -254,8 +256,16 @@ function characterSummary(character, subclasses = true) {
|
||||
characterClass: character.characterClass,
|
||||
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
|
||||
level: character.level,
|
||||
levelUpsPending: character.levelUpsPending,
|
||||
// Every class it has levels in; one entry unless it has multiclassed.
|
||||
classes: classLevels(character).map((entry) => ({
|
||||
characterClass: entry.characterClass,
|
||||
level: entry.level,
|
||||
subclassName: subclasses ? subclassName(entry.characterClass, entry.subclass) : null,
|
||||
})),
|
||||
xp: character.xp,
|
||||
xpProgress: xpProgress(character.xp, character.level),
|
||||
// A level already approved is no longer due.
|
||||
xpProgress: xpProgress(character.xp, Math.min(MAX_LEVEL, character.level + character.levelUpsPending)),
|
||||
buildStage: character.buildStage,
|
||||
currentHp: character.currentHp,
|
||||
tempHp: character.tempHp,
|
||||
@@ -362,6 +372,62 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Level-ups — the DM approves a level for any of the party at once. Each
|
||||
// approval is a checklist waiting (Character.levelUpsPending): the
|
||||
// character's player is told and makes their own picks (POST
|
||||
// /characters/:id/level-up), so five players levelling together do not queue
|
||||
// for the DM. A character with no player is the DM's to finish.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const approveLevelUpSchema = z.object({
|
||||
characterIds: z.array(z.string()).min(1).max(200),
|
||||
// Takes an approval back (one not yet used) instead of giving one.
|
||||
revoke: z.boolean().default(false),
|
||||
});
|
||||
|
||||
router.post('/:id/level-up', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = approveLevelUpSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { revoke } = parsed.data;
|
||||
const ids = [...new Set(parsed.data.characterIds)];
|
||||
|
||||
const characters = await prisma.character.findMany({
|
||||
where: { id: { in: ids }, campaignId: req.campaign.id, buildStage: 'COMPLETE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (characters.length !== ids.length) return res.status(400).json({ error: 'Pick finished characters from this campaign.' });
|
||||
|
||||
const results = characters.map((character) => {
|
||||
const who = { characterId: character.id, name: character.name, userId: character.userId, level: character.level };
|
||||
if (revoke) {
|
||||
return character.levelUpsPending > 0
|
||||
? { ...who, levelUpsPending: character.levelUpsPending - 1, skipped: null }
|
||||
: { ...who, levelUpsPending: 0, skipped: `${character.name} has no level up waiting.` };
|
||||
}
|
||||
if (character.level + character.levelUpsPending >= MAX_LEVEL) {
|
||||
return { ...who, levelUpsPending: character.levelUpsPending, skipped: `${character.name} is already at (or approved up to) level ${MAX_LEVEL}.` };
|
||||
}
|
||||
return { ...who, levelUpsPending: character.levelUpsPending + 1, skipped: null };
|
||||
});
|
||||
|
||||
const changed = results.filter((result) => !result.skipped);
|
||||
await prisma.$transaction(
|
||||
changed.map((result) => prisma.character.update({ where: { id: result.characterId }, data: { levelUpsPending: result.levelUpsPending } }))
|
||||
);
|
||||
const io = req.app.get('io');
|
||||
for (const { characterId, userId } of changed) {
|
||||
io.to(`campaign:${req.campaign.id}`).emit('character:updated', { characterId });
|
||||
// Tells the character's player there is a level to take (ids only).
|
||||
if (!revoke) io.to(`campaign:${req.campaign.id}`).emit('character:level-up-granted', { characterId, userId });
|
||||
}
|
||||
res.json({ revoke, results });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rests — the DM rests any of the party at once (utils/rest.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -579,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) => {
|
||||
@@ -635,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,
|
||||
});
|
||||
|
||||
@@ -706,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) => {
|
||||
@@ -768,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.
|
||||
@@ -782,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 },
|
||||
@@ -814,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);
|
||||
@@ -876,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];
|
||||
@@ -897,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) {
|
||||
@@ -909,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);
|
||||
@@ -1021,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),
|
||||
@@ -1028,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) => {
|
||||
@@ -1038,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.` });
|
||||
@@ -1178,4 +1288,128 @@ router.post('/:id/monsters/from-library', requireCampaignMember(), requireDM(),
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dice rolls — the table's dice roller (game/dice.js). A digital roll is made
|
||||
// here, never taken from the client; a physical one is typed in and checked
|
||||
// against its dice. Every roll is kept and announced, so the table (and above
|
||||
// all the DM) sees it.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rollSchema = z
|
||||
.object({
|
||||
label: z.string().max(120).optional().nullable(),
|
||||
// A d20 plus `modifier` (a check, a save, an attack), or a dice expression (damage).
|
||||
kind: z.enum(['check', 'dice']),
|
||||
modifier: z.number().int().min(-99).max(99).default(0),
|
||||
mode: z.enum(D20_MODES).default('NORMAL'),
|
||||
expression: z.string().max(60).optional(),
|
||||
// Dice rolled at the table: what each showed, in order. Left out, the dice are rolled here.
|
||||
values: z.array(z.number().int().min(1).max(1000)).max(MAX_DICE).optional(),
|
||||
hidden: z.boolean().default(false), // the DM's alone
|
||||
encounterId: z.string().optional().nullable(),
|
||||
})
|
||||
.refine((roll) => roll.kind === 'check' || roll.expression, { message: 'A dice roll needs its dice, like "2d6+3".' });
|
||||
|
||||
// Rolls older than this are cleared away as new ones come in.
|
||||
const ROLL_KEEP_DAYS = 14;
|
||||
|
||||
const publicRoll = ({ id, userId, rollerName, label, notation, dice, modifier, total, natural, mode, physical, hidden, encounterId, createdAt }) => ({
|
||||
id,
|
||||
userId,
|
||||
rollerName,
|
||||
label,
|
||||
notation,
|
||||
dice,
|
||||
modifier,
|
||||
total,
|
||||
natural,
|
||||
mode,
|
||||
physical,
|
||||
hidden,
|
||||
encounterId,
|
||||
createdAt,
|
||||
});
|
||||
|
||||
// Which rolls a member may see: the DM every one; a player their own, and
|
||||
// everyone's (but the DM's hidden ones) where the campaign shows all rolls.
|
||||
function visibleRolls(req) {
|
||||
if (req.membership.role === 'DM') return {};
|
||||
if (req.campaign.rollVisibility === 'OWN') return { userId: req.user.id };
|
||||
return { OR: [{ userId: req.user.id }, { hidden: false }] };
|
||||
}
|
||||
|
||||
router.post('/:id/rolls', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = rollSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { label, kind, modifier, mode, expression, values, encounterId } = parsed.data;
|
||||
const isDM = req.membership.role === 'DM';
|
||||
|
||||
// The campaign's dice decide whether a roll is made here or typed in.
|
||||
const diceMode = req.campaign.diceMode;
|
||||
if (values && diceMode === 'DIGITAL') return res.status(400).json({ error: 'This campaign plays with digital dice: the app rolls.' });
|
||||
if (!values && diceMode === 'PHYSICAL') return res.status(400).json({ error: 'This campaign plays with physical dice: roll at the table and enter what the dice show.' });
|
||||
|
||||
let rolled;
|
||||
let notation;
|
||||
if (kind === 'check') {
|
||||
const dice = Array.from({ length: d20Count(mode) }, () => ({ sides: 20 }));
|
||||
if (values && !validValues(dice, values)) {
|
||||
return res.status(400).json({ error: `Enter ${dice.length === 1 ? 'the d20' : 'both d20s'} you rolled: whole numbers from 1 to 20.` });
|
||||
}
|
||||
rolled = rollD20(modifier, mode, { values });
|
||||
notation = `1d20${modifier ? (modifier > 0 ? `+${modifier}` : `${modifier}`) : ''}`;
|
||||
} else {
|
||||
const dice = parseDice(expression);
|
||||
if (!dice) return res.status(400).json({ error: 'Those dice cannot be read. Write them like "2d6+3".' });
|
||||
if (values && !validValues(diceOf(dice), values)) {
|
||||
return res.status(400).json({ error: 'Enter what each die showed, in order, each within its die.' });
|
||||
}
|
||||
rolled = { ...rollExpression(dice, { values }), natural: null };
|
||||
notation = expression.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
const [roll] = await prisma.$transaction([
|
||||
prisma.diceRoll.create({
|
||||
data: {
|
||||
campaignId: req.campaign.id,
|
||||
userId: req.user.id,
|
||||
rollerName: req.user.displayName || 'Someone',
|
||||
label: label || null,
|
||||
notation,
|
||||
dice: rolled.dice,
|
||||
modifier: rolled.modifier,
|
||||
total: rolled.total,
|
||||
natural: rolled.natural,
|
||||
mode: kind === 'check' ? mode : 'NORMAL',
|
||||
physical: Boolean(values),
|
||||
hidden: isDM && parsed.data.hidden, // only the DM rolls behind the screen
|
||||
encounterId: encounterId || null,
|
||||
},
|
||||
}),
|
||||
prisma.diceRoll.deleteMany({ where: { campaignId: req.campaign.id, createdAt: { lt: new Date(Date.now() - ROLL_KEEP_DAYS * 24 * 60 * 60 * 1000) } } }),
|
||||
]);
|
||||
// Ids only: each member's page asks for the roll and gets it only if it is theirs to see.
|
||||
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('roll:created', { rollId: roll.id, userId: roll.userId });
|
||||
res.status(201).json({ roll: publicRoll(roll) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /campaigns/:id/rolls — the latest rolls this member may see, newest first.
|
||||
router.get('/:id/rolls', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 30, 1), 100);
|
||||
const rolls = await prisma.diceRoll.findMany({
|
||||
where: { campaignId: req.campaign.id, ...visibleRolls(req) },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
res.json({ rolls: rolls.map(publicRoll) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -8,6 +8,7 @@ const {
|
||||
computeCharacterSheet,
|
||||
computeWeaponStats,
|
||||
getClassSaveProficiencies,
|
||||
getClassHitDieSize,
|
||||
getClassSpellcastingAbility,
|
||||
scaledDamageDice,
|
||||
canUseGear,
|
||||
@@ -18,15 +19,19 @@ const {
|
||||
MAX_SLOT_LEVEL,
|
||||
ABILITIES,
|
||||
} = require('../game/calculations');
|
||||
const { LevelUpError, advance, levelUpPlan, switchSubclass, subclassByDefault } = require('../game/levelUp');
|
||||
const { LevelUpError, advance, advanceIn, levelUpPlan, switchSubclass, subclassByDefault } = require('../game/levelUp');
|
||||
const { classLevels, withLevelIn } = require('../game/multiclass');
|
||||
const { CLASSES } = require('../game/data/classes');
|
||||
const { resourceTally, MAX_EXHAUSTION } = require('../game/classResources');
|
||||
const { SUBCLASS_LEVEL, subclassesOf, findSubclass, subclassName } = require('../game/data/subclasses');
|
||||
const { SUBCLASS_LEVEL, SUBCLASSES, subclassesOf, findSubclass, subclassName, newerVersionOf, subclassSummary } = require('../game/data/subclasses');
|
||||
const {
|
||||
rollDie,
|
||||
rollAbilityScore,
|
||||
abilityScoreFromDice,
|
||||
speciesChoicesForRoll,
|
||||
classChoicesForRoll,
|
||||
SPECIES_ROLL_DIE,
|
||||
CLASS_ROLL_DIE,
|
||||
autoSelectSkills,
|
||||
validateManualRoll,
|
||||
validatePicks,
|
||||
@@ -39,10 +44,13 @@ const {
|
||||
effectiveDiceMode,
|
||||
} = require('../game/buildFlow');
|
||||
const characterOptions = require('../game/data/characterOptions');
|
||||
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();
|
||||
@@ -59,7 +67,8 @@ async function loadFullCharacter(id) {
|
||||
weapons: { include: { weapon: true } },
|
||||
spells: { include: { spell: true } },
|
||||
currentLocation: true,
|
||||
campaign: { select: { subclassesEnabled: true, diceMode: true } },
|
||||
classLevels: true,
|
||||
campaign: { select: { subclassesEnabled: true, multiclassEnabled: true, diceMode: true, hpOnLevelUp: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -93,13 +102,25 @@ function serializeCharacter(character, isOwner, isDM = true) {
|
||||
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
|
||||
// What the class can choose from, and the level it chooses at.
|
||||
subclassLevel: subclasses ? (SUBCLASS_LEVEL[character.characterClass] ?? null) : null,
|
||||
subclassOptions: subclasses ? subclassesOf(character.characterClass).map(({ key, name, summary }) => ({ key, name, summary })) : [],
|
||||
subclassOptions: subclasses ? subclassesOf(character.characterClass).map(subclassSummary) : [],
|
||||
// The 2024 subclass that updates the one the character follows, if any.
|
||||
subclassUpgrade: subclasses && newerVersionOf(character.characterClass, character.subclass) ? subclassSummary(newerVersionOf(character.characterClass, character.subclass)) : null,
|
||||
level: character.level,
|
||||
// Levels the DM has approved that are still to be taken (their picks made).
|
||||
levelUpsPending: character.levelUpsPending,
|
||||
// Every class the character has levels in, the first first; one entry
|
||||
// unless they have multiclassed.
|
||||
classes: classLevels(character).map((entry) => ({
|
||||
characterClass: entry.characterClass,
|
||||
level: entry.level,
|
||||
subclass: subclasses ? entry.subclass : null,
|
||||
subclassName: subclasses ? subclassName(entry.characterClass, entry.subclass) : null,
|
||||
})),
|
||||
xp: character.xp,
|
||||
xpProgress: xpProgress(character.xp, character.level),
|
||||
// A level already approved is no longer due.
|
||||
xpProgress: xpProgress(character.xp, Math.min(MAX_LEVEL, character.level + character.levelUpsPending)),
|
||||
motivation: character.motivation,
|
||||
halfElfBonusAbility1: character.halfElfBonusAbility1,
|
||||
halfElfBonusAbility2: character.halfElfBonusAbility2,
|
||||
speciesBonusChoices: character.speciesBonusChoices || {},
|
||||
rolledScores: {
|
||||
STR: character.strScore,
|
||||
DEX: character.dexScore,
|
||||
@@ -154,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,
|
||||
};
|
||||
}
|
||||
@@ -171,6 +192,10 @@ router.get('/build/options', (req, res) => {
|
||||
talentPicks: TALENT_PICKS,
|
||||
background: BACKGROUND_GROUPS,
|
||||
appearance: APPEARANCE_GROUPS,
|
||||
// Each class's subclasses, as the builder's subclass step lists them, and
|
||||
// the level each class gets its subclass's first gifts at.
|
||||
subclasses: Object.fromEntries(Object.entries(SUBCLASSES).map(([cls, list]) => [cls, list.map(subclassSummary)])),
|
||||
subclassLevels: SUBCLASS_LEVEL,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -272,9 +297,8 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
|
||||
const picked = askedSubclass;
|
||||
if (picked !== null) {
|
||||
if (!findSubclass(characterClass, picked)) return res.status(400).json({ error: 'That is not a subclass of this character\'s class.' });
|
||||
if (newLevel < SUBCLASS_LEVEL[characterClass]) {
|
||||
return res.status(400).json({ error: `${req.character.name} chooses a subclass at level ${SUBCLASS_LEVEL[characterClass]}.` });
|
||||
}
|
||||
// A path can be set before the class reaches it, as the builder does;
|
||||
// its lines are written as the levels come.
|
||||
}
|
||||
// The old subclass's lines come off the sheet and the new one's go on.
|
||||
if (writesAbilities) parsed.data.specialAbilities = switchSubclass(abilities, characterClass, newLevel, req.character.subclass, picked);
|
||||
@@ -431,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.
|
||||
@@ -531,33 +556,91 @@ router.post('/:id/resources', requireCharacterAccess({ write: true }), async (re
|
||||
// and the free weapon upgrade.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Answers the request and returns false when this character cannot be levelled up.
|
||||
// Answers the request and returns false when this character cannot be levelled
|
||||
// up. The DM always can; the character's own player can once the DM has
|
||||
// approved a level for them (levelUpsPending).
|
||||
function canLevelUp(req, res, character) {
|
||||
let refusal = null;
|
||||
if (!req.isDM) refusal = [403, 'Only the DM can level a character up.'];
|
||||
else if (character.buildStage !== 'COMPLETE') refusal = [400, 'Character build is not finished yet.'];
|
||||
if (!req.isDM && !(req.isOwner && character.levelUpsPending > 0)) {
|
||||
refusal = [403, req.isOwner ? 'Your DM has not approved a level up yet.' : 'Only the DM can level a character up.'];
|
||||
} else if (character.buildStage !== 'COMPLETE') refusal = [400, 'Character build is not finished yet.'];
|
||||
else if (character.level >= MAX_LEVEL) refusal = [400, `${character.name} is already level ${MAX_LEVEL}, the highest there is.`];
|
||||
if (refusal) res.status(refusal[0]).json({ error: refusal[1] });
|
||||
return !refusal;
|
||||
}
|
||||
|
||||
// The plan for a character's next level in `toClass` (their first class when
|
||||
// not given). Answers the request and returns null when the class can't take
|
||||
// it: another class needs the campaign to allow multiclassing, and the
|
||||
// character to meet both classes' requirements.
|
||||
function planFor(req, res, character, toClass) {
|
||||
const sheet = sheetOf(character);
|
||||
const scores = Object.fromEntries(Object.entries(sheet.abilities).map(([ability, detail]) => [ability, detail.final]));
|
||||
const multiclass = Boolean(character.campaign && character.campaign.multiclassEnabled);
|
||||
if (toClass && !CLASSES[toClass]) {
|
||||
res.status(400).json({ error: 'That is not a class.' });
|
||||
return null;
|
||||
}
|
||||
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character), multiclass, toClass, scores });
|
||||
if (plan.characterClass !== character.characterClass && !classLevels(character).some((e) => e.characterClass === plan.characterClass)) {
|
||||
if (!multiclass) {
|
||||
res.status(400).json({ error: 'This campaign does not allow multiclassing.' });
|
||||
return null;
|
||||
}
|
||||
const option = plan.classChoice.options.find((o) => o.characterClass === plan.characterClass);
|
||||
if (!option.eligible) {
|
||||
res.status(400).json({ error: option.problems.join(' ') });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return { plan, sheet };
|
||||
}
|
||||
|
||||
// How the level's hit points are come by: the campaign's method, the die of
|
||||
// the class taking the level, the average it would count, and whether the
|
||||
// die is rolled at the table (physical dice) and typed in.
|
||||
function hpGain(character, plan) {
|
||||
const die = getClassHitDieSize(plan.characterClass);
|
||||
return {
|
||||
method: (character.campaign && character.campaign.hpOnLevelUp) || 'AVERAGE',
|
||||
die,
|
||||
average: Math.floor(die / 2) + 1,
|
||||
physical: effectiveDiceMode(character.campaign, character) === 'PHYSICAL',
|
||||
};
|
||||
}
|
||||
|
||||
// What the next level brings, for the DM to look over before saying yes.
|
||||
// `?class=` asks about a level in another class (multiclassing).
|
||||
router.get('/:id/level-up', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
const character = await loadFullCharacter(req.character.id);
|
||||
if (!canLevelUp(req, res, character)) return;
|
||||
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character) });
|
||||
const after = sheetOf({ ...character, level: plan.toLevel, spellSlotsUsed: [] });
|
||||
res.json({ plan: { ...plan, maxHp: { from: sheetOf(character).hp.max, to: after.hp.max }, spellSlots: after.spellSlots } });
|
||||
const planned = planFor(req, res, character, typeof req.query.class === 'string' ? req.query.class : null);
|
||||
if (!planned) return;
|
||||
const { plan, sheet } = planned;
|
||||
const after = sheetOf({ ...withLevelIn(character, plan.characterClass), spellSlotsUsed: [] });
|
||||
// The spells that could be granted now: the campaign's, of a level the
|
||||
// class can cast, that the character does not know. Sent with the plan
|
||||
// because a player otherwise sees only the spells they hold.
|
||||
const spellOptions = plan.spells
|
||||
? await prisma.spell.findMany({
|
||||
where: { campaignId: character.campaignId, level: { lte: plan.spells.maxSpellLevel }, id: { notIn: character.spells.map((held) => held.spellId) } },
|
||||
select: { id: true, name: true, level: true, school: true, description: true },
|
||||
orderBy: [{ level: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
: [];
|
||||
res.json({ plan: { ...plan, maxHp: { from: sheet.hp.max, to: after.hp.max }, hp: hpGain(character, plan), spellSlots: after.spellSlots, spellOptions } });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const levelUpSchema = z.object({
|
||||
characterClass: z.enum(Object.keys(CLASSES)).optional(), // the class taking the level; the first one when left out
|
||||
subclass: z.string().optional(), // which subclass, at the level the class chooses one
|
||||
boon: z.string().optional(), // which Boon, at a level that grants one
|
||||
weaponId: z.string().nullable().optional(), // the weapon that gets the free upgrade
|
||||
hpRoll: z.number().int().min(1).max(12).optional(), // the hit die rolled at the table, where HP is rolled with physical dice
|
||||
spellIds: z.array(z.string()).max(50).default([]), // spells newly granted
|
||||
});
|
||||
|
||||
@@ -569,7 +652,9 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
|
||||
const character = await loadFullCharacter(req.character.id);
|
||||
if (!canLevelUp(req, res, character)) return;
|
||||
|
||||
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character) });
|
||||
const planned = planFor(req, res, character, parsed.data.characterClass || null);
|
||||
if (!planned) return;
|
||||
const { plan } = planned;
|
||||
if (plan.subclass && !subclass) return res.status(400).json({ error: `Level ${plan.toLevel} needs a subclass: pick one.` });
|
||||
if (!plan.subclass && subclass) return res.status(400).json({ error: `There is no subclass to pick at level ${plan.toLevel}.` });
|
||||
if (subclass && !plan.subclass.options.some((option) => option.key === subclass)) {
|
||||
@@ -584,18 +669,42 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
|
||||
if (new Set(spellIds).size !== spellIds.length || spellsFound !== spellIds.length) {
|
||||
return res.status(400).json({ error: "Pick the new spells from this campaign's spell list, each once." });
|
||||
}
|
||||
// The DM grants what they like; a player picks within what the level allows.
|
||||
if (!req.isDM && spellIds.length > 0) {
|
||||
const allowed = plan.spells ? plan.spells.newSpells + plan.spells.newCantrips : 0;
|
||||
const inReach = plan.spells ? await prisma.spell.count({ where: { id: { in: spellIds }, level: { lte: plan.spells.maxSpellLevel } } }) : 0;
|
||||
if (spellIds.length > allowed || inReach !== spellIds.length) {
|
||||
return res.status(400).json({ error: allowed > 0 ? `This level allows ${allowed} new spell${allowed === 1 ? '' : 's'}, of a level you can cast.` : 'This level grants no new spell.' });
|
||||
}
|
||||
}
|
||||
|
||||
// The subclass picked, or the one taken for them where the campaign has no choosing.
|
||||
const newSubclass = subclass || plan.subclassTaken || null;
|
||||
const first = plan.characterClass === character.characterClass;
|
||||
const heldSubclass = (classLevels(character).find((e) => e.characterClass === plan.characterClass) || {}).subclass || null;
|
||||
let gained;
|
||||
try {
|
||||
// The subclass picked, or the one taken for them where the campaign has no choosing.
|
||||
const newSubclass = subclass || plan.subclassTaken;
|
||||
gained = advance(character.specialAbilities, character.characterClass, plan.toLevel, boon, newSubclass ? { key: newSubclass, chosen: true } : { key: character.subclass });
|
||||
gained = advanceIn(character.specialAbilities, plan, boon, newSubclass ? { key: newSubclass, chosen: true } : { key: heldSubclass });
|
||||
} catch (err) {
|
||||
if (!(err instanceof LevelUpError)) throw err;
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
const armorBonus = clamp(character.armorBonus + gained.armorBonus, -30, 30);
|
||||
const maxHp = sheetOf({ ...character, level: plan.toLevel, armorBonus }).hp.max;
|
||||
// Rolled hit points: the die is rolled here, or typed in from the table.
|
||||
const hp = hpGain(character, plan);
|
||||
let hpRolled = null;
|
||||
if (hp.method === 'ROLL') {
|
||||
if (hp.physical) {
|
||||
if (!validateManualRoll(hp.die, parsed.data.hpRoll)) {
|
||||
return res.status(400).json({ error: `Roll a d${hp.die} for hit points and enter what it shows (1 to ${hp.die}).` });
|
||||
}
|
||||
hpRolled = parsed.data.hpRoll;
|
||||
} else {
|
||||
hpRolled = rollDie(hp.die);
|
||||
}
|
||||
}
|
||||
const hpRollAdjust = character.hpRollAdjust + (hpRolled == null ? 0 : hpRolled - hp.average);
|
||||
const maxHp = sheetOf({ ...withLevelIn(character, plan.characterClass, newSubclass || undefined), armorBonus, hpRollAdjust }).hp.max;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (weaponId) {
|
||||
@@ -605,11 +714,22 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
|
||||
});
|
||||
}
|
||||
await tx.characterSpell.createMany({ data: spellIds.map((spellId) => ({ characterId: character.id, spellId })), skipDuplicates: true });
|
||||
// A level in another class goes on that class's row.
|
||||
if (!first) {
|
||||
await tx.characterClassLevel.upsert({
|
||||
where: { characterId_characterClass: { characterId: character.id, characterClass: plan.characterClass } },
|
||||
create: { characterId: character.id, characterClass: plan.characterClass, level: 1, subclass: newSubclass },
|
||||
update: { level: plan.classLevel, ...(newSubclass ? { subclass: newSubclass } : {}) },
|
||||
});
|
||||
}
|
||||
await tx.character.update({
|
||||
where: { id: character.id },
|
||||
data: {
|
||||
level: plan.toLevel,
|
||||
...(subclass || plan.subclassTaken ? { subclass: subclass || plan.subclassTaken } : {}),
|
||||
hpRollAdjust,
|
||||
// An approved level is now taken.
|
||||
levelUpsPending: Math.max(0, character.levelUpsPending - 1),
|
||||
...(first && newSubclass ? { subclass: newSubclass } : {}),
|
||||
specialAbilities: gained.specialAbilities,
|
||||
armorBonus,
|
||||
tempHp: Math.max(character.tempHp, gained.tempHp),
|
||||
@@ -622,7 +742,8 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
|
||||
|
||||
const full = await loadFullCharacter(character.id);
|
||||
emitCharacterUpdated(req);
|
||||
res.json({ character: serializeCharacter(full, req.isOwner) });
|
||||
// hpRolled: what the hit die showed, where HP is rolled (null otherwise).
|
||||
res.json({ character: serializeCharacter(full, req.isOwner, req.isDM), hpRolled });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -745,8 +866,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
|
||||
intScore: source.intScore,
|
||||
wisScore: source.wisScore,
|
||||
chaScore: source.chaScore,
|
||||
halfElfBonusAbility1: source.halfElfBonusAbility1,
|
||||
halfElfBonusAbility2: source.halfElfBonusAbility2,
|
||||
speciesBonusChoices: source.speciesBonusChoices,
|
||||
currentHp: sheet.hp.max,
|
||||
armorBonus: source.armorBonus,
|
||||
speedOverride: source.speedOverride,
|
||||
@@ -762,6 +882,9 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
|
||||
privateNotes: req.isOwner ? source.privateNotes : null,
|
||||
diceMode: source.diceMode,
|
||||
buildStage: 'COMPLETE',
|
||||
classLevels: {
|
||||
create: (source.classLevels || []).map(({ characterClass, level, subclass }) => ({ characterClass, level, subclass })),
|
||||
},
|
||||
skillProficiencies: {
|
||||
create: source.skillProficiencies.map(({ skill, proficient, expertise }) => ({ skill, proficient, expertise })),
|
||||
},
|
||||
@@ -865,18 +988,29 @@ router.post('/:id/build/roll', requireCharacterAccess({ write: true }), async (r
|
||||
if (buildState.speciesRoll) {
|
||||
return res.status(400).json({ error: 'Species die already rolled for this character — choose from what it offered.' });
|
||||
}
|
||||
const roll = resolveSingleRoll(character, req.body, 6);
|
||||
const roll = resolveSingleRoll(character, req.body, SPECIES_ROLL_DIE);
|
||||
buildState.speciesRoll = roll;
|
||||
buildState.speciesChoices = speciesChoicesForRoll(roll);
|
||||
} else if (stage === 'ROLLING_CLASS') {
|
||||
const alreadyRolled = buildState.classChoices && buildState.classChoices.type !== 'reroll';
|
||||
if (alreadyRolled) {
|
||||
// A 7 may be rerolled once (a build from before kept a bare "reroll").
|
||||
const previous = buildState.classChoices;
|
||||
const mayReroll = previous && (previous.canReroll || previous.type === 'reroll');
|
||||
if (previous && !mayReroll) {
|
||||
return res.status(400).json({ error: 'Class die already rolled for this character — submit a choice, or reroll if the current roll allows it.' });
|
||||
}
|
||||
const roll = resolveSingleRoll(character, req.body, 8);
|
||||
const roll = resolveSingleRoll(character, req.body, CLASS_ROLL_DIE);
|
||||
buildState.classRoll = roll;
|
||||
buildState.classChoices = classChoicesForRoll(roll);
|
||||
buildState.classChoices = classChoicesForRoll(roll, { rerolled: Boolean(mayReroll) });
|
||||
delete buildState.characterClass;
|
||||
} else if (stage === 'CHOOSING_SUBCLASS') {
|
||||
// A die with a face per subclass suggests one; the player still picks freely.
|
||||
if (buildState.subclassRoll) {
|
||||
return res.status(400).json({ error: 'The subclass die was already rolled — pick from the list.' });
|
||||
}
|
||||
const options = subclassesOf(buildState.characterClass);
|
||||
const roll = resolveSingleRoll(character, req.body, options.length);
|
||||
buildState.subclassRoll = roll;
|
||||
buildState.subclassSuggestion = options[roll - 1].key;
|
||||
} else if (stage === 'ASSIGNING_SCORES') {
|
||||
if (buildState.abilityRolls) {
|
||||
return res.status(400).json({ error: 'Ability scores were already rolled for this character.' });
|
||||
@@ -899,14 +1033,17 @@ router.post('/:id/build/roll', requireCharacterAccess({ write: true }), async (r
|
||||
}
|
||||
});
|
||||
|
||||
const HALF_ELF_ELIGIBLE_ABILITIES = ABILITIES.filter((a) => a !== 'CHA');
|
||||
|
||||
const chooseSchemas = {
|
||||
// `bonusChoices` are the species' flexible bonuses, { ability: amount }.
|
||||
// `halfElfBonusAbilities` is the older way to give a Half-Elf's two +1s.
|
||||
ROLLING_SPECIES: z.object({
|
||||
species: z.string(),
|
||||
halfElfBonusAbilities: z.array(z.enum(HALF_ELF_ELIGIBLE_ABILITIES)).length(2).optional(),
|
||||
bonusChoices: z.partialRecord(z.enum(ABILITIES), z.number().int().min(0).max(2)).optional(),
|
||||
halfElfBonusAbilities: z.array(z.enum(ABILITIES)).length(2).optional(),
|
||||
}),
|
||||
ROLLING_CLASS: z.object({ characterClass: z.string() }),
|
||||
// null: decide later, at the level the class chooses.
|
||||
CHOOSING_SUBCLASS: z.object({ subclass: z.string().nullable() }),
|
||||
// Motivation is story only, so it is simply picked from the list (the stage
|
||||
// keeps its old name from when it was a die roll).
|
||||
ROLLING_MOTIVATION: z.object({ motivation: z.number().int() }),
|
||||
@@ -962,6 +1099,7 @@ async function allStartingOptions(model, ids, campaignId, reach) {
|
||||
const STAGE_ORDER = [
|
||||
'ROLLING_SPECIES',
|
||||
'ROLLING_CLASS',
|
||||
'CHOOSING_SUBCLASS',
|
||||
'ROLLING_MOTIVATION',
|
||||
'ASSIGNING_SCORES',
|
||||
'ASSIGNING_PROFICIENCIES',
|
||||
@@ -972,9 +1110,19 @@ const STAGE_ORDER = [
|
||||
'COMPLETE',
|
||||
];
|
||||
|
||||
function nextStage(current) {
|
||||
const idx = STAGE_ORDER.indexOf(current);
|
||||
return STAGE_ORDER[idx + 1];
|
||||
// Steps a campaign can leave out: the subclass (without subclasses) and the
|
||||
// motivation (when it skips it).
|
||||
function stageSkipped(stage, campaign) {
|
||||
if (stage === 'CHOOSING_SUBCLASS') return !campaign.subclassesEnabled;
|
||||
if (stage === 'ROLLING_MOTIVATION') return campaign.skipMotivationRoll;
|
||||
return false;
|
||||
}
|
||||
|
||||
// The next (or, with step -1, the previous) step this campaign uses.
|
||||
function nextStage(current, campaign, step = 1) {
|
||||
let idx = STAGE_ORDER.indexOf(current) + step;
|
||||
while (campaign && STAGE_ORDER[idx] && stageSkipped(STAGE_ORDER[idx], campaign)) idx += step;
|
||||
return STAGE_ORDER[idx];
|
||||
}
|
||||
|
||||
router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
@@ -988,7 +1136,7 @@ router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
const buildState = { ...(character.buildState || {}) };
|
||||
let advanceTo = nextStage(stage);
|
||||
let advanceTo = nextStage(stage, req.campaign);
|
||||
|
||||
if (stage === 'ROLLING_SPECIES') {
|
||||
const choices = buildState.speciesChoices;
|
||||
@@ -997,17 +1145,18 @@ router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async
|
||||
if (choices.type !== 'wildcard' && !choices.options.includes(species)) {
|
||||
return res.status(400).json({ error: `${species} was not offered by this roll.` });
|
||||
}
|
||||
if (species === 'HALF_ELF') {
|
||||
if (!halfElfBonusAbilities) {
|
||||
return res.status(400).json({ error: 'Half-Elf requires choosing two abilities (other than CHA) for the +1/+1 bonus.' });
|
||||
}
|
||||
let bonusChoices = parsed.data.bonusChoices || {};
|
||||
if (halfElfBonusAbilities) {
|
||||
if (new Set(halfElfBonusAbilities).size !== 2) {
|
||||
return res.status(400).json({ error: 'The two Half-Elf bonus abilities must be different.' });
|
||||
}
|
||||
buildState.halfElfBonusAbilities = halfElfBonusAbilities;
|
||||
} else {
|
||||
delete buildState.halfElfBonusAbilities;
|
||||
bonusChoices = Object.fromEntries(halfElfBonusAbilities.map((ability) => [ability, 1]));
|
||||
}
|
||||
bonusChoices = Object.fromEntries(Object.entries(bonusChoices).filter(([, amount]) => amount));
|
||||
const problem = speciesBonusProblem(species, bonusChoices);
|
||||
if (problem) return res.status(400).json({ error: problem });
|
||||
delete buildState.halfElfBonusAbilities;
|
||||
buildState.speciesBonusChoices = bonusChoices;
|
||||
buildState.species = species;
|
||||
} else if (stage === 'ROLLING_CLASS') {
|
||||
const choices = buildState.classChoices;
|
||||
@@ -1018,11 +1167,20 @@ router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async
|
||||
if (choices.type !== 'wildcard' && !choices.options.includes(characterClass)) {
|
||||
return res.status(400).json({ error: `${characterClass} was not offered by this roll.` });
|
||||
}
|
||||
buildState.characterClass = characterClass;
|
||||
if (req.campaign.skipMotivationRoll) {
|
||||
advanceTo = 'ASSIGNING_SCORES';
|
||||
buildState.motivationText = null;
|
||||
// A subclass picked for another class goes with it.
|
||||
if (buildState.characterClass !== characterClass) {
|
||||
delete buildState.subclass;
|
||||
delete buildState.subclassRoll;
|
||||
delete buildState.subclassSuggestion;
|
||||
}
|
||||
buildState.characterClass = characterClass;
|
||||
if (req.campaign.skipMotivationRoll) buildState.motivationText = null;
|
||||
} else if (stage === 'CHOOSING_SUBCLASS') {
|
||||
const { subclass } = parsed.data;
|
||||
if (subclass !== null && !findSubclass(buildState.characterClass, subclass)) {
|
||||
return res.status(400).json({ error: 'That is not a subclass of this class.' });
|
||||
}
|
||||
buildState.subclass = subclass;
|
||||
} else if (stage === 'ROLLING_MOTIVATION') {
|
||||
const motivation = characterOptions.MOTIVATIONS.find((m) => m.key === parsed.data.motivation);
|
||||
if (!motivation) return res.status(400).json({ error: 'Pick a motivation from the list.' });
|
||||
@@ -1141,8 +1299,7 @@ router.post('/:id/build/back', requireCharacterAccess({ write: true }), async (r
|
||||
if (idx <= 0 || stage === 'COMPLETE') {
|
||||
return res.status(400).json({ error: 'There is no earlier step to go back to.' });
|
||||
}
|
||||
backTo = STAGE_ORDER[idx - 1];
|
||||
if (backTo === 'ROLLING_MOTIVATION' && req.campaign.skipMotivationRoll) backTo = 'ROLLING_CLASS';
|
||||
backTo = nextStage(stage, req.campaign, -1);
|
||||
}
|
||||
delete buildState.equipment;
|
||||
|
||||
@@ -1173,7 +1330,9 @@ router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), asyn
|
||||
rolledScores[ability] = bs.abilityRolls[rollIndex].total;
|
||||
}
|
||||
|
||||
const [halfElfBonusAbility1, halfElfBonusAbility2] = bs.halfElfBonusAbilities || [null, null];
|
||||
// A build begun before chosen bonuses were general kept a Half-Elf's two picks on their own.
|
||||
const speciesBonusChoices =
|
||||
bs.speciesBonusChoices || Object.fromEntries((bs.halfElfBonusAbilities || []).map((ability) => [ability, 1]));
|
||||
const saveProfs = getClassSaveProficiencies(bs.characterClass);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
@@ -1185,6 +1344,8 @@ router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), asyn
|
||||
data: {
|
||||
species: bs.species,
|
||||
characterClass: bs.characterClass,
|
||||
// Chosen now, its gifts still come at the level the class chooses at.
|
||||
subclass: req.campaign.subclassesEnabled && findSubclass(bs.characterClass, bs.subclass) ? bs.subclass : null,
|
||||
motivation: bs.motivationText || null,
|
||||
// Composed here, from the picks, so the text can only ever say what
|
||||
// the lists allow.
|
||||
@@ -1197,8 +1358,7 @@ router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), asyn
|
||||
intScore: rolledScores.INT,
|
||||
wisScore: rolledScores.WIS,
|
||||
chaScore: rolledScores.CHA,
|
||||
halfElfBonusAbility1,
|
||||
halfElfBonusAbility2,
|
||||
speciesBonusChoices,
|
||||
buildStage: 'COMPLETE',
|
||||
buildState: null,
|
||||
skillProficiencies: {
|
||||
@@ -1281,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,
|
||||
@@ -1462,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 });
|
||||
}
|
||||
@@ -1587,6 +1787,11 @@ for (const gear of GEAR) {
|
||||
const blocked = await equipBlocked(req, gear.pool, req.params.poolId);
|
||||
if (blocked) return res.status(400).json({ error: blocked });
|
||||
}
|
||||
// Drawing or putting away a weapon in a battle waits for the right moment.
|
||||
if (gear.pool === 'weapon' && !req.isDM && parsed.data.equipped !== undefined && parsed.data.equipped !== existing.equipped) {
|
||||
const wait = await weaponSwapWait(req.character);
|
||||
if (wait) return res.status(400).json({ error: wait });
|
||||
}
|
||||
|
||||
const row = await prisma[gear.held].update({ where: whereHeld(req), data, include });
|
||||
if (gear.ownLevel) await settleHp(prisma, [req.character.id]);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const { z } = require('zod');
|
||||
const { Prisma } = require('@prisma/client');
|
||||
const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
const { computeCharacterSheet, computeWeaponStats, scaledDamageDice, canUseGear, abilityModifier, encounterDifficulty, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
const { CURRENCIES, InsufficientGoldError, adjustGold, spendCoins, giveGear } = require('../utils/grants');
|
||||
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
|
||||
const { SHEET_INCLUDE } = require('../utils/sheet');
|
||||
const { SHEET_INCLUDE, bySpellLevel } = require('../utils/sheet');
|
||||
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
|
||||
const { subclassName } = require('../game/data/subclasses');
|
||||
const { settle, rollDamage, rollCritDamage, inTurnOrder } = require('../game/combat');
|
||||
const { rollDie } = require('../game/dice');
|
||||
const { ActionError, targetRows, describeSource, autoActions } = require('../utils/actions');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -55,13 +59,6 @@ function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
// Highest initiative first; ties keep the order combatants were added in.
|
||||
// Anyone who has not rolled yet waits at the bottom.
|
||||
function inTurnOrder(participants) {
|
||||
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
|
||||
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
|
||||
}
|
||||
|
||||
// What is added to a combatant's d20 for initiative: the sheet's initiative
|
||||
// bonus for a character, the Dexterity modifier for a monster, and nothing for
|
||||
// an NPC (they have no ability scores). Takes a participant with its
|
||||
@@ -177,6 +174,7 @@ function characterCombatInfo(character, subclasses = true) {
|
||||
}));
|
||||
const spells = character.spells
|
||||
.filter((cs) => canUseGear(character.level, cs.spell.level))
|
||||
.sort(bySpellLevel)
|
||||
.map((cs) => ({
|
||||
characterSpellId: cs.id,
|
||||
name: cs.spell.name,
|
||||
@@ -184,6 +182,10 @@ function characterCombatInfo(character, subclasses = true) {
|
||||
description: cs.spell.description,
|
||||
damageDice: cs.spell.damageDice,
|
||||
prepared: cs.prepared,
|
||||
// How it lands: a spell attack, a save by each target, or neither.
|
||||
attackRoll: cs.spell.attackRoll,
|
||||
saveAbility: cs.spell.saveAbility,
|
||||
halfOnSave: cs.spell.halfOnSave,
|
||||
}));
|
||||
return {
|
||||
level: character.level,
|
||||
@@ -288,13 +290,16 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
// Likewise their conditions: the character's own, which outlast the scene.
|
||||
out.conditions = character.conditions;
|
||||
out.character = { id: character.id, name: character.name, userId: character.userId };
|
||||
// Where each character is, is part of a free-roam scene; elsewhere it stays on their sheet.
|
||||
if (encounter.mode === 'FREE_ROAM') out.character.currentLocationId = character.currentLocationId;
|
||||
out.characterCombatInfo = combatInfo;
|
||||
}
|
||||
out.conditionRounds = roundsFor(p.conditionRounds, out.conditions);
|
||||
if (monster) out.monster = isDM ? monster : { id: monster.id, name: monster.name };
|
||||
if (npc) {
|
||||
const { dmNotes, ac, maxHp, attackBonus, damageDice, campaignId, createdAt, ...publicNpc } = npc;
|
||||
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice } : publicNpc;
|
||||
// Where they are usually found is the DM's to know, like their notes and stats.
|
||||
const { dmNotes, ac, maxHp, attackBonus, damageDice, locationId, campaignId, createdAt, ...publicNpc } = npc;
|
||||
out.npc = isDM ? { ...publicNpc, dmNotes, ac, maxHp, attackBonus, damageDice, locationId } : publicNpc;
|
||||
}
|
||||
if (hideHp && !character) {
|
||||
// Rounded up, so anything still standing shows a sliver of bar.
|
||||
@@ -305,6 +310,55 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
return out;
|
||||
});
|
||||
|
||||
// Attacks and spells in play: those still being resolved, and the last few
|
||||
// that landed. One the DM is keeping to themselves (a monster's auto-rolled
|
||||
// attack) is theirs alone until it is applied.
|
||||
const actionRows = await prisma.encounterAction.findMany({
|
||||
where: { encounterId, status: { in: ['PENDING', 'APPLIED'] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 30,
|
||||
});
|
||||
const showAction = (action) => {
|
||||
const settled = settle(action);
|
||||
// A player is not told a monster's or NPC's AC.
|
||||
const targets = isDM ? settled.targets : settled.targets.map((t) => (t.kind === 'CHARACTER' ? t : { ...t, ac: null }));
|
||||
const { encounterId: _encounterId, createdById, ...rest } = settled;
|
||||
return { ...rest, targets };
|
||||
};
|
||||
const actions = actionRows
|
||||
.filter((a) => a.status === 'PENDING' && (isDM || !a.hidden))
|
||||
.reverse()
|
||||
.map(showAction);
|
||||
const recentActions = actionRows
|
||||
.filter((a) => a.status === 'APPLIED')
|
||||
.slice(0, 6)
|
||||
.map(showAction);
|
||||
|
||||
// Free roam: the places the party can go to, each with who is found there,
|
||||
// what is for sale there, and which of the characters in the scene are
|
||||
// there now. Everyone in the scene sees every place in it. An NPC's secrets
|
||||
// and stats stay with the DM, as anywhere.
|
||||
let places;
|
||||
if (encounter.mode === 'FREE_ROAM') {
|
||||
const rows = await prisma.encounterLocation.findMany({
|
||||
where: { encounterId },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { location: { include: { npcs: { include: { wares: WARES }, orderBy: { name: 'asc' } }, wares: WARES } } },
|
||||
});
|
||||
places = rows.map(({ location: place }) => ({
|
||||
id: place.id,
|
||||
name: place.name,
|
||||
description: place.description,
|
||||
wares: place.wares,
|
||||
npcs: place.npcs.map(({ dmNotes: secrets, ac, maxHp, attackBonus, damageDice, locationId, campaignId, createdAt, ...publicNpc }) =>
|
||||
isDM ? { ...publicNpc, dmNotes: secrets, ac, maxHp, attackBonus, damageDice, locationId } : publicNpc
|
||||
),
|
||||
here: encounter.participants
|
||||
.filter((p) => p.character && p.character.currentLocationId === place.id)
|
||||
.map((p) => ({ characterId: p.character.id, name: p.displayName, userId: p.character.userId })),
|
||||
}));
|
||||
}
|
||||
|
||||
const { dmNotes, rewards, campaign, location, ...scene } = encounter;
|
||||
const knowsPlace = isDM || (location && (await visitedLocationIds(prisma, viewerId, encounter.campaignId)).has(location.id));
|
||||
return {
|
||||
@@ -316,6 +370,9 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
targetParticipantIds: isDM ? scene.targetParticipantIds : scene.targetParticipantIds.filter((id) => !hiddenIds.has(id)),
|
||||
participants,
|
||||
fallen,
|
||||
actions,
|
||||
recentActions,
|
||||
...(places ? { places } : {}),
|
||||
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
|
||||
};
|
||||
}
|
||||
@@ -338,7 +395,8 @@ async function syncCharacterLocations(req) {
|
||||
where: { id: req.encounter.id },
|
||||
include: { participants: { where: { characterId: { not: null } }, include: { character: { select: { id: true, currentLocationId: true } } } } },
|
||||
});
|
||||
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId) return;
|
||||
// In free roam each character goes where their player sends them.
|
||||
if (!encounter || encounter.status !== 'ACTIVE' || !encounter.locationId || encounter.mode === 'FREE_ROAM') return;
|
||||
|
||||
const elsewhere = encounter.participants.map((p) => p.character).filter((c) => c.currentLocationId !== encounter.locationId);
|
||||
await moveCharacters(prisma, elsewhere.map((c) => c.id), encounter.locationId);
|
||||
@@ -362,7 +420,7 @@ router.get('/:id', requireEncounterAccess, async (req, res, next) => {
|
||||
|
||||
const patchEncounterSchema = z.object({
|
||||
status: z.enum(['PLANNING', 'ACTIVE', 'COMPLETE']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
mode: z.enum(['BATTLE', 'QUEST', 'FREE_ROAM']).optional(),
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
description: z.string().max(4000).nullable().optional(),
|
||||
dmNotes: z.string().max(4000).nullable().optional(),
|
||||
@@ -392,7 +450,8 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
|
||||
// Targets belong to the moment; beginning, ending or reopening clears them.
|
||||
if (data.status && data.status !== req.encounter.status) data.targetParticipantIds = [];
|
||||
|
||||
await prisma.encounter.update({ where: { id: req.encounter.id }, data });
|
||||
const updated = await prisma.encounter.update({ where: { id: req.encounter.id }, data });
|
||||
if (data.status === 'ACTIVE' && req.encounter.status !== 'ACTIVE') await declareAuto(req, updated);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -741,7 +800,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
|
||||
const index = order.findIndex((p) => p.id === req.encounter.activeParticipantId);
|
||||
const upNext = standingFrom(order, index + 1);
|
||||
const wrapped = index !== -1 && order.indexOf(upNext) <= index;
|
||||
await prisma.encounter.update({
|
||||
const updated = await prisma.encounter.update({
|
||||
where: { id: req.encounter.id },
|
||||
data: {
|
||||
status: 'ACTIVE',
|
||||
@@ -750,6 +809,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
|
||||
...(wrapped ? { currentRound: { increment: 1 } } : {}),
|
||||
},
|
||||
});
|
||||
await declareAuto(req, updated);
|
||||
// What wore off as the new round began, for the DM to read out.
|
||||
const expired = wrapped ? await tickConditions(req) : [];
|
||||
await respondWithEncounter(req, res, 200, { expired });
|
||||
@@ -772,6 +832,7 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
|
||||
},
|
||||
});
|
||||
const expired = await tickConditions(req);
|
||||
await declareAuto(req, updated);
|
||||
await syncCharacterLocations(req);
|
||||
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
|
||||
encounterId: updated.id,
|
||||
@@ -783,6 +844,381 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions — an attack or a spell, from being declared to its damage landing
|
||||
// (game/combat.js, utils/actions.js). A player declares their own character's
|
||||
// on their turn and rolls for it; the DM declares anyone's, enters or
|
||||
// overrules any roll, and is the one who applies it. With the campaign's
|
||||
// auto-roll settings a monster's or NPC's turn begins with its attack already
|
||||
// declared and rolled (declareAuto), for the DM to accept, change or discard.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Everything an action needs of the scene's combatants.
|
||||
const COMBATANTS = {
|
||||
character: { include: { ...SHEET_INCLUDE, spells: { include: { spell: true } } } },
|
||||
monster: true,
|
||||
npc: true,
|
||||
};
|
||||
|
||||
// What is left of the turn before: nothing. A turn passing discards whatever
|
||||
// was still being resolved, and then, where the campaign says so, the
|
||||
// combatant now on turn (a monster, or an NPC) declares its own attacks.
|
||||
async function declareAuto(req, encounter) {
|
||||
await prisma.encounterAction.updateMany({ where: { encounterId: encounter.id, status: 'PENDING' }, data: { status: 'DISCARDED' } });
|
||||
if (encounter.mode !== 'BATTLE' || encounter.status !== 'ACTIVE' || !encounter.activeParticipantId) return;
|
||||
const { autoRollMonsters, autoRollNpcs, areaAttackAcPenalty, critRule, hideMonsterRolls } = req.campaign;
|
||||
if (!autoRollMonsters && !autoRollNpcs) return;
|
||||
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: encounter.id }, include: COMBATANTS });
|
||||
const actor = participants.find((p) => p.id === encounter.activeParticipantId);
|
||||
if (!actor || actor.character) return;
|
||||
if (actor.npc ? !autoRollNpcs : !autoRollMonsters) return;
|
||||
|
||||
const history = await prisma.encounterAction.findMany({ where: { encounterId: encounter.id, status: 'APPLIED' }, orderBy: { appliedAt: 'desc' }, take: 40 });
|
||||
const declared = autoActions(actor, participants, { penaltyStep: areaAttackAcPenalty, critRule, history });
|
||||
for (const action of declared) {
|
||||
await prisma.encounterAction.create({ data: { ...action, encounterId: encounter.id, round: encounter.currentRound, hidden: hideMonsterRolls, createdById: null } });
|
||||
}
|
||||
}
|
||||
|
||||
const abilityEnum = z.enum(['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']);
|
||||
|
||||
const actionSourceSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('weapon'), characterWeaponId: z.string() }),
|
||||
z.object({ type: z.literal('spell'), characterSpellId: z.string(), slotLevel: z.number().int().min(1).max(9).optional() }),
|
||||
z.object({ type: z.literal('basic'), index: z.number().int().min(0).max(20).optional() }),
|
||||
z.object({
|
||||
type: z.literal('custom'),
|
||||
name: z.string().trim().min(1).max(60),
|
||||
kind: z.enum(['ATTACK', 'SAVE', 'AUTO']),
|
||||
attackBonus: z.number().int().min(-20).max(50).optional(),
|
||||
saveAbility: abilityEnum.optional(),
|
||||
saveDC: z.number().int().min(1).max(40).optional(),
|
||||
halfOnSave: z.boolean().optional(),
|
||||
damageDice: z.string().max(60).optional().nullable(),
|
||||
damageType: z.string().max(30).optional().nullable(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const createActionSchema = z.object({
|
||||
// Who is acting; whoever is on turn when left out.
|
||||
actorParticipantId: z.string().optional(),
|
||||
source: actionSourceSchema,
|
||||
// Who it is aimed at; the targets marked in the encounter when left out.
|
||||
targetIds: z.array(z.string()).max(50).optional(),
|
||||
hidden: z.boolean().optional(), // the DM's: keep it from the players until applied
|
||||
});
|
||||
|
||||
// Whether the requester's own character is this participant.
|
||||
const ownsParticipant = (req, p) => Boolean(p && p.character && p.character.userId === req.user.id);
|
||||
|
||||
router.post('/:id/actions', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = createActionSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { source } = parsed.data;
|
||||
if (req.encounter.status !== 'ACTIVE') return res.status(400).json({ error: 'Begin the encounter before anyone attacks.' });
|
||||
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id }, include: COMBATANTS });
|
||||
const actorId = parsed.data.actorParticipantId || req.encounter.activeParticipantId;
|
||||
const actor = participants.find((p) => p.id === actorId) || null;
|
||||
if (!req.isDM) {
|
||||
// A player acts for their own character, on its turn, with what it has.
|
||||
if (!ownsParticipant(req, actor) || actor.id !== req.encounter.activeParticipantId) {
|
||||
return res.status(403).json({ error: "You can only attack on your own character's turn." });
|
||||
}
|
||||
if (source.type === 'custom' || source.type === 'basic') return res.status(403).json({ error: 'Pick one of your weapons or spells.' });
|
||||
}
|
||||
|
||||
const targetIds = [...new Set(parsed.data.targetIds || req.encounter.targetParticipantIds)];
|
||||
const targets = targetIds.map((id) => participants.find((p) => p.id === id));
|
||||
if (targets.length === 0) return res.status(400).json({ error: 'Pick a target first.' });
|
||||
if (targets.some((p) => !p)) return res.status(400).json({ error: 'Combatant not found in this encounter' });
|
||||
|
||||
let described;
|
||||
try {
|
||||
described = describeSource(actor, source);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ActionError)) throw err;
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
const { kind, name, detail } = described;
|
||||
|
||||
// One thing at a time: a new action takes the place of the actor's unfinished one.
|
||||
await prisma.encounterAction.updateMany({
|
||||
where: { encounterId: req.encounter.id, status: 'PENDING', actorParticipantId: actor ? actor.id : null },
|
||||
data: { status: 'DISCARDED' },
|
||||
});
|
||||
const action = await prisma.encounterAction.create({
|
||||
data: {
|
||||
encounterId: req.encounter.id,
|
||||
round: req.encounter.currentRound,
|
||||
actorParticipantId: actor ? actor.id : null,
|
||||
actorName: actor ? actor.displayName : 'The DM',
|
||||
kind,
|
||||
name,
|
||||
detail,
|
||||
targets: targetRows(kind, detail, targets, req.campaign.areaAttackAcPenalty),
|
||||
hidden: req.isDM && Boolean(parsed.data.hidden),
|
||||
createdById: req.user.id,
|
||||
},
|
||||
});
|
||||
await respondWithEncounter(req, res, 201, { actionId: action.id });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const d20Schema = z.number().int().min(1, 'A d20 shows 1 to 20').max(20, 'A d20 shows 1 to 20');
|
||||
|
||||
const patchActionSchema = z.object({
|
||||
// A d20 typed in for a target: the attack roll against them, or their saving throw. null clears it.
|
||||
rolls: z.array(z.object({ participantId: z.string(), d20: d20Schema.nullable() })).max(50).optional(),
|
||||
// Have the server roll every d20 still missing that the requester may roll.
|
||||
rollRemaining: z.boolean().optional(),
|
||||
// The DM overruling the dice for a target; null goes back to what was rolled.
|
||||
outcomes: z.array(z.object({ participantId: z.string(), outcome: z.enum(['HIT', 'MISS', 'CRIT', 'SAVED', 'FAILED']).nullable() })).max(50).optional(),
|
||||
// The damage, typed in (dice rolled at the table), or rolled by the server.
|
||||
damage: z.number().int().min(0).max(100000).nullable().optional(),
|
||||
rollDamage: z.boolean().optional(),
|
||||
// The DM setting what one target takes; null goes back to what the action deals.
|
||||
targetDamage: z.array(z.object({ participantId: z.string(), damage: z.number().int().min(0).max(100000).nullable() })).max(50).optional(),
|
||||
// The DM aiming it at someone else: rolls already made stay with their place in the list.
|
||||
targetIds: z.array(z.string()).min(1).max(50).optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
discard: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Loads a pending action of this encounter with its actor, or answers and returns null.
|
||||
async function loadAction(req, res) {
|
||||
const action = await prisma.encounterAction.findUnique({ where: { id: req.params.actionId } });
|
||||
if (!action || action.encounterId !== req.encounter.id) {
|
||||
res.status(404).json({ error: 'That attack is not part of this encounter.' });
|
||||
return null;
|
||||
}
|
||||
if (action.status !== 'PENDING') {
|
||||
res.status(400).json({ error: action.status === 'APPLIED' ? 'That attack has already landed.' : 'That attack was discarded.' });
|
||||
return null;
|
||||
}
|
||||
if (!req.isDM && action.hidden) {
|
||||
res.status(404).json({ error: 'That attack is not part of this encounter.' });
|
||||
return null;
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
router.patch('/:id/actions/:actionId', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = patchActionSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const action = await loadAction(req, res);
|
||||
if (!action) return;
|
||||
const input = parsed.data;
|
||||
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id }, include: COMBATANTS });
|
||||
const actor = participants.find((p) => p.id === action.actorParticipantId) || null;
|
||||
const actsForActor = req.isDM || ownsParticipant(req, actor);
|
||||
// Who may put in the d20 for a target: for an attack, whoever is
|
||||
// attacking; for a save, the target's own player. The DM, always.
|
||||
const mayRollFor = (target) =>
|
||||
req.isDM || (action.kind === 'ATTACK' ? actsForActor : ownsParticipant(req, participants.find((p) => p.id === target.participantId)));
|
||||
// A player follows the campaign's dice; the DM enters or rolls as they like.
|
||||
const { diceMode } = req.campaign;
|
||||
const mayType = req.isDM || diceMode !== 'DIGITAL';
|
||||
const mayAsk = req.isDM || diceMode !== 'PHYSICAL';
|
||||
|
||||
if (input.discard) {
|
||||
if (!actsForActor) return res.status(403).json({ error: 'Only the DM or whoever declared it can call it off.' });
|
||||
await prisma.encounterAction.update({ where: { id: action.id }, data: { status: 'DISCARDED' } });
|
||||
return await respondWithEncounter(req, res);
|
||||
}
|
||||
const dmOnly = input.outcomes || input.targetDamage || input.targetIds || input.hidden !== undefined;
|
||||
if (dmOnly && !req.isDM) return res.status(403).json({ error: 'Only the DM overrules a roll, sets damage by hand or changes the targets.' });
|
||||
|
||||
let targets = action.targets.map((t) => ({ ...t }));
|
||||
let damage = action.damage;
|
||||
const data = {};
|
||||
|
||||
if (input.targetIds) {
|
||||
const next = [...new Set(input.targetIds)].map((id) => participants.find((p) => p.id === id));
|
||||
if (next.some((p) => !p)) return res.status(400).json({ error: 'Combatant not found in this encounter' });
|
||||
const fresh = targetRows(action.kind, action.detail, next, req.campaign.areaAttackAcPenalty);
|
||||
// A target kept keeps its roll; a new one in an old one's place takes the
|
||||
// roll already made (the die was cast before the aim was changed).
|
||||
targets = fresh.map((row, i) => {
|
||||
const kept = targets.find((t) => t.participantId === row.participantId);
|
||||
const d20 = kept ? kept.d20 : targets[i] && !next.some((p) => p.id === targets[i].participantId) ? targets[i].d20 : null;
|
||||
return { ...row, d20: action.kind === 'SAVE' && !kept ? null : d20 };
|
||||
});
|
||||
}
|
||||
|
||||
for (const { participantId, d20 } of input.rolls || []) {
|
||||
const target = targets.find((t) => t.participantId === participantId);
|
||||
if (!target) return res.status(400).json({ error: 'That is not one of its targets.' });
|
||||
if (!mayRollFor(target)) return res.status(403).json({ error: action.kind === 'SAVE' ? 'You can only enter the save for your own character.' : 'Only the attacker (or the DM) enters the attack roll.' });
|
||||
if (!mayType) return res.status(400).json({ error: 'This campaign plays with digital dice: the app rolls.' });
|
||||
target.d20 = d20;
|
||||
}
|
||||
if (input.rollRemaining) {
|
||||
if (!mayAsk) return res.status(400).json({ error: 'This campaign plays with physical dice: roll at the table and enter what the d20 shows.' });
|
||||
for (const target of targets) if (target.d20 == null && action.kind !== 'AUTO' && mayRollFor(target)) target.d20 = rollDie(20);
|
||||
}
|
||||
for (const { participantId, outcome } of input.outcomes || []) {
|
||||
const target = targets.find((t) => t.participantId === participantId);
|
||||
if (!target) return res.status(400).json({ error: 'That is not one of its targets.' });
|
||||
const allowed = action.kind === 'SAVE' ? ['SAVED', 'FAILED'] : ['HIT', 'MISS', 'CRIT'];
|
||||
if (outcome !== null && !allowed.includes(outcome)) return res.status(400).json({ error: `For this it is ${allowed.join(', ')}.` });
|
||||
target.outcome = outcome;
|
||||
}
|
||||
for (const { participantId, damage: amount } of input.targetDamage || []) {
|
||||
const target = targets.find((t) => t.participantId === participantId);
|
||||
if (!target) return res.status(400).json({ error: 'That is not one of its targets.' });
|
||||
target.damage = amount;
|
||||
}
|
||||
|
||||
if (input.damage !== undefined) {
|
||||
if (!actsForActor) return res.status(403).json({ error: 'Only the attacker (or the DM) enters the damage.' });
|
||||
if (!mayType) return res.status(400).json({ error: 'This campaign plays with digital dice: the app rolls.' });
|
||||
damage = input.damage === null ? null : { total: input.damage, entered: true };
|
||||
}
|
||||
const settledNow = () => settle({ ...action, targets, damage });
|
||||
if (input.rollDamage) {
|
||||
if (!actsForActor) return res.status(403).json({ error: 'Only the attacker (or the DM) rolls the damage.' });
|
||||
if (!mayAsk) return res.status(400).json({ error: 'This campaign plays with physical dice: roll the damage at the table and enter the total.' });
|
||||
const critical = settledNow().targets.some((t) => t.outcome === 'CRIT');
|
||||
damage = rollDamage(action.detail, { critical, critRule: req.campaign.critRule });
|
||||
if (!damage) return res.status(400).json({ error: 'It has no damage dice to roll. Enter the damage instead.' });
|
||||
}
|
||||
// A critical hit that turns up after the damage was rolled gets its dice.
|
||||
if (damage && !damage.entered && !damage.crit && settledNow().needsCrit) {
|
||||
damage = { ...damage, crit: rollCritDamage(action.detail, { critRule: req.campaign.critRule }) };
|
||||
}
|
||||
|
||||
if (input.hidden !== undefined) data.hidden = input.hidden;
|
||||
await prisma.encounterAction.update({ where: { id: action.id }, data: { ...data, targets, damage: damage === null ? Prisma.DbNull : damage } });
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// The DM lets it land: every target takes what the action comes to for them
|
||||
// (temp HP first, death saves at 0, as with any damage), a spell takes its
|
||||
// slot, and the action is done and shown to everyone. Answers with a line for
|
||||
// anyone hurt while concentrating, for the DM to call the save.
|
||||
router.post('/:id/actions/:actionId/apply', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const action = await loadAction(req, res);
|
||||
if (!action) return;
|
||||
const settled = settle(action);
|
||||
if (!settled.ready) {
|
||||
return res.status(400).json({
|
||||
error: settled.needsRolls > 0 ? `Still waiting on ${settled.needsRolls} d20${settled.needsRolls === 1 ? '' : 's'}.` : 'The damage has not been rolled or entered yet.',
|
||||
});
|
||||
}
|
||||
|
||||
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id }, include: COMBATANTS });
|
||||
const concentration = [];
|
||||
for (const target of settled.targets) {
|
||||
const p = participants.find((x) => x.id === target.participantId);
|
||||
const amount = target.finalDamage || 0;
|
||||
if (!p || amount <= 0) continue;
|
||||
const conditions = p.character ? p.character.conditions : p.conditions;
|
||||
if (conditions.includes('Concentrating')) concentration.push({ name: p.displayName, damage: amount, dc: Math.max(10, Math.floor(amount / 2)) });
|
||||
if (p.character) {
|
||||
if (lifeState(p.character) === 'DEAD') continue;
|
||||
const maxHp = computeCharacterSheet(p.character, p.character.skillProficiencies, p.character.saveProficiencies).hp.max;
|
||||
const after = changeHp(p.character, p.character.currentHp - amount, maxHp);
|
||||
await prisma.character.update({ where: { id: p.character.id }, data: after });
|
||||
await prisma.encounterParticipant.update({ where: { id: p.id }, data: { currentHp: after.currentHp, maxHp } });
|
||||
emitCharacterUpdated(req, p.character.id);
|
||||
} else if (p.maxHp > 0) {
|
||||
await prisma.encounterParticipant.update({ where: { id: p.id }, data: { currentHp: clamp(p.currentHp - amount, 0, p.maxHp) } });
|
||||
}
|
||||
}
|
||||
|
||||
// A spell takes a slot of the level it was cast at, when the caster has one left.
|
||||
let slotNote = null;
|
||||
const { characterId, slotLevel } = action.detail;
|
||||
const caster = slotLevel ? participants.map((p) => p.character).find((c) => c && c.id === characterId) : null;
|
||||
if (caster) {
|
||||
const slots = computeCharacterSheet(caster, caster.skillProficiencies, caster.saveProficiencies).spellSlots;
|
||||
const slot = slots ? slots.levels.find((l) => l.level === slotLevel) : null;
|
||||
if (slot && slot.remaining > 0) {
|
||||
const used = Array.from({ length: slots.levels[slots.levels.length - 1].level }, (_, i) => {
|
||||
const at = slots.levels.find((l) => l.level === i + 1);
|
||||
return at ? at.used + (at.level === slotLevel ? 1 : 0) : 0;
|
||||
});
|
||||
await prisma.character.update({ where: { id: caster.id }, data: { spellSlotsUsed: used } });
|
||||
emitCharacterUpdated(req, caster.id);
|
||||
} else {
|
||||
slotNote = `${caster.name} had no level ${slotLevel} spell slot left for ${action.name}: nothing was spent.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Kept as it landed, so later turns know who hurt whom.
|
||||
await prisma.encounterAction.update({
|
||||
where: { id: action.id },
|
||||
data: { status: 'APPLIED', appliedAt: new Date(), hidden: false, targets: settled.targets },
|
||||
});
|
||||
await respondWithEncounter(req, res, 200, { concentration, slotNote });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Free roam — the party wanders between the places the DM has laid out for
|
||||
// the scene: to shop, to meet whoever is found there. The DM sets the places;
|
||||
// each player sends their own character to one (the DM can send anyone).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The whole list of places; an empty one clears it.
|
||||
router.put('/:id/places', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ locationIds: z.array(z.string()).max(50) }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const locationIds = [...new Set(parsed.data.locationIds)];
|
||||
if ((await prisma.location.count({ where: { id: { in: locationIds }, campaignId: req.encounter.campaignId } })) !== locationIds.length) {
|
||||
return res.status(400).json({ error: "Pick places from this campaign's locations." });
|
||||
}
|
||||
const had = await prisma.encounterLocation.findMany({ where: { encounterId: req.encounter.id }, select: { locationId: true } });
|
||||
const keep = new Set(had.map((row) => row.locationId));
|
||||
await prisma.$transaction([
|
||||
prisma.encounterLocation.deleteMany({ where: { encounterId: req.encounter.id, locationId: { notIn: locationIds } } }),
|
||||
// Added one at a time, in the order given, so the list keeps that order.
|
||||
...locationIds.filter((id) => !keep.has(id)).map((locationId) => prisma.encounterLocation.create({ data: { encounterId: req.encounter.id, locationId } })),
|
||||
]);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// A character goes to one of the scene's places: their own player sends them, or the DM.
|
||||
router.post('/:id/visit', requireEncounterAccess, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = z.object({ characterId: z.string(), locationId: z.string() }).safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { characterId, locationId } = parsed.data;
|
||||
if (req.encounter.mode !== 'FREE_ROAM') return res.status(400).json({ error: 'Only a free-roam scene has places to go to.' });
|
||||
if (req.encounter.status !== 'ACTIVE') return res.status(400).json({ error: 'The scene has not begun yet.' });
|
||||
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, characterId }, include: { character: true } });
|
||||
if (!row) return res.status(400).json({ error: 'That character is not in this scene.' });
|
||||
if (!req.isDM && row.character.userId !== req.user.id) return res.status(403).json({ error: 'You can only move your own character.' });
|
||||
if (isFallen(row)) return res.status(400).json({ error: `${row.character.name} is dead.` });
|
||||
const place = await prisma.encounterLocation.findFirst({ where: { encounterId: req.encounter.id, locationId } });
|
||||
if (!place) return res.status(400).json({ error: 'That is not one of the places in this scene.' });
|
||||
|
||||
await moveCharacters(prisma, [characterId], locationId);
|
||||
emitCharacterUpdated(req, characterId);
|
||||
await respondWithEncounter(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Experience — awarded once, shared evenly between the characters in the scene
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -973,10 +1409,18 @@ function money(amount, currency) {
|
||||
}
|
||||
|
||||
// Trading takes someone to trade with and the time to do it, so it happens
|
||||
// in a quest in progress and never in a battle.
|
||||
// in a quest or a free-roam scene in progress, and never in a battle.
|
||||
function assertTradeOpen(encounter) {
|
||||
if (encounter.mode !== 'QUEST') throw new TradeError('Trading happens in a quest, not in a battle.');
|
||||
if (encounter.status !== 'ACTIVE') throw new TradeError('Trading opens once the quest is in progress.');
|
||||
if (encounter.mode === 'BATTLE') throw new TradeError('Trading happens in a quest or while roaming, not in a battle.');
|
||||
if (encounter.status !== 'ACTIVE') throw new TradeError(`Trading opens once the ${encounter.mode === 'QUEST' ? 'quest' : 'scene'} is in progress.`);
|
||||
}
|
||||
|
||||
// In a free-roam scene a character trades where they are: the place has to
|
||||
// be one of the scene's, and they have to have gone there.
|
||||
async function assertAtPlace(req, character, locationId, what) {
|
||||
const place = locationId ? await prisma.encounterLocation.findFirst({ where: { encounterId: req.encounter.id, locationId }, include: { location: true } }) : null;
|
||||
if (!place) throw new TradeError(`${what} is not here to trade with.`);
|
||||
if (character.currentLocationId !== locationId) throw new TradeError(`${character.name} has to go to ${place.location.name} first.`);
|
||||
}
|
||||
|
||||
// The character doing the trading: in this scene, alive, and the player's
|
||||
@@ -989,8 +1433,15 @@ async function loadTrader(req, characterId) {
|
||||
return row.character;
|
||||
}
|
||||
|
||||
// An NPC to trade with has to be in the scene and still standing.
|
||||
async function loadTradingNpc(req, npcId) {
|
||||
// An NPC to trade with has to be in the scene and still standing; in a
|
||||
// free-roam scene, at one of its places, with the character there too.
|
||||
async function loadTradingNpc(req, npcId, character) {
|
||||
if (req.encounter.mode === 'FREE_ROAM') {
|
||||
const npc = await prisma.npc.findFirst({ where: { id: npcId, campaignId: req.encounter.campaignId } });
|
||||
if (!npc) throw new TradeError('That NPC is not here to trade with.');
|
||||
await assertAtPlace(req, character, npc.locationId, npc.name);
|
||||
return npc;
|
||||
}
|
||||
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, npcId }, include: { npc: true } });
|
||||
if (!row || isFallen(row)) throw new TradeError('That NPC is not here to trade with.');
|
||||
return row.npc;
|
||||
@@ -1023,7 +1474,7 @@ router.post(
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
|
||||
const character = await loadTrader(req, characterId);
|
||||
const npc = await loadTradingNpc(req, npcId);
|
||||
const npc = await loadTradingNpc(req, npcId, character);
|
||||
if (!npc.exchangeGold || !npc.exchangeCash) throw new TradeError(`${npc.name} does not change money.`);
|
||||
const rate = { GOLD: npc.exchangeGold, CASH: npc.exchangeCash };
|
||||
if (amount % rate[from] !== 0) {
|
||||
@@ -1065,7 +1516,12 @@ router.post(
|
||||
if (!ware || ware.campaignId !== req.encounter.campaignId || (ware.encounterId && ware.encounterId !== req.encounter.id)) {
|
||||
throw new TradeError('That is not for sale here.', 404);
|
||||
}
|
||||
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId)).name : null;
|
||||
// A shop at a place sells to whoever has gone there, while that place is part of the scene.
|
||||
if (ware.locationId) {
|
||||
if (req.encounter.mode !== 'FREE_ROAM') throw new TradeError('That is not for sale here.', 404);
|
||||
await assertAtPlace(req, character, ware.locationId, 'That shop');
|
||||
}
|
||||
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId, character)).name : null;
|
||||
const kind = ware.weapon ? 'weapon' : 'item';
|
||||
const entry = ware[kind];
|
||||
const total = ware.price * quantity;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Rules the app knows, for any signed-in user: nothing here belongs to a
|
||||
// campaign or a character.
|
||||
const express = require('express');
|
||||
const { z } = require('zod');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { CLASSES } = require('../game/data/classes');
|
||||
const { findSubclass } = require('../game/data/subclasses');
|
||||
const { progression } = require('../game/levelUp');
|
||||
const { MULTICLASS_PREREQS, PREREQ_SCORE, prereqText } = require('../game/multiclass');
|
||||
const { MULTICLASS_PATHS } = require('../game/data/multiclassPaths');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
// GET /reference/progression?class=FIGHTER&subclass=CHAMPION — what each
|
||||
// level from 1 to 20 gives a class, with the subclass's lines when one is
|
||||
// named. Shown as the progression timeline in the builder, on the sheet and
|
||||
// when levelling up.
|
||||
router.get('/progression', (req, res) => {
|
||||
const parsed = z.object({ class: z.enum(Object.keys(CLASSES)), subclass: z.string().optional() }).safeParse(req.query);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { class: characterClass, subclass } = parsed.data;
|
||||
if (subclass && !findSubclass(characterClass, subclass)) return res.status(400).json({ error: 'That is not a subclass of this class.' });
|
||||
res.json({ characterClass, subclass: subclass || null, levels: progression(characterClass, subclass) });
|
||||
});
|
||||
|
||||
// GET /reference/multiclass — what each class asks of the ability scores to
|
||||
// multiclass into or out of it, and the suggested routes (data/multiclassPaths.js).
|
||||
router.get('/multiclass', (req, res) => {
|
||||
res.json({
|
||||
prereqScore: PREREQ_SCORE,
|
||||
prereqs: Object.fromEntries(Object.keys(CLASSES).map((cls) => [cls, { alternatives: MULTICLASS_PREREQS[cls], text: prereqText(cls) }])),
|
||||
paths: MULTICLASS_PATHS,
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -60,7 +60,7 @@ const POOLS = {
|
||||
},
|
||||
items: {
|
||||
library: ITEM_LIBRARY,
|
||||
row: z.strictObject({ name: text(120), description: optionalText(2000), damageDice: dice, ...gear }),
|
||||
row: z.strictObject({ name: text(120), description: optionalText(2000), damageDice: dice, armorType: z.enum(['LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD']).nullish(), ...gear }),
|
||||
},
|
||||
spells: {
|
||||
library: SPELL_LIBRARY,
|
||||
@@ -75,6 +75,10 @@ const POOLS = {
|
||||
description: optionalText(4000),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
revives: z.boolean().optional(),
|
||||
// How it lands in a battle: a spell attack, or a save by each target.
|
||||
attackRoll: z.boolean().optional(),
|
||||
saveAbility: z.enum(['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA']).nullish(),
|
||||
halfOnSave: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
monsters: {
|
||||
@@ -95,6 +99,19 @@ const POOLS = {
|
||||
challengeRating: optionalText(10),
|
||||
specialTrait: optionalText(500),
|
||||
notes: optionalText(2000),
|
||||
// Named attacks, for more than the one above; `count` makes it a multiattack.
|
||||
attacks: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
name: text(40),
|
||||
attackBonus: z.number().int().min(-10).max(30),
|
||||
damageDice: damage,
|
||||
damageType: optionalText(30),
|
||||
count: z.number().int().min(1).max(10).optional(),
|
||||
})
|
||||
)
|
||||
.max(8)
|
||||
.optional(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Battle actions against the database's shapes: reading a combatant's numbers
|
||||
// off a loaded participant, and putting together the action a weapon, a spell
|
||||
// or a creature's attack makes (EncounterAction). The rules themselves are in
|
||||
// game/combat.js. No queries here: everything is passed in loaded.
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { computeCharacterSheet, computeWeaponStats, abilityModifier, canUseGear } = require('../game/calculations');
|
||||
const { attacksOf, opponentsOf, pickTarget, rollDamage, attackOutcome, lands } = require('../game/combat');
|
||||
const { rollDie } = require('../game/dice');
|
||||
const { lifeState } = require('./hp');
|
||||
|
||||
// A problem with what was asked for: the message is for whoever asked.
|
||||
class ActionError extends Error {}
|
||||
|
||||
const sheetOf = (character) => computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
|
||||
|
||||
const kindOf = (p) => (p.npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER');
|
||||
|
||||
// Out of the fight: a dead character, or a monster or NPC at 0 HP.
|
||||
function isFallen(p) {
|
||||
if (p.character) return lifeState(p.character) === 'DEAD';
|
||||
return p.maxHp > 0 && p.currentHp <= 0;
|
||||
}
|
||||
|
||||
// A participant's AC: the sheet's for a character, the stat block's for a
|
||||
// monster or NPC (10 for an NPC with no combat stats).
|
||||
function armorClassOf(p) {
|
||||
if (p.character) return sheetOf(p.character).armorClass;
|
||||
if (p.monster) return p.monster.ac;
|
||||
return p.npc && p.npc.ac != null ? p.npc.ac : 10;
|
||||
}
|
||||
|
||||
// What a participant adds to a saving throw with an ability: the sheet's save
|
||||
// bonus for a character, the ability modifier for a monster, nothing for an NPC.
|
||||
function saveBonusOf(p, ability) {
|
||||
if (p.character) return sheetOf(p.character).saves[ability].bonus;
|
||||
if (p.monster) return abilityModifier(p.monster[`${ability.toLowerCase()}Score`] ?? 10);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// A participant as game/combat.js sees a combatant.
|
||||
const combatantOf = (p) => ({ id: p.id, name: p.displayName, kind: kindOf(p), disposition: p.npc ? p.npc.disposition : null, ac: armorClassOf(p), fallen: isFallen(p) });
|
||||
|
||||
const areaPenalty = (targetCount, step) => Math.max(0, targetCount - 1) * step;
|
||||
|
||||
// The targets of an action as it starts: who, and the number each roll is
|
||||
// against. `penaltyStep` is the campaign's area-attack AC penalty.
|
||||
function targetRows(kind, detail, targets, penaltyStep) {
|
||||
const penalty = kind === 'ATTACK' ? areaPenalty(targets.length, penaltyStep) : 0;
|
||||
return targets.map((p) => ({
|
||||
participantId: p.id,
|
||||
name: p.displayName,
|
||||
kind: kindOf(p),
|
||||
ac: kind === 'ATTACK' ? armorClassOf(p) : null,
|
||||
penalty,
|
||||
saveBonus: kind === 'SAVE' ? saveBonusOf(p, detail.saveAbility) : null,
|
||||
d20: null,
|
||||
outcome: null,
|
||||
damage: null,
|
||||
}));
|
||||
}
|
||||
|
||||
// What an action is made of, from what it is made with:
|
||||
// { type: 'weapon', characterWeaponId } a character's equipped weapon
|
||||
// { type: 'spell', characterSpellId, slotLevel? } a spell a character knows
|
||||
// { type: 'basic', index? } a monster's or NPC's own attack
|
||||
// { type: 'custom', name, kind, attackBonus?, saveAbility?, saveDC?, halfOnSave?, damageDice? }
|
||||
// anything else, spelled out (the DM's: a breath weapon, a trap)
|
||||
// Returns { kind, name, detail }. Throws ActionError when the actor can't.
|
||||
function describeSource(actor, source) {
|
||||
const character = actor ? actor.character : null;
|
||||
if (source.type === 'custom') {
|
||||
const kind = source.kind || 'ATTACK';
|
||||
return {
|
||||
kind,
|
||||
name: source.name || 'Attack',
|
||||
detail: {
|
||||
attackBonus: kind === 'ATTACK' ? source.attackBonus || 0 : undefined,
|
||||
saveAbility: kind === 'SAVE' ? source.saveAbility || 'DEX' : undefined,
|
||||
saveDC: kind === 'SAVE' ? source.saveDC || 10 : undefined,
|
||||
halfOnSave: kind === 'SAVE' ? Boolean(source.halfOnSave) : undefined,
|
||||
damageDice: source.damageDice || null,
|
||||
damageBonus: 0,
|
||||
damageType: source.damageType || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!actor) throw new ActionError('Nobody is on turn to make that attack.');
|
||||
|
||||
if (source.type === 'basic') {
|
||||
const attacks = attacksOf({ monster: actor.monster, npc: actor.npc });
|
||||
const attack = attacks[source.index || 0];
|
||||
if (!attack) throw new ActionError(`${actor.displayName} has no attack to make. Give it an attack bonus and damage first.`);
|
||||
return { kind: 'ATTACK', name: attack.name, detail: { attackBonus: attack.attackBonus, damageDice: attack.damageDice, damageBonus: 0, damageType: attack.damageType } };
|
||||
}
|
||||
|
||||
if (!character) throw new ActionError(`${actor.displayName} fights with its own attack, not a weapon or a spell.`);
|
||||
const sheet = sheetOf(character);
|
||||
|
||||
if (source.type === 'weapon') {
|
||||
const held = character.weapons.find((cw) => cw.id === source.characterWeaponId);
|
||||
if (!held) throw new ActionError(`${character.name} does not hold that weapon.`);
|
||||
if (!held.equipped || !canUseGear(character.level, held.level)) throw new ActionError(`${held.weapon.name} has to be equipped (and within ${character.name}'s level) to be used.`);
|
||||
const stats = computeWeaponStats(held.weapon, sheet.abilities, sheet.proficiencyBonus, true, held.level);
|
||||
return {
|
||||
kind: 'ATTACK',
|
||||
name: held.weapon.name,
|
||||
detail: { attackBonus: stats.attackBonus, damageDice: stats.damageDice, damageBonus: stats.damageBonus, damageType: stats.damageType, characterId: character.id },
|
||||
};
|
||||
}
|
||||
|
||||
if (source.type === 'spell') {
|
||||
const known = character.spells.find((cs) => cs.id === source.characterSpellId);
|
||||
if (!known) throw new ActionError(`${character.name} does not know that spell.`);
|
||||
const spell = known.spell;
|
||||
if (!canUseGear(character.level, spell.level)) throw new ActionError(`${spell.name} is beyond ${character.name}'s level.`);
|
||||
const casting = sheet.spellcasting;
|
||||
// A cantrip takes no slot; any other spell takes one of its own level, or
|
||||
// the lowest one above it that is left. With none left it cannot be cast.
|
||||
// (A character with no slots at all, handed a spell by the DM, casts it freely.)
|
||||
let slotLevel = null;
|
||||
if (spell.level > 0) {
|
||||
slotLevel = Math.max(spell.level, source.slotLevel || spell.level);
|
||||
if (sheet.spellSlots) {
|
||||
const wanted = slotLevel;
|
||||
const free = sheet.spellSlots.levels.find((l) => l.level >= wanted && l.remaining > 0);
|
||||
if (!free) throw new ActionError(`${character.name} has no spell slot of level ${wanted} or higher left, so cannot cast ${spell.name}.`);
|
||||
slotLevel = free.level;
|
||||
}
|
||||
}
|
||||
const base = { damageDice: spell.damageDice || null, damageBonus: 0, damageType: null, characterId: character.id, spellLevel: spell.level, slotLevel };
|
||||
if (spell.attackRoll) return { kind: 'ATTACK', name: spell.name, detail: { ...base, attackBonus: casting ? casting.attackBonus : 0 } };
|
||||
if (spell.saveAbility) {
|
||||
return { kind: 'SAVE', name: spell.name, detail: { ...base, saveAbility: spell.saveAbility, saveDC: casting ? casting.saveDC : 10, halfOnSave: spell.halfOnSave } };
|
||||
}
|
||||
return { kind: 'AUTO', name: spell.name, detail: base };
|
||||
}
|
||||
throw new ActionError('That is not something to attack with.');
|
||||
}
|
||||
|
||||
// A fair number from 0 up to (not including) 1, for picking a target.
|
||||
const randomUnit = () => crypto.randomInt(0, 1000000) / 1000000;
|
||||
|
||||
// The attacks a monster or NPC makes by itself on its turn, each already
|
||||
// aimed and rolled: who it goes for (game/combat.js, pickTarget), the d20, and
|
||||
// the damage where it hits. `history` is the encounter's applied actions,
|
||||
// newest first, for whom it fought last and who has hurt it. Returns the
|
||||
// actions to create (without ids); empty when it has no attack or nobody to
|
||||
// go for.
|
||||
function autoActions(actor, participants, { penaltyStep = 0, critRule = 'DOUBLE_DICE', history = [] } = {}) {
|
||||
const attacks = attacksOf({ monster: actor.monster, npc: actor.npc });
|
||||
const combatants = participants.map(combatantOf);
|
||||
const opponents = opponentsOf(combatantOf(actor), combatants);
|
||||
if (attacks.length === 0 || opponents.length === 0) return [];
|
||||
|
||||
const mine = history.find((action) => action.actorParticipantId === actor.id);
|
||||
let lastTargetId = mine && mine.targets[0] ? mine.targets[0].participantId : null;
|
||||
const hurtBy = history
|
||||
.filter((action) => (action.targets || []).some((t) => t.participantId === actor.id && t.finalDamage > 0))
|
||||
.map((action) => action.actorParticipantId)
|
||||
.filter(Boolean);
|
||||
|
||||
return attacks.map((attack) => {
|
||||
const picked = pickTarget(opponents, { lastTargetId, hurtBy }, randomUnit);
|
||||
lastTargetId = picked.id; // a creature with several attacks tends to keep at one foe
|
||||
const target = participants.find((p) => p.id === picked.id);
|
||||
const detail = { attackBonus: attack.attackBonus, damageDice: attack.damageDice, damageBonus: 0, damageType: attack.damageType };
|
||||
const [row] = targetRows('ATTACK', detail, [target], penaltyStep);
|
||||
row.d20 = rollDie(20);
|
||||
const outcome = attackOutcome(row.d20, detail.attackBonus, row.ac, row.penalty);
|
||||
return {
|
||||
actorParticipantId: actor.id,
|
||||
actorName: actor.displayName,
|
||||
kind: 'ATTACK',
|
||||
name: attack.name,
|
||||
detail,
|
||||
targets: [row],
|
||||
damage: lands(outcome) ? rollDamage(detail, { critical: outcome === 'CRIT', critRule }) : null,
|
||||
auto: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { ActionError, kindOf, isFallen, armorClassOf, saveBonusOf, combatantOf, areaPenalty, targetRows, describeSource, autoActions };
|
||||
@@ -5,6 +5,8 @@
|
||||
const { computeCharacterSheet } = require('../game/calculations');
|
||||
|
||||
const SHEET_INCLUDE = {
|
||||
// A multiclassed character's other classes (game/multiclass.js).
|
||||
classLevels: true,
|
||||
skillProficiencies: true,
|
||||
saveProficiencies: true,
|
||||
inventoryItems: { include: { item: true } },
|
||||
@@ -32,4 +34,8 @@ async function settleHp(db, characterIds) {
|
||||
return changed;
|
||||
}
|
||||
|
||||
module.exports = { SHEET_INCLUDE, sheetOf, settleHp };
|
||||
// A character's spells in the order they are listed everywhere: cantrips
|
||||
// first, then by spell level, then by name.
|
||||
const bySpellLevel = (a, b) => a.spell.level - b.spell.level || a.spell.name.localeCompare(b.spell.name);
|
||||
|
||||
module.exports = { SHEET_INCLUDE, sheetOf, settleHp, bySpellLevel };
|
||||
|
||||
@@ -27,8 +27,8 @@ turning XP tracking off keeps everyone's XP; it just stops showing it.
|
||||
|
||||
| Setting | Default | What it does |
|
||||
|---|---|---|
|
||||
| `diceMode` | `BOTH` | `DIGITAL`: the app rolls every die. `PHYSICAL`: everyone rolls real dice and types in what they show (the server checks each value is on the die). `BOTH`: each character picks one or the other before their first build roll. |
|
||||
| `rollVisibility` | `ALL` | `ALL`: everyone sees every roll. `OWN`: a player sees only their own rolls. The DM always sees every roll. |
|
||||
| `diceMode` | `BOTH` | `DIGITAL`: the app rolls every die. `PHYSICAL`: everyone rolls real dice and types in what they show (the server checks each value is on the die). `BOTH`: either. In the builder each character picks one before their first roll; in the dice roller each person ticks "I roll real dice" when they want to. It covers the builder, the dice roller and the hit point roll on a level up. |
|
||||
| `rollVisibility` | `ALL` | Who sees a roll made in the dice roller. `ALL`: everyone sees every roll. `OWN`: a player sees only their own rolls. The DM always sees every roll, and can roll hidden either way. |
|
||||
| `hideMonsterRolls` | on | The DM's rolls for monsters and NPCs stay hidden from players until they are put into play. |
|
||||
|
||||
## Combat
|
||||
|
||||
+934
-103
File diff suppressed because it is too large
Load Diff
+216
-32
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -119,15 +132,17 @@ should hide those fields entirely rather than show them as zero/blank.
|
||||
|
||||
## Species Ability Score Increases & Speed
|
||||
|
||||
See `seed-data/species.json` for the full table (10 species). Note: Half-Elf gets
|
||||
+2 Charisma fixed, plus +1 to **two other** abilities of the player's choice — the
|
||||
seed data defaults that flexible pair to STR/INT, but the character builder UI
|
||||
must let the player pick which two abilities get the +1 during Half-Elf
|
||||
character creation (don't hardcode the default in the UI).
|
||||
See `seed-data/species.json` for the full table (43 species). Note: Half-Elf gets
|
||||
+2 Charisma fixed, plus +1 to **two other** abilities of the player's choice.
|
||||
The 2024 species (Aasimar, Goliath) and those of Monsters of the Multiverse
|
||||
have no fixed bonuses: the player chooses +2 and +1, or +1 to three
|
||||
abilities. These choices are a species' `flexible` patterns; the builder must
|
||||
ask for them (don't default them in the UI), and they are stored on the
|
||||
character as `speciesBonusChoices`.
|
||||
|
||||
## Class Quick Facts
|
||||
|
||||
See `seed-data/classes.json` for the full table (12 classes): Hit Die, Primary
|
||||
See `seed-data/classes.json` for the full table (13 classes, the Artificer included): Hit Die, Primary
|
||||
Ability, the two Saving Throw proficiencies, and Spellcasting Ability.
|
||||
|
||||
## Skill → Governing Ability
|
||||
|
||||
+28
-15
@@ -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
@@ -40,12 +40,17 @@ This describes the code as it stands. The built-in list is
|
||||
| Ability scores | STR, DEX, CON, INT, WIS, CHA. 10 unless set otherwise |
|
||||
| Attack bonus | Added to the monster's attack roll |
|
||||
| Damage | Dice for its main attack, e.g. `1d6+2`, with the damage type if you like: `1d6+2 necrotic` |
|
||||
| Named attacks | Optional, up to 8: each a name, an attack bonus, damage dice, a damage type and **how many times it is made each turn** (a multiattack: "Claw ×2, Bite ×1"). With any listed, these are what the monster attacks with in an encounter, in place of the single attack above |
|
||||
| Challenge rating | A rough danger rating, e.g. `1/4`, `2`. It sets what the monster is worth in XP and how hard an encounter is ([ENCOUNTERS.md](ENCOUNTERS.md), section 9); a monster with none, or with text that is not a rating, is left out of both |
|
||||
| Special trait | A short note such as "Pack Tactics" |
|
||||
| Notes | Anything else: what it attacks with, how it behaves |
|
||||
|
||||
Apart from the challenge rating, none of these are used in formulas. They
|
||||
are reference numbers shown to the DM during a fight. A monster's ability
|
||||
The challenge rating feeds the XP and difficulty sums. The AC, the attack
|
||||
bonus and damage (or the named attacks) and the ability scores are used when
|
||||
an attack or a spell is resolved in an encounter: an attack roll is judged
|
||||
against the AC, a saving throw adds the ability's modifier, and with
|
||||
auto-roll on the monster's attacks are aimed and rolled for the DM
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), "Attacks and spells"). A monster's ability
|
||||
modifiers are worked out from its scores for display, for its initiative
|
||||
bonus (Dexterity) and for the rolls a click makes (section 5).
|
||||
|
||||
@@ -186,7 +191,8 @@ Then they get the health bar only.
|
||||
|
||||
## 8. What is not modelled
|
||||
|
||||
- Multiple attacks, spells, legendary actions and resistances (use Notes)
|
||||
- Spells, legendary actions and resistances (use Notes; in an encounter the
|
||||
DM can spell out any attack or save on the spot)
|
||||
- Trade: monsters do not sell things or change money (NPCs do)
|
||||
- Saving throw proficiencies and skills (on an encounter card a click on an
|
||||
ability score rolls a d20 with its modifier, for a check or a save)
|
||||
|
||||
@@ -50,6 +50,13 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
|
||||
A new NPC is Neutral unless you choose otherwise.
|
||||
|
||||
**Found at.** An NPC can be given one of the campaign's locations as the place
|
||||
they are usually found, on their pop-out. In a **free-roam** encounter that
|
||||
includes that place, the NPC appears on its card, and a character who goes
|
||||
there can buy from them and change money with them
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), section 2, "Free roam"). It changes nothing in
|
||||
a quest or a battle, where an NPC takes part because the DM added them.
|
||||
|
||||
## 3. Managing NPCs
|
||||
|
||||
The NPC list is the "NPCs" part of the campaign page (DM only). Each NPC is a
|
||||
|
||||
+25
-14
@@ -11,23 +11,26 @@ the in-progress rolls/choices held in `Character.buildState` (JSON) until the
|
||||
build is finalized, at which point the real columns (`species`, `characterClass`,
|
||||
ability scores, proficiencies) get set and `buildStage` becomes `COMPLETE`.
|
||||
|
||||
## Step 1 — Roll Species (d6)
|
||||
## Step 1 — Roll Species (d8)
|
||||
|
||||
Tell the player: **"Roll a d6."** Then offer the choice(s) that result maps to.
|
||||
Most rolls offer 2 species to pick between; keep the flavor text short.
|
||||
Tell the player: **"Roll a d8."** Then offer the family of species that result
|
||||
maps to; keep the flavor text short.
|
||||
|
||||
| d6 Roll | Choose between |
|
||||
| d8 Roll | Choose from |
|
||||
|---|---|
|
||||
| 1 | Human or Half-Elf |
|
||||
| 2 | Elf or Gnome |
|
||||
| 3 | Dwarf or Halfling |
|
||||
| 4 | Dragonborn or Tiefling |
|
||||
| 5 | Half-Orc or Orc |
|
||||
| 6 | Wildcard — pick any species |
|
||||
| 1 | Human, Half-Elf, Elf, Half-Orc |
|
||||
| 2 | Dwarf, Gnome, Halfling, Deep Gnome, Duergar |
|
||||
| 3 | Dragonborn, Tiefling, Aasimar, Goliath |
|
||||
| 4 | Orc, Goblin, Hobgoblin, Bugbear, Kobold |
|
||||
| 5 | Eladrin, Sea Elf, Shadar-kai, Githyanki, Githzerai, Changeling |
|
||||
| 6 | Air, Earth, Fire or Water Genasi, Triton, Tortle |
|
||||
| 7 | Tabaxi, Kenku, Aarakocra, Harengon, Lizardfolk, Owlin, Minotaur, Centaur, Satyr, Shifter, Firbolg, Fairy, Yuan-ti |
|
||||
| 8 | Wildcard — pick any species |
|
||||
|
||||
If the player rolls Half-Elf, immediately prompt for which two abilities (other
|
||||
than Charisma) get the flexible +1 — this choice has to happen here, not be
|
||||
silently defaulted (see GAME_RULES_REFERENCE.md).
|
||||
If the species has chosen bonuses, immediately prompt for where they go — this
|
||||
choice has to happen here, not be silently defaulted. A Half-Elf picks two
|
||||
abilities (other than Charisma) for +1 each; the 2024 and Multiverse species
|
||||
pick +2 and +1, or +1 to three abilities.
|
||||
|
||||
## Step 2 — Roll Class (d8)
|
||||
|
||||
@@ -41,9 +44,17 @@ Tell the player: **"Roll a d8."**
|
||||
| 4 | Cleric or Paladin |
|
||||
| 5 | Bard or Warlock |
|
||||
| 6 | Druid or Monk |
|
||||
| 7 | Reroll once, keep either result |
|
||||
| 7 | Artificer, or reroll once (the reroll stands) |
|
||||
| 8 | Wildcard — pick any class |
|
||||
|
||||
## Step 2b — Choose a Subclass
|
||||
|
||||
When the campaign uses subclasses, the player now picks the path within their
|
||||
class from its full list (each with its book and a level 1–20 preview), or
|
||||
rolls a die with one face per subclass for a suggestion, or leaves it until
|
||||
the class reaches the level it chooses at. A path chosen here only starts
|
||||
giving at that level.
|
||||
|
||||
## Step 3 — Roll Motivation (d6, optional flavor)
|
||||
|
||||
Tell the player: **"Roll a d6"** (optional — DM can skip this step for the whole
|
||||
|
||||
@@ -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:
|
||||
@@ -211,6 +240,13 @@ section 7): a **long rest** gives every slot back; a **short rest** gives a
|
||||
warlock theirs. Levelling up with "⬆ Level Up…" gives a fresh set. Changing
|
||||
the Level field keeps what was spent, as far as the new level has those slots.
|
||||
|
||||
An **Artificer** casts from level 1 at half a full caster's pace, rounding up
|
||||
(2 slots at levels 1 and 2, 2nd-level slots at 5). An **Eldritch Knight** or
|
||||
**Arcane Trickster** casts from level 3 at a third of the pace, with
|
||||
Intelligence. A **multiclassed** character adds their casting classes up to a
|
||||
caster level on the full caster table, with a warlock's pact slots kept apart
|
||||
([CHARACTERS.md](CHARACTERS.md), "Multiclassing").
|
||||
|
||||
**Bard, Cleric, Druid, Sorcerer, Wizard** (back on a long rest):
|
||||
|
||||
| Character level | 1st | 2nd | 3rd | 4th | 5th |
|
||||
|
||||
+4
-1
@@ -109,7 +109,10 @@ On a character's sheet, the DM picks a weapon from the pool and clicks Give.
|
||||
keeps the level it has. A character holds one stack per weapon, so two
|
||||
copies of the same weapon at different levels is not possible.
|
||||
- The DM can take a weapon away with ✕ on the sheet.
|
||||
- The owning player, or the DM, ticks **Equipped**.
|
||||
- The owning player, or the DM, ticks **Equipped**. While the character is in
|
||||
a battle in progress, a player can only change it as the battle begins or on
|
||||
their own turn after their attack has landed
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), section 5); the DM always can.
|
||||
- **Only what is equipped counts.** A weapon has to be equipped to be used
|
||||
in an encounter, and gear only gives its effects while equipped. What is
|
||||
not equipped stays in the pack: still the character's, still listed on the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.22.0",
|
||||
"version": "1.32.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+142
-8
@@ -1,13 +1,19 @@
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from './client';
|
||||
import type {
|
||||
Ability,
|
||||
ArmorType,
|
||||
ActionPatch,
|
||||
ActionSource,
|
||||
BuildOptions,
|
||||
Campaign,
|
||||
CharacterClass,
|
||||
CampaignLibrary,
|
||||
CampaignMemberSummary,
|
||||
CharacterDetail,
|
||||
CharacterSummary,
|
||||
Currency,
|
||||
DiceRollRecord,
|
||||
DiceMode,
|
||||
Encounter,
|
||||
EncounterMode,
|
||||
@@ -15,15 +21,19 @@ import type {
|
||||
GearEffect,
|
||||
GoldTransaction,
|
||||
Item,
|
||||
LevelUpApproval,
|
||||
LevelUpPlan,
|
||||
Location,
|
||||
MulticlassReference,
|
||||
Monster,
|
||||
Note,
|
||||
Npc,
|
||||
ProgressionLevel,
|
||||
SaleOffer,
|
||||
QuickStats,
|
||||
RestResult,
|
||||
RestType,
|
||||
RollSpec,
|
||||
Spell,
|
||||
Weapon,
|
||||
} from './types';
|
||||
@@ -250,12 +260,14 @@ export function useCharacterState(characterId: string) {
|
||||
}
|
||||
|
||||
// What a character's next level brings. DM only.
|
||||
export function useLevelUpPlan(characterId: string, enabled: boolean) {
|
||||
// `toClass`: the plan for a level in that class (multiclassing); the first class when null.
|
||||
export function useLevelUpPlan(characterId: string, enabled: boolean, toClass: CharacterClass | null = null) {
|
||||
return useQuery({
|
||||
queryKey: ['character', characterId, 'level-up'],
|
||||
queryKey: ['character', characterId, 'level-up', toClass],
|
||||
enabled,
|
||||
gcTime: 0, // never open on the plan for a level the character has already reached
|
||||
queryFn: async () => (await apiClient.get<{ plan: LevelUpPlan }>(`/characters/${characterId}/level-up`)).data.plan,
|
||||
queryFn: async () =>
|
||||
(await apiClient.get<{ plan: LevelUpPlan }>(`/characters/${characterId}/level-up`, { params: toClass ? { class: toClass } : {} })).data.plan,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -263,9 +275,9 @@ export function useLevelUpPlan(characterId: string, enabled: boolean) {
|
||||
export function useLevelUp(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[] }) =>
|
||||
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/level-up`, data)).data.character,
|
||||
onSuccess: (character) => {
|
||||
mutationFn: async (data: { characterClass?: CharacterClass; subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[]; hpRoll?: number }) =>
|
||||
(await apiClient.post<{ character: CharacterDetail; hpRolled: number | null }>(`/characters/${characterId}/level-up`, data)).data,
|
||||
onSuccess: ({ character }) => {
|
||||
invalidateCharacter(qc, characterId);
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
|
||||
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
||||
@@ -289,6 +301,20 @@ export function useRest(campaignId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// The DM approves a level for any of the party at once (or takes an approval
|
||||
// back); each character's player then takes theirs.
|
||||
export function useApproveLevelUps(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterIds: string[]; revoke?: boolean }) =>
|
||||
(await apiClient.post<{ revoke: boolean; results: LevelUpApproval[] }>(`/campaigns/${campaignId}/level-up`, data)).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCharacter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -360,6 +386,53 @@ 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({
|
||||
queryKey: ['multiclass-reference'],
|
||||
enabled,
|
||||
staleTime: Infinity,
|
||||
queryFn: async () => (await apiClient.get<MulticlassReference>('/reference/multiclass')).data,
|
||||
});
|
||||
}
|
||||
|
||||
// What each level from 1 to 20 gives a class, with a subclass's lines when one is given.
|
||||
export function useProgression(characterClass: CharacterClass | null | undefined, subclass?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['progression', characterClass, subclass ?? null],
|
||||
enabled: Boolean(characterClass),
|
||||
staleTime: Infinity, // rules: they never change while the app is open
|
||||
queryFn: async () =>
|
||||
(
|
||||
await apiClient.get<{ levels: ProgressionLevel[] }>('/reference/progression', {
|
||||
params: { class: characterClass, ...(subclass ? { subclass } : {}) },
|
||||
})
|
||||
).data.levels,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidateCharacter(qc: ReturnType<typeof useQueryClient>, characterId: string) {
|
||||
qc.invalidateQueries({ queryKey: ['character', characterId] });
|
||||
}
|
||||
@@ -575,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: () => {
|
||||
@@ -769,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'] }),
|
||||
});
|
||||
@@ -915,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) {
|
||||
@@ -1017,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.
|
||||
|
||||
+288
-9
@@ -10,7 +10,40 @@ export type Species =
|
||||
| 'DRAGONBORN'
|
||||
| 'TIEFLING'
|
||||
| 'HALF_ORC'
|
||||
| 'ORC';
|
||||
| 'ORC'
|
||||
| 'AASIMAR'
|
||||
| 'GOLIATH'
|
||||
| 'AARAKOCRA'
|
||||
| 'BUGBEAR'
|
||||
| 'CENTAUR'
|
||||
| 'CHANGELING'
|
||||
| 'DEEP_GNOME'
|
||||
| 'DUERGAR'
|
||||
| 'ELADRIN'
|
||||
| 'FAIRY'
|
||||
| 'FIRBOLG'
|
||||
| 'AIR_GENASI'
|
||||
| 'EARTH_GENASI'
|
||||
| 'FIRE_GENASI'
|
||||
| 'WATER_GENASI'
|
||||
| 'GITHYANKI'
|
||||
| 'GITHZERAI'
|
||||
| 'GOBLIN'
|
||||
| 'HARENGON'
|
||||
| 'HOBGOBLIN'
|
||||
| 'KENKU'
|
||||
| 'KOBOLD'
|
||||
| 'LIZARDFOLK'
|
||||
| 'MINOTAUR'
|
||||
| 'SEA_ELF'
|
||||
| 'SHADAR_KAI'
|
||||
| 'SHIFTER'
|
||||
| 'TABAXI'
|
||||
| 'TORTLE'
|
||||
| 'TRITON'
|
||||
| 'YUAN_TI'
|
||||
| 'OWLIN'
|
||||
| 'SATYR';
|
||||
|
||||
export type CharacterClass =
|
||||
| 'FIGHTER'
|
||||
@@ -24,12 +57,14 @@ export type CharacterClass =
|
||||
| 'BARD'
|
||||
| 'WARLOCK'
|
||||
| 'DRUID'
|
||||
| 'MONK';
|
||||
| 'MONK'
|
||||
| 'ARTIFICER';
|
||||
|
||||
export type BuildStage =
|
||||
| 'NOT_STARTED'
|
||||
| 'ROLLING_SPECIES'
|
||||
| 'ROLLING_CLASS'
|
||||
| 'CHOOSING_SUBCLASS'
|
||||
| 'ROLLING_MOTIVATION'
|
||||
| 'ASSIGNING_SCORES'
|
||||
| 'ASSIGNING_PROFICIENCIES'
|
||||
@@ -81,6 +116,44 @@ export interface BuildOptions {
|
||||
talentPicks: number;
|
||||
background: PickGroup[];
|
||||
appearance: PickGroup[];
|
||||
// Every class's subclasses, and the level each class gets its subclass's first gifts at.
|
||||
subclasses: Record<CharacterClass, SubclassSummary[]>;
|
||||
subclassLevels: Record<CharacterClass, number>;
|
||||
}
|
||||
|
||||
// A subclass as a list shows it: its book, whether it is from the 2014 or
|
||||
// 2024 rules, and the 2014 subclass a 2024 one updates.
|
||||
export interface SubclassSummary {
|
||||
key: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
source: string;
|
||||
edition: '2014' | '2024';
|
||||
replaces: string | null;
|
||||
}
|
||||
|
||||
// What each class asks of the scores to multiclass, and the suggested routes
|
||||
// (GET /reference/multiclass). An alternative is a list of abilities that must
|
||||
// all be at least prereqScore.
|
||||
export interface MulticlassReference {
|
||||
prereqScore: number;
|
||||
prereqs: Record<CharacterClass, { alternatives: Ability[][]; text: string }>;
|
||||
paths: {
|
||||
key: string;
|
||||
name: string;
|
||||
from: CharacterClass | null;
|
||||
steps: { class: CharacterClass; toLevel: number; subclass?: string }[];
|
||||
why: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// One level of a class's road from 1 to 20 (GET /reference/progression).
|
||||
export interface ProgressionLevel {
|
||||
level: number;
|
||||
features: { name: string; text: string; fromSubclass: boolean }[];
|
||||
boon: boolean;
|
||||
choosesSubclass: boolean;
|
||||
unlocksSpellLevel: number | null;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
@@ -158,7 +231,7 @@ export interface SpellSlotLevel {
|
||||
// A character's spell slots, from class and level. A warlock's (PACT) are all
|
||||
// of one slot level and come back on a short rest; everyone else's on a long one.
|
||||
export interface SpellSlots {
|
||||
kind: 'FULL' | 'HALF' | 'PACT';
|
||||
kind: 'FULL' | 'HALF' | 'THIRD' | 'PACT';
|
||||
restoresOn: 'SHORT' | 'LONG';
|
||||
levels: SpellSlotLevel[];
|
||||
}
|
||||
@@ -169,6 +242,16 @@ export interface HitDice {
|
||||
count: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
// A multiclassed character's dice by class (each class's own size).
|
||||
pools?: { characterClass: CharacterClass; size: number; count: number }[];
|
||||
}
|
||||
|
||||
// A class a character has levels in. One entry unless they have multiclassed.
|
||||
export interface ClassLevel {
|
||||
characterClass: CharacterClass;
|
||||
level: number;
|
||||
subclass?: string | null;
|
||||
subclassName: string | null;
|
||||
}
|
||||
|
||||
// A class counter (Rage, Ki, Channel Divinity...): how many uses there are,
|
||||
@@ -220,6 +303,8 @@ export interface CharacterSummary extends CharacterCondition {
|
||||
characterClass: CharacterClass | null;
|
||||
subclassName: string | null;
|
||||
level: number;
|
||||
levelUpsPending: number; // levels the DM has approved, still to be taken
|
||||
classes: ClassLevel[];
|
||||
xp: number;
|
||||
xpProgress: XpProgress;
|
||||
buildStage: BuildStage;
|
||||
@@ -292,9 +377,14 @@ export interface CharacterSheet {
|
||||
passiveInvestigation: number;
|
||||
passiveInsight: number;
|
||||
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
|
||||
// A multiclassed caster's DC and attack for each class that casts.
|
||||
spellcastingByClass: { characterClass: CharacterClass; ability: Ability; saveDC: number; attackBonus: number }[];
|
||||
// A multiclassed warlock's pact slots, apart from the other slots; null otherwise.
|
||||
pactSlots: { count: number; slotLevel: number } | null;
|
||||
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 {
|
||||
@@ -344,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 {
|
||||
@@ -367,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 {
|
||||
@@ -416,13 +523,17 @@ export interface CharacterDetail extends CharacterCondition {
|
||||
subclass: string | null;
|
||||
subclassName: string | null;
|
||||
subclassLevel: number | null;
|
||||
subclassOptions: { key: string; name: string; summary: string }[];
|
||||
subclassOptions: SubclassSummary[];
|
||||
// The 2024 subclass that updates the one followed, if there is one.
|
||||
subclassUpgrade: SubclassSummary | null;
|
||||
level: number;
|
||||
levelUpsPending: number; // levels the DM has approved, still to be taken
|
||||
classes: ClassLevel[];
|
||||
xp: number;
|
||||
xpProgress: XpProgress;
|
||||
motivation: string | null;
|
||||
halfElfBonusAbility1: Ability | null;
|
||||
halfElfBonusAbility2: Ability | null;
|
||||
// The species bonuses the player chose, { ability: amount }: a Half-Elf's +1/+1, a Goblin's +2/+1...
|
||||
speciesBonusChoices: Partial<Record<Ability, number>>;
|
||||
rolledScores: Record<Ability, number>;
|
||||
currentHp: number;
|
||||
tempHp: number;
|
||||
@@ -473,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.
|
||||
@@ -486,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`
|
||||
@@ -508,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.
|
||||
@@ -564,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 {
|
||||
@@ -621,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;
|
||||
@@ -653,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
|
||||
}
|
||||
@@ -823,7 +1037,17 @@ export interface BoonOption {
|
||||
export interface LevelUpPlan {
|
||||
fromLevel: number;
|
||||
toLevel: number;
|
||||
// The class taking the level, its own level after it, and whether it is the
|
||||
// character's first level in that class (multiclassing).
|
||||
characterClass: CharacterClass;
|
||||
classLevel: number;
|
||||
joining: boolean;
|
||||
// With multiclassing on: every class the level could go to, and why not.
|
||||
classChoice: { options: { characterClass: CharacterClass; level: number; eligible: boolean; problems: string[] }[] } | null;
|
||||
maxHp: { from: number; to: number };
|
||||
// How the level's hit points are come by: the average, or a roll of the
|
||||
// class's die (typed in from the table when `physical`).
|
||||
hp: { method: HpMethod; die: number; average: number; physical: boolean };
|
||||
// Set when a subclass has to be picked on the way to this level: the level
|
||||
// the class chooses at, and each option with everything it would write.
|
||||
subclass: { level: number; options: { key: string; name: string; summary: string; features: { name: string; text: string }[] }[] } | null;
|
||||
@@ -836,9 +1060,64 @@ export interface LevelUpPlan {
|
||||
// What the book lets a caster be granted now; null for a class with no spells.
|
||||
spells: { maxSpellLevel: number; unlocksSpellLevel: number | null; newSpells: number; newCantrips: number } | null;
|
||||
spellSlots: SpellSlots | null; // the slots at the new level
|
||||
// The campaign's spells that could be granted now: of a level the class can
|
||||
// cast, and not yet known.
|
||||
spellOptions: { id: string; name: string; level: number; school: string | null; description: string | null }[];
|
||||
// Held weapons that can take the free upgrade of one level.
|
||||
weapons: { weaponId: string; name: string; equipped: boolean; level: number; damageDice: string; nextDamageDice: string }[];
|
||||
beyondTables: boolean; // past level 10, where the class tables stop
|
||||
beyondTables: boolean; // past level 20, where the class tables stop
|
||||
}
|
||||
|
||||
// What approving (or taking back) a level did for one character (POST /campaigns/:id/level-up).
|
||||
export interface LevelUpApproval {
|
||||
characterId: string;
|
||||
name: string;
|
||||
userId: string | null;
|
||||
level: number;
|
||||
levelUpsPending: number;
|
||||
skipped: string | null; // why nothing changed for them
|
||||
}
|
||||
|
||||
// One die of a roll: its size, what it showed, whether it counted (the lower
|
||||
// d20 with advantage does not), and whether it adds (1) or takes away (-1).
|
||||
// `maxed`: counted at its most rather than rolled (a critical hit).
|
||||
export interface RolledDie {
|
||||
sides: number;
|
||||
value: number;
|
||||
kept: boolean;
|
||||
sign: number;
|
||||
maxed?: boolean;
|
||||
}
|
||||
|
||||
// A roll made at the table's dice roller, as everyone who may see it gets it.
|
||||
export interface DiceRollRecord {
|
||||
id: string;
|
||||
userId: string;
|
||||
rollerName: string;
|
||||
label: string | null; // what it was for: "Aria: Stealth"
|
||||
notation: string; // "1d20+3"
|
||||
dice: RolledDie[];
|
||||
modifier: number;
|
||||
total: number;
|
||||
natural: number | null; // the d20 that counted, for a single d20
|
||||
mode: 'NORMAL' | 'ADVANTAGE' | 'DISADVANTAGE';
|
||||
physical: boolean; // rolled at the table and typed in
|
||||
hidden: boolean; // the DM's alone
|
||||
encounterId: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// What is asked of the server for a roll: a d20 plus a modifier, or dice.
|
||||
// `values` are dice rolled at the table, in order.
|
||||
export interface RollSpec {
|
||||
label?: string | null;
|
||||
kind: 'check' | 'dice';
|
||||
modifier?: number;
|
||||
mode?: 'NORMAL' | 'ADVANTAGE' | 'DISADVANTAGE';
|
||||
expression?: string;
|
||||
values?: number[];
|
||||
hidden?: boolean;
|
||||
encounterId?: string | null;
|
||||
}
|
||||
|
||||
export type RestType = 'SHORT' | 'LONG';
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { fmtMod } from '../lib/format';
|
||||
import { useCampaign, useRoll, useRolls } from '../api/queries';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useLastCampaignId } from '../lib/lastCampaign';
|
||||
import { extractErrorMessage, fmtMod } from '../lib/format';
|
||||
import { onRollRequest, parseDice, rollD20, rollDie, type RollMode, type RollRequest } from '../lib/diceRoller';
|
||||
import { DiceRow } from './dice/Die';
|
||||
import { settleMs } from '../lib/diceAnimation';
|
||||
import { RollFeed, RollLine } from './RollFeed';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import type { DiceRollRecord, RollSpec, RolledDie } from '../api/types';
|
||||
|
||||
const DICE = [4, 6, 8, 10, 12, 20, 100];
|
||||
const MAX_DICE = 20;
|
||||
const HISTORY_LENGTH = 8;
|
||||
// How long another player's roll stays up beside the dice button.
|
||||
const INCOMING_MS = 6000;
|
||||
const BY_HAND_KEY = 'dice-by-hand';
|
||||
|
||||
const MODES: { mode: RollMode; label: string; title: string }[] = [
|
||||
{ mode: 'DISADVANTAGE', label: 'Disadv.', title: 'Disadvantage: roll two d20s and keep the lower' },
|
||||
@@ -12,77 +23,164 @@ const MODES: { mode: RollMode; label: string; title: string }[] = [
|
||||
{ mode: 'ADVANTAGE', label: 'Adv.', title: 'Advantage: roll two d20s and keep the higher' },
|
||||
];
|
||||
|
||||
interface Roll {
|
||||
id: number;
|
||||
label?: string; // what it was for, when it was asked for from a sheet
|
||||
notation: string; // "1d20+3", "1d8+1d6+2"
|
||||
dice: number[]; // every die rolled, in order
|
||||
modifier: number;
|
||||
total: number;
|
||||
// The d20 that counted, for a roll of a single d20 (a natural 20 or 1 means something).
|
||||
natural: number | null;
|
||||
mode: RollMode; // how a single d20 was rolled
|
||||
}
|
||||
|
||||
function notation(count: number, sides: number, modifier: number): string {
|
||||
return `${count}d${sides}${modifier ? fmtMod(modifier) : ''}`;
|
||||
}
|
||||
|
||||
// A d20 plus a bonus, with advantage or disadvantage as chosen.
|
||||
function d20Roll(modifier: number, mode: RollMode, label?: string): Omit<Roll, 'id'> {
|
||||
const { kept, dice } = rollD20(mode);
|
||||
return { label, notation: notation(1, 20, modifier), dice, modifier, total: kept + modifier, natural: kept, mode };
|
||||
// What a spec is written as: "1d20+3", "2d6+1".
|
||||
function specNotation(spec: RollSpec): string {
|
||||
return spec.kind === 'check' ? notation(1, 20, spec.modifier ?? 0) : (spec.expression ?? '').replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
// What a request from a sheet comes to; null for dice that cannot be read.
|
||||
function answer(request: RollRequest, mode: RollMode): Omit<Roll, 'id'> | null {
|
||||
if (request.kind === 'check') return d20Roll(request.modifier, mode, request.label);
|
||||
const parsed = parseDice(request.expression);
|
||||
if (!parsed) return null;
|
||||
const dice: number[] = [];
|
||||
let total = parsed.flat;
|
||||
for (const term of parsed.dice) {
|
||||
for (let i = 0; i < term.count; i++) {
|
||||
const die = rollDie(term.sides);
|
||||
dice.push(die);
|
||||
total += term.sign * die;
|
||||
}
|
||||
// The size of each die a spec rolls, in order; null for dice that cannot be read.
|
||||
function specDice(spec: RollSpec): number[] | null {
|
||||
if (spec.kind === 'check') return spec.mode && spec.mode !== 'NORMAL' ? [20, 20] : [20];
|
||||
const parsed = parseDice(spec.expression ?? '');
|
||||
return parsed ? parsed.dice.flatMap((term) => Array.from({ length: term.count }, () => term.sides)) : null;
|
||||
}
|
||||
|
||||
let localId = 0;
|
||||
|
||||
// A roll made in the browser, in the shape the server gives: used outside a
|
||||
// campaign, where there is no table to show it to. `values` are dice typed in.
|
||||
function localRoll(spec: RollSpec, rollerName: string, values?: number[]): DiceRollRecord | null {
|
||||
const base = { id: `local-${++localId}`, userId: 'local', rollerName, label: spec.label ?? null, physical: Boolean(values), hidden: false, encounterId: null, createdAt: new Date().toISOString() };
|
||||
if (spec.kind === 'check') {
|
||||
const mode = spec.mode ?? 'NORMAL';
|
||||
const rolled = values ?? rollD20(mode).dice;
|
||||
const natural = mode === 'ADVANTAGE' ? Math.max(...rolled) : mode === 'DISADVANTAGE' ? Math.min(...rolled) : rolled[0];
|
||||
const keptAt = rolled.indexOf(natural);
|
||||
const dice: RolledDie[] = rolled.map((value, i) => ({ sides: 20, value, kept: i === keptAt, sign: 1 }));
|
||||
return { ...base, notation: specNotation(spec), dice, modifier: spec.modifier ?? 0, total: natural + (spec.modifier ?? 0), natural, mode };
|
||||
}
|
||||
return { label: request.label, notation: request.expression.replace(/\s+/g, ''), dice, modifier: parsed.flat, total, natural: null, mode: 'NORMAL' };
|
||||
const parsed = parseDice(spec.expression ?? '');
|
||||
if (!parsed) return null;
|
||||
let at = 0;
|
||||
const dice: RolledDie[] = parsed.dice.flatMap((term) =>
|
||||
Array.from({ length: term.count }, () => ({ sides: term.sides, value: values ? values[at++] : rollDie(term.sides), kept: true, sign: term.sign }))
|
||||
);
|
||||
return { ...base, notation: specNotation(spec), dice, modifier: parsed.flat, total: dice.reduce((sum, d) => sum + d.sign * d.value, 0) + parsed.flat, natural: null, mode: 'NORMAL' };
|
||||
}
|
||||
|
||||
// A table-side dice roller, reachable from every page. It rolls what is set
|
||||
// up here, and whatever is clicked on a sheet or an encounter card (a skill,
|
||||
// a save, an attack, damage: lib/diceRoller.ts), with the bonus already in.
|
||||
// These rolls are for the table: they are not sent to the server and do not
|
||||
// feed the character builder, whose dice stay locked in server-side.
|
||||
// The table's dice roller, reachable from every page. It rolls what is set up
|
||||
// here, and whatever is clicked on a sheet or an encounter card (a skill, a
|
||||
// save, an attack, damage: lib/diceRoller.ts), with the bonus already in.
|
||||
//
|
||||
// In a campaign the server makes every digital roll, keeps it and tells the
|
||||
// table: the DM sees each roll as it lands, and players see what the
|
||||
// campaign's "who sees a roll" setting allows. The campaign's dice setting
|
||||
// decides how a roll is made: by the app, by real dice typed in, or either.
|
||||
// Outside a campaign the dice are rolled here and go nowhere.
|
||||
export function DiceRoller() {
|
||||
const { user } = useAuth();
|
||||
const lastCampaignId = useLastCampaignId();
|
||||
const { data: campaignData } = useCampaign(user && lastCampaignId ? lastCampaignId : undefined);
|
||||
const campaign = campaignData?.campaign;
|
||||
const campaignId = campaign ? campaign.id : null;
|
||||
const isDM = campaign?.role === 'DM';
|
||||
const diceMode = campaign?.diceMode ?? 'DIGITAL';
|
||||
const { data: feed = [] } = useRolls(campaignId);
|
||||
const send = useRoll(campaignId);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tab, setTab] = useState<'roll' | 'table'>('roll');
|
||||
const [sides, setSides] = useState(20);
|
||||
const [count, setCount] = useState(1);
|
||||
const [modifier, setModifier] = useState(0);
|
||||
const [mode, setMode] = useState<RollMode>('NORMAL');
|
||||
const [history, setHistory] = useState<Roll[]>([]);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [byHand, setByHand] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(BY_HAND_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [latest, setLatest] = useState<DiceRollRecord | null>(null);
|
||||
const [history, setHistory] = useState<DiceRollRecord[]>([]); // outside a campaign only
|
||||
// A roll waiting for real dice to be typed in, and what has been typed.
|
||||
const [pending, setPending] = useState<RollSpec | null>(null);
|
||||
const [typed, setTyped] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The id of somebody else's roll that has just arrived, while it is shown.
|
||||
const [incomingId, setIncomingId] = useState<string | null>(null);
|
||||
|
||||
const push = (roll: Omit<Roll, 'id'>) => setHistory((prev) => [{ ...roll, id: (prev[0]?.id ?? 0) + 1 }, ...prev].slice(0, HISTORY_LENGTH));
|
||||
// Real dice are typed in when the campaign plays with them, or allows both and the roller prefers them.
|
||||
const typing = diceMode === 'PHYSICAL' || (diceMode === 'BOTH' && byHand);
|
||||
|
||||
// A roll asked for from elsewhere on the page: rolled, and shown. It is
|
||||
// rolled the way the roller is set at that moment.
|
||||
const currentMode = useRef(mode);
|
||||
async function perform(spec: RollSpec, values?: number[]) {
|
||||
setError(null);
|
||||
let roll: DiceRollRecord | null;
|
||||
if (campaignId) {
|
||||
try {
|
||||
roll = await send.mutateAsync({ ...spec, values, hidden: isDM && hidden });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
roll = localRoll(spec, 'You', values);
|
||||
if (!roll) return;
|
||||
setHistory((prev) => [roll!, ...prev].slice(0, HISTORY_LENGTH));
|
||||
}
|
||||
setLatest(roll);
|
||||
setPending(null);
|
||||
setTyped([]);
|
||||
}
|
||||
|
||||
// A roll to make: straight away, or held until its dice are typed in.
|
||||
function ask(spec: RollSpec) {
|
||||
if (!specDice(spec)) return;
|
||||
setTab('roll');
|
||||
setOpen(true);
|
||||
if (typing) {
|
||||
setPending(spec);
|
||||
setTyped([]);
|
||||
setError(null);
|
||||
} else {
|
||||
perform(spec);
|
||||
}
|
||||
}
|
||||
|
||||
// A roll asked for from elsewhere on the page, rolled the way the roller is set at that moment.
|
||||
const current = useRef({ ask, mode });
|
||||
useEffect(() => {
|
||||
currentMode.current = mode;
|
||||
current.current = { ask, mode };
|
||||
});
|
||||
useEffect(
|
||||
() =>
|
||||
onRollRequest((request) => {
|
||||
const roll = answer(request, currentMode.current);
|
||||
if (!roll) return;
|
||||
push(roll);
|
||||
setOpen(true);
|
||||
onRollRequest((request: RollRequest) => {
|
||||
const { ask: askNow, mode: modeNow } = current.current;
|
||||
askNow(request.kind === 'check' ? { kind: 'check', label: request.label, modifier: request.modifier, mode: modeNow } : { kind: 'dice', label: request.label, expression: request.expression });
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Somebody else's roll arriving: shown beside the dice button for a moment,
|
||||
// so the DM sees each roll without opening anything. What the feed held
|
||||
// when it first loaded is not news.
|
||||
const newest = feed[0];
|
||||
const newestId = newest?.id ?? null;
|
||||
const newestIsMine = newest?.userId === user?.id;
|
||||
const seen = useRef<string | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!campaignId) return;
|
||||
if (seen.current === undefined) {
|
||||
seen.current = newestId;
|
||||
return;
|
||||
}
|
||||
if (!newestId || newestId === seen.current) return;
|
||||
seen.current = newestId;
|
||||
// One's own roll is not news, and takes down whatever was showing.
|
||||
const show = setTimeout(() => setIncomingId(newestIsMine ? null : newestId), 0);
|
||||
const hide = setTimeout(() => setIncomingId((shown) => (shown === newestId ? null : shown)), INCOMING_MS);
|
||||
return () => {
|
||||
clearTimeout(show);
|
||||
clearTimeout(hide);
|
||||
};
|
||||
}, [campaignId, newestId, newestIsMine]);
|
||||
const incoming = incomingId ? (feed.find((roll) => roll.id === incomingId) ?? null) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
|
||||
@@ -90,17 +188,29 @@ export function DiceRoller() {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open]);
|
||||
|
||||
const latest = history[0];
|
||||
const singleD20 = sides === 20 && count === 1;
|
||||
const setup: RollSpec = singleD20 ? { kind: 'check', modifier, mode } : { kind: 'dice', expression: notation(count, sides, modifier) };
|
||||
const pendingDice = pending ? (specDice(pending) ?? []) : [];
|
||||
const typedValues = pendingDice.map((_, i) => Number(typed[i]));
|
||||
const typedOk = pendingDice.length > 0 && pendingDice.every((dieSides, i) => typed[i] && Number.isInteger(typedValues[i]) && typedValues[i] >= 1 && typedValues[i] <= dieSides);
|
||||
|
||||
function roll() {
|
||||
if (singleD20) return push(d20Roll(modifier, mode));
|
||||
const dice = Array.from({ length: count }, () => rollDie(sides));
|
||||
push({ notation: notation(count, sides, modifier), dice, modifier, total: dice.reduce((sum, d) => sum + d, 0) + modifier, natural: null, mode: 'NORMAL' });
|
||||
function chooseByHand(value: boolean) {
|
||||
setByHand(value);
|
||||
try {
|
||||
localStorage.setItem(BY_HAND_KEY, value ? '1' : '0');
|
||||
} catch {
|
||||
// Private browsing: the choice lasts as long as the page.
|
||||
}
|
||||
if (!value && pending) {
|
||||
// "Let the app roll" with a roll waiting: it is rolled now.
|
||||
const spec = pending;
|
||||
setPending(null);
|
||||
perform(spec);
|
||||
}
|
||||
}
|
||||
|
||||
const natural = latest?.natural ?? null;
|
||||
const modeNote = (r: Roll) => (r.mode === 'ADVANTAGE' ? 'advantage' : r.mode === 'DISADVANTAGE' ? 'disadvantage' : '');
|
||||
const settle = latest ? settleMs(latest.dice.length) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -114,111 +224,217 @@ export function DiceRoller() {
|
||||
🎲
|
||||
</button>
|
||||
|
||||
{incoming && !(open && tab === 'table') && (
|
||||
<button className="dice-incoming" onClick={() => {
|
||||
setTab('table');
|
||||
setOpen(true);
|
||||
setIncomingId(null);
|
||||
}} title="Open the table's rolls">
|
||||
<RollLine roll={incoming} mine={false} rolling size={30} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="dice-panel" role="dialog" aria-label="Dice roller">
|
||||
<div className="flex-between">
|
||||
<div className="card-title" style={{ margin: 0 }}>
|
||||
Roll Dice
|
||||
</div>
|
||||
{campaignId ? (
|
||||
<div className="dice-tabs" role="tablist">
|
||||
<button role="tab" aria-selected={tab === 'roll'} className={`die-btn${tab === 'roll' ? ' is-selected' : ''}`} onClick={() => setTab('roll')}>
|
||||
Roll
|
||||
</button>
|
||||
<button role="tab" aria-selected={tab === 'table'} className={`die-btn${tab === 'table' ? ' is-selected' : ''}`} onClick={() => setTab('table')}>
|
||||
{isDM ? 'Everyone’s rolls' : 'Table'} {feed.length > 0 && <span className="combatant-sub">({feed.length})</span>}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-title" style={{ margin: 0 }}>
|
||||
Roll Dice
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-ghost" onClick={() => setOpen(false)} aria-label="Close the dice roller">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="dice-choices mt-sm" role="group" aria-label="Which die">
|
||||
{DICE.map((d) => (
|
||||
<button key={d} className={`die-btn${d === sides ? ' is-selected' : ''}`} aria-pressed={d === sides} onClick={() => setSides(d)}>
|
||||
d{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-between mt-sm">
|
||||
<div className="flex-row gap-sm">
|
||||
<span className="dice-label">Dice</span>
|
||||
<button className="btn" aria-label="One fewer die" disabled={count <= 1} onClick={() => setCount(count - 1)}>
|
||||
−
|
||||
</button>
|
||||
<span className="mono" aria-live="polite" style={{ minWidth: '1.5ch', textAlign: 'center' }}>
|
||||
{count}
|
||||
</span>
|
||||
<button className="btn" aria-label="One more die" disabled={count >= MAX_DICE} onClick={() => setCount(count + 1)}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<label className="flex-row gap-sm">
|
||||
<span className="dice-label">Bonus</span>
|
||||
<input
|
||||
type="number"
|
||||
min={-99}
|
||||
max={99}
|
||||
value={modifier}
|
||||
onChange={(e) => setModifier(Math.max(-99, Math.min(99, Math.trunc(Number(e.target.value)) || 0)))}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* For any single d20: one rolled here, and every check, save and attack clicked on a sheet. */}
|
||||
<div className="dice-choices mt-sm" role="group" aria-label="How a d20 is rolled">
|
||||
{MODES.map((m) => (
|
||||
<button key={m.mode} className={`die-btn${m.mode === mode ? ' is-selected' : ''}`} aria-pressed={m.mode === mode} title={m.title} onClick={() => setMode(m.mode)}>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary btn-block btn-lg mt-sm" onClick={roll}>
|
||||
🎲 Roll {notation(count, sides, modifier)}
|
||||
{singleD20 && mode !== 'NORMAL' && ` (${modeNote({ mode } as Roll)})`}
|
||||
</button>
|
||||
|
||||
<div className="dice-result" aria-live="polite">
|
||||
{latest ? (
|
||||
<>
|
||||
{latest.label && <div className="dice-label roll-label">{latest.label}</div>}
|
||||
{/* Keyed on the roll so the pop animation replays every time, even on a repeat result. */}
|
||||
<div key={latest.id} className={`roll-result animate${natural === 20 ? ' is-crit' : natural === 1 ? ' is-fumble' : ''}`}>
|
||||
{latest.total}
|
||||
</div>
|
||||
{natural === 20 && <div className="dice-callout">Natural 20!</div>}
|
||||
{natural === 1 && <div className="dice-callout is-fumble">Natural 1</div>}
|
||||
{(latest.dice.length > 1 || latest.modifier !== 0 || latest.label) && (
|
||||
<div className="dice-breakdown mono">
|
||||
{latest.natural != null && latest.dice.length > 1
|
||||
? `${latest.dice.join(' / ')} → ${latest.natural} (${modeNote(latest)})`
|
||||
: latest.natural != null
|
||||
? latest.natural
|
||||
: `${latest.notation}: ${latest.dice.join(', ')}`}
|
||||
{latest.modifier !== 0 && ` ${latest.modifier > 0 ? '+' : '−'} ${Math.abs(latest.modifier)}`} = {latest.total}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="subtitle" style={{ margin: 0 }}>
|
||||
Pick a die and roll, or click a skill, save or attack on a sheet.
|
||||
{tab === 'table' && campaignId ? (
|
||||
<div className="mt-sm">
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
{isDM
|
||||
? 'Every roll made at this table, as it lands. Hidden ones are yours alone.'
|
||||
: campaign?.rollVisibility === 'OWN'
|
||||
? 'Your rolls. This campaign keeps each player’s rolls between them and the DM.'
|
||||
: 'Rolls made at this table.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{history.length > 1 && (
|
||||
<RollFeed rolls={feed} userId={user?.id} empty="Nobody has rolled yet." />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex-between">
|
||||
<span className="dice-label">Earlier rolls</span>
|
||||
<button className="btn btn-ghost" onClick={() => setHistory([])}>
|
||||
Clear
|
||||
</button>
|
||||
<div className="dice-choices mt-sm" role="group" aria-label="Which die">
|
||||
{DICE.map((d) => (
|
||||
<button key={d} className={`die-btn${d === sides ? ' is-selected' : ''}`} aria-pressed={d === sides} onClick={() => setSides(d)}>
|
||||
d{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{history.slice(1).map((r) => (
|
||||
<div key={r.id} className="flex-between dice-history-row">
|
||||
<span>
|
||||
{r.label ? `${r.label} · ` : ''}
|
||||
{r.notation}
|
||||
{r.dice.length > 1 && <span className="dice-history-dice"> ({r.dice.join(', ')})</span>}
|
||||
|
||||
<div className="flex-between mt-sm">
|
||||
<div className="flex-row gap-sm">
|
||||
<span className="dice-label">Dice</span>
|
||||
<button className="btn" aria-label="One fewer die" disabled={count <= 1} onClick={() => setCount(count - 1)}>
|
||||
−
|
||||
</button>
|
||||
<span className="mono" aria-live="polite" style={{ minWidth: '1.5ch', textAlign: 'center' }}>
|
||||
{count}
|
||||
</span>
|
||||
<strong className="mono">{r.total}</strong>
|
||||
<button className="btn" aria-label="One more die" disabled={count >= MAX_DICE} onClick={() => setCount(count + 1)}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<label className="flex-row gap-sm">
|
||||
<span className="dice-label">Bonus</span>
|
||||
<input
|
||||
type="number"
|
||||
min={-99}
|
||||
max={99}
|
||||
value={modifier}
|
||||
onChange={(e) => setModifier(Math.max(-99, Math.min(99, Math.trunc(Number(e.target.value)) || 0)))}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* For any single d20: one rolled here, and every check, save and attack clicked on a sheet. */}
|
||||
<div className="dice-choices mt-sm" role="group" aria-label="How a d20 is rolled">
|
||||
{MODES.map((m) => (
|
||||
<button key={m.mode} className={`die-btn${m.mode === mode ? ' is-selected' : ''}`} aria-pressed={m.mode === mode} title={m.title} onClick={() => setMode(m.mode)}>
|
||||
{m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(diceMode === 'BOTH' || (isDM && campaignId)) && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{diceMode === 'BOTH' && (
|
||||
<label className="checkbox-row" title="Roll real dice at the table and type in what they show">
|
||||
<input type="checkbox" checked={byHand} onChange={(e) => chooseByHand(e.target.checked)} />
|
||||
🎯 I roll real dice
|
||||
</label>
|
||||
)}
|
||||
{isDM && campaignId && (
|
||||
<label className="checkbox-row" title="Only you see a hidden roll">
|
||||
<input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} />
|
||||
🙈 Roll hidden
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{diceMode === 'PHYSICAL' && <p className="combatant-sub mt-sm" style={{ marginBottom: 0 }}>This campaign plays with real dice: roll at the table and enter what they show.</p>}
|
||||
|
||||
{pending ? (
|
||||
<form
|
||||
className="dice-entry mt-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (typedOk) perform(pending, typedValues);
|
||||
}}
|
||||
>
|
||||
<div className="dice-label roll-label">{pending.label || 'Your roll'}</div>
|
||||
<p style={{ margin: '0.2rem 0 0.4rem' }}>
|
||||
Roll <strong className="mono">{specNotation(pending)}</strong>
|
||||
{pending.kind === 'check' && pending.mode && pending.mode !== 'NORMAL' ? ` with ${pending.mode.toLowerCase()} (both d20s)` : ''} and enter what{' '}
|
||||
{pendingDice.length === 1 ? 'it shows' : 'each die shows'}:
|
||||
</p>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
{pendingDice.map((dieSides, i) => (
|
||||
<label key={i} className="dice-entry-die">
|
||||
<span className="dice-label">d{dieSides}</span>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={dieSides}
|
||||
placeholder={`1–${dieSides}`}
|
||||
value={typed[i] ?? ''}
|
||||
autoFocus={i === 0}
|
||||
onChange={(e) => setTyped((prev) => Object.assign([...prev], { [i]: e.target.value }))}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-row gap-sm mt-sm">
|
||||
<button type="submit" className="btn btn-primary" disabled={!typedOk || send.isPending}>
|
||||
Enter roll
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setPending(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
{diceMode === 'BOTH' && (
|
||||
<button type="button" className="btn btn-ghost" onClick={() => chooseByHand(false)}>
|
||||
Let the app roll
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button className="btn btn-primary btn-block btn-lg mt-sm" onClick={() => ask(setup)} disabled={send.isPending}>
|
||||
{typing ? '🎯' : '🎲'} Roll {notation(count, sides, modifier)}
|
||||
{singleD20 && mode !== 'NORMAL' && ` (${mode === 'ADVANTAGE' ? 'advantage' : 'disadvantage'})`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="dice-error">{error}</p>}
|
||||
|
||||
<div className="dice-result" aria-live="polite">
|
||||
{latest ? (
|
||||
// Keyed on the roll so the dice tumble again every time, even on a repeat result.
|
||||
<div key={latest.id} className="dice-stage">
|
||||
{latest.label && <div className="dice-label roll-label">{latest.label}</div>}
|
||||
<DiceRow dice={latest.dice} rolling size={latest.dice.length > 6 ? 40 : 58} />
|
||||
<div className={`roll-result roll-reveal${natural === 20 ? ' is-crit' : natural === 1 ? ' is-fumble' : ''}`} style={{ animationDelay: `${settle}ms` }}>
|
||||
{latest.total}
|
||||
</div>
|
||||
<div className="roll-reveal" style={{ animationDelay: `${settle}ms` }}>
|
||||
{natural === 20 && <div className="dice-callout">Natural 20!</div>}
|
||||
{natural === 1 && <div className="dice-callout is-fumble">Natural 1</div>}
|
||||
<div className="dice-breakdown mono">
|
||||
{latest.notation}
|
||||
{latest.mode !== 'NORMAL' && ` (${latest.mode === 'ADVANTAGE' ? 'advantage' : 'disadvantage'})`}
|
||||
{latest.modifier !== 0 && `: dice ${latest.total - latest.modifier} ${latest.modifier > 0 ? '+' : '−'} ${Math.abs(latest.modifier)}`} = {latest.total}
|
||||
{latest.hidden && ' · hidden'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="subtitle" style={{ margin: 0 }}>
|
||||
Pick a die and roll, or click a skill, save or attack on a sheet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!campaignId && history.length > 1 && (
|
||||
<>
|
||||
<div className="flex-between">
|
||||
<span className="dice-label">Earlier rolls</span>
|
||||
<button className="btn btn-ghost" onClick={() => setHistory([])}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{history.slice(1).map((r) => (
|
||||
<div key={r.id} className="flex-between dice-history-row">
|
||||
<span>
|
||||
{r.label ? `${r.label} · ` : ''}
|
||||
{r.notation}
|
||||
{r.dice.length > 1 && <span className="dice-history-dice"> ({r.dice.map((d) => d.value).join(', ')})</span>}
|
||||
</span>
|
||||
<strong className="mono">{r.total}</strong>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{campaignId && (
|
||||
<p className="combatant-sub" style={{ margin: 0 }}>
|
||||
{isDM ? 'Your rolls are shown to the table unless hidden.' : campaign?.rollVisibility === 'OWN' ? 'Your DM sees this roll.' : 'The table sees this roll.'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ const STAT_HELP: Record<string, string> = {
|
||||
SKILL_BONUS: 'Skill Bonus — added when you roll to use a skill. The governing ability modifier, plus your proficiency bonus if you are proficient (doubled if you have expertise).',
|
||||
EXPERTISE: 'Expertise — doubles your proficiency bonus for this skill, on top of being proficient.',
|
||||
ABILITY_MODIFIER: 'Ability Modifier — the number added to rolls that use this ability. floor((score − 10) / 2).',
|
||||
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item.',
|
||||
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item, 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.',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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';
|
||||
import { DiceRoller } from './DiceRoller';
|
||||
import { CharacterPanel } from './CharacterPanel';
|
||||
import { Toasts } from './Toasts';
|
||||
|
||||
export function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
@@ -14,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 = [
|
||||
@@ -38,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>
|
||||
⚄
|
||||
@@ -70,6 +84,7 @@ export function Layout() {
|
||||
)}
|
||||
<DiceRoller />
|
||||
{user && <CharacterPanel />}
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react';
|
||||
import { useApproveLevelUps } from '../api/queries';
|
||||
import { classLabel, extractErrorMessage } from '../lib/format';
|
||||
import { Modal } from './Modal';
|
||||
import { ErrorBanner } from './Spinner';
|
||||
import type { CharacterSummary, LevelUpApproval } from '../api/types';
|
||||
|
||||
const MAX_LEVEL = 20;
|
||||
|
||||
// The DM's level-up button: approve a level for any of the party at once.
|
||||
// Each approval is a checklist waiting for its character: their player is
|
||||
// told and makes the picks themselves, so nobody queues for the DM. A
|
||||
// character without a player is the DM's to finish, from its sheet.
|
||||
// `trackXp`: with XP kept, those who have the XP for their next level start
|
||||
// ticked; without it (milestones) the DM ticks who levels.
|
||||
export function LevelUpBulkModal({
|
||||
campaignId,
|
||||
characters,
|
||||
trackXp,
|
||||
onClose,
|
||||
}: {
|
||||
campaignId: string;
|
||||
characters: CharacterSummary[];
|
||||
trackXp: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const approve = useApproveLevelUps(campaignId);
|
||||
const party = characters.filter((c) => c.buildStage === 'COMPLETE');
|
||||
const canLevel = (c: CharacterSummary) => c.level + c.levelUpsPending < MAX_LEVEL;
|
||||
const [picked, setPicked] = useState<Set<string>>(() => new Set(party.filter((c) => trackXp && c.xpProgress.levelUpDue && canLevel(c)).map((c) => c.id)));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<LevelUpApproval[] | null>(null);
|
||||
|
||||
function toggle(id: string) {
|
||||
setPicked((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function send(characterIds: string[], revoke = false) {
|
||||
setError(null);
|
||||
try {
|
||||
const { results } = await approve.mutateAsync({ characterIds, revoke });
|
||||
if (!revoke) setDone(results);
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<Modal title="Level ups approved" subtitle="Each player makes their own picks" onClose={onClose}>
|
||||
{done.map((result) => (
|
||||
<div key={result.characterId} className="reward-row">
|
||||
<strong>{result.name}</strong>
|
||||
{result.skipped ? (
|
||||
<div className="combatant-sub">{result.skipped}</div>
|
||||
) : (
|
||||
<div className="combatant-sub">
|
||||
Level {result.level} → {result.level + 1} is theirs to take.{' '}
|
||||
{result.userId ? 'Their player has been told and chooses what they gain.' : 'No player: finish it from the sheet.'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="modal-foot">
|
||||
<button className="btn btn-primary" onClick={onClose}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Level up" subtitle="Approve a level for the party; each player then picks what they gain" wide onClose={onClose}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<button className="btn" onClick={() => setPicked(new Set(party.filter(canLevel).map((c) => c.id)))}>
|
||||
Everyone
|
||||
</button>
|
||||
{trackXp && (
|
||||
<button className="btn" onClick={() => setPicked(new Set(party.filter((c) => c.xpProgress.levelUpDue && canLevel(c)).map((c) => c.id)))}>
|
||||
Those with the XP
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-ghost" onClick={() => setPicked(new Set())}>
|
||||
Nobody
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-md">
|
||||
{party.length === 0 && <p className="combatant-sub">No finished characters yet.</p>}
|
||||
{party.map((c) => (
|
||||
<label key={c.id} className="checkbox-row level-choice" style={canLevel(c) ? undefined : { opacity: 0.55 }}>
|
||||
<input type="checkbox" checked={picked.has(c.id)} disabled={!canLevel(c)} onChange={() => toggle(c.id)} />
|
||||
<span style={{ flex: 1 }}>
|
||||
<strong>{c.name}</strong>{' '}
|
||||
<span className="combatant-sub">
|
||||
{classLabel(c.characterClass, c.subclassName, c.classes)} · Level {c.level}
|
||||
</span>
|
||||
{trackXp && c.xpProgress.levelUpDue && <span className="badge badge-live" style={{ marginLeft: 6 }}>has the XP</span>}
|
||||
{c.levelUpsPending > 0 && (
|
||||
<span className="badge" style={{ marginLeft: 6 }}>
|
||||
{c.levelUpsPending} waiting on their picks
|
||||
</span>
|
||||
)}
|
||||
{!c.userId && <span className="badge" style={{ marginLeft: 6 }}>no player: yours to finish</span>}
|
||||
</span>
|
||||
{c.levelUpsPending > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
title="Takes back one approved level that has not been taken yet"
|
||||
disabled={approve.isPending}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
send([c.id], true);
|
||||
}}
|
||||
>
|
||||
Take one back
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<button className="btn btn-primary" disabled={picked.size === 0 || approve.isPending} onClick={() => send([...picked])}>
|
||||
{approve.isPending ? 'Approving…' : `Approve a level for ${picked.size} character${picked.size === 1 ? '' : 's'}`}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useLevelUp, useLevelUpPlan } from '../api/queries';
|
||||
import { extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format';
|
||||
import { CLASS_LABELS, classLabel, extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format';
|
||||
import { Modal } from './Modal';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import { Spinner, ErrorBanner } from './Spinner';
|
||||
import type { CharacterDetail, LevelUpPlan, Spell } from '../api/types';
|
||||
import { showToast } from '../lib/toasts';
|
||||
import type { CharacterClass, CharacterDetail, LevelUpPlan } from '../api/types';
|
||||
|
||||
// One numbered step of the checklist.
|
||||
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
|
||||
@@ -27,15 +29,20 @@ function allowance(spells: NonNullable<LevelUpPlan['spells']>): string {
|
||||
// The levelling-up checklist for one character, one level at a time: the
|
||||
// level, HP, the class's new special abilities (with a subclass or a Boon
|
||||
// where the level calls for one), new spells and fresh slots, and the free weapon upgrade. The
|
||||
// site does every step; the DM makes the choices and says yes.
|
||||
export function LevelUpModal({ character, campaignSpells, onClose }: { character: CharacterDetail; campaignSpells: Spell[]; onClose: () => void }) {
|
||||
const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true);
|
||||
// site does every step; whoever is levelling makes the choices and says yes:
|
||||
// the DM, or the character's own player once the DM has approved the level.
|
||||
// A player picks spells within what the level allows; the DM grants freely.
|
||||
export function LevelUpModal({ character, isDM, onClose }: { character: CharacterDetail; isDM: boolean; onClose: () => void }) {
|
||||
// The class taking the level: the first one, unless another is picked (multiclassing).
|
||||
const [toClass, setToClass] = useState<CharacterClass | null>(null);
|
||||
const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true, toClass);
|
||||
const levelUp = useLevelUp(character.id);
|
||||
const [subclass, setSubclass] = useState('');
|
||||
const [boon, setBoon] = useState('');
|
||||
// undefined until picked: the equipped weapon is offered first.
|
||||
const [weaponChoice, setWeaponChoice] = useState<string | undefined>(undefined);
|
||||
const [spellIds, setSpellIds] = useState<Set<string>>(new Set());
|
||||
const [hpRoll, setHpRoll] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (isLoading || !plan) {
|
||||
@@ -47,13 +54,16 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
}
|
||||
|
||||
const weaponId = weaponChoice ?? plan.weapons.find((w) => w.equipped)?.weaponId ?? plan.weapons[0]?.weaponId ?? '';
|
||||
const known = new Set(character.spells.map((s) => s.spellId));
|
||||
const grantable = plan.spells
|
||||
? campaignSpells.filter((s) => !known.has(s.id) && s.level <= plan.spells!.maxSpellLevel).sort((a, b) => a.level - b.level || a.name.localeCompare(b.name))
|
||||
: [];
|
||||
const grantable = plan.spellOptions;
|
||||
// A player picks up to the level's allowance; the DM is not held to it.
|
||||
const spellAllowance = plan.spells ? plan.spells.newSpells + plan.spells.newCantrips : 0;
|
||||
const spellsFull = !isDM && spellIds.size >= spellAllowance;
|
||||
const needsHpRoll = plan.hp.method === 'ROLL' && plan.hp.physical;
|
||||
const hpRollValid = Number.isInteger(Number(hpRoll)) && Number(hpRoll) >= 1 && Number(hpRoll) <= plan.hp.die;
|
||||
const pickedBoon = plan.boon?.find((b) => b.key === boon);
|
||||
const pickedSubclass = plan.subclass?.options.find((option) => option.key === subclass);
|
||||
const owed = plan.subclass && !subclass ? 'Choose a subclass first' : plan.boon && !boon ? 'Choose a Boon first' : null;
|
||||
const owed =
|
||||
plan.subclass && !subclass ? 'Choose a subclass first' : plan.boon && !boon ? 'Choose a Boon first' : needsHpRoll && !hpRollValid ? `Roll your d${plan.hp.die} for hit points` : null;
|
||||
|
||||
function toggleSpell(id: string) {
|
||||
setSpellIds((current) => {
|
||||
@@ -66,7 +76,14 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
async function handleLevelUp() {
|
||||
setError(null);
|
||||
try {
|
||||
await levelUp.mutateAsync({ subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
|
||||
const { hpRolled } = await levelUp.mutateAsync({
|
||||
hpRoll: needsHpRoll ? Number(hpRoll) : undefined,
|
||||
characterClass: plan!.characterClass,
|
||||
subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
|
||||
showToast({
|
||||
tone: 'good',
|
||||
text: `${character.name} is level ${plan!.toLevel}!${hpRolled != null ? ` The d${plan!.hp.die} for hit points came up ${hpRolled}.` : ''}`,
|
||||
});
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
@@ -78,15 +95,74 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
<Step n={1} title="Level">
|
||||
{plan.classChoice && (
|
||||
<div className="mb-2">
|
||||
<label className="flex-row flex-wrap gap-sm">
|
||||
Which class gains this level?
|
||||
<select
|
||||
value={plan.characterClass}
|
||||
onChange={(e) => {
|
||||
setToClass(e.target.value as CharacterClass);
|
||||
setSubclass('');
|
||||
setBoon('');
|
||||
setSpellIds(new Set());
|
||||
}}
|
||||
>
|
||||
{plan.classChoice.options.map((o) => (
|
||||
<option key={o.characterClass} value={o.characterClass} disabled={!o.eligible} title={o.problems.join(' ')}>
|
||||
{CLASS_LABELS[o.characterClass]}
|
||||
{o.level > 0 ? ` (level ${o.level} → ${o.level + 1})` : o.eligible ? ' (new class)' : ' (requirements not met)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{plan.joining && (
|
||||
<p className="combatant-sub">
|
||||
Multiclassing into {CLASS_LABELS[plan.characterClass]}: it starts at its own level 1, with what that class grants at level 1 (not its saves or
|
||||
skills) and what it teaches a newcomer.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
Level <strong>{plan.fromLevel}</strong> → <strong>{plan.toLevel}</strong>. Proficiency bonus, saves, skills and spell DC follow by themselves.
|
||||
Level <strong>{plan.fromLevel}</strong> → <strong>{plan.toLevel}</strong>
|
||||
{(character.classes.length > 1 || plan.characterClass !== character.characterClass) && (
|
||||
<span className="combatant-sub">
|
||||
{' '}
|
||||
· {CLASS_LABELS[plan.characterClass]} {plan.classLevel - 1} → {plan.classLevel} (now {classLabel(character.characterClass, character.subclassName, character.classes)})
|
||||
</span>
|
||||
)}
|
||||
. Proficiency bonus, saves, skills and spell DC follow by themselves.
|
||||
</p>
|
||||
</Step>
|
||||
|
||||
<Step n={2} title="Hit points">
|
||||
<p>
|
||||
Max HP <strong>{plan.maxHp.from}</strong> → <strong>{plan.maxHp.to}</strong>, and healed to full. One more Hit Die.
|
||||
</p>
|
||||
{plan.hp.method === 'AVERAGE' ? (
|
||||
<p>
|
||||
Max HP <strong>{plan.maxHp.from}</strong> → <strong>{plan.maxHp.to}</strong>, and healed to full. One more Hit Die (d{plan.hp.die}).
|
||||
</p>
|
||||
) : needsHpRoll ? (
|
||||
<div>
|
||||
<p style={{ marginBottom: '0.3rem' }}>
|
||||
This campaign rolls for hit points. Roll a <strong>d{plan.hp.die}</strong> at the table and enter what it shows; your Constitution
|
||||
modifier is added for you. Healed to full, and one more Hit Die.
|
||||
</p>
|
||||
<label className="flex-row gap-sm">
|
||||
I rolled
|
||||
<NumberInput min={1} max={plan.hp.die} placeholder={`1–${plan.hp.die}`} value={hpRoll} onChange={(e) => setHpRoll(e.target.value)} style={{ width: 80 }} />
|
||||
{hpRollValid && (
|
||||
<span className="combatant-sub">
|
||||
Max HP {plan.maxHp.from} → {plan.maxHp.to - plan.hp.average + Number(hpRoll)}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<p>
|
||||
This campaign rolls for hit points: a <strong>d{plan.hp.die}</strong> is rolled when you confirm (between {plan.maxHp.to - plan.hp.average + 1} and{' '}
|
||||
{plan.maxHp.to - plan.hp.average + plan.hp.die} max HP), plus healing to full and one more Hit Die.
|
||||
</p>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Special abilities">
|
||||
@@ -120,7 +196,7 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
)}
|
||||
{plan.features.length === 0 && !plan.boon && !plan.subclass && (
|
||||
<p className="combatant-sub">
|
||||
{plan.beyondTables ? 'The class tables stop at level 10. Add anything new to the special abilities by hand.' : 'No new feature at this level.'}
|
||||
{plan.beyondTables ? 'The class tables stop at level 20. Add anything new to the special abilities by hand.' : 'No new feature at this level.'}
|
||||
</p>
|
||||
)}
|
||||
{plan.features.map((feature) => (
|
||||
@@ -150,7 +226,9 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
</div>
|
||||
)}
|
||||
{(plan.features.length > 0 || plan.boon || plan.subclass) && (
|
||||
<p className="combatant-sub">Written into Special Abilities for you. Where a line says "choose", edit it there once the player has.</p>
|
||||
<p className="combatant-sub">
|
||||
Written into Special Abilities for you. {isDM ? 'Where a line says "choose", edit it there once the player has.' : 'Where a line says "choose", agree it with your DM.'}
|
||||
</p>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
@@ -176,16 +254,22 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
<div className="level-spells">
|
||||
{grantable.map((spell) => (
|
||||
<label key={spell.id} className="checkbox-row level-choice">
|
||||
<input type="checkbox" checked={spellIds.has(spell.id)} onChange={() => toggleSpell(spell.id)} />
|
||||
<span>
|
||||
{spell.name} <span className="combatant-sub">{spellLevelLabel(spell.level)}</span>
|
||||
<input type="checkbox" checked={spellIds.has(spell.id)} disabled={spellsFull && !spellIds.has(spell.id)} onChange={() => toggleSpell(spell.id)} />
|
||||
<span title={spell.description ?? undefined}>
|
||||
{spell.name}{' '}
|
||||
<span className="combatant-sub">
|
||||
{spellLevelLabel(spell.level)}
|
||||
{spell.school ? ` · ${spell.school}` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="combatant-sub">
|
||||
{spellIds.size > 0 && `${spellIds.size} picked. `}A player may also swap one known spell for another: take the old one away on the sheet.
|
||||
{isDM
|
||||
? `${spellIds.size > 0 ? `${spellIds.size} picked. ` : ''}A player may also swap one known spell for another: take the old one away on the sheet.`
|
||||
: `${spellIds.size} of ${spellAllowance} picked. Hover a spell to read what it does.`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMulticlassReference } from '../api/queries';
|
||||
import { CLASS_LABELS } from '../lib/format';
|
||||
import { Spinner } from './Spinner';
|
||||
import type { Ability, CharacterClass, ClassLevel, MulticlassReference } from '../api/types';
|
||||
|
||||
// Whether final scores meet a class's multiclassing requirement.
|
||||
function meets(reference: MulticlassReference, characterClass: CharacterClass, scores: Record<Ability, number>): boolean {
|
||||
return reference.prereqs[characterClass].alternatives.some((all) => all.every((a) => scores[a] >= reference.prereqScore));
|
||||
}
|
||||
|
||||
// Suggested multiclass routes from where a character stands: each route's
|
||||
// steps, how far along the character is, and whether their scores meet every
|
||||
// class the route takes them through (multiclassing needs the requirement of
|
||||
// the class joined and of each class left).
|
||||
export function MulticlassRoutes({ classes, scores }: { classes: ClassLevel[]; scores: Record<Ability, number> }) {
|
||||
const { data: reference, isLoading } = useMulticlassReference(true);
|
||||
if (isLoading || !reference) return <Spinner label="Reading the routes…" />;
|
||||
|
||||
const levelIn = (cls: CharacterClass) => classes.find((c) => c.characterClass === cls)?.level ?? 0;
|
||||
const have = new Set(classes.map((c) => c.characterClass));
|
||||
const paths = reference.paths.filter((p) => !p.from || have.has(p.from));
|
||||
const yours = classes.map((c) => c.characterClass);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Multiclassing is on. A level can go to another class when {classes.length > 1 ? 'every class you have' : `your ${CLASS_LABELS[yours[0]]}`} and the new class’s
|
||||
requirements are met: {yours.map((c) => `${CLASS_LABELS[c]} needs ${reference.prereqs[c].text}`).join('; ')}.
|
||||
</p>
|
||||
<div className="choice-grid">
|
||||
{paths.map((path) => {
|
||||
const classesOnRoute = [...new Set(path.steps.map((s) => s.class))];
|
||||
const blocked = classesOnRoute.filter((cls) => !meets(reference, cls, scores));
|
||||
return (
|
||||
<div key={path.key} className="choice-card" style={{ cursor: 'default' }}>
|
||||
<span className="choice-card-title">
|
||||
{path.name}
|
||||
{blocked.length === 0 ? <span className="badge badge-friendly">Open to you</span> : <span className="badge badge-hostile">Needs scores</span>}
|
||||
</span>
|
||||
<span className="choice-card-blurb">{path.why}</span>
|
||||
<ol style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
{path.steps.map((step, i) => {
|
||||
const done = levelIn(step.class) >= step.toLevel;
|
||||
return (
|
||||
<li key={i} style={done ? { opacity: 0.6 } : undefined}>
|
||||
{done ? '✓ ' : ''}
|
||||
{CLASS_LABELS[step.class]} to level {step.toLevel}
|
||||
{step.subclass ? ` (${step.subclass.toLowerCase().replace(/_/g, ' ')})` : ''}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{blocked.length > 0 && (
|
||||
<span className="choice-card-facts">{blocked.map((cls) => `${CLASS_LABELS[cls]}: ${reference.prereqs[cls].text}`).join(' · ')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
import { useProgression } from '../api/queries';
|
||||
import { slotLevelLabel } from '../lib/format';
|
||||
import { Spinner } from './Spinner';
|
||||
import type { CharacterClass } from '../api/types';
|
||||
|
||||
// The road from level 1 to 20 for a class and subclass: what each level
|
||||
// gives. With `currentLevel` the levels already reached are muted, the next
|
||||
// one is marked "next up", and only the road ahead shows until "Show all".
|
||||
export function ProgressionTimeline({
|
||||
characterClass,
|
||||
subclass,
|
||||
currentLevel,
|
||||
subclassName,
|
||||
}: {
|
||||
characterClass: CharacterClass;
|
||||
subclass?: string | null;
|
||||
currentLevel?: number;
|
||||
subclassName?: string | null;
|
||||
}) {
|
||||
const { data: levels, isLoading } = useProgression(characterClass, subclass);
|
||||
const [showAll, setShowAll] = useState(!currentLevel);
|
||||
|
||||
if (isLoading || !levels) return <Spinner label="Reading the road ahead…" />;
|
||||
const shown = showAll || !currentLevel ? levels : levels.filter((l) => l.level >= currentLevel);
|
||||
|
||||
return (
|
||||
<div className="progression">
|
||||
<ol className="progression-list">
|
||||
{shown.map((l) => {
|
||||
const reached = currentLevel !== undefined && l.level <= currentLevel;
|
||||
const nextUp = currentLevel !== undefined && l.level === currentLevel + 1;
|
||||
const empty = l.features.length === 0 && !l.boon && !l.choosesSubclass && !l.unlocksSpellLevel;
|
||||
return (
|
||||
<li key={l.level} className={`progression-level${reached ? ' is-reached' : ''}${nextUp ? ' is-next' : ''}`}>
|
||||
<span className="progression-badge">
|
||||
{l.level}
|
||||
{nextUp && <span className="badge badge-live">next up</span>}
|
||||
</span>
|
||||
<div className="progression-gains">
|
||||
{l.features.map((f) => (
|
||||
<div key={f.name} className="progression-feature" title={f.text}>
|
||||
<strong>{f.name}</strong>
|
||||
{f.fromSubclass && <span className="badge">{subclassName || 'subclass'}</span>}
|
||||
<span className="combatant-sub"> — {f.text}</span>
|
||||
</div>
|
||||
))}
|
||||
{l.boon && <div className="progression-feature">⭐ A Boon of your choice</div>}
|
||||
{l.choosesSubclass && <div className="progression-feature">🧭 Choose your subclass</div>}
|
||||
{l.unlocksSpellLevel && <div className="progression-feature">✨ {slotLevelLabel(l.unlocksSpellLevel)}-level spells unlock</div>}
|
||||
{empty && <div className="combatant-sub">More hit points, and the numbers grow.</div>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{currentLevel !== undefined && currentLevel > 1 && (
|
||||
<button type="button" className="btn btn-ghost mt-sm" onClick={() => setShowAll((s) => !s)}>
|
||||
{showAll ? 'Hide the levels already reached' : 'Show all 20 levels'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DiceRow } from './dice/Die';
|
||||
import type { DiceRollRecord } from '../api/types';
|
||||
|
||||
const MODE_NOTE = { NORMAL: '', ADVANTAGE: 'advantage', DISADVANTAGE: 'disadvantage' } as const;
|
||||
|
||||
function timeOf(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// One roll as a line: who, what for, the dice as they fell, and the total.
|
||||
export function RollLine({ roll, mine, rolling = false, size = 34 }: { roll: DiceRollRecord; mine: boolean; rolling?: boolean; size?: number }) {
|
||||
const crit = roll.natural === 20;
|
||||
const fumble = roll.natural === 1;
|
||||
return (
|
||||
<div className={`roll-line${crit ? ' is-crit' : fumble ? ' is-fumble' : ''}`}>
|
||||
<div className="roll-line-head">
|
||||
<span>
|
||||
<strong>{mine ? 'You' : roll.rollerName}</strong>
|
||||
{roll.label && <span> · {roll.label}</span>}
|
||||
</span>
|
||||
<span className="combatant-sub">{timeOf(roll.createdAt)}</span>
|
||||
</div>
|
||||
<div className="roll-line-body">
|
||||
<DiceRow dice={roll.dice} rolling={rolling} size={size} />
|
||||
<span className="roll-line-math mono">
|
||||
{roll.modifier !== 0 && `${roll.modifier > 0 ? '+' : '−'} ${Math.abs(roll.modifier)} `}=
|
||||
</span>
|
||||
<strong className="roll-line-total mono">{roll.total}</strong>
|
||||
</div>
|
||||
<div className="roll-line-tags">
|
||||
<span className="combatant-sub mono">{roll.notation}</span>
|
||||
{roll.mode !== 'NORMAL' && <span className="badge">{MODE_NOTE[roll.mode]}</span>}
|
||||
{crit && <span className="badge badge-friendly">Natural 20</span>}
|
||||
{fumble && <span className="badge badge-hostile">Natural 1</span>}
|
||||
{roll.physical && <span className="badge" title="Rolled with real dice and typed in">🎯 at the table</span>}
|
||||
{roll.hidden && <span className="badge badge-dm" title="Only you see this roll">🙈 hidden</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The table's rolls, newest first: every roll this member may see. For the
|
||||
// DM that is every roll anyone makes.
|
||||
export function RollFeed({ rolls, userId, empty }: { rolls: DiceRollRecord[]; userId: string | undefined; empty: string }) {
|
||||
if (rolls.length === 0) return <p className="subtitle">{empty}</p>;
|
||||
return (
|
||||
<div className="roll-feed">
|
||||
{rolls.map((roll) => (
|
||||
<RollLine key={roll.id} roll={roll} mine={roll.userId === userId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { dismissToast, subscribeToasts, type Toast } from '../lib/toasts';
|
||||
|
||||
// The stack of toasts (lib/toasts.ts), mounted once in the layout.
|
||||
export function Toasts() {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
useEffect(() => subscribeToasts(setToasts), []);
|
||||
if (toasts.length === 0) return null;
|
||||
return (
|
||||
<div className="toasts" role="status" aria-live="polite">
|
||||
{toasts.map((toast) => (
|
||||
<div key={toast.id} className={`toast${toast.tone ? ` toast-${toast.tone}` : ''}`}>
|
||||
<span className="toast-text">{toast.text}</span>
|
||||
{toast.action && (
|
||||
<Link to={toast.action.to} className="btn btn-primary btn-sm" onClick={() => dismissToast(toast.id)}>
|
||||
{toast.action.label}
|
||||
</Link>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-sm" aria-label="Dismiss" onClick={() => dismissToast(toast.id)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { CharacterSummary, Encounter, Item, SaleOffer, Weapon } from '../ap
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// (A free-roam scene has its own panel: components/encounter/FreeRoam.tsx.)
|
||||
// Trade in a quest: changing gold and cash with an NPC who does that, at
|
||||
// their own rate, and buying what the NPCs in the scene — or the scene itself
|
||||
// — have for sale. Everyone sees what is on offer; a player trades for their
|
||||
|
||||
@@ -73,8 +73,8 @@ function WareRow({ campaignId, ware, onError }: { campaignId: string; ware: Sale
|
||||
);
|
||||
}
|
||||
|
||||
// The DM's list of what one seller has for sale — an NPC, or an encounter
|
||||
// itself — with a form to put something from the campaign pools up for sale.
|
||||
// The DM's list of what one seller has for sale — an NPC, an encounter
|
||||
// itself, or a location (a shop that is always there) — with a form to put something from the campaign pools up for sale.
|
||||
export function WaresEditor({
|
||||
campaignId,
|
||||
seller,
|
||||
@@ -84,7 +84,7 @@ export function WaresEditor({
|
||||
onError,
|
||||
}: {
|
||||
campaignId: string;
|
||||
seller: { npcId: string } | { encounterId: string };
|
||||
seller: { npcId: string } | { encounterId: string } | { locationId: string };
|
||||
wares: SaleOffer[];
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useDeleteMonster, useDeletePoolEntry, usePatchPoolEntry, useUpdateMonster } from '../../api/queries';
|
||||
import { useCampaignLocations, useDeleteMonster, useDeletePoolEntry, usePatchPoolEntry, useUpdateMonster } from '../../api/queries';
|
||||
import { Modal } from '../Modal';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { ErrorBanner } from '../Spinner';
|
||||
@@ -7,8 +7,9 @@ import { EffectsEditor } from '../EffectsEditor';
|
||||
import { WaresEditor } from '../Wares';
|
||||
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
|
||||
import { abilityLine, rateLabel, type EntryKind, type EntryRef } from '../../lib/campaignEntries';
|
||||
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, StartLevelSelect } from './parts';
|
||||
import type { GearEffect, Item, Monster, Npc, Spell, Weapon } from '../../api/types';
|
||||
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, SpellLandingSelect, StartLevelSelect } from './parts';
|
||||
import { landingValue } from '../../lib/campaignEntries';
|
||||
import type { GearEffect, Item, Monster, Npc, Spell, Weapon, ArmorType } from '../../api/types';
|
||||
|
||||
const KIND_NOUNS: Record<EntryKind, string> = { weapon: 'weapon', item: 'item', spell: 'spell', monster: 'monster', npc: 'NPC' };
|
||||
const ATTACK_ABILITY_LABELS: Record<Weapon['attackAbility'], string> = { STR: 'STR', DEX: 'DEX', FINESSE: 'Finesse (the better of STR and DEX)' };
|
||||
@@ -89,6 +90,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>
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Pagination } from '../Paginated';
|
||||
import { usePagination } from '../../hooks/usePagination';
|
||||
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS } from '../../lib/format';
|
||||
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../../lib/previewCalc';
|
||||
import { matchesSearch, scoreField, type MonsterInput } from '../../lib/campaignEntries';
|
||||
import type { Monster } from '../../api/types';
|
||||
import { landingFrom, matchesSearch, scoreField, type MonsterInput, type SpellLanding } from '../../lib/campaignEntries';
|
||||
import type { Ability, Monster, MonsterAttack } from '../../api/types';
|
||||
|
||||
// A labelled field in a pop-out form. The child is handed the id its label points at.
|
||||
export function Field({ label, style, children }: { label: string; style?: CSSProperties; children: (id: string) => ReactNode }) {
|
||||
@@ -19,6 +19,30 @@ export function Field({ label, style, children }: { label: string; style?: CSSPr
|
||||
);
|
||||
}
|
||||
|
||||
const LANDING_ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
const ABILITY_NAMES: Record<Ability, string> = { STR: 'Strength', DEX: 'Dexterity', CON: 'Constitution', INT: 'Intelligence', WIS: 'Wisdom', CHA: 'Charisma' };
|
||||
|
||||
// How a spell lands in a battle, as one choice (lib/campaignEntries.ts, SpellLanding).
|
||||
export function SpellLandingSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: string; onChange?: (landing: SpellLanding) => void }) {
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={name}
|
||||
title="In a battle: whether the caster rolls to hit, each target makes a saving throw, or it simply happens"
|
||||
{...(onChange ? { value, onChange: (e) => onChange(landingFrom(e.target.value)) } : { defaultValue: '' })}
|
||||
>
|
||||
<option value="">It just takes effect (no roll)</option>
|
||||
<option value="ATTACK">The caster rolls a spell attack</option>
|
||||
{LANDING_ABILITIES.map((a) => (
|
||||
<optgroup key={a} label={`${ABILITY_NAMES[a]} save`}>
|
||||
<option value={`${a}:HALF`}>{ABILITY_NAMES[a]} save, half damage on a success</option>
|
||||
<option value={`${a}:NONE`}>{ABILITY_NAMES[a]} save, nothing on a success</option>
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// How fast a weapon's or item's dice grow as the DM levels a held copy up.
|
||||
export function ScalingSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: number; onChange?: (scaling: number) => void }) {
|
||||
return (
|
||||
@@ -119,6 +143,17 @@ export function MonsterForm({
|
||||
onSave: (data: MonsterInput) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
}) {
|
||||
// Named attacks, as typed: kept as text until saved.
|
||||
const [attacks, setAttacks] = useState<{ name: string; attackBonus: string; damageDice: string; damageType: string; count: string }[]>(() =>
|
||||
(monster?.attacks ?? []).map((a) => ({
|
||||
name: a.name,
|
||||
attackBonus: String(a.attackBonus),
|
||||
damageDice: a.damageDice ?? '',
|
||||
damageType: a.damageType ?? '',
|
||||
count: String(a.count ?? 1),
|
||||
}))
|
||||
);
|
||||
const setAttack = (index: number, change: Partial<(typeof attacks)[number]>) => setAttacks((rows) => rows.map((row, i) => (i === index ? { ...row, ...change } : row)));
|
||||
const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => (
|
||||
<div className="field" style={{ marginBottom: 0, ...style }}>
|
||||
<label htmlFor={`monster-${name}`}>{label}</label>
|
||||
@@ -164,6 +199,17 @@ export function MonsterForm({
|
||||
chaScore: num('chaScore'),
|
||||
specialTrait: text('specialTrait'),
|
||||
notes: text('notes'),
|
||||
attacks: attacks
|
||||
.filter((a) => a.name.trim())
|
||||
.map(
|
||||
(a): MonsterAttack => ({
|
||||
name: a.name.trim(),
|
||||
attackBonus: Math.trunc(Number(a.attackBonus)) || 0,
|
||||
damageDice: a.damageDice.trim() || null,
|
||||
damageType: a.damageType.trim() || null,
|
||||
count: Math.min(10, Math.max(1, Math.trunc(Number(a.count)) || 1)),
|
||||
})
|
||||
),
|
||||
});
|
||||
} catch {
|
||||
// Whoever opened the form shows the error; what was typed stays.
|
||||
@@ -190,6 +236,30 @@ export function MonsterForm({
|
||||
<div className="grid mt-sm" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))' }}>
|
||||
{ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))}
|
||||
</div>
|
||||
<div className="field" style={{ marginTop: '0.5rem', marginBottom: 0 }}>
|
||||
<label>Named attacks (optional)</label>
|
||||
<p className="combatant-sub" style={{ margin: '0 0 0.3rem' }}>
|
||||
For a creature with more than the one attack above: each with its own bonus and damage, and how many times it is made each turn (a multiattack).
|
||||
With any listed here, these are what it attacks with.
|
||||
</p>
|
||||
{attacks.map((a, i) => (
|
||||
<div key={i} className="flex-row flex-wrap gap-sm" style={{ marginBottom: '0.3rem' }}>
|
||||
<input aria-label="Attack name" placeholder="Claw" value={a.name} maxLength={40} onChange={(e) => setAttack(i, { name: e.target.value })} style={{ flex: '2 1 110px' }} />
|
||||
<NumberInput aria-label="Attack bonus" placeholder="+hit" min={-20} max={50} value={a.attackBonus} onChange={(e) => setAttack(i, { attackBonus: e.target.value })} style={{ width: 70 }} />
|
||||
<input aria-label="Damage dice" placeholder="2d6+3" value={a.damageDice} maxLength={60} onChange={(e) => setAttack(i, { damageDice: e.target.value })} style={{ flex: '1 1 80px' }} />
|
||||
<input aria-label="Damage type" placeholder="slashing" value={a.damageType} maxLength={30} onChange={(e) => setAttack(i, { damageType: e.target.value })} style={{ flex: '1 1 80px' }} />
|
||||
<NumberInput aria-label="Times per turn" title="How many times it is made each turn" min={1} max={10} value={a.count} onChange={(e) => setAttack(i, { count: e.target.value })} style={{ width: 60 }} />
|
||||
<button type="button" className="btn btn-ghost" aria-label={`Remove attack ${a.name || i + 1}`} onClick={() => setAttacks((rows) => rows.filter((_, at) => at !== i))}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{attacks.length < 8 && (
|
||||
<button type="button" className="btn btn-sm" onClick={() => setAttacks((rows) => [...rows, { name: '', attackBonus: String(monster?.attackBonus ?? 0), damageDice: '', damageType: '', count: '1' }])}>
|
||||
+ Add an attack
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{field(
|
||||
'specialTrait',
|
||||
'Special trait',
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { STAGGER_MS, TUMBLE_MS, prefersStill } from '../../lib/diceAnimation';
|
||||
import type { RolledDie } from '../../api/types';
|
||||
|
||||
// The outline of each die, seen face on, in a 100 × 100 box: the shape that
|
||||
// says which die it is, and the lines that give it facets.
|
||||
const SHAPES: Record<number, { outline: string; facets?: string[] }> = {
|
||||
4: { outline: '50,8 94,86 6,86', facets: ['50,8 50,86'] },
|
||||
6: { outline: '14,14 86,14 86,86 14,86' },
|
||||
8: { outline: '50,4 94,50 50,96 6,50', facets: ['6,50 94,50'] },
|
||||
10: { outline: '50,4 92,40 50,96 8,40', facets: ['8,40 50,58 92,40', '50,58 50,96'] },
|
||||
12: { outline: '50,4 93,35 77,90 23,90 7,35', facets: ['50,4 50,28', '93,35 72,42', '77,90 64,70', '23,90 36,70', '7,35 28,42', '50,28 72,42 64,70 36,70 28,42 50,28'] },
|
||||
20: { outline: '50,3 91,26 91,74 50,97 9,74 9,26', facets: ['50,22 80,70 20,70 50,22', '50,3 50,22', '91,74 80,70', '9,74 20,70', '9,26 50,22 91,26', '20,70 50,97 80,70'] },
|
||||
};
|
||||
|
||||
// Where the number sits: lower in a triangle, whose middle is below its centre.
|
||||
const TEXT_Y: Record<number, number> = { 4: 66, 10: 46, 20: 58 };
|
||||
|
||||
const randomFace = (sides: number) => Math.floor(Math.random() * sides) + 1;
|
||||
|
||||
// One die, drawn as its own shape with the number it shows. With `rolling` it
|
||||
// tumbles in, flickering through faces, and lands on its value; `order` is
|
||||
// its place among the dice rolled with it. A die that was dropped (the lower
|
||||
// d20 with advantage) is faded; a natural 20 or 1 on a d20 is marked.
|
||||
export function Die({ die, rolling = false, order = 0, size = 56 }: { die: RolledDie; rolling?: boolean; order?: number; size?: number }) {
|
||||
const animate = rolling && !prefersStill();
|
||||
const [landed, setLanded] = useState(!animate);
|
||||
const [face, setFace] = useState(animate ? randomFace(die.sides) : die.value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!animate) return;
|
||||
const flicker = setInterval(() => setFace(randomFace(die.sides)), 70);
|
||||
const land = setTimeout(
|
||||
() => {
|
||||
clearInterval(flicker);
|
||||
setFace(die.value);
|
||||
setLanded(true);
|
||||
},
|
||||
TUMBLE_MS + order * STAGGER_MS
|
||||
);
|
||||
return () => {
|
||||
clearInterval(flicker);
|
||||
clearTimeout(land);
|
||||
};
|
||||
}, [animate, die.sides, die.value, order]);
|
||||
|
||||
const shape = SHAPES[die.sides];
|
||||
const shown = landed ? die.value : face;
|
||||
const crit = landed && die.sides === 20 && die.kept && die.value === 20;
|
||||
const fumble = landed && die.sides === 20 && die.kept && die.value === 1;
|
||||
const classes = ['die', `die-d${shape ? die.sides : 'x'}`, landed ? 'is-landed' : 'is-rolling', die.kept ? '' : 'is-dropped', crit ? 'is-crit' : '', fumble ? 'is-fumble' : '', die.sign < 0 ? 'is-minus' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classes}
|
||||
style={{ width: size, height: size, animationDelay: animate ? `${order * STAGGER_MS}ms` : undefined, animationDuration: animate ? `${TUMBLE_MS}ms` : undefined }}
|
||||
title={`d${die.sides}: ${die.value}${die.kept ? '' : ' (dropped)'}${die.maxed ? ' (at its most, for the critical hit)' : ''}`}
|
||||
aria-label={`d${die.sides} showing ${landed ? die.value : 'rolling'}`}
|
||||
>
|
||||
<svg viewBox="0 0 100 100" width={size} height={size} aria-hidden="true">
|
||||
{shape ? (
|
||||
<>
|
||||
<polygon className="die-body" points={shape.outline} strokeLinejoin="round" />
|
||||
{shape.facets?.map((points) => (
|
||||
<polyline key={points} className="die-facet" points={points} fill="none" />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
// Any other die (a d100, a d3...): a plain round one.
|
||||
<circle className="die-body" cx="50" cy="50" r="44" />
|
||||
)}
|
||||
<text className="die-face" x="50" y={TEXT_Y[die.sides] ?? 52} textAnchor="middle" dominantBaseline="middle" fontSize={shown > 99 ? 26 : shown > 9 ? 34 : 40}>
|
||||
{shown}
|
||||
</text>
|
||||
</svg>
|
||||
<span className="die-kind">d{die.sides}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// A roll's dice in a row, landing one after another.
|
||||
export function DiceRow({ dice, rolling = false, size = 56 }: { dice: RolledDie[]; rolling?: boolean; size?: number }) {
|
||||
return (
|
||||
<span className="dice-row">
|
||||
{dice.map((die, i) => (
|
||||
<Die key={i} die={die} rolling={rolling} order={i} size={size} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
import { useState } from 'react';
|
||||
import { useApplyAction, useDeclareAction, usePatchAction } from '../../api/queries';
|
||||
import { ABILITY_LABELS, fmtMod, slotLevelLabel, spellLevelLabel } from '../../lib/format';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { DiceRow } from '../dice/Die';
|
||||
import type { Ability, ActionOutcome, ActionSource, ActionTarget, CampaignDiceMode, EncounterAction, EncounterParticipant, MonsterAttack } from '../../api/types';
|
||||
|
||||
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
|
||||
const OUTCOME_LABELS: Record<ActionOutcome, string> = { HIT: 'HIT', MISS: 'MISS', CRIT: 'CRITICAL HIT', FUMBLE: 'NATURAL 1: MISS', SAVED: 'SAVED', FAILED: 'FAILED' };
|
||||
const OUTCOME_CLASS: Record<ActionOutcome, string> = { HIT: 'is-hit', CRIT: 'is-crit', MISS: 'is-miss', FUMBLE: 'is-miss', SAVED: 'is-saved', FAILED: 'is-hit' };
|
||||
|
||||
// What an action rolls with, in a few words: "+5 to hit", "DC 14 Dexterity save, half on a success".
|
||||
function rollLine(action: EncounterAction): string {
|
||||
const { detail } = action;
|
||||
if (action.kind === 'ATTACK') return `${fmtMod(detail.attackBonus ?? 0)} to hit`;
|
||||
if (action.kind === 'SAVE') {
|
||||
return `DC ${detail.saveDC} ${detail.saveAbility ? ABILITY_LABELS[detail.saveAbility] : ''} save, ${detail.halfOnSave ? 'half damage' : 'nothing'} on a success`;
|
||||
}
|
||||
return 'no roll: it just lands';
|
||||
}
|
||||
|
||||
function damageLine(action: EncounterAction): string | null {
|
||||
const { damageDice, damageBonus, damageType } = action.detail;
|
||||
if (!damageDice) return null;
|
||||
return `${damageDice}${damageBonus ? ` ${fmtMod(damageBonus)}` : ''}${damageType ? ` ${damageType}` : ''}`;
|
||||
}
|
||||
|
||||
// One attack or spell being resolved: the d20 for each target (typed in or
|
||||
// rolled), whether it hits or they save, the damage, and, for the DM, the
|
||||
// means to overrule any of it and let it land. A monster's auto-rolled attack
|
||||
// arrives here with everything already rolled, to accept, change or discard.
|
||||
export function ActionCard({
|
||||
action,
|
||||
participants,
|
||||
isDM,
|
||||
userId,
|
||||
diceMode,
|
||||
encounterId,
|
||||
onNotice,
|
||||
onError,
|
||||
}: {
|
||||
action: EncounterAction;
|
||||
participants: EncounterParticipant[];
|
||||
isDM: boolean;
|
||||
userId: string | undefined;
|
||||
diceMode: CampaignDiceMode;
|
||||
encounterId: string;
|
||||
// Lines for the DM once it has landed: a concentration save to call for, a slot that was not there.
|
||||
onNotice: (...lines: string[]) => void;
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const patch = usePatchAction(encounterId);
|
||||
const apply = useApplyAction(encounterId);
|
||||
const [damageTyped, setDamageTyped] = useState('');
|
||||
const fail = { onError };
|
||||
const send = (data: Parameters<typeof patch.mutate>[0]) => patch.mutate(data, fail);
|
||||
|
||||
const ownerOf = (participantId: string | null) => participants.find((p) => p.id === participantId)?.character?.userId;
|
||||
const mine = Boolean(userId) && ownerOf(action.actorParticipantId) === userId;
|
||||
const actsForActor = isDM || mine;
|
||||
// Who may put in the d20 for a target: the attacker for an attack; the target's own player for a save.
|
||||
const mayRollFor = (t: ActionTarget) => isDM || (action.kind === 'ATTACK' ? mine : Boolean(userId) && ownerOf(t.participantId) === userId);
|
||||
// A player follows the campaign's dice; the DM types or rolls as they like.
|
||||
const mayType = isDM || diceMode !== 'DIGITAL';
|
||||
const mayAsk = isDM || diceMode !== 'PHYSICAL';
|
||||
const waitingOnMe = action.targets.filter((t) => t.d20 == null && mayRollFor(t));
|
||||
const busy = patch.isPending || apply.isPending;
|
||||
const outcomes: ('HIT' | 'MISS' | 'CRIT' | 'SAVED' | 'FAILED')[] = action.kind === 'SAVE' ? ['SAVED', 'FAILED'] : ['HIT', 'MISS', 'CRIT'];
|
||||
const damage = damageLine(action);
|
||||
const total = action.targets.reduce((sum, t) => sum + (t.finalDamage ?? 0), 0);
|
||||
|
||||
function land() {
|
||||
apply.mutate(action.id, {
|
||||
onError,
|
||||
onSuccess: (data) =>
|
||||
onNotice(
|
||||
...data.concentration.map((c) => `${c.name} is concentrating and took ${c.damage} damage: Constitution save, DC ${c.dc}. On a failure the spell ends: remove Concentrating.`),
|
||||
...(data.slotNote ? [data.slotNote] : [])
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`card action-card${action.auto ? ' is-auto' : ''}${action.ready ? ' is-ready' : ''}`}>
|
||||
<div className="flex-between flex-wrap">
|
||||
<div className="action-title">
|
||||
{action.auto ? '🤖' : action.kind === 'SAVE' ? '✨' : '⚔'} {action.actorName}: <strong>{action.name}</strong>
|
||||
</div>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
{action.auto && <span className="badge badge-dm" title="Aimed and rolled by the app as this turn began">auto-rolled</span>}
|
||||
{action.hidden && <span className="badge" title="The players do not see this until you accept it">🙈 hidden from players</span>}
|
||||
{action.detail.slotLevel != null && <span className="badge">{slotLevelLabel(action.detail.slotLevel)}-level slot</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="combatant-sub">
|
||||
{rollLine(action)}
|
||||
{damage && ` · ${damage}`}
|
||||
</div>
|
||||
|
||||
{action.targets.map((t) => {
|
||||
const canEnter = mayRollFor(t) && mayType && action.kind !== 'AUTO';
|
||||
const bonus = action.kind === 'SAVE' ? (t.saveBonus ?? 0) : (action.detail.attackBonus ?? 0);
|
||||
const against = action.kind === 'ATTACK' ? (t.ac != null ? `AC ${t.ac + t.penalty}${t.penalty > 0 ? ` (${t.ac} +${t.penalty} area)` : ''}` : 'their AC') : action.kind === 'SAVE' ? `DC ${action.detail.saveDC}` : '';
|
||||
return (
|
||||
<div key={t.participantId} className="action-target">
|
||||
<span className="action-target-name">
|
||||
{action.kind === 'SAVE' ? '🛡' : '🎯'} {t.name}
|
||||
</span>
|
||||
{action.kind !== 'AUTO' && (
|
||||
<span className="action-roll">
|
||||
{canEnter ? (
|
||||
<NumberInput
|
||||
// Remounted when the roll changes elsewhere (the server rolled it, or someone else typed it).
|
||||
key={`${t.participantId}:${t.d20}`}
|
||||
min={1}
|
||||
max={20}
|
||||
placeholder="d20"
|
||||
aria-label={action.kind === 'SAVE' ? `${t.name}'s saving throw (d20)` : `Attack roll against ${t.name} (d20)`}
|
||||
defaultValue={t.d20 ?? ''}
|
||||
autoFocus={t.d20 == null && waitingOnMe[0]?.participantId === t.participantId}
|
||||
style={{ width: 64 }}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
||||
onBlur={(e) => {
|
||||
const d20 = e.target.value === '' ? null : Number(e.target.value);
|
||||
if (d20 !== t.d20) send({ actionId: action.id, rolls: [{ participantId: t.participantId, d20 }] });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="mono">{t.d20 ?? '—'}</span>
|
||||
)}
|
||||
<span className="mono combatant-sub">
|
||||
{fmtMod(bonus)}
|
||||
{t.total != null && ` = ${t.total}`}
|
||||
{against && ` vs ${against}`}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{t.outcome ? (
|
||||
<span className={`action-outcome ${OUTCOME_CLASS[t.outcome]}`}>
|
||||
{OUTCOME_LABELS[t.outcome]}
|
||||
{t.overruled && <span title={`The dice said ${t.rolled ? OUTCOME_LABELS[t.rolled] : 'nothing yet'}; you ruled otherwise`}> ✎</span>}
|
||||
</span>
|
||||
) : (
|
||||
<span className="action-outcome is-waiting">{action.kind === 'SAVE' ? 'waiting for their save' : 'waiting for the d20'}</span>
|
||||
)}
|
||||
{t.finalDamage != null && t.finalDamage > 0 && <span className="action-damage">−{t.finalDamage} HP</span>}
|
||||
{isDM && (
|
||||
<span className="action-overrule">
|
||||
{outcomes.map((o) => (
|
||||
<button
|
||||
key={o}
|
||||
type="button"
|
||||
className={`btn btn-sm${t.outcome === o || (o === 'MISS' && t.outcome === 'FUMBLE') ? ' is-on' : ''}`}
|
||||
title={t.overruled && t.outcome === o ? 'Go back to what the dice say' : `Rule it ${OUTCOME_LABELS[o].toLowerCase()}, whatever the dice say`}
|
||||
disabled={busy}
|
||||
onClick={() => send({ actionId: action.id, outcomes: [{ participantId: t.participantId, outcome: t.overruled && t.outcome === o ? null : o }] })}
|
||||
>
|
||||
{o === 'CRIT' ? 'Crit' : o.charAt(0) + o.slice(1).toLowerCase()}
|
||||
</button>
|
||||
))}
|
||||
<NumberInput
|
||||
key={`dmg:${t.participantId}:${t.damage}`}
|
||||
min={0}
|
||||
max={99999}
|
||||
placeholder="dmg"
|
||||
title="Set what this target takes by hand. Empty: what the action deals."
|
||||
aria-label={`Damage ${t.name} takes, by hand`}
|
||||
defaultValue={t.damage ?? ''}
|
||||
style={{ width: 64 }}
|
||||
onBlur={(e) => {
|
||||
const amount = e.target.value === '' ? null : Number(e.target.value);
|
||||
if (amount !== t.damage) send({ actionId: action.id, targetDamage: [{ participantId: t.participantId, damage: amount }] });
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* The DM aims a creature's own attack elsewhere: the d20 already rolled stays. */}
|
||||
{isDM && action.targets.length === 1 && action.actorParticipantId && (
|
||||
<label className="flex-row flex-wrap gap-sm mt-sm combatant-sub">
|
||||
Aim it at
|
||||
<select value={action.targets[0].participantId} disabled={busy} onChange={(e) => send({ actionId: action.id, targetIds: [e.target.value] })} style={{ width: 'auto' }}>
|
||||
{participants
|
||||
.filter((p) => !p.hidden && p.id !== action.actorParticipantId)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span>(the d20 already rolled stays)</span>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{action.damage && (
|
||||
<div className="action-damage-roll">
|
||||
<span className="dice-label">Damage</span>
|
||||
{action.damage.dice && <DiceRow dice={action.damage.dice} size={30} />}
|
||||
<strong className="mono">{action.damage.total}</strong>
|
||||
{action.damage.entered && <span className="combatant-sub">entered by hand</span>}
|
||||
{action.damage.crit && (
|
||||
<>
|
||||
<span className="dice-label">Critical</span>
|
||||
<DiceRow dice={action.damage.crit.dice} size={30} />
|
||||
<strong className="mono">{action.damage.crit.total}</strong>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{action.targets.some((t) => t.outcome === 'CRIT') && action.damage?.entered && (
|
||||
<p className="combatant-sub" style={{ margin: '0.3rem 0 0' }}>
|
||||
A critical hit with damage entered by hand: roll the damage dice twice and put the total in that target’s “dmg” box.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{waitingOnMe.length > 0 && mayAsk && action.kind !== 'AUTO' && (
|
||||
<button className="btn" disabled={busy} onClick={() => send({ actionId: action.id, rollRemaining: true })}>
|
||||
🎲 Roll {waitingOnMe.length === 1 ? 'the d20' : `${waitingOnMe.length} d20s`}
|
||||
</button>
|
||||
)}
|
||||
{actsForActor && damage && (action.needsDamage || action.damage) && (
|
||||
<>
|
||||
{mayAsk && (
|
||||
<button className={`btn${action.needsDamage ? ' btn-primary' : ''}`} disabled={busy} onClick={() => send({ actionId: action.id, rollDamage: true })}>
|
||||
🎲 {action.damage ? 'Reroll' : 'Roll'} damage
|
||||
</button>
|
||||
)}
|
||||
{mayType && (
|
||||
<span className="flex-row gap-sm">
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={99999}
|
||||
placeholder="damage"
|
||||
aria-label="Damage rolled at the table"
|
||||
value={damageTyped}
|
||||
onChange={(e) => setDamageTyped(e.target.value)}
|
||||
style={{ width: 90 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && damageTyped !== '') {
|
||||
send({ actionId: action.id, damage: Number(damageTyped) });
|
||||
setDamageTyped('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={busy || damageTyped === ''}
|
||||
onClick={() => {
|
||||
send({ actionId: action.id, damage: Number(damageTyped) });
|
||||
setDamageTyped('');
|
||||
}}
|
||||
>
|
||||
Enter damage
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<span style={{ flex: 1 }} />
|
||||
{isDM ? (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !action.ready}
|
||||
title={
|
||||
action.ready
|
||||
? 'Let it land: the damage is applied and everyone sees what happened. Shortcut: A'
|
||||
: action.needsRolls > 0
|
||||
? `Waiting on ${action.needsRolls} d20${action.needsRolls === 1 ? '' : 's'}`
|
||||
: 'Waiting on the damage'
|
||||
}
|
||||
onClick={land}
|
||||
>
|
||||
✔ {action.auto ? 'Accept' : 'Apply'}
|
||||
{action.ready && total > 0 ? ` (${total} damage)` : ''}
|
||||
</button>
|
||||
<button className="btn btn-ghost" disabled={busy} onClick={() => send({ actionId: action.id, discard: true })}>
|
||||
✕ Discard
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
mine && (
|
||||
<>
|
||||
<span className="combatant-sub">{action.ready ? 'Ready: your DM lets it land.' : ''}</span>
|
||||
<button className="btn btn-ghost" disabled={busy} onClick={() => send({ actionId: action.id, discard: true })}>
|
||||
✕ Call it off
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// What a monster or NPC attacks with, as the server works it out
|
||||
// (game/combat.js, attacksOf): its named attacks, each as often as it makes
|
||||
// it, or its single attack. Empty for a player (who is not sent the stats).
|
||||
function creatureAttacks(p: EncounterParticipant): (MonsterAttack & { index: number })[] {
|
||||
const named = (p.monster?.attacks ?? []).filter((a) => a.name);
|
||||
if (named.length > 0) {
|
||||
return named.flatMap((a) => Array.from({ length: Math.max(1, a.count ?? 1) }, () => a)).map((a, index) => ({ ...a, index }));
|
||||
}
|
||||
const stats = p.monster ?? p.npc;
|
||||
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
|
||||
return [{ name: 'Attack', attackBonus: stats.attackBonus ?? 0, damageDice: stats.damageDice, index: 0 }];
|
||||
}
|
||||
|
||||
// What whoever is acting can attack with, one button each: a character's
|
||||
// equipped weapons and their spells (each saying how it lands), or a
|
||||
// creature's own attacks. Pressing one declares it against the targets
|
||||
// marked. The DM can also spell out something else (a trap, a breath weapon).
|
||||
export function ActionComposer({
|
||||
actor,
|
||||
targetCount,
|
||||
isDM,
|
||||
encounterId,
|
||||
onError,
|
||||
}: {
|
||||
actor: EncounterParticipant | undefined;
|
||||
targetCount: number;
|
||||
isDM: boolean;
|
||||
encounterId: string;
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const declare = useDeclareAction(encounterId);
|
||||
const [custom, setCustom] = useState(false);
|
||||
const [kind, setKind] = useState<'ATTACK' | 'SAVE' | 'AUTO'>('ATTACK');
|
||||
const info = actor?.characterCombatInfo;
|
||||
const attacks = actor && !info ? creatureAttacks(actor) : [];
|
||||
const noTarget = targetCount === 0;
|
||||
const go = (source: ActionSource) => declare.mutate({ source, actorParticipantId: actor?.id }, { onError });
|
||||
|
||||
return (
|
||||
<div className="card action-composer">
|
||||
<div className="flex-between flex-wrap">
|
||||
<div className="action-title">{actor ? `${actor.displayName} attacks with…` : 'An attack from nobody in particular'}</div>
|
||||
{noTarget && <span className="badge badge-hostile">Pick a target first: click a name on the track, or 🎯 on a card</span>}
|
||||
</div>
|
||||
|
||||
<div className="action-options">
|
||||
{info?.weapons.map((w) => (
|
||||
<button
|
||||
key={w.characterWeaponId}
|
||||
className="btn action-option is-equipped"
|
||||
disabled={noTarget || declare.isPending}
|
||||
title={`${w.name}: equipped. Roll a d20 ${fmtMod(w.attackBonus)} against the target's AC.`}
|
||||
onClick={() => go({ type: 'weapon', characterWeaponId: w.characterWeaponId })}
|
||||
>
|
||||
<span className="action-option-name">⚔ {w.name}</span>
|
||||
<span className="combatant-sub mono">
|
||||
{fmtMod(w.attackBonus)} to hit · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{info && info.weapons.length === 0 && <span className="combatant-sub">No weapon equipped: equip one on the sheet to attack with it.</span>}
|
||||
{info?.spells.map((s) => {
|
||||
const cantrip = s.level === 0;
|
||||
// The slot it would take: its own level, or the lowest one above it that is left.
|
||||
const slot = cantrip ? undefined : info.spellSlots?.levels.find((l) => l.level >= s.level && l.remaining > 0);
|
||||
const spent = !cantrip && Boolean(info.spellSlots) && !slot;
|
||||
const how = s.attackRoll
|
||||
? `${fmtMod(info.spellcasting?.attackBonus ?? 0)} to hit`
|
||||
: s.saveAbility
|
||||
? `DC ${info.spellcasting?.saveDC ?? '?'} ${s.saveAbility} save`
|
||||
: 'no roll';
|
||||
return (
|
||||
<button
|
||||
key={s.characterSpellId}
|
||||
className="btn action-option"
|
||||
disabled={noTarget || spent || declare.isPending}
|
||||
title={[
|
||||
s.description,
|
||||
cantrip ? 'A cantrip: no spell slot needed.' : null,
|
||||
spent ? `No spell slot of ${slotLevelLabel(s.level)} level or higher left: it cannot be cast until slots come back.` : null,
|
||||
slot ? `Takes a ${slotLevelLabel(slot.level)}-level slot when it lands (${slot.remaining} left).` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' — ')}
|
||||
onClick={() => go({ type: 'spell', characterSpellId: s.characterSpellId })}
|
||||
>
|
||||
<span className="action-option-name">
|
||||
✨ {s.name} <span className="badge">{spellLevelLabel(s.level)}</span> {spent && <span className="badge badge-hostile">no slot left</span>}
|
||||
</span>
|
||||
<span className="combatant-sub mono">
|
||||
{cantrip ? 'no slot' : slot && slot.level > s.level ? `${slotLevelLabel(slot.level)}-level slot` : 'uses a slot'} · {how}
|
||||
{s.damageDice ? ` · ${s.damageDice}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{attacks.map((a) => (
|
||||
<button
|
||||
key={a.index}
|
||||
className="btn action-option is-equipped"
|
||||
disabled={noTarget || declare.isPending}
|
||||
onClick={() => go({ type: 'basic', index: a.index })}
|
||||
>
|
||||
<span className="action-option-name">⚔ {a.name}</span>
|
||||
<span className="combatant-sub mono">
|
||||
{fmtMod(a.attackBonus)} to hit{a.damageDice ? ` · ${a.damageDice}` : ''}
|
||||
{a.damageType ? ` ${a.damageType}` : ''}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{isDM && (
|
||||
<button className={`btn action-option${custom ? ' is-on' : ''}`} onClick={() => setCustom((c) => !c)} title="Spell out an attack, a save or an effect of your own: a trap, a breath weapon, a monster's spell">
|
||||
<span className="action-option-name">✎ Something else…</span>
|
||||
<span className="combatant-sub">a trap, a breath weapon, a monster’s spell</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDM && custom && (
|
||||
<form
|
||||
className="flex-row flex-wrap gap-sm mt-sm"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target as HTMLFormElement);
|
||||
go({
|
||||
type: 'custom',
|
||||
name: String(fd.get('name') || '').trim() || 'Attack',
|
||||
kind,
|
||||
attackBonus: Number(fd.get('attackBonus')) || 0,
|
||||
saveAbility: (fd.get('saveAbility') as Ability) || 'DEX',
|
||||
saveDC: Number(fd.get('saveDC')) || 10,
|
||||
halfOnSave: fd.get('halfOnSave') === 'on',
|
||||
damageDice: String(fd.get('damageDice') || '').trim() || null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<input name="name" placeholder="What it is (Fire breath)" maxLength={60} aria-label="What it is" style={{ flex: '2 1 140px' }} />
|
||||
<select value={kind} onChange={(e) => setKind(e.target.value as typeof kind)} aria-label="How it lands" style={{ width: 'auto' }}>
|
||||
<option value="ATTACK">Attack roll vs AC</option>
|
||||
<option value="SAVE">Targets make a save</option>
|
||||
<option value="AUTO">It just lands</option>
|
||||
</select>
|
||||
{kind === 'ATTACK' && <NumberInput name="attackBonus" min={-20} max={50} placeholder="+ to hit" aria-label="Attack bonus" style={{ width: 90 }} />}
|
||||
{kind === 'SAVE' && (
|
||||
<>
|
||||
<NumberInput name="saveDC" min={1} max={40} placeholder="DC" aria-label="Save DC" style={{ width: 70 }} />
|
||||
<select name="saveAbility" defaultValue="DEX" aria-label="Save ability" style={{ width: 'auto' }}>
|
||||
{ABILITIES.map((a) => (
|
||||
<option key={a} value={a}>
|
||||
{a}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" name="halfOnSave" defaultChecked /> half on a success
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<input name="damageDice" placeholder="Damage (6d6)" maxLength={60} aria-label="Damage dice" style={{ flex: '1 1 100px' }} />
|
||||
<button type="submit" className="btn btn-primary" disabled={noTarget || declare.isPending}>
|
||||
Declare
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The last few attacks and spells that landed, newest first, one line each.
|
||||
export function RecentActions({ actions }: { actions: EncounterAction[] }) {
|
||||
if (actions.length === 0) return <p className="subtitle">Nothing has landed yet.</p>;
|
||||
return (
|
||||
<div className="roll-feed">
|
||||
{actions.map((action) => (
|
||||
<div key={action.id} className="roll-line">
|
||||
<div className="roll-line-head">
|
||||
<span>
|
||||
<strong>{action.actorName}</strong> · {action.name}
|
||||
</span>
|
||||
<span className="combatant-sub">Round {action.round}</span>
|
||||
</div>
|
||||
{action.targets.map((t) => (
|
||||
<div key={t.participantId} className="combatant-sub">
|
||||
→ {t.name}: {t.outcome ? OUTCOME_LABELS[t.outcome].toLowerCase() : '—'}
|
||||
{t.d20 != null && ` (d20 ${t.d20})`}
|
||||
{t.finalDamage ? `, ${t.finalDamage} damage` : ''}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useBuyWare, useExchangeWithNpc, useSetPlaces, useVisitPlace } from '../../api/queries';
|
||||
import { NumberInput } from '../NumberInput';
|
||||
import { WaresEditor } from '../Wares';
|
||||
import { stockLabel, wareName } from '../../lib/wares';
|
||||
import { formatMoney, formatPurse } from '../../lib/format';
|
||||
import type { CharacterSummary, Encounter, FreeRoamPlace, Item, Location, SaleOffer, Weapon } from '../../api/types';
|
||||
|
||||
type ExchangeSide = 'GOLD' | 'CASH';
|
||||
|
||||
// A free-roam scene: the places the DM has laid out, each with who is found
|
||||
// there and what is for sale, and who of the party is there now. A player
|
||||
// sends their own character to a place and shops where they stand; the DM
|
||||
// can send anyone, picks which of the campaign's locations are part of the
|
||||
// scene, and stocks each place's own shop here. (What an NPC sells, and where
|
||||
// they are found, is set on the NPC, on the campaign page.)
|
||||
export function FreeRoamPanel({
|
||||
encounter,
|
||||
isDM,
|
||||
userId,
|
||||
characters,
|
||||
locations,
|
||||
weapons,
|
||||
items,
|
||||
onError,
|
||||
}: {
|
||||
encounter: Encounter;
|
||||
isDM: boolean;
|
||||
userId: string | undefined;
|
||||
characters: CharacterSummary[];
|
||||
locations: Location[]; // the campaign's, for the DM to pick places from
|
||||
weapons: Weapon[];
|
||||
items: Item[];
|
||||
onError: (err: unknown) => void;
|
||||
}) {
|
||||
const setPlaces = useSetPlaces(encounter.id);
|
||||
const visit = useVisitPlace(encounter.id);
|
||||
const buy = useBuyWare(encounter.id);
|
||||
const exchange = useExchangeWithNpc(encounter.id);
|
||||
const [roamerId, setRoamerId] = useState('');
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({});
|
||||
const [sides, setSides] = useState<Record<string, ExchangeSide>>({});
|
||||
|
||||
const places = encounter.places ?? [];
|
||||
const open = encounter.status === 'ACTIVE';
|
||||
// Who can be moved and can shop: a player's own characters in the scene; for the DM, anyone's.
|
||||
const roamers = (encounter.participants ?? []).flatMap((p) => (p.character && !p.hidden && (isDM || p.character.userId === userId) ? [p.character] : []));
|
||||
const roamer = roamers.find((c) => c.id === roamerId) ?? roamers[0];
|
||||
const purse = characters.find((c) => c.id === roamer?.id);
|
||||
const whereabouts = places.find((place) => place.here.some((h) => h.characterId === roamer?.id));
|
||||
const busy = visit.isPending || buy.isPending || exchange.isPending;
|
||||
const placeIds = places.map((p) => p.id);
|
||||
|
||||
function go(place: FreeRoamPlace) {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
visit.mutate({ characterId: roamer.id, locationId: place.id }, { onError, onSuccess: () => setDone(`${roamer.name} goes to ${place.name}.`) });
|
||||
}
|
||||
|
||||
function handleBuy(ware: SaleOffer) {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
buy.mutate(
|
||||
{ wareId: ware.id, characterId: roamer.id },
|
||||
{ onError, onSuccess: () => setDone(`${roamer.name} bought ${(ware.weapon ?? ware.item)?.name} for ${formatMoney(ware.price, ware.currency, true)}.`) }
|
||||
);
|
||||
}
|
||||
|
||||
const wareRow = (ware: SaleOffer, atPlace: boolean) => (
|
||||
<div key={ware.id} className="reward-row">
|
||||
<div className="flex-between">
|
||||
<span>
|
||||
<strong>{wareName(ware)}</strong>
|
||||
<span className="combatant-sub"> · {stockLabel(ware)}</span>
|
||||
</span>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!open || !roamer || !atPlace || ware.stock === 0 || busy}
|
||||
title={!open ? 'The scene has not begun' : !atPlace ? `${roamer?.name ?? 'Your character'} has to go here first` : undefined}
|
||||
onClick={() => handleBuy(ware)}
|
||||
>
|
||||
Buy · {formatMoney(ware.price, ware.currency)}
|
||||
</button>
|
||||
</div>
|
||||
{ware.item?.description && <div className="combatant-sub">{ware.item.description}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
<div className="card-title">
|
||||
<span>🧭 Places to go</span>
|
||||
{!open && <span className="badge">{encounter.status === 'PLANNING' ? 'Not begun yet' : 'Over'}</span>}
|
||||
</div>
|
||||
{roamers.length > 0 ? (
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<label className="flex-row gap-sm" style={{ flex: 1, minWidth: 180 }}>
|
||||
{roamers.length > 1 ? 'Moving' : 'You are'}
|
||||
<select aria-label="Which character is moving and shopping" value={roamer?.id ?? ''} onChange={(e) => setRoamerId(e.target.value)} style={{ flex: 1 }}>
|
||||
{roamers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="combatant-sub">📍 {whereabouts ? whereabouts.name : 'not at any of these places yet'}</span>
|
||||
{purse && purse.gold !== null && <span className="mono">{formatPurse(purse)}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="combatant-sub" style={{ margin: 0 }}>
|
||||
{isDM ? 'No characters are in this scene yet: add them from "Add to the scene".' : 'Your character has to be in the scene to go anywhere.'}
|
||||
</p>
|
||||
)}
|
||||
{done && <p className="combatant-sub" style={{ marginBottom: 0 }}>✓ {done}</p>}
|
||||
{places.length === 0 && (
|
||||
<p className="combatant-sub" style={{ marginBottom: 0 }}>
|
||||
{isDM ? 'No places yet. Tick the locations the party can go to, below.' : 'The DM has not opened any places yet.'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isDM && (
|
||||
<details className="mt-sm" open={places.length === 0}>
|
||||
<summary className="section-label" style={{ cursor: 'pointer' }}>
|
||||
Places in this scene ({places.length} of {locations.length} locations)
|
||||
</summary>
|
||||
{locations.length === 0 ? (
|
||||
<p className="combatant-sub">
|
||||
This campaign has no locations yet. Add some under Locations on the <Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{locations.map((loc) => {
|
||||
const included = placeIds.includes(loc.id);
|
||||
return (
|
||||
<label key={loc.id} className="checkbox-row" title={loc.description ?? undefined}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={included}
|
||||
disabled={setPlaces.isPending}
|
||||
onChange={() => setPlaces.mutate(included ? placeIds.filter((id) => id !== loc.id) : [...placeIds, loc.id], { onError })}
|
||||
/>
|
||||
📍 {loc.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="place-grid">
|
||||
{places.map((place) => {
|
||||
const atPlace = Boolean(roamer) && place.here.some((h) => h.characterId === roamer?.id);
|
||||
return (
|
||||
<div key={place.id} className={`card place-card${atPlace ? ' is-here' : ''}`}>
|
||||
<div className="card-title">
|
||||
<span>📍 {place.name}</span>
|
||||
{atPlace ? (
|
||||
<span className="badge badge-live">{roamer?.name} is here</span>
|
||||
) : (
|
||||
<button className="btn btn-primary" disabled={!open || !roamer || busy} onClick={() => go(place)}>
|
||||
Go here
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{place.description && <p style={{ margin: '0 0 0.4rem', whiteSpace: 'pre-wrap' }}>{place.description}</p>}
|
||||
<div className="combatant-sub">{place.here.length > 0 ? `Here now: ${place.here.map((h) => h.name).join(', ')}` : 'Nobody from the party is here.'}</div>
|
||||
|
||||
{place.npcs.map((npc) => {
|
||||
const rate = { GOLD: npc.exchangeGold ?? 0, CASH: npc.exchangeCash ?? 0 };
|
||||
const changes = rate.GOLD > 0 && rate.CASH > 0;
|
||||
const from = sides[npc.id] ?? 'GOLD';
|
||||
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
|
||||
const amount = Math.floor(Number(amounts[npc.id]));
|
||||
const whole = changes && amount > 0 && amount % rate[from] === 0;
|
||||
return (
|
||||
<div key={npc.id} className="mt-sm">
|
||||
<div className="section-label">
|
||||
🧑 {npc.name}
|
||||
{npc.role ? `, ${npc.role}` : ''}
|
||||
</div>
|
||||
{npc.description && <p className="combatant-sub" style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{npc.description}</p>}
|
||||
{npc.dmNotes && (
|
||||
<p className="combatant-sub" style={{ margin: 0 }} title="Only you can see this">
|
||||
🔒 {npc.dmNotes}
|
||||
</p>
|
||||
)}
|
||||
{(npc.wares ?? []).map((ware) => wareRow(ware, atPlace))}
|
||||
{changes && (
|
||||
<div className="reward-row">
|
||||
<span className="combatant-sub">
|
||||
💱 Changes {formatMoney(rate.GOLD, 'GOLD', true)} for {formatMoney(rate.CASH, 'CASH')}, either way.
|
||||
</span>
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={1000000}
|
||||
placeholder="Amount"
|
||||
aria-label={`How much to change with ${npc.name}`}
|
||||
value={amounts[npc.id] ?? ''}
|
||||
onChange={(e) => setAmounts((a) => ({ ...a, [npc.id]: e.target.value }))}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<select aria-label={`What to hand ${npc.name}`} value={from} onChange={(e) => setSides((s) => ({ ...s, [npc.id]: e.target.value as ExchangeSide }))}>
|
||||
<option value="GOLD">gold into cash</option>
|
||||
<option value="CASH">cash into gold</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn"
|
||||
disabled={!open || !roamer || !atPlace || !whole || busy}
|
||||
onClick={() => {
|
||||
if (!roamer) return;
|
||||
setDone(null);
|
||||
exchange.mutate(
|
||||
{ characterId: roamer.id, npcId: npc.id, from, amount },
|
||||
{
|
||||
onError,
|
||||
onSuccess: ({ received, currency }) => {
|
||||
setDone(`${roamer.name} changed ${formatMoney(amount, from, true)} for ${formatMoney(received, currency, true)} with ${npc.name}.`);
|
||||
setAmounts((a) => ({ ...a, [npc.id]: '' }));
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
>
|
||||
{whole ? `Get ${formatMoney((amount / rate[from]) * rate[to], to, true)}` : 'Exchange'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(npc.wares ?? []).length === 0 && !changes && <div className="combatant-sub">Nothing to sell: someone to talk to.</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{place.wares.length > 0 && (
|
||||
<div className="mt-sm">
|
||||
<div className="section-label">🛒 For sale here</div>
|
||||
{place.wares.map((ware) => wareRow(ware, atPlace))}
|
||||
</div>
|
||||
)}
|
||||
{place.npcs.length === 0 && place.wares.length === 0 && !isDM && <div className="combatant-sub mt-sm">Nothing to buy here.</div>}
|
||||
|
||||
{isDM && (
|
||||
<details className="mt-sm">
|
||||
<summary className="section-label" style={{ cursor: 'pointer' }}>
|
||||
Set up this place’s own shop
|
||||
</summary>
|
||||
<p className="combatant-sub" style={{ marginTop: '0.3rem' }}>
|
||||
Sold here whenever this place is part of a free-roam scene. To have an NPC found here, set their location on the NPC (campaign page).
|
||||
</p>
|
||||
<WaresEditor campaignId={encounter.campaignId} seller={{ locationId: place.id }} wares={place.wares} weapons={weapons} items={items} onError={onError} />
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { EncounterParticipant } from '../../api/types';
|
||||
|
||||
const KIND_CLASS = { CHARACTER: 'track-pc', NPC: 'track-npc', MONSTER: 'track-monster' } as const;
|
||||
const KIND_WORD = { CHARACTER: 'player character', NPC: 'NPC', MONSTER: 'monster' } as const;
|
||||
|
||||
// Who acts when, as one line of names: whoever is on turn first, then
|
||||
// everyone still to act this round, a flag where the round ends, and the
|
||||
// whole order again for the round after. Names only, coloured by what they
|
||||
// are (player character, NPC, monster), so the DM can see what is coming.
|
||||
//
|
||||
// Clicking a name marks them as a target when the viewer may pick targets
|
||||
// (the DM, or a player on their own turn); otherwise it goes to their card.
|
||||
export function InitiativeTrack({
|
||||
participants,
|
||||
activeId,
|
||||
round,
|
||||
targetIds,
|
||||
userId,
|
||||
canTarget,
|
||||
onTarget,
|
||||
onJump,
|
||||
}: {
|
||||
participants: EncounterParticipant[]; // those still standing, in initiative order
|
||||
activeId: string | undefined;
|
||||
round: number;
|
||||
targetIds: string[];
|
||||
userId: string | undefined;
|
||||
canTarget: boolean;
|
||||
onTarget: (participantId: string) => void;
|
||||
onJump: (participantId: string) => void;
|
||||
}) {
|
||||
const activeIndex = participants.findIndex((p) => p.id === activeId);
|
||||
// This round: from whoever is on turn to the end of the order.
|
||||
const thisRound = activeIndex >= 0 ? participants.slice(activeIndex) : [];
|
||||
|
||||
const chip = (p: EncounterParticipant, key: string, upcoming: boolean) => {
|
||||
const isActive = !upcoming && p.id === activeId;
|
||||
const isTargeted = targetIds.includes(p.id);
|
||||
const isMine = p.character != null && p.character.userId === userId;
|
||||
const targetable = canTarget && p.id !== activeId;
|
||||
const dying = p.characterCombatInfo?.deathSaves.state === 'DYING';
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`track-chip ${KIND_CLASS[p.kind]}${isActive ? ' is-active' : ''}${isTargeted ? ' is-targeted' : ''}${upcoming ? ' is-upcoming' : ''}${dying ? ' is-dying' : ''}`}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
aria-pressed={targetable ? isTargeted : undefined}
|
||||
title={[
|
||||
`${p.displayName} (${KIND_WORD[p.kind]})`,
|
||||
isActive && 'on turn',
|
||||
dying && 'dying',
|
||||
isTargeted && 'targeted',
|
||||
targetable ? (isTargeted ? 'click to stop targeting' : 'click to target') : 'click to go to their card',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
onClick={() => (targetable ? onTarget(p.id) : onJump(p.id))}
|
||||
>
|
||||
{isActive && <span aria-hidden="true">▶ </span>}
|
||||
{isMine && <span aria-hidden="true">★ </span>}
|
||||
{isTargeted && <span aria-hidden="true">🎯 </span>}
|
||||
{p.displayName}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="initiative-track" aria-label="Turn order: this round, then the next">
|
||||
<span className="track-round">Round {round}</span>
|
||||
{thisRound.map((p) => chip(p, `now-${p.id}`, false))}
|
||||
<span className="track-flag" title={`The round ends here. Round ${round + 1} starts from the top of the order.`}>
|
||||
🏁 Round {round + 1}
|
||||
</span>
|
||||
{participants.map((p) => chip(p, `next-${p.id}`, true))}
|
||||
<span className="track-legend" aria-hidden="true">
|
||||
<span className="track-dot track-pc" /> PC
|
||||
<span className="track-dot track-npc" /> NPC
|
||||
<span className="track-dot track-monster" /> Monster
|
||||
</span>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { useQueryClient, type QueryKey } from '@tanstack/react-query';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
import { API_URL } from '../api/client';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { showToast } from '../lib/toasts';
|
||||
import type { CharacterDetail } from '../api/types';
|
||||
|
||||
let sharedSocket: Socket | null = null;
|
||||
|
||||
@@ -24,8 +26,9 @@ const FLUSH_MS = 50;
|
||||
// sync with server-pushed events (HP/condition changes, encounter rounds,
|
||||
// new characters) so every open tab in the campaign updates live.
|
||||
export function useCampaignSocket(campaignId: string | undefined) {
|
||||
const { token } = useAuth();
|
||||
const { token, user } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
const userId = user?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (!campaignId || !token) return;
|
||||
@@ -56,6 +59,17 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
// A character arriving somewhere new adds it to the places their player can see.
|
||||
invalidate(['campaign', campaignId, 'locations']);
|
||||
};
|
||||
// The DM approved a level for a character: its player is told, wherever
|
||||
// in the campaign they are, with a way straight to the checklist.
|
||||
const onLevelUpGranted = ({ characterId, userId: ownerId }: { characterId?: string; userId?: string | null } = {}) => {
|
||||
if (!characterId || !ownerId || ownerId !== userId) return;
|
||||
const name = qc.getQueryData<CharacterDetail>(['character', characterId])?.name ?? 'Your character';
|
||||
showToast({ text: `⬆ ${name} can level up! See what you gain and make your picks.`, tone: 'good', action: { label: 'Level up', to: `/characters/${characterId}?levelup=1` } });
|
||||
};
|
||||
// Somebody rolled: the feed asks again, and gets the roll if it is this member's to see.
|
||||
const onRollCreated = () => {
|
||||
invalidate(['campaign', campaignId, 'rolls']);
|
||||
};
|
||||
const onCharacterCreated = () => {
|
||||
invalidate(['campaign', campaignId, 'characters']);
|
||||
};
|
||||
@@ -74,6 +88,8 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
|
||||
socket.on('character:updated', onCharacterUpdated);
|
||||
socket.on('character:created', onCharacterCreated);
|
||||
socket.on('character:level-up-granted', onLevelUpGranted);
|
||||
socket.on('roll:created', onRollCreated);
|
||||
socket.on('encounter:participant-updated', onParticipantUpdated);
|
||||
socket.on('encounter:round-advanced', onRoundAdvanced);
|
||||
socket.on('campaign:deleted', onCampaignDeleted);
|
||||
@@ -86,9 +102,11 @@ export function useCampaignSocket(campaignId: string | undefined) {
|
||||
socket.emit('campaign:leave', campaignId);
|
||||
socket.off('character:updated', onCharacterUpdated);
|
||||
socket.off('character:created', onCharacterCreated);
|
||||
socket.off('character:level-up-granted', onLevelUpGranted);
|
||||
socket.off('roll:created', onRollCreated);
|
||||
socket.off('encounter:participant-updated', onParticipantUpdated);
|
||||
socket.off('encounter:round-advanced', onRoundAdvanced);
|
||||
socket.off('campaign:deleted', onCampaignDeleted);
|
||||
};
|
||||
}, [campaignId, token, qc]);
|
||||
}, [campaignId, token, qc, userId]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+689
-31
@@ -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);
|
||||
@@ -403,6 +410,65 @@ input[type='radio'] {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Toasts: notices that slide in over any page (components/Toasts.tsx). */
|
||||
.toasts {
|
||||
position: fixed;
|
||||
top: 4.2rem;
|
||||
right: 1rem;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-width: min(26rem, calc(100vw - 2rem));
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
|
||||
animation: toastIn 0.25s ease-out;
|
||||
}
|
||||
.toast-good {
|
||||
border-color: var(--accent-strong);
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45), 0 0 14px rgba(201, 162, 39, 0.3);
|
||||
}
|
||||
.toast-text {
|
||||
flex: 1;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
@keyframes toastIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(1.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A notice across the top of a card or page that something is waiting. */
|
||||
.notice-banner {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
margin-bottom: 0.9rem;
|
||||
border: 1px solid var(--accent-strong);
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(160deg, var(--bg-card-hover) 0%, var(--bg-inset) 100%);
|
||||
}
|
||||
|
||||
/* A group of campaign settings under its own heading. */
|
||||
.settings-group {
|
||||
border: 1px solid var(--border);
|
||||
@@ -553,6 +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;
|
||||
@@ -853,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 {
|
||||
@@ -1047,6 +1657,61 @@ button.death-pip {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* The road from level 1 to 20 (ProgressionTimeline). */
|
||||
.progression-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
.progression-level {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.35rem 0 0.35rem 0.75rem;
|
||||
position: relative;
|
||||
}
|
||||
.progression-level::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
top: 0.75rem;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--border);
|
||||
}
|
||||
.progression-level.is-reached {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.progression-level.is-reached::before {
|
||||
background: var(--accent);
|
||||
}
|
||||
.progression-level.is-next::before {
|
||||
background: var(--accent-strong);
|
||||
box-shadow: 0 0 8px var(--accent-strong);
|
||||
}
|
||||
.progression-badge {
|
||||
font-family: var(--font-heading, inherit);
|
||||
font-weight: 700;
|
||||
min-width: 2.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.progression-gains {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.progression-feature + .progression-feature {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
.progression-feature .badge {
|
||||
margin-left: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.choice-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
@@ -1518,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;
|
||||
@@ -1536,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;
|
||||
@@ -1579,7 +2254,6 @@ textarea {
|
||||
min-width: min(100%, 8rem) !important;
|
||||
}
|
||||
.btn {
|
||||
white-space: normal;
|
||||
padding: 0.6rem 0.9rem;
|
||||
}
|
||||
.grid-cols-2 {
|
||||
@@ -1631,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;
|
||||
}
|
||||
@@ -2090,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;
|
||||
@@ -2155,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 {
|
||||
@@ -2228,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;
|
||||
@@ -2244,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,
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export const CLASS_SPELLCASTING_ABILITY: Record<CharacterClass, Ability | null>
|
||||
WARLOCK: 'CHA',
|
||||
DRUID: 'WIS',
|
||||
MONK: null,
|
||||
ARTIFICER: 'INT',
|
||||
};
|
||||
|
||||
export const CLASS_HIT_DIE: Record<CharacterClass, number> = {
|
||||
@@ -31,6 +32,7 @@ export const CLASS_HIT_DIE: Record<CharacterClass, number> = {
|
||||
WARLOCK: 8,
|
||||
DRUID: 8,
|
||||
MONK: 8,
|
||||
ARTIFICER: 8,
|
||||
};
|
||||
|
||||
export const CLASS_SAVE_PROFICIENCIES: Record<CharacterClass, Ability[]> = {
|
||||
@@ -46,4 +48,5 @@ export const CLASS_SAVE_PROFICIENCIES: Record<CharacterClass, Ability[]> = {
|
||||
WARLOCK: ['WIS', 'CHA'],
|
||||
DRUID: ['INT', 'WIS'],
|
||||
MONK: ['STR', 'DEX'],
|
||||
ARTIFICER: ['CON', 'INT'],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user