Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57fba68a48 | ||
|
|
61221f262c | ||
|
|
e9f8169286 | ||
|
|
3a867fd1a1 |
@@ -11,9 +11,9 @@ monsters, and a real-time encounter/combat tracker.
|
||||
|
||||
| Document | Covers |
|
||||
|---|---|
|
||||
| `docs/CHARACTERS.md` | Building a character, every number on the sheet, levels, money, permissions |
|
||||
| `docs/CHARACTERS.md` | Building a character, every number on the sheet, levelling up, rests, money, permissions |
|
||||
| `docs/WEAPONS.md` | The weapon pool and library, equipping, weapon levels and how damage grows |
|
||||
| `docs/SPELLS.md` | The spell list, who can use which spells, the spell library |
|
||||
| `docs/SPELLS.md` | The spell list, who can use which spells, spell slots, the spell library |
|
||||
| `docs/ITEMS.md` | The item pool and library, armor and other gear effects, what can be equipped, items for sale |
|
||||
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, trade, what players see |
|
||||
| `docs/MONSTERS.md` | The monster list and built-in bestiary |
|
||||
@@ -33,13 +33,28 @@ The same four destinations are always on screen: **Campaigns** (the list),
|
||||
including a character sheet or an encounter) and **Account**.
|
||||
|
||||
- On a wide screen they are links along the top bar, and the dice roller
|
||||
floats at the bottom left.
|
||||
floats at the bottom left. **A number with a dotted underline rolls when
|
||||
clicked** (a skill, a save, an attack, damage), with the bonus already in
|
||||
and advantage or disadvantage if the roller is set to it. The DM's "🎲 Call
|
||||
for a roll…" rolls one check for the whole party.
|
||||
- On a phone or small tablet (900px wide or less) they are a bar along the
|
||||
bottom of the screen, with the **dice roller** as its last button. Nothing
|
||||
floats over the page: the 🧙 character button sits in the top bar.
|
||||
- The **🧙 character button** is on every page (floating at the bottom right
|
||||
on a wide screen). It opens a panel with your character's numbers and
|
||||
everything they hold; the DM can open it for any character.
|
||||
on a wide screen). It opens a panel with your character's numbers, saving
|
||||
throws, skills and everything they hold; the DM can open it for any
|
||||
character. "📋 Sheet" on an encounter card and 📋 on the party overview open
|
||||
the same panel for that character, without leaving the page.
|
||||
- **In an encounter** a strip across the top lists everyone in turn order and
|
||||
stays in view; a click goes to that card. On a wide screen the stat block
|
||||
of whoever is on turn stays in view beside the cards, and **📌 Pin** keeps
|
||||
anyone else's there instead. For the DM, **N** is Next Turn and **S** opens
|
||||
the sheet of whoever is on turn.
|
||||
- The DM adds **several of a monster at once** (numbered for them), rolls
|
||||
initiative for all the monsters and NPCs with one button, and can give a
|
||||
**condition a number of rounds**, after which it comes off by itself.
|
||||
Hovering over a condition shows its rule, and damaging someone who is
|
||||
Concentrating shows the save to call for.
|
||||
- The **campaign page shows one part at a time**, picked from a row of
|
||||
buttons under its name: Overview (characters and encounters), NPCs,
|
||||
Locations, Weapons, Items, Spells, Monsters, Notes and Settings for the DM;
|
||||
@@ -56,11 +71,34 @@ including a character sheet or an encounter) and **Account**.
|
||||
- The DM's lists (NPCs, weapons, items, spells, monsters) are short rows: a
|
||||
name and a few words. **Clicking a row opens a pop-out** with everything
|
||||
about it and everything that can be changed. Adding one, by hand or from the
|
||||
library, is a pop-out too. A pop-out is a window over the page on a wide
|
||||
library, is a pop-out too. One added by hand opens its own pop-out, with an
|
||||
**"+ Add another"** button at the top that goes straight back to an empty
|
||||
form. A pop-out is a window over the page on a wide
|
||||
screen and the whole screen on a phone; ✕, Escape or a click outside it
|
||||
closes it.
|
||||
- Every paged list has Prev and Next, and with three pages or more the page
|
||||
number is a menu to **jump straight to any page**.
|
||||
- The DM's **🛏 Rest…** button, beside "Characters" on the campaign page and
|
||||
on the party overview, rests any of the party at once: a short rest says
|
||||
which Hit Dice each character can roll and heals from what they rolled; a
|
||||
long rest puts back HP, spell slots and Hit Dice.
|
||||
- A caster's **spell slots** are on their sheet and in the character panel:
|
||||
the player presses Use when they cast, and only the DM gives a slot back.
|
||||
- Each encounter shows the DM **how hard it is** (Easy to Deadly, from the
|
||||
monsters' challenge ratings against the party) and what it is worth in
|
||||
**XP**, which one button shares out. A sheet shows XP and when a level is
|
||||
due; levelling up stays the DM's call.
|
||||
- A character's **condition and counters** are on the sheet and in the panel:
|
||||
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
|
||||
subclass" in its Settings and every class follows one path, unasked.
|
||||
- **⬆ 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.
|
||||
- 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
|
||||
@@ -163,7 +201,7 @@ no database configured.
|
||||
src/game/data/*.js and used directly by the calculation engine,
|
||||
not stored as DB rows)
|
||||
/src
|
||||
/game — calculations.js (the ONLY place D&D formulas live) + buildFlow.js
|
||||
/game — calculations.js (the ONLY place D&D formulas live) + buildFlow.js + levelUp.js
|
||||
/middleware — auth + campaign/character authorization
|
||||
/routes — auth, campaigns, characters, encounters, notes
|
||||
/seeds — finds, checks and creates campaigns from the world seeds
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.13.0",
|
||||
"version": "1.21.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable: what a character has spent since their last rest. Spell slots
|
||||
-- by slot level (the first entry is 1st-level slots), and Hit Dice. How many
|
||||
-- of each there are comes from class and level, so only what is used is kept.
|
||||
ALTER TABLE "characters" ADD COLUMN "spellSlotsUsed" INTEGER[] DEFAULT ARRAY[]::INTEGER[],
|
||||
ADD COLUMN "hitDiceUsed" INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- AlterTable: the path a character takes within their class, as a key from
|
||||
-- game/data/subclasses.js.
|
||||
ALTER TABLE "characters" ADD COLUMN "subclass" TEXT;
|
||||
|
||||
-- Until now every class followed one path, written onto the sheet as the
|
||||
-- character levelled. Finished characters already at the level their class
|
||||
-- chooses at are given that path as their subclass.
|
||||
UPDATE "characters" SET "subclass" = CASE "characterClass"
|
||||
WHEN 'BARBARIAN' THEN 'BERSERKER'
|
||||
WHEN 'BARD' THEN 'LORE'
|
||||
WHEN 'CLERIC' THEN 'LIFE'
|
||||
WHEN 'DRUID' THEN 'LAND'
|
||||
WHEN 'FIGHTER' THEN 'CHAMPION'
|
||||
WHEN 'MONK' THEN 'OPEN_HAND'
|
||||
WHEN 'PALADIN' THEN 'DEVOTION'
|
||||
WHEN 'RANGER' THEN 'HUNTER'
|
||||
WHEN 'ROGUE' THEN 'THIEF'
|
||||
WHEN 'SORCERER' THEN 'DRACONIC'
|
||||
WHEN 'WARLOCK' THEN 'FIEND'
|
||||
WHEN 'WIZARD' THEN 'EVOCATION'
|
||||
END
|
||||
WHERE "buildStage" = 'COMPLETE'
|
||||
AND "characterClass" IS NOT NULL
|
||||
AND "level" >= CASE WHEN "characterClass" IN ('CLERIC', 'DRUID', 'WIZARD') THEN 2 ELSE 3 END;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- AlterTable: how a character is doing beyond HP, and the class counters
|
||||
-- (Rage, Ki...) spent since the rest that gives them back, as { key: used }.
|
||||
ALTER TABLE "characters" ADD COLUMN "resourcesUsed" JSONB NOT NULL DEFAULT '{}',
|
||||
ADD COLUMN "inspiration" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "exhaustion" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "conditions" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- A character's conditions used to be kept per encounter. They are the
|
||||
-- character's own now, so those given in an encounter still in progress move
|
||||
-- onto the character.
|
||||
UPDATE "characters" c SET "conditions" = held.conditions
|
||||
FROM (
|
||||
SELECT p."characterId", array_agg(DISTINCT condition) AS conditions
|
||||
FROM "encounter_participants" p
|
||||
JOIN "encounters" e ON e."id" = p."encounterId"
|
||||
CROSS JOIN LATERAL unnest(p."conditions") AS condition
|
||||
WHERE p."characterId" IS NOT NULL AND e."status" = 'ACTIVE'
|
||||
GROUP BY p."characterId"
|
||||
) held
|
||||
WHERE c."id" = held."characterId";
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable: conditions that wear off by themselves, as
|
||||
-- { condition: rounds left }, counted down each time the round goes up.
|
||||
ALTER TABLE "encounter_participants" ADD COLUMN "conditionRounds" JSONB NOT NULL DEFAULT '{}';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable: the experience points handed out for an encounter, in all;
|
||||
-- null until the DM awards them.
|
||||
ALTER TABLE "encounters" ADD COLUMN "xpAwarded" INTEGER;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable: a campaign can do without subclasses. Off, nobody chooses one
|
||||
-- and each class follows its first.
|
||||
ALTER TABLE "campaigns" ADD COLUMN "subclassesEnabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -179,6 +179,9 @@ model Campaign {
|
||||
// bear at one time. A pair of daggers is one weapon, made as such.
|
||||
maxEquipped Int @default(2)
|
||||
maxEquippedWeapons Int @default(1)
|
||||
// Off: nobody chooses a subclass. Each class follows its first one without
|
||||
// being asked, and subclasses are not named anywhere.
|
||||
subclassesEnabled Boolean @default(true)
|
||||
ownerId String
|
||||
// Set while the campaign is archived: kept in full, but moved off the main
|
||||
// list and closed to new members until the DM restores it.
|
||||
@@ -260,6 +263,9 @@ model Character {
|
||||
name String
|
||||
species Species?
|
||||
characterClass CharacterClass?
|
||||
// The path taken within the class: a key from game/data/subclasses.js. Null
|
||||
// until the class reaches the level it chooses at.
|
||||
subclass String?
|
||||
level Int @default(1)
|
||||
xp Int @default(0)
|
||||
motivation String?
|
||||
@@ -283,6 +289,21 @@ model Character {
|
||||
deathSaveSuccesses Int @default(0)
|
||||
deathSaveFailures Int @default(0)
|
||||
stable Boolean @default(false) // at 0 HP but no longer rolling
|
||||
// What has been spent since the last rest. Spell slots by slot level (the
|
||||
// first entry is 1st-level slots); how many there are comes from class and
|
||||
// level (game/calculations.js, spellSlotsFor). A character has one Hit Die
|
||||
// per level, spent on short rests.
|
||||
spellSlotsUsed Int[] @default([])
|
||||
hitDiceUsed Int @default(0)
|
||||
// Class counters spent since the rest that gives them back, as
|
||||
// { key: used } (game/classResources.js says which a character has).
|
||||
resourcesUsed Json @default("{}")
|
||||
// How the character is doing beyond HP. Reminders for the table: none of
|
||||
// them changes a number on the sheet. Conditions are the character's own,
|
||||
// so they outlast the encounter they were given in.
|
||||
inspiration Boolean @default(false)
|
||||
exhaustion Int @default(0) // 0 to 6
|
||||
conditions String[] @default([])
|
||||
armorBonus Int @default(0) // a flat AC adjustment the DM sets by hand, on top of what equipped gear gives
|
||||
speedOverride Int?
|
||||
|
||||
@@ -590,6 +611,9 @@ model Encounter {
|
||||
archivedAt DateTime?
|
||||
// Place in the campaign's own order, lowest first. The DM rearranges it.
|
||||
sortOrder Int @default(0)
|
||||
// The experience points handed out for this encounter, in all; null until
|
||||
// the DM awards them. They are awarded once.
|
||||
xpAwarded Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -678,7 +702,10 @@ model EncounterParticipant {
|
||||
initiative Int @default(0)
|
||||
currentHp Int
|
||||
maxHp Int
|
||||
conditions String[] @default([]) // e.g. ["poisoned", "prone"]
|
||||
conditions String[] @default([]) // e.g. ["poisoned", "prone"]; a character's own are on the Character row
|
||||
// Conditions that wear off by themselves, as { condition: rounds left }.
|
||||
// Counted down each time the round goes up; at 0 the condition is removed.
|
||||
conditionRounds Json @default("{}")
|
||||
isMonster Boolean
|
||||
|
||||
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@ -14,6 +14,9 @@ const { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require
|
||||
const options = require('../src/game/data/characterOptions');
|
||||
const { SPECIES } = require('../src/game/data/species');
|
||||
const { CLASSES } = require('../src/game/data/classes');
|
||||
const { BOONS, MAX_FEATURE_LEVEL } = require('../src/game/data/levelFeatures');
|
||||
const { featuresAt } = require('../src/game/levelUp');
|
||||
const { SUBCLASSES, SUBCLASS_LEVEL } = require('../src/game/data/subclasses');
|
||||
|
||||
const DOCS = path.join(__dirname, '..', '..', 'docs');
|
||||
|
||||
@@ -114,12 +117,44 @@ function builderLists() {
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
// What "Level Up" writes for each class at each level, what each of its
|
||||
// subclasses adds, and the Boons.
|
||||
function levelFeatures() {
|
||||
const line = (f) => (f.boon ? '**Boon** (choose one)' : `**${f.name}**${f.upgrade ? ' (replaces)' : ''}: ${f.text}`);
|
||||
const classes = Object.keys(CLASSES)
|
||||
.sort()
|
||||
.map((key) => {
|
||||
const rows = [];
|
||||
for (let level = 2; level <= MAX_FEATURE_LEVEL; level++) {
|
||||
const features = featuresAt(key, level);
|
||||
rows.push([level, features.length > 0 ? features.map(line).join('<br>') : 'No new feature']);
|
||||
}
|
||||
const subclassRows = SUBCLASSES[key].flatMap((subclass) =>
|
||||
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>')])
|
||||
);
|
||||
return [
|
||||
`### ${CLASSES[key].name}`,
|
||||
table(['Level', 'Written into Special Abilities'], rows),
|
||||
`**${CLASSES[key].name} subclasses** (chosen at level ${SUBCLASS_LEVEL[key]}). What each adds to the table above:`,
|
||||
table(['Subclass', 'Level', 'Written into Special Abilities'], subclassRows),
|
||||
].join('\n\n');
|
||||
});
|
||||
const boons = table(
|
||||
['Boon', 'First time', 'Can be taken'],
|
||||
BOONS.map((b) => [b.name, b.text(b.per), b.max === 1 ? 'Once' : `${b.max} times`])
|
||||
);
|
||||
return [`### Boons\n\n${boons}`, ...classes].join('\n\n');
|
||||
}
|
||||
|
||||
const SECTIONS = {
|
||||
'SPELLS.md': { 'spell-library': spellLibrary },
|
||||
'WEAPONS.md': { 'weapon-library': weaponLibrary },
|
||||
'ITEMS.md': { 'item-library': itemLibrary },
|
||||
'MONSTERS.md': { 'monster-library': monsterLibrary },
|
||||
'CHARACTERS.md': { 'builder-lists': builderLists },
|
||||
'CHARACTERS.md': { 'builder-lists': builderLists, 'level-features': levelFeatures },
|
||||
};
|
||||
|
||||
for (const [file, sections] of Object.entries(SECTIONS)) {
|
||||
|
||||
@@ -5626,7 +5626,7 @@ const book = {
|
||||
"The Story in One Page (DM only)\n\nMorvain Ashgrave, once the Royal Archmage of Highmarch, made a pact with Nyxaloth, the Devourer Beneath, a colossal shadow dragon chained in the underworld. With the help of a spy, Chancellor Malvoy, Morvain stole into the royal vault and shattered the Concord Crown. Its five shards scattered across both worlds. With the Crown broken, the Veil between Aldervane and Port Meridian is tearing.\nIn Act I, the first Seam opens over Thornwick during the harvest festival. A city bus driven by Dee Okafor crashes through, chased by shadow creatures. The heroes escort the old court wizard Master Quillon to Highmarch, clear a goblin cave on the way and win a Riftstone that points toward Crown shards. They find King Aldous cursed into an endless sleep by three swamp witches who serve Morvain's lieutenant, Vessa Thorne. The heroes break the curse in the Witchfen. The king wakes, names them Wardens of the Crown and sends them after the shards.\nIn Act II, the heroes recover three shards in any order: the Shard of Dawn from the dying elven forest, where Vessa's Blight is killing the trees; the Shard of Iron from Gorrug Skullbreaker, an ogre warlord the shard has made clever and pompous; and the Shard of Tides from Dr. Julian Crane, a Port Meridian tech billionaire who is secretly Morvain's warlock. When the third shard is won, Morvain appears to the heroes as a shadow and tempts each of them.\nIn Act III, the heroes bargain with or battle the red dragon Cindermaw for the Shard of Ember, then return to find Highmarch under siege: Malvoy has opened the gates to Morvain's undead knights. They save the city (and perhaps the king), and the dwarf smith Brannoc reforges four shards into an incomplete Crown.\nIn Act IV, the heroes descend into the Hollow Deep, cross an underworld market, a river of whispering dead and a cathedral of bone, storm Morvain's Obsidian Citadel, defeat him, take the last shard and face Nyxaloth itself at the Abyssal Seal. They must reseal the dragon (and someone must stay behind as the Crown's Keeper) or slay it for good.",
|
||||
"Campaign at a Glance\n\nPart · Levels · Sessions · Where · The party's goal\nAct I: The Night the Sky Tore · 1 to 4 · 1 to 7 · Thornwick, the Kingsroad, Highmarch, the Witchfen · Survive the first Seam, reach the capital and break the curse on the sleeping king\nAct II: Shards of Two Worlds · 4 to 7 · 8 to 16 · the Elderwood, the Grimtooth Hills, Port Meridian · Recover three Crown Shards, in any order, from an elf queen, an ogre warlord and a tech billionaire\nAct III: Fire and Betrayal · 7 to 9 · 17 to 21 · Ashpeak, Highmarch · Win the fourth shard from a red dragon, survive the siege of the capital and reforge the Crown\nAct IV: Into the Hollow Deep · 9 to 10 · 22 to 28 · the Hollow Deep, the Obsidian Citadel · Descend into the underworld, defeat the Hollow King and reseal or slay the dragon\nEpilogue · 10 · After 28 · Both worlds · Decide what happens to the gate, the allies and the heroes\nSide Quests · 1 to 10 · Any time · Everywhere · Fourteen short detours with rewards and new allies\n\nMILESTONE LEVELLING (no XP)\nLevel reached · When\n2 · After Session 2 (the party sets out on the Kingsroad)\n3 · After Session 4 (arrival in Highmarch)\n4 · After Session 7 (end of Act I: the king wakes)\n5 · After Session 10 (first Act II shard won)\n6 · After Session 13 (second Act II shard won)\n7 · After Session 16 (end of Act II: Morvain's projection)\n8 · After Session 19 (the Siege of Highmarch is won or lost)\n9 · After Session 21 (the Reforging; end of Act III)\n10 · After Session 25 (the Bone Cathedral)",
|
||||
"The 28-Session Roadmap\n\nAct II’s three quests can be played in any order; swap the rows but keep the levels where they are.\n\nSession · Level · Where · What happens · Key encounters · Level up after?\n1 · 1 · Act I, Thornwick · Harvest Night: the sky tears, Dee's bus crashes into the square · Gloamlings, Rift Hounds · No\n2 · 1 · Act I, Hexed Scarecrows · Quillon arrives on Parsnip; scarecrows stalk Farmer Bodkin's fields; Last Bus Home begins; the party sets out · Hexed Scarecrows · Yes: level 2\n3 · 2 · Act I, Kingsroad · Goblin ambush, Rattlejaw Cave, Skrit and the Riftstone · Gloam-touched Goblins, Skrit · No\n4 · 2 · Act I, Highmarch · The cursed king, Rowena, Malvoy, Brannoc; the shattered vault · Social and investigation; optional street brawl · Yes: level 3\n5 · 3 · Act I, Witchfen · Into the swamp; Mother Bramblewick names her price · Fen Lanterns, Bog Lurker · No\n6 · 3 · Act I, Witchfen · Finding the Sisters' hut; traps, thornlings, crone tricks · Thornlings, one Witchfen Crone scouting · No\n7 · 3 · Act I, the Sisters' hut · The Three Sisters; destroy the Hex Doll; Vessa gloats; the king wakes; knighted Wardens · Three Witchfen Crones (Coven) · Yes: level 4\n8 · 4 · Act II, Elderwood · Thalion and Pip guide the party to Silverbough; Ilyndra's demand · Blighted wildlife, Thornlings · No\n9 · 4 · Act II, Silverbough · The Three Trials: Riddle Pool, Blighted Stag, the fauns · Blighted Stag · No\n10 · 4 · Act II, Rotheart Grove · Destroy the Blight Heart; Vessa's last stand; Shard of Dawn · Vessa Thorne, Rotwood Treant · Yes: level 5\n11 · 5 · Act II, Barrowdowns, Grimtooth · Crossing the barrows and hills; griffons; arrive at Skullcrag · Library Griffons, Wights · No\n12 · 5 · Act II, Skullcrag · The Great Gut-Clash, a heist, or open battle · Ogre Bone-Shaman, library Ogres · No\n13 · 5 · Act II, Skullcrag · Gorrug's fate; the Shard of Iron; maybe an ogre alliance · Gorrug · Yes: level 6\n14 · 6 · Act II, Port Meridian · Through the Seam with Dee; Zuri; Detective Hale; burgers and police · Tunnel Ghasts or Vantage agents · No\n15 · 6 · Act II, Vantage Tower · The heist: cameras, drones, hexers · Sentry Drones, Corporate Hexer · No\n16 · 6 · Act II, Vantage Tower basement · Dr. Crane and the Rift Engine; Shard of Tides; Morvain's projection and temptations · Dr. Crane, Rift Abomination · Yes: level 7\n17 · 7 · Act III, Ashpeak · Climbing the volcano · Cinderscale Kobolds, Ash Wraiths, Wyvern · No\n18 · 7 · Act III, Cindermaw's lair · Fight, trick or bargain with the dragon; Shard of Ember · Cindermaw · No\n19 · 7 · Act III, Highmarch · The Siege, part 1: smoke on the horizon, the gates are open, hold the walls · Hollow Knights, gloamlings, Wight captains · Yes: level 8\n20 · 8 · Act III, Highmarch castle · The Siege, part 2: save the king; unmask Malvoy in the throne room · Malvoy, Rift Abomination · No\n21 · 8 · Act III, Brannoc's forge · The Reforging; Quillon's confession; farewells · Roleplay; optional cultist raid · Yes: level 9\n22 · 9 · Act IV, Hollow Deep · Down the Sunken Stair to the Weeping Gate · Hollowborn Warriors, Hollow Cultists · No\n23 · 9 · Act IV, Underkin Market · Whisper the guide; buying information; Mushroom Court, Market of Lost Things · Mostly social · No\n24 · 9 · Act IV, River of Whispers · The ferry of the dead; voices of lost loved ones · Soul Leech Swarms · No\n25 · 9 · Act IV, Bone Cathedral · Redeem or fight the Bone Abbess · Bone Abbess, Bone Colossus · Yes: level 10\n26 · 10 · Act IV, Obsidian Citadel · Infiltration or assault · Gloam Stalkers, Hollow Knights · No\n27 · 10 · Act IV, the throne room · Morvain, the Hollow King, in two phases; the Shard of Night · Morvain · No\n28 · 10 · Act IV, the Abyssal Seal, Epilogue · Nyxaloth rises; allies arrive; reseal or slay; what comes after · Nyxaloth · Campaign ends",
|
||||
"About this seeded campaign\n\nEverything in the Hollowcrown book that fits the site is already here: the weapon pool, spell list, item pool, monsters, NPCs, locations, and an encounter for every encounter box in the book plus the big talking scenes, in story order (side quests sit roughly where the book suggests running them).\n\n• The book advises adding magic weapons, later monsters and custom spells only as the story reaches them. They are all preloaded instead, so nothing needs typing mid-session. None of them is a starting option, and players only see what you give them or put in a scene.\n• Encounters are built for four players. Each one’s DM Notes hold the book’s full box: scaling for 3 and 5 players, terrain, tactics and what-ifs.\n• Rewards are lined up unawarded. “One per hero” rewards are set to a quantity of 4, and optional or either/or rewards say so in their note: remove what the party did not earn.\n• Bosses the party talks to before fighting (Skrit, Vessa, Gorrug, Crane, Malvoy, Cindermaw, the Bone Abbess, Morvain) are NPCs with combat stats. Their DM notes carry their attacks and phases.\n• Gear effects are set. Every weapon and item carries the book’s “Effects (site)” numbers and note, so armor, shields and worn gear change the sheet by themselves once equipped. Extra AC is only for class features and Dexterity corrections (Appendix D): Unarmored Defense, the Defense fighting style, or −(DEX mod − 2) for medium armor and −DEX mod for heavy.\n• The equip limit starts at 3 things, 1 of them a weapon. Raise it to 4 in Campaign Settings at the start of Act II.\n• Shops, money changers and cash are set up: see “Money, shops and money changers” below.\n• Still to do by hand: track spell slots and charges on paper, and apply weapon special abilities yourself (see the notes below).\n• Malvoy is seeded Friendly. Flipping him to Hostile is a reveal every player sees: save it for the throne room.",
|
||||
"About this seeded campaign\n\nEverything in the Hollowcrown book that fits the site is already here: the weapon pool, spell list, item pool, monsters, NPCs, locations, and an encounter for every encounter box in the book plus the big talking scenes, in story order (side quests sit roughly where the book suggests running them).\n\n• The book advises adding magic weapons, later monsters and custom spells only as the story reaches them. They are all preloaded instead, so nothing needs typing mid-session. None of them is a starting option, and players only see what you give them or put in a scene.\n• Encounters are built for four players. Each one’s DM Notes hold the book’s full box: scaling for 3 and 5 players, terrain, tactics and what-ifs.\n• Rewards are lined up unawarded. “One per hero” rewards are set to a quantity of 4, and optional or either/or rewards say so in their note: remove what the party did not earn.\n• Bosses the party talks to before fighting (Skrit, Vessa, Gorrug, Crane, Malvoy, Cindermaw, the Bone Abbess, Morvain) are NPCs with combat stats. Their DM notes carry their attacks and phases.\n• Gear effects are set. Every weapon and item carries the book’s “Effects (site)” numbers and note, so armor, shields and worn gear change the sheet by themselves once equipped. Extra AC is only for class features and Dexterity corrections (Appendix D): Unarmored Defense, the Defense fighting style, or −(DEX mod − 2) for medium armor and −DEX mod for heavy.\n• The equip limit starts at 3 things, 1 of them a weapon. Raise it to 4 in Campaign Settings at the start of Act II.\n• Shops, money changers and cash are set up: see “Money, shops and money changers” below.\n• Spell slots, Hit Dice and rests are on the site: each caster has a slot tracker on their sheet, the Rest button is beside the character list, and Level Up on a sheet walks the levelling checklist.\n• Still to do by hand: track charges, ki and sorcery points on paper, and apply weapon special abilities yourself (see the notes below).\n• Malvoy is seeded Friendly. Flipping him to Hostile is a reveal every player sees: save it for the throne room.",
|
||||
"Money, shops and money changers\n\nCoins and cash. Each character holds gold, silver and copper (1 gp = 10 sp = 100 cp; the site makes change) and cash ($), which is separate: a $ price is paid from cash only, and anything found in Port Meridian is awarded as cash.\n\nMoney changers (gold and cash only, whole multiples, during an NPC quest in progress with the NPC in the scene):\nRay, Port Meridian · 1 gold = $50 (the reference rate)\nDee Okafor · 1 gold = $40\nCaptain Silas Crook, Saltmere · 1 gold = $30\nMother Bramblewick and Tallow · 1 gold = $100 (the only way to turn leftover cash into gold in Aldervane or the Deep)\nDetective Hale does not change money. Other Aldervane folk will not take cash at all.\n\nShops are NPC quests, already in the encounter list where the story reaches them:\nShopping in Thornwick · Hattie (everyday goods), Sister Fenna (3 healing potions), Old Tobbin Ashby (weapons and armor)\nHighmarch: Brannoc’s Forge · armor, Brannoc’s Whetstone, the Gauntlets of Might from Act III\nMother Bramblewick’s Bargain · healing potions and antitoxin\nSilverbough: The Market of Leaves · Silverbough Cloaks and faun salve\nSkullcrag: The Swap-Pile · only once the ogres are friends\nPort Meridian: Ray’s Cash for Gold and the Shops · everything priced in $\nSmugglers of Saltmere: Crook’s Back Room · black-market goods\nHighmarch Market · before the descent, at level 8\nThe Old Shrine at the Weeping Gate · a Ferryman’s Coin for an offering\nUnderkin Market · six stalls, at level 9\n\nAn NPC’s wares follow them into every quest they are in; “For sale here” belongs to one encounter. A ware is sold at its level, for its price, until its stock runs out.\n\nBy hand: there is no selling and no haggling on the site. Merchants pay half price for ordinary goods and potions and full value for gems and art; record it as a gain in the money log. To haggle, Edit the ware’s price before they click Buy. Smiths’ upgrades (100 gp × the new level) are a spend, then raise the gear level on the sheet. Rooms, meals, rumours and services are spends too.",
|
||||
"Factions\n\nFaction · Leader · Starting attitude · Where\nKnights of the Silver Gauntlet · Dame Rowena Vance · Neutral, then Friendly · Highmarch\nThe Silverbough Court · Queen Ilyndra · Unfriendly · Silverbough\nThe Witchfen Coven · Vessa Thorne · Hostile · The Witchfen\nVantage Dynamics · Dr. Julian Crane · Hostile (pretends Neutral) · Port Meridian\nThe Hollow Court · Morvain Ashgrave · Hostile · The Hollow Deep\nThe Skullcrag Ogres · Gorrug Skullbreaker · Hostile, but can be won · The Grimtooth Hills\nThe Underkin · The Market Wardens · Neutral · The Hollow Deep\n\nKNIGHTS OF THE SILVER GAUNTLET\nGoal: Protect the king; find who broke the Crown. Leader: Dame Rowena Vance. Attitude: Neutral, then Friendly once the king wakes and names the party Wardens of the Crown. About sixty knights and two hundred men-at-arms, sworn to the Ironwarden, brave and stubborn, baffled by phones.\n- Help: Escorts, horses, rooms in Gauntlet Hall, healing from their chaplains, and twelve knights at your side in the Siege of Highmarch.\n- Hinder: They arrest anyone waving a gun in the city and distrust witches, warlocks and \"other-siders\". A PC warlock should mind their words near Rowena.\n\nTHE SILVERBOUGH COURT\nGoal: Save the Elderwood from the Blight; keep the Shard of Dawn from humans. Leader: Queen Ilyndra. Attitude: Unfriendly: \"the humans let the Crown be broken.\" Her nephew Thalion Duskleaf is the exception; he wants elves and humans to stand together again.\n- Help: After the Three Trials: the Shard of Dawn, a magic bow or blade, and Silverbough archers for the siege and the finale.\n- Hinder: No humans in the city without a guide; riddles instead of answers; the shard stays put until the party proves itself.\n\nTHE WITCHFEN COVEN\nGoal: Serve the Hollow King; spread the Blight; seize the Shard of Dawn. Leader: Vessa Thorne, the Hexmother. Attitude: Hostile. Its strongest members are the Three Sisters of the Witchfen, Old Nettle, Grey Agatha and Little Moth (Witchfen Crones), who cursed the king. Mother Bramblewick is not one of them and hates them all.\n- Hinder: Curses, hexed scarecrows, thornlings, moth and crow spies, the Blight.\n- Help: Almost never; a captured lesser witch might trade a secret for her freedom.\n\nVANTAGE DYNAMICS\nGoal: Officially \"clean energy\". Really, widen the Seams with the Rift Engine so the worlds merge. Leader: Dr. Julian Crane, secretly Morvain's warlock. Attitude: Hostile, though Crane plays friendly as long as he can. Most of its employees have no idea.\n- Hinder: Security agents, drones, cordon \"contractors\", a press office that calls the party \"dangerous cosplayers\", and money that makes police reports vanish.\n- Help: Crane's offer of \"partnership\" is a trap, but a junior scientist who sees the truth can become an inside contact in Act II.\n\nTHE HOLLOW COURT\nGoal: Wake Nyxaloth, merge the worlds, crown Morvain. Leader: Morvain Ashgrave, the Hollow King. Attitude: Hostile. Hollow Knights, Hollowborn Warriors, gloamlings, gloam stalkers and Hollow Cultists. Their sign is a crown turned upside down.\n- Hinder: Everything; they also have a spy in Highmarch (Chancellor Malvoy).\n- Help: Never as a group, but Morvain tempts individual PCs with offers (Act II, Act IV).\n\nTHE SKULLCRAG OGRES\nGoal: Whatever Gorrug says: right now \"King of All Hills\", a raid on Highmarch and a proper library. Leader: Gorrug Skullbreaker. Attitude: Hostile, not evil. About forty loud, hungry, contest-loving ogres, with hill giants and an Ogre Bone-Shaman or two.\n- Hinder: Raids, thrown rocks, the planned attack on Highmarch.\n- Help: Win the Great Gut-Clash or Gorrug's respect and they march to the siege and the finale.\n\nTHE UNDERKIN\nGoal: Stay alive, stay neutral, make a profit. Leader: None; the Market Wardens keep the peace. Attitude: Neutral. Small, grey, huge-eyed gloam-gnomes who have traded with everyone in the Deep for a thousand years.\n- Help: Guides such as Whisper, gloam lanterns, food, information, a safe place to rest.\n- Hinder: They sell information to anyone who pays, including the Hollow Court.",
|
||||
"Malvoy Clue Reminders\n\nChancellor Malvoy is Morvain's spy. He gave Morvain the vault key, and in Act III he will open Highmarch's gates to the Hollow Court. Act II is where you build the case against him, one small clue at a time, so that when the betrayal comes the players slap the table and say \"I KNEW it!\"\nKeep track of every clue you drop on the Malvoy Clue Tracker. Aim for one clue per quest, plus one each time the party visits Highmarch. Never more than two in one session.\nWhen · The clue · How to play it\nThe King's Council · Malvoy copies the map \"for the archive\" and asks which way they will go. His fingertips are stained black, not the blue-black of ordinary ink. · If asked, he chuckles: \"Occupational hazard. The royal ink is very old.\"\nReturning to Highmarch with any shard · Malvoy asks to hold the shard \"to record its weight and size\". He holds it a moment too long and his hand shakes. · If the party refuses, he bows politely and says \"Of course, of course.\" Rowena backs the party.\nThe Rotting Wood · In Vessa's shrine (G5) the party finds a letter: \"Our friend with the black fingers says the Wardens leave for the wood in three days.\" · The letter is unsigned. The handwriting is very neat, like a clerk's.\nThe Ogre King's Gambit · Gorrug boasts that \"a little man of letters\" wrote to warn him the Wardens would come to steal Thinker. He can't remember the name: \"Mal... Malvo... MALODOROUS!\" · If the party has the letter, its paper has the royal watermark of Highmarch.\nThe Glass Tower · Crane's files include a note: \"Our man in Highmarch confirms the Wardens have two shards. He asks again about his payment.\" · The note calls him \"the Ink Man\".\nAny visit to Brannoc · Brannoc grumbles that \"the Chancellor\" came by asking how hard it would be to melt a shard. \"Daft question. You can't melt them. I told him so.\" · Brannoc does not think it suspicious. He thinks Malvoy is just nosy.",
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
const { SPECIES } = require('./data/species');
|
||||
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 ABILITIES = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
|
||||
@@ -54,6 +57,11 @@ function passivePerception(perceptionSkillBonus) {
|
||||
return 10 + perceptionSkillBonus;
|
||||
}
|
||||
|
||||
// Any skill has a passive score: what it comes to without a roll.
|
||||
function passiveScore(skillBonus) {
|
||||
return 10 + skillBonus;
|
||||
}
|
||||
|
||||
function armorClass(dexMod, armorBonus = 0) {
|
||||
return 10 + dexMod + armorBonus;
|
||||
}
|
||||
@@ -121,6 +129,108 @@ function getClassSpellcastingAbility(characterClass) {
|
||||
return data ? data.spellcastingAbility : null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spell slots and Hit Dice: what a character has to spend between rests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_LEVEL = 20;
|
||||
|
||||
function clampLevel(level) {
|
||||
return Math.min(Math.max(Math.round(Number(level) || 1), 1), MAX_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];
|
||||
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]] };
|
||||
}
|
||||
|
||||
// The slot tracker: for each slot level the character has, how many slots
|
||||
// there are and how many are spent. `used` is the stored tally (1st-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);
|
||||
if (!slots) return null;
|
||||
const levels = slots.max
|
||||
.map((max, i) => {
|
||||
const spent = Math.min(Math.max(Number((used || [])[i]) || 0, 0), max);
|
||||
return { level: i + 1, max, used: spent, remaining: max - spent };
|
||||
})
|
||||
.filter((slot) => slot.max > 0);
|
||||
return levels.length > 0 ? { kind: slots.kind, restoresOn: slots.restoresOn, levels } : null;
|
||||
}
|
||||
|
||||
// The highest level of spell a class can cast at a level; 0 for none.
|
||||
function maxSpellLevel(characterClass, level) {
|
||||
const slots = spellSlotsFor(characterClass, level);
|
||||
return slots ? slots.max.length : 0;
|
||||
}
|
||||
|
||||
// One Hit Die per level, of the class's size. Spent ones come back on a long rest.
|
||||
function hitDiceState(characterClass, level, used = 0) {
|
||||
const count = clampLevel(level);
|
||||
const spent = Math.min(Math.max(Number(used) || 0, 0), count);
|
||||
return { size: getClassHitDieSize(characterClass), count, used: spent, remaining: count - spent };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Experience points and encounter difficulty (data/xp.js).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The XP one monster of a challenge rating is worth. The rating is free text
|
||||
// on a monster ("1/4", "5", "0.5"); null for anything that is not a rating.
|
||||
function challengeXp(challengeRating) {
|
||||
if (challengeRating == null) return null;
|
||||
const text = String(challengeRating).trim().replace(/^cr\s*/i, '');
|
||||
const fractions = { 0.125: '1/8', 0.25: '1/4', 0.5: '1/2' };
|
||||
const key = fractions[text] || text;
|
||||
return Object.prototype.hasOwnProperty.call(CR_XP, key) ? CR_XP[key] : null;
|
||||
}
|
||||
|
||||
// How hard a fight is for a party. `partyLevels` is the level of each
|
||||
// character in it; `challengeRatings` the rating of each monster. `xp` is
|
||||
// what the monsters are worth; `adjustedXp` is that multiplied for their
|
||||
// number, which is what is held against the party's thresholds. `rating` is
|
||||
// TRIVIAL, EASY, MEDIUM, HARD or DEADLY, or null with no party or no rated
|
||||
// monster to judge by. Monsters with no readable rating are counted in
|
||||
// `unrated` and left out of everything else.
|
||||
function encounterDifficulty(partyLevels, challengeRatings) {
|
||||
const worth = challengeRatings.map(challengeXp);
|
||||
const rated = worth.filter((xp) => xp !== null);
|
||||
const xp = rated.reduce((sum, value) => sum + value, 0);
|
||||
const multiplier = rated.length === 0 ? 1 : COUNT_MULTIPLIERS.find(([atLeast]) => rated.length >= atLeast)[1];
|
||||
const adjustedXp = Math.floor(xp * multiplier);
|
||||
|
||||
let thresholds = null;
|
||||
let rating = null;
|
||||
if (partyLevels.length > 0) {
|
||||
const [easy, medium, hard, deadly] = [0, 1, 2, 3].map((i) => partyLevels.reduce((sum, level) => sum + XP_THRESHOLDS[clampLevel(level) - 1][i], 0));
|
||||
thresholds = { easy, medium, hard, deadly };
|
||||
if (rated.length > 0) {
|
||||
rating = adjustedXp >= deadly ? 'DEADLY' : adjustedXp >= hard ? 'HARD' : adjustedXp >= medium ? 'MEDIUM' : adjustedXp >= easy ? 'EASY' : 'TRIVIAL';
|
||||
}
|
||||
}
|
||||
return { xp, adjustedXp, multiplier, rating, thresholds, monsters: rated.length, unrated: worth.length - rated.length };
|
||||
}
|
||||
|
||||
// Where a character's XP puts them: what their next level takes, and whether
|
||||
// they have it. Levelling stays the DM's decision; this only says it is due.
|
||||
function xpProgress(xp, level) {
|
||||
const at = clampLevel(level);
|
||||
const nextLevelAt = at >= MAX_LEVEL ? null : LEVEL_XP[at];
|
||||
return { nextLevelAt, levelUpDue: nextLevelAt !== null && xp >= nextLevelAt };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gear effects (house rule). A weapon or item in the campaign pools can carry
|
||||
// effects: signed amounts added to one of the numbers on the sheet. They count
|
||||
@@ -193,6 +303,8 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
halfElfBonusAbility2 = null,
|
||||
armorBonus = 0,
|
||||
speedOverride = null,
|
||||
spellSlotsUsed = [],
|
||||
hitDiceUsed = 0,
|
||||
} = character;
|
||||
|
||||
const halfElfBonusAbilities = [halfElfBonusAbility1, halfElfBonusAbility2].filter(Boolean);
|
||||
@@ -246,10 +358,12 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
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: { size: hitDieSize, count: level },
|
||||
hitDice: hitDiceState(characterClass, level, hitDiceUsed),
|
||||
saves,
|
||||
skills,
|
||||
passivePerception: passivePerception(skills.PERCEPTION.bonus) + fromGear('PASSIVE_PERCEPTION'),
|
||||
passiveInvestigation: passiveScore(skills.INVESTIGATION.bonus),
|
||||
passiveInsight: passiveScore(skills.INSIGHT.bonus),
|
||||
// What equipped gear is doing to the numbers above, and what else it does.
|
||||
gear,
|
||||
spellcasting: spellcastingAbility
|
||||
@@ -259,6 +373,10 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
attackBonus: spellAttackBonus(profBonus, abilities[spellcastingAbility].modifier),
|
||||
}
|
||||
: null,
|
||||
// The slot tracker; null for a character with no spell slots.
|
||||
spellSlots: spellSlotState(characterClass, level, spellSlotsUsed),
|
||||
// The class counters (Rage, Ki...), with how many of each are left.
|
||||
resources: resourceState(character, abilities, profBonus),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -393,6 +511,7 @@ module.exports = {
|
||||
saveBonus,
|
||||
skillBonus,
|
||||
passivePerception,
|
||||
passiveScore,
|
||||
armorClass,
|
||||
initiative,
|
||||
hpMax,
|
||||
@@ -405,6 +524,15 @@ module.exports = {
|
||||
getClassSaveProficiencies,
|
||||
getClassHitDieSize,
|
||||
getClassSpellcastingAbility,
|
||||
MAX_LEVEL,
|
||||
MAX_SLOT_LEVEL,
|
||||
spellSlotsFor,
|
||||
spellSlotState,
|
||||
maxSpellLevel,
|
||||
hitDiceState,
|
||||
challengeXp,
|
||||
encounterDifficulty,
|
||||
xpProgress,
|
||||
GEAR_STATS,
|
||||
activeGear,
|
||||
computeCharacterSheet,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// Class counters: the things a class can do a set number of times before a
|
||||
// rest (Rage, Ki, Channel Divinity...). How many there are comes from class,
|
||||
// subclass, level and ability modifiers; the character row keeps only how
|
||||
// many are spent (`resourcesUsed`, { key: used }), as it does for spell slots.
|
||||
// Pure: no DB calls.
|
||||
//
|
||||
// What a counter does is on the sheet in the special abilities; nothing here
|
||||
// feeds another number.
|
||||
|
||||
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
|
||||
// `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' },
|
||||
{ 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: ['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' },
|
||||
{ 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: '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' },
|
||||
{ key: 'WHOLENESS_OF_BODY', name: 'Wholeness of Body', classes: ['MONK'], subclass: 'OPEN_HAND', from: 6, max: () => 1, rest: 'LONG' },
|
||||
|
||||
{ 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: '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' },
|
||||
{ key: 'TIDES_OF_CHAOS', name: 'Tides of Chaos', classes: ['SORCERER'], subclass: 'WILD_MAGIC', from: 3, max: () => 1, rest: 'LONG' },
|
||||
|
||||
{ key: 'FEY_PRESENCE', name: 'Fey Presence', classes: ['WARLOCK'], subclass: 'ARCHFEY', from: 3, max: () => 1, rest: 'SHORT' },
|
||||
{ key: 'MISTY_ESCAPE', name: 'Misty Escape', classes: ['WARLOCK'], subclass: 'ARCHFEY', from: 6, max: () => 1, rest: 'SHORT' },
|
||||
{ key: 'PATRONS_LUCK', name: 'Patron’s Luck', classes: ['WARLOCK'], subclass: 'FIEND', from: 6, max: () => 1, rest: 'SHORT' },
|
||||
{ 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: 'ILLUSORY_SELF', name: 'Illusory Self', classes: ['WIZARD'], subclass: 'ILLUSION', from: 10, max: () => 1, rest: 'SHORT' },
|
||||
];
|
||||
|
||||
// The counters a character has, with how many of each are spent and left:
|
||||
// [{ key, name, max, used, remaining, restoresOn }]. `used` is the stored
|
||||
// tally, which may name counters the character no longer has or hold more
|
||||
// than they now allow.
|
||||
function resourceState({ characterClass, subclass = null, level = 1, resourcesUsed = {} }, abilities, profBonus) {
|
||||
const mods = Object.fromEntries(Object.entries(abilities).map(([ability, detail]) => [ability, detail.modifier]));
|
||||
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)
|
||||
).map((resource) => {
|
||||
const max = resource.max(context);
|
||||
const used = Math.min(Math.max(Math.round(Number(tally[resource.key]) || 0), 0), max);
|
||||
return {
|
||||
key: resource.key,
|
||||
name: resource.name,
|
||||
max,
|
||||
used,
|
||||
remaining: max - used,
|
||||
restoresOn: typeof resource.rest === 'function' ? resource.rest(context) : resource.rest,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// The tally to store, from a character's counters: only what is spent.
|
||||
function resourceTally(resources) {
|
||||
return Object.fromEntries(resources.filter((resource) => resource.used > 0).map((resource) => [resource.key, resource.used]));
|
||||
}
|
||||
|
||||
// What a rest gives back: the tally afterwards, and the names of the counters
|
||||
// that were refilled. A long rest refills every counter; a short rest those
|
||||
// that restore on one.
|
||||
function restResources(resources, restType) {
|
||||
const refills = (resource) => resource.used > 0 && (restType === 'LONG' || resource.restoresOn === 'SHORT');
|
||||
return {
|
||||
resourcesUsed: resourceTally(resources.filter((resource) => !refills(resource))),
|
||||
restored: resources.filter(refills).map((resource) => resource.name),
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_EXHAUSTION = 6;
|
||||
|
||||
module.exports = { RESOURCES, resourceState, resourceTally, restResources, MAX_EXHAUSTION };
|
||||
@@ -0,0 +1,60 @@
|
||||
const { RESOURCES, resourceState, resourceTally, restResources } = require('./classResources');
|
||||
const { computeCharacterSheet } = require('./calculations');
|
||||
const { CLASSES } = require('./data/classes');
|
||||
const { findSubclass } = require('./data/subclasses');
|
||||
|
||||
const of = (character) => computeCharacterSheet({ level: 1, ...character }).resources;
|
||||
const named = (resources) => Object.fromEntries(resources.map((r) => [r.key, r.max]));
|
||||
|
||||
describe('class counters', () => {
|
||||
test('every counter belongs to a real class and, where it names one, a real subclass', () => {
|
||||
for (const resource of RESOURCES) {
|
||||
for (const cls of resource.classes) {
|
||||
expect(CLASSES[cls]).toBeDefined();
|
||||
if (resource.subclass) expect(findSubclass(cls, resource.subclass)).not.toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('come from class and level', () => {
|
||||
expect(named(of({ characterClass: 'BARBARIAN' }))).toEqual({ RAGE: 2 });
|
||||
expect(named(of({ characterClass: 'BARBARIAN', level: 6 }))).toEqual({ RAGE: 4 });
|
||||
expect(named(of({ characterClass: 'FIGHTER' }))).toEqual({ SECOND_WIND: 1 });
|
||||
expect(named(of({ characterClass: 'FIGHTER', level: 9 }))).toEqual({ SECOND_WIND: 1, ACTION_SURGE: 1, INDOMITABLE: 1 });
|
||||
expect(named(of({ characterClass: 'MONK' }))).toEqual({});
|
||||
expect(named(of({ characterClass: 'MONK', level: 4 }))).toEqual({ KI: 4 });
|
||||
expect(named(of({ characterClass: 'PALADIN', level: 3, chaScore: 10 }))).toEqual({ CHANNEL_DIVINITY: 1, LAY_ON_HANDS: 15, DIVINE_SENSE: 1 });
|
||||
expect(of({ characterClass: 'ROGUE', level: 10 })).toEqual([]);
|
||||
expect(of({ characterClass: null })).toEqual([]);
|
||||
});
|
||||
|
||||
test('some follow an ability modifier, never below one', () => {
|
||||
expect(named(of({ characterClass: 'BARD', chaScore: 16 })).BARDIC_INSPIRATION).toBe(3);
|
||||
expect(named(of({ characterClass: 'BARD', chaScore: 6 })).BARDIC_INSPIRATION).toBe(1);
|
||||
});
|
||||
|
||||
test('a subclass adds its own', () => {
|
||||
expect(named(of({ characterClass: 'FIGHTER', level: 3 })).SUPERIORITY_DICE).toBeUndefined();
|
||||
expect(named(of({ characterClass: 'FIGHTER', level: 3, subclass: 'BATTLE_MASTER' })).SUPERIORITY_DICE).toBe(4);
|
||||
expect(named(of({ characterClass: 'FIGHTER', level: 7, subclass: 'BATTLE_MASTER' })).SUPERIORITY_DICE).toBe(5);
|
||||
expect(named(of({ characterClass: 'DRUID', level: 2, subclass: 'LAND' }))).toEqual({ WILD_SHAPE: 2, NATURAL_RECOVERY: 1 });
|
||||
});
|
||||
|
||||
test('a bard\'s inspiration comes back on a short rest from level 5', () => {
|
||||
expect(of({ characterClass: 'BARD', level: 4 })[0].restoresOn).toBe('LONG');
|
||||
expect(of({ characterClass: 'BARD', level: 5 })[0].restoresOn).toBe('SHORT');
|
||||
});
|
||||
|
||||
test('the stored tally is squared up with what the character has', () => {
|
||||
const resources = of({ characterClass: 'MONK', level: 4, resourcesUsed: { KI: 9, RAGE: 1 } });
|
||||
expect(resources).toEqual([{ key: 'KI', name: 'Ki', max: 4, used: 4, remaining: 0, restoresOn: 'SHORT' }]);
|
||||
expect(resourceTally(resources)).toEqual({ KI: 4 });
|
||||
expect(resourceState({ characterClass: 'MONK', level: 4, resourcesUsed: null }, { WIS: { modifier: 0 } }, 2)[0].used).toBe(0);
|
||||
});
|
||||
|
||||
test('a short rest refills what restores on one; a long rest everything', () => {
|
||||
const resources = of({ characterClass: 'FIGHTER', level: 9, resourcesUsed: { SECOND_WIND: 1, INDOMITABLE: 1 } });
|
||||
expect(restResources(resources, 'SHORT')).toEqual({ resourcesUsed: { INDOMITABLE: 1 }, restored: ['Second Wind'] });
|
||||
expect(restResources(resources, 'LONG')).toEqual({ resourcesUsed: {}, restored: ['Second Wind', 'Indomitable'] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
// 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
|
||||
// adds is in subclasses.js. Level 1 features are in characterOptions.js and
|
||||
// are written by the builder.
|
||||
//
|
||||
// Each entry becomes a line of the character's special abilities, "Name —
|
||||
// text". An `upgrade` rewrites the line that already has that name (Sneak
|
||||
// Attack growing a die); anything else is added as a new line. `{ boon: true }`
|
||||
// is a Boon: the DM picks one from BOONS below.
|
||||
//
|
||||
// None of this feeds the calculation engine: like the level 1 features, these
|
||||
// are table rules in plain words.
|
||||
|
||||
const BOON = { boon: true };
|
||||
|
||||
const rage = (uses, damage) => ({
|
||||
name: 'Rage',
|
||||
text: `${uses} per day, for one minute: +${damage} melee damage, advantage on Strength checks and saves, and physical damage against you is halved.`,
|
||||
upgrade: true,
|
||||
});
|
||||
|
||||
const sneakAttack = (dice) => ({
|
||||
name: 'Sneak Attack',
|
||||
text: `Once per turn, deal an extra ${dice} damage to a target you have advantage against, or that has one of your allies next to it.`,
|
||||
upgrade: true,
|
||||
});
|
||||
|
||||
const bardicInspiration = (die) => ({
|
||||
name: 'Bardic Inspiration',
|
||||
text: `A few times per day, as a bonus action, give an ally a ${die} to add to one attack, check or save.`,
|
||||
upgrade: true,
|
||||
});
|
||||
|
||||
const wildShape = (limit) => ({
|
||||
name: 'Wild Shape',
|
||||
text: `Twice per short rest, become a beast you have seen of ${limit}, for up to half your level in hours. Use the beast's HP; when it hits 0 you return to your own form.`,
|
||||
upgrade: true,
|
||||
});
|
||||
|
||||
const EXTRA_ATTACK = { name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' };
|
||||
const EVASION = { name: 'Evasion', text: 'On a Dexterity save for half damage, take none on a success and half on a failure.' };
|
||||
const LANDS_STRIDE = { name: 'Land’s Stride', text: 'Ordinary difficult terrain and plants do not slow you.' };
|
||||
|
||||
const INVOCATIONS =
|
||||
'Agonizing Blast (add your Charisma modifier to each Eldritch Blast hit), Devil’s Sight (see normally in darkness, even magical darkness, to 120 ft), Repelling Blast (Eldritch Blast hits push the target 10 ft away), Mask of Many Faces (cast Disguise Self at will), Beguiling Influence (proficiency in Deception and Persuasion, added by hand), Eyes of the Rune Keeper (read any writing)';
|
||||
const invocations = (total) => ({
|
||||
name: 'Eldritch Invocations',
|
||||
text: `Choose one more with your DM (${total} in all). From level 5 the list adds Thirsting Blade (Pact of the Blade: attack twice with your pact weapon) and One with Shadows (become invisible while standing still in dim light or darkness).`,
|
||||
});
|
||||
|
||||
const LEVEL_FEATURES = {
|
||||
BARBARIAN: {
|
||||
2: [
|
||||
{ name: 'Reckless Attack', text: 'On your first attack each turn you may attack with advantage; attacks against you have advantage until your next turn.' },
|
||||
{ name: 'Danger Sense', text: 'Advantage on Dexterity saves against effects you can see (traps, breath, spells).' },
|
||||
],
|
||||
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).' }],
|
||||
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)],
|
||||
},
|
||||
BARD: {
|
||||
2: [
|
||||
{ name: 'Jack of All Trades', text: 'Add half your proficiency bonus (round down) to any check you are not proficient in.' },
|
||||
{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d6.' },
|
||||
],
|
||||
3: [
|
||||
{ name: 'Expertise', text: 'Double proficiency on two proficient skills (add by hand).' },
|
||||
],
|
||||
4: [BOON],
|
||||
5: [bardicInspiration('d8'), { name: 'Font of Inspiration', text: 'Regain Bardic Inspiration on a short rest.' }],
|
||||
6: [
|
||||
{ name: 'Countercharm', text: 'Action: allies within 30 ft get advantage on saves against fear and charm until your next turn.' },
|
||||
],
|
||||
8: [BOON],
|
||||
9: [{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d8.', upgrade: true }],
|
||||
10: [
|
||||
bardicInspiration('d10'),
|
||||
{ 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).' },
|
||||
],
|
||||
},
|
||||
CLERIC: {
|
||||
2: [
|
||||
{ name: 'Channel Divinity', text: 'Once per short rest, use Turn Undead or your domain’s Channel Divinity.' },
|
||||
{ name: 'Turn Undead', text: 'Channel Divinity. Action: undead within 30 ft make a Wisdom save or flee from you for 1 minute.' },
|
||||
],
|
||||
4: [BOON],
|
||||
5: [{ name: 'Destroy Undead', text: 'Undead of CR 1/2 or lower that fail against your Turn Undead are destroyed.' }],
|
||||
6: [
|
||||
{ name: 'Channel Divinity', text: 'Twice per short rest, use Turn Undead or your domain’s Channel Divinity.', upgrade: true },
|
||||
],
|
||||
8: [
|
||||
BOON,
|
||||
{ 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).' }],
|
||||
},
|
||||
DRUID: {
|
||||
2: [
|
||||
wildShape('CR 1/4 or less (no flying or swimming)'),
|
||||
],
|
||||
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)')],
|
||||
},
|
||||
FIGHTER: {
|
||||
2: [{ name: 'Action Surge', text: 'Once per short rest, take one extra action on your turn.' }],
|
||||
4: [BOON],
|
||||
5: [EXTRA_ATTACK],
|
||||
6: [BOON],
|
||||
8: [BOON],
|
||||
9: [{ name: 'Indomitable', text: 'Once per long rest, reroll a failed saving throw.' }],
|
||||
},
|
||||
MONK: {
|
||||
2: [
|
||||
{ name: 'Ki', text: 'Points equal to your level; they come back on a short rest. Ki save DC is 8 + proficiency bonus + Wisdom modifier.' },
|
||||
{ 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).' },
|
||||
],
|
||||
3: [
|
||||
{ name: 'Deflect Missiles', text: 'Reaction: reduce ranged weapon damage by 1d10 + Dexterity modifier + your level.' },
|
||||
],
|
||||
4: [BOON, { name: 'Slow Fall', text: 'Reaction: reduce falling damage by 5 × your level.' }],
|
||||
5: [
|
||||
EXTRA_ATTACK,
|
||||
{ name: 'Stunning Strike', text: '1 ki after a melee hit: the target makes a Constitution save or is stunned until your next turn.' },
|
||||
{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d6 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.', upgrade: true },
|
||||
],
|
||||
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 },
|
||||
],
|
||||
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 }],
|
||||
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 },
|
||||
],
|
||||
},
|
||||
PALADIN: {
|
||||
2: [
|
||||
{ name: 'Divine Smite', text: 'After a melee hit, spend a spell slot to deal +2d8 radiant (+1d8 per slot level above 1st; +1d8 more against undead and fiends).' },
|
||||
{ name: 'Fighting Style', text: 'Choose one with your DM: +1 AC in armor (the DM raises Extra AC by 1), +2 damage with a one-handed weapon, or protect an adjacent ally (impose disadvantage on an attack as a reaction).' },
|
||||
{ name: 'Spellcasting', text: 'Paladin spells unlock: you now have spell slots.' },
|
||||
],
|
||||
3: [
|
||||
{ name: 'Divine Health', text: 'Immune to disease.' },
|
||||
],
|
||||
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.' }],
|
||||
8: [BOON],
|
||||
10: [{ name: 'Aura of Courage', text: 'You and allies within 10 ft can’t be frightened.' }],
|
||||
},
|
||||
RANGER: {
|
||||
2: [
|
||||
{ name: 'Fighting Style', text: 'Choose one with your DM: +2 to ranged attacks, +1 AC in armor (the DM raises Extra AC by 1), or add your ability modifier again to a paired weapon’s damage (added by hand).' },
|
||||
{ name: 'Spellcasting', text: 'Ranger spells unlock: you now have spell slots.' },
|
||||
],
|
||||
3: [
|
||||
{ name: 'Primeval Awareness', text: 'Spend a spell slot: sense aberrations, dragons, fey, fiends, undead and elementals within 1 mile for 1 minute per slot level.' },
|
||||
],
|
||||
4: [BOON],
|
||||
5: [EXTRA_ATTACK],
|
||||
6: [
|
||||
{ name: 'Favored Enemy', text: 'Choose a second favored enemy with your DM.' },
|
||||
{ name: 'Natural Explorer', text: 'Choose a second favored terrain with your DM.' },
|
||||
],
|
||||
8: [BOON, LANDS_STRIDE],
|
||||
10: [
|
||||
{ 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.' },
|
||||
],
|
||||
},
|
||||
ROGUE: {
|
||||
2: [{ name: 'Cunning Action', text: 'Bonus action: Dash, Disengage or Hide.' }],
|
||||
3: [sneakAttack('2d6')],
|
||||
4: [BOON],
|
||||
5: [sneakAttack('3d6'), { name: 'Uncanny Dodge', text: 'Reaction: halve the damage of an attack that hits you.' }],
|
||||
6: [{ name: 'Expertise', text: 'Double proficiency on two more proficient skills (add by hand).' }],
|
||||
7: [sneakAttack('4d6'), EVASION],
|
||||
8: [BOON],
|
||||
9: [sneakAttack('5d6')],
|
||||
10: [BOON],
|
||||
},
|
||||
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.' }],
|
||||
3: [{ name: 'Metamagic', text: 'Choose two with your DM: Quickened Spell (2 points: cast an action spell as a bonus action), Twinned Spell (points = spell level: hit a second target with a single-target spell), Careful Spell (1 point: allies auto-succeed on your spell’s save), Subtle Spell (1 point: cast without words or gestures), Extended Spell (1 point: double duration), Heightened Spell (3 points: one target has disadvantage on its first save).' }],
|
||||
4: [BOON],
|
||||
8: [BOON],
|
||||
10: [{ name: 'Metamagic', text: 'Choose a third option with your DM.' }],
|
||||
},
|
||||
WARLOCK: {
|
||||
2: [{ name: 'Eldritch Invocations', text: `Choose two with your DM: ${INVOCATIONS}.` }],
|
||||
3: [{ name: 'Pact Boon', text: 'Choose one with your DM: Pact of the Blade (summon a weapon of your choice as an action; it counts as magical), Pact of the Chain (gain a small familiar spirit: an imp, a sprite or a cat-shaped shadow), or Pact of the Tome (a book of three extra cantrips from any class, which the DM grants).' }],
|
||||
4: [BOON],
|
||||
5: [invocations('three')],
|
||||
7: [invocations('four')],
|
||||
8: [BOON],
|
||||
9: [invocations('five')],
|
||||
},
|
||||
WIZARD: {
|
||||
4: [BOON],
|
||||
8: [BOON],
|
||||
},
|
||||
};
|
||||
|
||||
// The class tables above stop here; a higher level still raises the numbers.
|
||||
const MAX_FEATURE_LEVEL = 10;
|
||||
|
||||
// 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.
|
||||
const CANTRIP_LEVEL = 5;
|
||||
const CANTRIP_FEATURE = {
|
||||
name: 'Cantrips',
|
||||
text: 'Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll).',
|
||||
};
|
||||
|
||||
// Boons stand in for ability score increases, which the site does not have
|
||||
// (every number on the sheet comes from the scores rolled at the build). Each
|
||||
// is worth what +2 to a best ability would be. `max` is how often one can be
|
||||
// taken; `per` is what each taking adds, and `text` the line for that many.
|
||||
const BOONS = [
|
||||
{
|
||||
key: 'WEAPON_MASTERY',
|
||||
name: 'Weapon Mastery',
|
||||
max: 2,
|
||||
per: 1,
|
||||
text: (amount) => `+${amount} to attack and damage rolls with weapons (add it to the numbers the site shows).`,
|
||||
},
|
||||
{
|
||||
key: 'ARCANE_FOCUS',
|
||||
name: 'Arcane Focus',
|
||||
max: 2,
|
||||
per: 1,
|
||||
text: (amount) => `+${amount} to spell save DC and spell attack bonus (add it to the numbers the site shows).`,
|
||||
},
|
||||
// AC is the most powerful number in the game, so this one is taken once.
|
||||
{ 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.` },
|
||||
];
|
||||
|
||||
module.exports = { LEVEL_FEATURES, MAX_FEATURE_LEVEL, CANTRIP_LEVEL, CANTRIP_FEATURE, BOONS };
|
||||
@@ -0,0 +1,92 @@
|
||||
// Spell slots by class and character level: the standard 5e tables, as the
|
||||
// Hollowcrown book's Slot Tracker prints them for levels 1 to 10. Each row is
|
||||
// how many slots there are of each slot level, 1st first.
|
||||
|
||||
// Bard, Cleric, Druid, Sorcerer, Wizard.
|
||||
const FULL_CASTER_SLOTS = [
|
||||
[2],
|
||||
[3],
|
||||
[4, 2],
|
||||
[4, 3],
|
||||
[4, 3, 2],
|
||||
[4, 3, 3],
|
||||
[4, 3, 3, 1],
|
||||
[4, 3, 3, 2],
|
||||
[4, 3, 3, 3, 1],
|
||||
[4, 3, 3, 3, 2],
|
||||
[4, 3, 3, 3, 2, 1],
|
||||
[4, 3, 3, 3, 2, 1],
|
||||
[4, 3, 3, 3, 2, 1, 1],
|
||||
[4, 3, 3, 3, 2, 1, 1],
|
||||
[4, 3, 3, 3, 2, 1, 1, 1],
|
||||
[4, 3, 3, 3, 2, 1, 1, 1],
|
||||
[4, 3, 3, 3, 2, 1, 1, 1, 1],
|
||||
[4, 3, 3, 3, 3, 1, 1, 1, 1],
|
||||
[4, 3, 3, 3, 3, 2, 1, 1, 1],
|
||||
[4, 3, 3, 3, 3, 2, 2, 1, 1],
|
||||
];
|
||||
|
||||
// Paladin, Ranger: nothing at level 1, then half a full caster's pace.
|
||||
const HALF_CASTER_SLOTS = [
|
||||
[],
|
||||
[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],
|
||||
];
|
||||
|
||||
// Warlock: a few slots, all of one slot level. [how many, which level].
|
||||
const PACT_SLOTS = [
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
[2, 2],
|
||||
[2, 2],
|
||||
[2, 3],
|
||||
[2, 3],
|
||||
[2, 4],
|
||||
[2, 4],
|
||||
[2, 5],
|
||||
[2, 5],
|
||||
[3, 5],
|
||||
[3, 5],
|
||||
[3, 5],
|
||||
[3, 5],
|
||||
[3, 5],
|
||||
[3, 5],
|
||||
[4, 5],
|
||||
[4, 5],
|
||||
[4, 5],
|
||||
[4, 5],
|
||||
];
|
||||
|
||||
// FULL and HALF casters get their slots back on a long rest; a warlock's
|
||||
// PACT slots come back on a short rest too.
|
||||
const CASTER_KIND = {
|
||||
BARD: 'FULL',
|
||||
CLERIC: 'FULL',
|
||||
DRUID: 'FULL',
|
||||
SORCERER: 'FULL',
|
||||
WIZARD: 'FULL',
|
||||
PALADIN: 'HALF',
|
||||
RANGER: 'HALF',
|
||||
WARLOCK: 'PACT',
|
||||
};
|
||||
|
||||
const MAX_SLOT_LEVEL = 9;
|
||||
|
||||
module.exports = { FULL_CASTER_SLOTS, HALF_CASTER_SLOTS, PACT_SLOTS, CASTER_KIND, MAX_SLOT_LEVEL };
|
||||
@@ -0,0 +1,501 @@
|
||||
// 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,51 @@
|
||||
// Experience points: what a monster is worth by challenge rating, how hard a
|
||||
// fight is for a party, and the XP each level takes. The 5e (2014) tables.
|
||||
|
||||
// XP for defeating one monster of a challenge rating.
|
||||
const CR_XP = {
|
||||
0: 10, '1/8': 25, '1/4': 50, '1/2': 100,
|
||||
1: 200, 2: 450, 3: 700, 4: 1100, 5: 1800, 6: 2300, 7: 2900, 8: 3900, 9: 5000, 10: 5900,
|
||||
11: 7200, 12: 8400, 13: 10000, 14: 11500, 15: 13000, 16: 15000, 17: 18000, 18: 20000, 19: 22000, 20: 25000,
|
||||
21: 33000, 22: 41000, 23: 50000, 24: 62000, 25: 75000, 26: 90000, 27: 105000, 28: 120000, 29: 135000, 30: 155000,
|
||||
};
|
||||
|
||||
// What one character of a level can take on: [easy, medium, hard, deadly],
|
||||
// level 1 first. A party's thresholds are its members' added up.
|
||||
const XP_THRESHOLDS = [
|
||||
[25, 50, 75, 100],
|
||||
[50, 100, 150, 200],
|
||||
[75, 150, 225, 400],
|
||||
[125, 250, 375, 500],
|
||||
[250, 500, 750, 1100],
|
||||
[300, 600, 900, 1400],
|
||||
[350, 750, 1100, 1700],
|
||||
[450, 900, 1400, 2100],
|
||||
[550, 1100, 1600, 2400],
|
||||
[600, 1200, 1900, 2800],
|
||||
[800, 1600, 2400, 3600],
|
||||
[1000, 2000, 3000, 4500],
|
||||
[1100, 2200, 3400, 5100],
|
||||
[1250, 2500, 3800, 5700],
|
||||
[1400, 2800, 4300, 6400],
|
||||
[1600, 3200, 4800, 7200],
|
||||
[2000, 3900, 5900, 8800],
|
||||
[2100, 4200, 6300, 9500],
|
||||
[2400, 4900, 7300, 10900],
|
||||
[2800, 5700, 8500, 12700],
|
||||
];
|
||||
|
||||
// Many monsters are harder than their XP says: the total is multiplied by
|
||||
// this to judge the fight (never to award it). [at least this many, multiplier].
|
||||
const COUNT_MULTIPLIERS = [
|
||||
[15, 4],
|
||||
[11, 3],
|
||||
[7, 2.5],
|
||||
[3, 2],
|
||||
[2, 1.5],
|
||||
[1, 1],
|
||||
];
|
||||
|
||||
// Total XP at which each level is reached, level 1 first.
|
||||
const LEVEL_XP = [0, 300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, 85000, 100000, 120000, 140000, 165000, 195000, 225000, 265000, 305000, 355000];
|
||||
|
||||
module.exports = { CR_XP, XP_THRESHOLDS, COUNT_MULTIPLIERS, LEVEL_XP };
|
||||
@@ -0,0 +1,250 @@
|
||||
// Levelling up: the Hollowcrown book's "Levelling-Up Checklist" (Rules Cheat
|
||||
// Sheet). Works out what a character gains at a level, and rewrites their
|
||||
// special abilities to say so. Pure: no DB calls.
|
||||
|
||||
const { LEVEL_FEATURES, MAX_FEATURE_LEVEL, CANTRIP_LEVEL, CANTRIP_FEATURE, BOONS } = require('./data/levelFeatures');
|
||||
const { SUBCLASS_LEVEL, subclassesOf, findSubclass, defaultSubclass } = require('./data/subclasses');
|
||||
const { CLASS_FEATURES } = require('./data/characterOptions');
|
||||
const { CASTER_KIND } = require('./data/spellSlots');
|
||||
const { getClassSpellcastingAbility, maxSpellLevel, scaledDamageDice, MAX_GEAR_LEVEL } = require('./calculations');
|
||||
|
||||
// A choice that cannot be made: the message is for whoever made it.
|
||||
class LevelUpError extends Error {}
|
||||
|
||||
// What a class gains on reaching `level`, in the order it is written down:
|
||||
// the class's own features, then those of the subclass (its key) when one is
|
||||
// given. An entry is { name, text, upgrade? } or { boon: true }.
|
||||
function featuresAt(characterClass, level, subclassKey = null) {
|
||||
const features = [...((LEVEL_FEATURES[characterClass] || {})[level] || [])];
|
||||
const subclass = findSubclass(characterClass, subclassKey);
|
||||
if (subclass) features.push(...(subclass.features[level] || []));
|
||||
if (level === CANTRIP_LEVEL && getClassSpellcastingAbility(characterClass)) features.push(CANTRIP_FEATURE);
|
||||
return features;
|
||||
}
|
||||
|
||||
// Special abilities are one per line, "Name — what it does". The builder
|
||||
// writes a long dash; a DM typing by hand may use "--".
|
||||
const featureLine = (feature) => `${feature.name} — ${feature.text}`;
|
||||
|
||||
function lineName(line) {
|
||||
const match = /^\s*(.+?)\s*(?:—|–|--)\s/.exec(line);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
const toLines = (specialAbilities) => (specialAbilities ? specialAbilities.split(/\r?\n/) : []);
|
||||
const fromLines = (lines) => lines.join('\n') || null;
|
||||
const findLine = (lines, name) => lines.findIndex((line) => lineName(line) === name.toLowerCase());
|
||||
|
||||
// Writes features into the text. An upgrade takes the place of the line with
|
||||
// its name (Sneak Attack growing a die); anything else goes on the end, unless
|
||||
// that very line is there already.
|
||||
function addFeatures(specialAbilities, features) {
|
||||
const lines = toLines(specialAbilities);
|
||||
for (const feature of features) {
|
||||
const line = featureLine(feature);
|
||||
const at = feature.upgrade ? findLine(lines, feature.name) : -1;
|
||||
if (at >= 0) lines[at] = line;
|
||||
else if (!lines.includes(line)) lines.push(line);
|
||||
}
|
||||
return fromLines(lines);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subclasses (data/subclasses.js). A class chooses one at SUBCLASS_LEVEL. Until
|
||||
// it has, a line on the sheet says the choice is owed.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SUBCLASS_OWED = 'Subclass';
|
||||
|
||||
// Whether a class at this level should have a subclass.
|
||||
function owesSubclass(characterClass, level) {
|
||||
return subclassesOf(characterClass).length > 0 && level >= SUBCLASS_LEVEL[characterClass];
|
||||
}
|
||||
|
||||
const subclassOwedFeature = (characterClass) => ({
|
||||
name: SUBCLASS_OWED,
|
||||
text: `Choose one with your DM: ${subclassesOf(characterClass).map((subclass) => subclass.name).join(', ')}.`,
|
||||
});
|
||||
|
||||
// Everything a subclass has given by `level`, in the order it came.
|
||||
function subclassFeaturesThrough(subclass, level) {
|
||||
return Object.keys(subclass.features)
|
||||
.map(Number)
|
||||
.filter((at) => at <= level)
|
||||
.sort((a, b) => a - b)
|
||||
.flatMap((at) => subclass.features[at]);
|
||||
}
|
||||
|
||||
// Writes a newly chosen subclass onto the sheet: everything it has given up
|
||||
// to the character's level, in place of the line saying a choice is owed.
|
||||
function withSubclass(specialAbilities, characterClass, level, subclassKey) {
|
||||
const subclass = findSubclass(characterClass, subclassKey);
|
||||
if (!subclass) throw new LevelUpError('That is not a subclass of this class.');
|
||||
const lines = toLines(specialAbilities).filter((line) => lineName(line) !== SUBCLASS_OWED.toLowerCase());
|
||||
return addFeatures(fromLines(lines), subclassFeaturesThrough(subclass, level));
|
||||
}
|
||||
|
||||
// Takes a subclass's lines back off the sheet, found by their names. A line
|
||||
// the class itself also writes under that name is left alone.
|
||||
function withoutSubclass(specialAbilities, characterClass, subclassKey) {
|
||||
const subclass = findSubclass(characterClass, subclassKey);
|
||||
if (!subclass) return specialAbilities;
|
||||
const classNames = new Set(
|
||||
[...(CLASS_FEATURES[characterClass] || []), ...Object.values(LEVEL_FEATURES[characterClass] || {}).flat()]
|
||||
.filter((feature) => feature.name)
|
||||
.map((feature) => feature.name.toLowerCase())
|
||||
);
|
||||
const names = new Set(
|
||||
Object.values(subclass.features)
|
||||
.flat()
|
||||
.map((feature) => feature.name.toLowerCase())
|
||||
.filter((name) => !classNames.has(name))
|
||||
);
|
||||
return fromLines(toLines(specialAbilities).filter((line) => !names.has(lineName(line))));
|
||||
}
|
||||
|
||||
// The DM changing (or setting, or clearing) a character's subclass after the
|
||||
// fact: the old one's lines go, the new one's are written.
|
||||
function switchSubclass(specialAbilities, characterClass, level, fromKey, toKey) {
|
||||
const cleared = withoutSubclass(specialAbilities, characterClass, fromKey);
|
||||
if (toKey) return withSubclass(cleared, characterClass, level, toKey);
|
||||
return owesSubclass(characterClass, level) ? addFeatures(cleared, [subclassOwedFeature(characterClass)]) : cleared;
|
||||
}
|
||||
|
||||
// The subclasses a character may pick from on reaching `level`, each with
|
||||
// what it would write; null when there is no choice to make (too low a level,
|
||||
// or one is chosen already).
|
||||
function subclassChoice(characterClass, level, currentKey) {
|
||||
if (!owesSubclass(characterClass, level) || findSubclass(characterClass, currentKey)) return null;
|
||||
return {
|
||||
level: SUBCLASS_LEVEL[characterClass],
|
||||
options: subclassesOf(characterClass).map((subclass) => ({
|
||||
key: subclass.key,
|
||||
name: subclass.name,
|
||||
summary: subclass.summary,
|
||||
features: subclassFeaturesThrough(subclass, level).map(({ name, text }) => ({ name, text })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// How many times a Boon has been taken, read off its line ("Hardy — +10
|
||||
// maximum HP…" is twice).
|
||||
function boonTaken(specialAbilities, boon) {
|
||||
const lines = toLines(specialAbilities);
|
||||
const at = findLine(lines, boon.name);
|
||||
if (at < 0) return 0;
|
||||
const amount = /\+\s*(\d+)/.exec(lines[at]);
|
||||
return Math.max(1, Math.round((amount ? Number(amount[1]) : boon.per) / boon.per));
|
||||
}
|
||||
|
||||
// Every Boon, with what taking it now would write and whether it still can be.
|
||||
function boonOptions(specialAbilities) {
|
||||
return BOONS.map((boon) => {
|
||||
const taken = boonTaken(specialAbilities, boon);
|
||||
return { key: boon.key, name: boon.name, taken, max: boon.max, available: taken < boon.max, text: boon.text((Math.min(taken, boon.max - 1) + 1) * boon.per) };
|
||||
});
|
||||
}
|
||||
|
||||
// Temp HP the Hardy Boon gives after each long rest; 0 without it.
|
||||
function hardyTempHp(specialAbilities) {
|
||||
const hardy = BOONS.find((boon) => boon.key === 'HARDY');
|
||||
return boonTaken(specialAbilities, hardy) * hardy.per;
|
||||
}
|
||||
|
||||
// What reaching `level` does to a character's special abilities. `boonKey` is
|
||||
// the Boon picked, at a level that grants one; without it the line says a
|
||||
// choice is still owed. `subclass` is { key, chosen }: the character's
|
||||
// subclass, and whether it is being picked at this very level (then everything
|
||||
// it has given so far is written). Without one, from the level the class
|
||||
// chooses at, a line says that choice is owed too. Also returns what the Boon
|
||||
// does beyond the text: how much Extra AC it adds, and the Temp HP it gives
|
||||
// straight away.
|
||||
function advance(specialAbilities, characterClass, level, boonKey, { key: subclassKey = null, chosen = false } = {}) {
|
||||
const entries = featuresAt(characterClass, level, chosen ? null : subclassKey);
|
||||
let text = addFeatures(specialAbilities, entries.filter((entry) => !entry.boon));
|
||||
if (chosen) text = withSubclass(text, characterClass, level, subclassKey);
|
||||
else if (!findSubclass(characterClass, subclassKey) && owesSubclass(characterClass, level)) text = addFeatures(text, [subclassOwedFeature(characterClass)]);
|
||||
const result = { armorBonus: 0, tempHp: 0 };
|
||||
|
||||
if (entries.some((entry) => entry.boon)) {
|
||||
if (!boonKey) {
|
||||
text = addFeatures(text, [{ name: `Boon (level ${level})`, text: `Choose one with your DM: ${BOONS.map((boon) => boon.name).join(', ')}.` }]);
|
||||
} else {
|
||||
const boon = BOONS.find((b) => b.key === boonKey);
|
||||
if (!boon) throw new LevelUpError('That is not one of the Boons.');
|
||||
const taken = boonTaken(text, boon);
|
||||
if (taken >= boon.max) {
|
||||
throw new LevelUpError(`${boon.name} can be taken ${boon.max === 1 ? 'only once' : `at most ${boon.max} times`}. Pick a different Boon.`);
|
||||
}
|
||||
const amount = (taken + 1) * boon.per;
|
||||
text = addFeatures(text, [{ name: boon.name, text: boon.text(amount), upgrade: true }]);
|
||||
if (boon.key === 'TOUGHENED') result.armorBonus = 1;
|
||||
if (boon.key === 'HARDY') result.tempHp = amount;
|
||||
}
|
||||
}
|
||||
return { specialAbilities: text, ...result };
|
||||
}
|
||||
|
||||
// The book's unlock schedule: what a caster may be granted on reaching
|
||||
// `level`. Null for a class with no spells.
|
||||
function spellsGainedAt(characterClass, level) {
|
||||
const kind = CASTER_KIND[characterClass];
|
||||
if (!kind) return null;
|
||||
const highest = maxSpellLevel(characterClass, level);
|
||||
// Half casters: two spells at level 2, then one at each odd level.
|
||||
const halfCasterSpells = level === 2 ? 2 : level > 2 && level % 2 === 1 ? 1 : 0;
|
||||
return {
|
||||
maxSpellLevel: highest,
|
||||
// Set when this level opens up a new level of spells.
|
||||
unlocksSpellLevel: highest > maxSpellLevel(characterClass, level - 1) ? highest : null,
|
||||
newSpells: kind === 'HALF' ? halfCasterSpells : characterClass === 'WIZARD' ? 2 : 1,
|
||||
newCantrips: kind !== 'HALF' && (level === 4 || level === 10) ? 1 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
// In a campaign with subclasses switched off nobody chooses: on reaching the
|
||||
// level their class would choose at, a character is put on the class's first
|
||||
// subclass. Returns that subclass's key when this is the moment, else null.
|
||||
function subclassByDefault(characterClass, level, currentKey) {
|
||||
if (!owesSubclass(characterClass, level) || findSubclass(characterClass, currentKey)) return null;
|
||||
return defaultSubclass(characterClass).key;
|
||||
}
|
||||
|
||||
// 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 } = {}) {
|
||||
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) : []),
|
||||
];
|
||||
return {
|
||||
fromLevel: character.level,
|
||||
toLevel,
|
||||
// Set when a subclass has to be picked on the way to this level.
|
||||
subclass: subclasses ? subclassChoice(character.characterClass, toLevel, character.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),
|
||||
// The free upgrade: one weapon of standard growth goes up a level, never
|
||||
// past its holder.
|
||||
weapons: (character.weapons || [])
|
||||
.filter((cw) => cw.weapon.damageScaling === 1 && cw.level < Math.min(toLevel, MAX_GEAR_LEVEL))
|
||||
.map((cw) => ({
|
||||
weaponId: cw.weaponId,
|
||||
name: cw.weapon.name,
|
||||
equipped: cw.equipped,
|
||||
level: cw.level,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { LevelUpError, featuresAt, owesSubclass, subclassChoice, subclassByDefault, switchSubclass, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
|
||||
@@ -0,0 +1,234 @@
|
||||
const { featuresAt, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan, switchSubclass, LevelUpError } = require('./levelUp');
|
||||
const { SUBCLASSES, SUBCLASS_LEVEL } = require('./data/subclasses');
|
||||
const { LEVEL_FEATURES, BOONS } = require('./data/levelFeatures');
|
||||
const { CLASSES } = require('./data/classes');
|
||||
const { composeSpecialAbilities } = require('./buildFlow');
|
||||
|
||||
const boon = (key) => BOONS.find((b) => b.key === key);
|
||||
const CHAMPION = { key: 'CHAMPION' };
|
||||
|
||||
describe('the class tables', () => {
|
||||
test('cover every class from level 2 to 10, with a Boon at 4 and 8', () => {
|
||||
for (const cls of Object.keys(CLASSES)) {
|
||||
expect(LEVEL_FEATURES[cls]).toBeDefined();
|
||||
for (const level of [4, 8]) expect(featuresAt(cls, level).some((f) => f.boon)).toBe(true);
|
||||
for (const level of Object.keys(LEVEL_FEATURES[cls])) expect(Number(level)).toBeGreaterThanOrEqual(2);
|
||||
}
|
||||
// Fighters get a third Boon at 6, rogues at 10.
|
||||
expect(featuresAt('FIGHTER', 6).some((f) => f.boon)).toBe(true);
|
||||
expect(featuresAt('ROGUE', 10).some((f) => f.boon)).toBe(true);
|
||||
expect(featuresAt('WIZARD', 6).some((f) => f.boon)).toBe(false);
|
||||
});
|
||||
|
||||
test('every feature has a name and a text', () => {
|
||||
for (const levels of Object.values(LEVEL_FEATURES)) {
|
||||
for (const features of Object.values(levels)) {
|
||||
for (const f of features) expect(f.boon || (f.name && f.text)).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('casters are told at level 5 that cantrips double; others are not', () => {
|
||||
expect(featuresAt('WIZARD', 5).map((f) => f.name)).toContain('Cantrips');
|
||||
expect(featuresAt('PALADIN', 5).map((f) => f.name)).toEqual(['Extra Attack', 'Cantrips']);
|
||||
expect(featuresAt('FIGHTER', 5).map((f) => f.name)).toEqual(['Extra Attack']);
|
||||
});
|
||||
|
||||
test('nothing is written past level 10', () => {
|
||||
expect(featuresAt('FIGHTER', 11)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addFeatures', () => {
|
||||
test('adds a new feature as a line of its own, once', () => {
|
||||
const once = addFeatures('Second Wind — heal.', [{ name: 'Action Surge', text: 'One extra action.' }]);
|
||||
expect(once).toBe('Second Wind — heal.\nAction Surge — One extra action.');
|
||||
expect(addFeatures(once, [{ name: 'Action Surge', text: 'One extra action.' }])).toBe(once);
|
||||
});
|
||||
|
||||
test('an upgrade takes the place of the line with its name, however the dash was typed', () => {
|
||||
const upgrade = { name: 'Sneak Attack', text: '2d6.', upgrade: true };
|
||||
expect(addFeatures('Sneak Attack — 1d6.\nThieves’ Cant — slang.', [upgrade])).toBe('Sneak Attack — 2d6.\nThieves’ Cant — slang.');
|
||||
expect(addFeatures('sneak attack -- 1d6.', [upgrade])).toBe('Sneak Attack — 2d6.');
|
||||
// With no line to rewrite it is simply added.
|
||||
expect(addFeatures(null, [upgrade])).toBe('Sneak Attack — 2d6.');
|
||||
});
|
||||
|
||||
test('a feature that is not an upgrade leaves a line of the same name alone', () => {
|
||||
const text = addFeatures('Favored Enemy — Undead.', [{ name: 'Favored Enemy', text: 'Choose a second.' }]);
|
||||
expect(text).toBe('Favored Enemy — Undead.\nFavored Enemy — Choose a second.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('advance', () => {
|
||||
test('a rogue reaching level 3 has a bigger Sneak Attack and two new features', () => {
|
||||
const level1 = composeSpecialAbilities('HUMAN', 'ROGUE', []);
|
||||
const { specialAbilities } = advance(advance(level1, 'ROGUE', 2).specialAbilities, 'ROGUE', 3, undefined, { key: 'THIEF', chosen: true });
|
||||
const lines = specialAbilities.split('\n');
|
||||
expect(lines.filter((l) => l.startsWith('Sneak Attack'))).toEqual([expect.stringContaining('2d6')]);
|
||||
expect(lines.some((l) => l.startsWith('Cunning Action — '))).toBe(true);
|
||||
expect(lines.some((l) => l.startsWith('Fast Hands — '))).toBe(true);
|
||||
expect(lines.some((l) => l.startsWith('Versatile — '))).toBe(true); // what was there stays
|
||||
});
|
||||
|
||||
test('a Boon is written down, and says what it does beyond the text', () => {
|
||||
expect(advance(null, 'FIGHTER', 4, 'TOUGHENED', CHAMPION)).toEqual({ specialAbilities: 'Toughened — +1 AC (counted in your Extra AC).', armorBonus: 1, tempHp: 0 });
|
||||
const hardy = advance(null, 'FIGHTER', 4, 'HARDY', CHAMPION);
|
||||
expect(hardy).toMatchObject({ armorBonus: 0, tempHp: 5 });
|
||||
// Taken again, its one line grows.
|
||||
const twice = advance(hardy.specialAbilities, 'FIGHTER', 6, 'HARDY', CHAMPION);
|
||||
expect(twice.specialAbilities).toBe('Hardy — +10 maximum HP: gain 10 Temp HP after each long rest.');
|
||||
expect(twice.tempHp).toBe(10);
|
||||
expect(hardyTempHp(twice.specialAbilities)).toBe(10);
|
||||
});
|
||||
|
||||
test('a Boon cannot be taken more often than it allows', () => {
|
||||
const toughened = advance(null, 'FIGHTER', 4, 'TOUGHENED', CHAMPION).specialAbilities;
|
||||
expect(() => advance(toughened, 'FIGHTER', 6, 'TOUGHENED')).toThrow(LevelUpError);
|
||||
const mastery = advance(advance(null, 'FIGHTER', 4, 'WEAPON_MASTERY').specialAbilities, 'FIGHTER', 6, 'WEAPON_MASTERY').specialAbilities;
|
||||
expect(boonTaken(mastery, boon('WEAPON_MASTERY'))).toBe(2);
|
||||
expect(() => advance(mastery, 'FIGHTER', 8, 'WEAPON_MASTERY')).toThrow(LevelUpError);
|
||||
expect(() => advance(null, 'FIGHTER', 4, 'FLIGHT')).toThrow(LevelUpError);
|
||||
expect(boonOptions(mastery).find((o) => o.key === 'WEAPON_MASTERY').available).toBe(false);
|
||||
expect(boonOptions(mastery).find((o) => o.key === 'HARDY')).toMatchObject({ available: true, taken: 0 });
|
||||
});
|
||||
|
||||
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(armorBonus).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('spellsGainedAt', () => {
|
||||
test('follows the unlock schedule', () => {
|
||||
expect(spellsGainedAt('FIGHTER', 3)).toBeNull();
|
||||
expect(spellsGainedAt('WIZARD', 3)).toEqual({ maxSpellLevel: 2, unlocksSpellLevel: 2, newSpells: 2, newCantrips: 0 });
|
||||
expect(spellsGainedAt('CLERIC', 4)).toEqual({ maxSpellLevel: 2, unlocksSpellLevel: null, newSpells: 1, newCantrips: 1 });
|
||||
expect(spellsGainedAt('WARLOCK', 5)).toMatchObject({ maxSpellLevel: 3, unlocksSpellLevel: 3, newSpells: 1 });
|
||||
// Half casters: two spells at level 2, then one at each odd level.
|
||||
expect(spellsGainedAt('PALADIN', 2)).toEqual({ maxSpellLevel: 1, unlocksSpellLevel: 1, newSpells: 2, newCantrips: 0 });
|
||||
expect(spellsGainedAt('RANGER', 4)).toMatchObject({ newSpells: 0, newCantrips: 0 });
|
||||
expect(spellsGainedAt('RANGER', 5)).toMatchObject({ maxSpellLevel: 2, unlocksSpellLevel: 2, newSpells: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('levelUpPlan', () => {
|
||||
const held = (over) => ({ weaponId: 'w', equipped: true, level: 1, weapon: { name: 'Longsword', damageDice: '1d8', damageScaling: 1, minLevel: 1 }, ...over });
|
||||
|
||||
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);
|
||||
// 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' }]);
|
||||
});
|
||||
|
||||
test('has no Boon to pick at a level that grants none', () => {
|
||||
const plan = levelUpPlan({ level: 1, characterClass: 'FIGHTER', specialAbilities: null, weapons: [] });
|
||||
expect(plan.boon).toBeNull();
|
||||
expect(plan.subclass).toBeNull();
|
||||
expect(plan.features.map((f) => f.name)).toEqual(['Action Surge']);
|
||||
});
|
||||
|
||||
test('asks for a subclass at the level the class chooses, and whenever one is still owed', () => {
|
||||
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[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);
|
||||
// Still owed later on: the options then carry everything missed so far.
|
||||
const late = levelUpPlan({ ...fighter, level: 6 });
|
||||
expect(late.subclass.options[0].features.map((f) => f.name)).toEqual(['Improved Critical', 'Remarkable Athlete']);
|
||||
// With one chosen there is nothing to ask, and its features are in the list.
|
||||
const chosen = levelUpPlan({ ...fighter, level: 6, subclass: 'CHAMPION' });
|
||||
expect(chosen.subclass).toBeNull();
|
||||
expect(chosen.features.map((f) => f.name)).toEqual(['Remarkable Athlete']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a campaign without subclasses', () => {
|
||||
const fighter = { characterClass: 'FIGHTER', specialAbilities: null, weapons: [] };
|
||||
|
||||
test('asks for nothing: the class takes its first subclass at the level it would have chosen', () => {
|
||||
const plan = levelUpPlan({ ...fighter, level: 2 }, { subclasses: false });
|
||||
expect(plan.subclass).toBeNull();
|
||||
expect(plan.subclassTaken).toBe('CHAMPION');
|
||||
expect(plan.features.map((f) => f.name)).toEqual(['Improved Critical']);
|
||||
// Before that level, and once it is taken, there is nothing to take.
|
||||
expect(levelUpPlan({ ...fighter, level: 1 }, { subclasses: false }).subclassTaken).toBeNull();
|
||||
const later = levelUpPlan({ ...fighter, level: 6, subclass: 'CHAMPION' }, { subclasses: false });
|
||||
expect(later.subclassTaken).toBeNull();
|
||||
expect(later.features.map((f) => f.name)).toEqual(['Remarkable Athlete']);
|
||||
});
|
||||
|
||||
test('a character who joined above that level gets everything missed so far', () => {
|
||||
const plan = levelUpPlan({ ...fighter, level: 6 }, { subclasses: false });
|
||||
expect(plan.subclassTaken).toBe('CHAMPION');
|
||||
expect(plan.features.map((f) => f.name)).toEqual(['Improved Critical', 'Remarkable Athlete']);
|
||||
});
|
||||
|
||||
test('with subclasses on, the plan takes none by default', () => {
|
||||
expect(levelUpPlan({ ...fighter, level: 2 }).subclassTaken).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('subclasses', () => {
|
||||
test('every class has three, each with a summary and features from its choice level to 10', () => {
|
||||
for (const cls of Object.keys(CLASSES)) {
|
||||
expect(SUBCLASSES[cls]).toHaveLength(3);
|
||||
for (const subclass of SUBCLASSES[cls]) {
|
||||
expect(subclass.key && subclass.name && subclass.summary).toBeTruthy();
|
||||
const levels = Object.keys(subclass.features).map(Number);
|
||||
expect(Math.min(...levels)).toBe(SUBCLASS_LEVEL[cls]);
|
||||
expect(Math.max(...levels)).toBeLessThanOrEqual(10);
|
||||
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
|
||||
}
|
||||
}
|
||||
expect(new Set(SUBCLASSES[cls].map((s) => s.key)).size).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
test('a subclass adds its features to the class\'s at each level', () => {
|
||||
expect(featuresAt('FIGHTER', 3).map((f) => f.name)).toEqual([]);
|
||||
expect(featuresAt('FIGHTER', 3, 'CHAMPION').map((f) => f.name)).toEqual(['Improved Critical']);
|
||||
expect(featuresAt('CLERIC', 8, 'WAR').map((f) => f.boon || f.name)).toEqual([true, 'Destroy Undead', 'Divine Strike']);
|
||||
expect(featuresAt('FIGHTER', 3, 'LIFE')).toEqual([]); // another class's subclass counts for nothing
|
||||
});
|
||||
|
||||
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.');
|
||||
// Said once, however many levels go by.
|
||||
expect(advance(owed, 'FIGHTER', 5).specialAbilities.split('\n').filter((l) => l.startsWith('Subclass — '))).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('picking one late writes everything it has given so far, and clears the line', () => {
|
||||
const owed = advance(advance(null, 'FIGHTER', 3).specialAbilities, 'FIGHTER', 5).specialAbilities;
|
||||
const { specialAbilities } = advance(owed, 'FIGHTER', 7, undefined, { key: 'BATTLE_MASTER', chosen: true });
|
||||
const lines = specialAbilities.split('\n');
|
||||
expect(lines.some((l) => l.startsWith('Subclass — '))).toBe(false);
|
||||
// The level 7 upgrade has taken the place of the level 3 line.
|
||||
expect(lines.filter((l) => l.startsWith('Combat Superiority — '))).toEqual([expect.stringContaining('Five superiority dice (d8)')]);
|
||||
expect(lines.filter((l) => l.startsWith('Manoeuvres — '))).toHaveLength(2);
|
||||
expect(lines.some((l) => l.startsWith('Know Your Enemy — '))).toBe(true);
|
||||
expect(() => advance(owed, 'FIGHTER', 7, undefined, { key: 'LIFE', chosen: true })).toThrow(LevelUpError);
|
||||
});
|
||||
|
||||
test('switching takes the old subclass\'s lines off and writes the new one\'s', () => {
|
||||
const champion = advance(advance('Second Wind — heal.', 'FIGHTER', 3, undefined, { key: 'CHAMPION', chosen: true }).specialAbilities, 'FIGHTER', 5, undefined, CHAMPION).specialAbilities;
|
||||
expect(champion.split('\n').map((l) => l.split(' — ')[0])).toEqual(['Second Wind', 'Improved Critical', 'Extra Attack']);
|
||||
const samurai = switchSubclass(champion, 'FIGHTER', 5, 'CHAMPION', 'SAMURAI');
|
||||
expect(samurai.split('\n').map((l) => l.split(' — ')[0])).toEqual(['Second Wind', 'Extra Attack', 'Fighting Spirit', 'Bonus Proficiency']);
|
||||
// Cleared altogether, the choice is owed again.
|
||||
expect(switchSubclass(samurai, 'FIGHTER', 5, 'SAMURAI', null).split('\n').map((l) => l.split(' — ')[0])).toEqual(['Second Wind', 'Extra Attack', 'Subclass']);
|
||||
// A line the class also writes under the same name is not taken away.
|
||||
const valor = advance('Extra Attack — mine.', 'BARD', 6, undefined, { key: 'VALOR', chosen: true }).specialAbilities;
|
||||
expect(switchSubclass(valor, 'BARD', 6, 'VALOR', 'LORE')).not.toContain('Combat Inspiration');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
const { spellSlotsFor, spellSlotState, maxSpellLevel, hitDiceState, computeCharacterSheet } = require('./calculations');
|
||||
|
||||
describe('spellSlotsFor', () => {
|
||||
test('full casters follow the standard table', () => {
|
||||
expect(spellSlotsFor('WIZARD', 1)).toEqual({ kind: 'FULL', restoresOn: 'LONG', max: [2] });
|
||||
expect(spellSlotsFor('CLERIC', 3).max).toEqual([4, 2]);
|
||||
expect(spellSlotsFor('BARD', 5).max).toEqual([4, 3, 2]);
|
||||
expect(spellSlotsFor('DRUID', 9).max).toEqual([4, 3, 3, 3, 1]);
|
||||
expect(spellSlotsFor('SORCERER', 10).max).toEqual([4, 3, 3, 3, 2]);
|
||||
expect(spellSlotsFor('WIZARD', 20).max).toEqual([4, 3, 3, 3, 3, 2, 2, 1, 1]);
|
||||
});
|
||||
|
||||
test('half casters start at level 2', () => {
|
||||
expect(spellSlotsFor('PALADIN', 1).max).toEqual([]);
|
||||
expect(spellSlotsFor('PALADIN', 2).max).toEqual([2]);
|
||||
expect(spellSlotsFor('RANGER', 4).max).toEqual([3]);
|
||||
expect(spellSlotsFor('RANGER', 5).max).toEqual([4, 2]);
|
||||
expect(spellSlotsFor('PALADIN', 10).max).toEqual([4, 3, 2]);
|
||||
});
|
||||
|
||||
test('a warlock has a few slots, all of one level, back on a short rest', () => {
|
||||
expect(spellSlotsFor('WARLOCK', 1)).toEqual({ kind: 'PACT', restoresOn: 'SHORT', max: [1] });
|
||||
expect(spellSlotsFor('WARLOCK', 2).max).toEqual([2]);
|
||||
expect(spellSlotsFor('WARLOCK', 3).max).toEqual([0, 2]);
|
||||
expect(spellSlotsFor('WARLOCK', 9).max).toEqual([0, 0, 0, 0, 2]);
|
||||
});
|
||||
|
||||
test('classes without spells have none', () => {
|
||||
for (const cls of ['FIGHTER', 'BARBARIAN', 'ROGUE', 'MONK', null]) expect(spellSlotsFor(cls, 10)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('spellSlotState', () => {
|
||||
test('counts what is spent against what there is', () => {
|
||||
expect(spellSlotState('WIZARD', 3, [1, 2]).levels).toEqual([
|
||||
{ level: 1, max: 4, used: 1, remaining: 3 },
|
||||
{ level: 2, max: 2, used: 2, remaining: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('never counts more spent than the class has, or a level it has lost', () => {
|
||||
expect(spellSlotState('WIZARD', 1, [9, 2]).levels).toEqual([{ level: 1, max: 2, used: 2, remaining: 0 }]);
|
||||
});
|
||||
|
||||
test('lists only the slot levels a warlock actually has', () => {
|
||||
expect(spellSlotState('WARLOCK', 5, [0, 0, 1])).toEqual({ kind: 'PACT', restoresOn: 'SHORT', levels: [{ level: 3, max: 2, used: 1, remaining: 1 }] });
|
||||
});
|
||||
|
||||
test('is null where there is nothing to track', () => {
|
||||
expect(spellSlotState('FIGHTER', 5, [])).toBeNull();
|
||||
expect(spellSlotState('PALADIN', 1, [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxSpellLevel', () => {
|
||||
test('is the highest slot level, as in the unlock schedule', () => {
|
||||
expect([1, 3, 5, 7, 9].map((level) => maxSpellLevel('WIZARD', level))).toEqual([1, 2, 3, 4, 5]);
|
||||
expect([1, 3, 5, 7, 9].map((level) => maxSpellLevel('WARLOCK', level))).toEqual([1, 2, 3, 4, 5]);
|
||||
expect([1, 2, 5, 9].map((level) => maxSpellLevel('PALADIN', level))).toEqual([0, 1, 2, 3]);
|
||||
expect(maxSpellLevel('ROGUE', 9)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hitDiceState', () => {
|
||||
test('one die per level, of the class size, less what is spent', () => {
|
||||
expect(hitDiceState('BARBARIAN', 4, 1)).toEqual({ size: 12, count: 4, used: 1, remaining: 3 });
|
||||
expect(hitDiceState('WIZARD', 2, 9)).toEqual({ size: 6, count: 2, used: 2, remaining: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the sheet', () => {
|
||||
test('carries the slot tracker and the Hit Dice', () => {
|
||||
const sheet = computeCharacterSheet({ characterClass: 'CLERIC', level: 3, spellSlotsUsed: [1], hitDiceUsed: 2 });
|
||||
expect(sheet.spellSlots.levels.map((s) => s.remaining)).toEqual([3, 2]);
|
||||
expect(sheet.hitDice).toEqual({ size: 8, count: 3, used: 2, remaining: 1 });
|
||||
expect(computeCharacterSheet({ characterClass: 'ROGUE', level: 3 }).spellSlots).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const { challengeXp, encounterDifficulty, xpProgress } = require('./calculations');
|
||||
const { CR_XP, XP_THRESHOLDS, LEVEL_XP } = require('./data/xp');
|
||||
const { MONSTER_LIBRARY } = require('./data/library');
|
||||
|
||||
describe('challengeXp', () => {
|
||||
test('reads a rating however it was typed', () => {
|
||||
expect(challengeXp('0')).toBe(10);
|
||||
expect(challengeXp('1/8')).toBe(25);
|
||||
expect(challengeXp(' 1/4 ')).toBe(50);
|
||||
expect(challengeXp('0.5')).toBe(100);
|
||||
expect(challengeXp('CR 3')).toBe(700);
|
||||
expect(challengeXp(5)).toBe(1800);
|
||||
expect(challengeXp('30')).toBe(155000);
|
||||
});
|
||||
|
||||
test('is null for what is not a rating', () => {
|
||||
for (const text of [null, undefined, '', 'boss', '31', '1/3', '-1']) expect(challengeXp(text)).toBeNull();
|
||||
});
|
||||
|
||||
test('every monster in the built-in bestiary has a rating it can read', () => {
|
||||
for (const monster of MONSTER_LIBRARY) expect(challengeXp(monster.challengeRating)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the tables', () => {
|
||||
test('cover every rating from 0 to 30 and every level from 1 to 20, always rising', () => {
|
||||
expect(Object.keys(CR_XP)).toHaveLength(34);
|
||||
expect(XP_THRESHOLDS).toHaveLength(20);
|
||||
expect(LEVEL_XP).toHaveLength(20);
|
||||
for (let level = 1; level < 20; level++) {
|
||||
expect(LEVEL_XP[level]).toBeGreaterThan(LEVEL_XP[level - 1]);
|
||||
for (let i = 0; i < 4; i++) expect(XP_THRESHOLDS[level][i]).toBeGreaterThanOrEqual(XP_THRESHOLDS[level - 1][i]);
|
||||
}
|
||||
for (const [easy, medium, hard, deadly] of XP_THRESHOLDS) expect(easy < medium && medium < hard && hard < deadly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('encounterDifficulty', () => {
|
||||
const party = [1, 1, 1, 1]; // easy 100, medium 200, hard 300, deadly 400
|
||||
|
||||
test('adds up the party\'s thresholds and the monsters\' XP', () => {
|
||||
const one = encounterDifficulty(party, ['1/2']);
|
||||
expect(one).toEqual({ xp: 100, adjustedXp: 100, multiplier: 1, rating: 'EASY', thresholds: { easy: 100, medium: 200, hard: 300, deadly: 400 }, monsters: 1, unrated: 0 });
|
||||
expect(encounterDifficulty([3, 5], []).thresholds).toEqual({ easy: 325, medium: 650, hard: 975, deadly: 1500 });
|
||||
});
|
||||
|
||||
test('more monsters make a harder fight than their XP alone', () => {
|
||||
expect(encounterDifficulty(party, ['1/8'])).toMatchObject({ xp: 25, adjustedXp: 25, rating: 'TRIVIAL' });
|
||||
expect(encounterDifficulty(party, ['1/4', '1/4'])).toMatchObject({ xp: 100, multiplier: 1.5, adjustedXp: 150, rating: 'EASY' });
|
||||
expect(encounterDifficulty(party, ['1/4', '1/4', '1/4'])).toMatchObject({ xp: 150, multiplier: 2, adjustedXp: 300, rating: 'HARD' });
|
||||
expect(encounterDifficulty(party, ['1/4', '1/4', '1/4', '1/4'])).toMatchObject({ xp: 200, adjustedXp: 400, rating: 'DEADLY' });
|
||||
expect(encounterDifficulty(party, Array(7).fill('0')).multiplier).toBe(2.5);
|
||||
expect(encounterDifficulty(party, Array(11).fill('0')).multiplier).toBe(3);
|
||||
expect(encounterDifficulty(party, Array(15).fill('0')).multiplier).toBe(4);
|
||||
});
|
||||
|
||||
test('leaves monsters with no rating out, and says how many', () => {
|
||||
expect(encounterDifficulty(party, ['1', 'boss', null])).toMatchObject({ xp: 200, adjustedXp: 200, monsters: 1, unrated: 2, rating: 'MEDIUM' });
|
||||
});
|
||||
|
||||
test('has no rating without a party, or with nothing rated to fight', () => {
|
||||
expect(encounterDifficulty([], ['1'])).toMatchObject({ xp: 200, rating: null, thresholds: null });
|
||||
expect(encounterDifficulty(party, [])).toMatchObject({ xp: 0, rating: null });
|
||||
expect(encounterDifficulty(party, ['boss'])).toMatchObject({ rating: null, unrated: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('xpProgress', () => {
|
||||
test('says what the next level takes and whether it is due', () => {
|
||||
expect(xpProgress(0, 1)).toEqual({ nextLevelAt: 300, levelUpDue: false });
|
||||
expect(xpProgress(299, 1)).toEqual({ nextLevelAt: 300, levelUpDue: false });
|
||||
expect(xpProgress(300, 1)).toEqual({ nextLevelAt: 300, levelUpDue: true });
|
||||
expect(xpProgress(300, 2)).toEqual({ nextLevelAt: 900, levelUpDue: false });
|
||||
expect(xpProgress(999999, 20)).toEqual({ nextLevelAt: null, levelUpDue: false });
|
||||
});
|
||||
});
|
||||
@@ -1623,6 +1623,38 @@ describe('encounter combat info', () => {
|
||||
expect(participant.characterCombatInfo.weapons[0].name).toBe('Dagger');
|
||||
expect(typeof participant.characterCombatInfo.weapons[0].attackBonus).toBe('number');
|
||||
});
|
||||
|
||||
itIfDb('sends saves, skills and passive scores with a combatant, in the character panel and on the party list', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('glance');
|
||||
const characterId = await buildCharacter(campaign.id, playerAuth, 'Glance');
|
||||
const { sheet } = (await request(app).get(`/characters/${characterId}`).set(dmAuth)).body.character;
|
||||
|
||||
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Glance' })).body.encounter.id;
|
||||
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId });
|
||||
const info = (await request(app).get(`/encounters/${encounterId}`).set(dmAuth)).body.encounter.participants[0].characterCombatInfo;
|
||||
expect(info.skills).toEqual(sheet.skills);
|
||||
expect(info.saves).toEqual(sheet.saves);
|
||||
expect(info.passiveInvestigation).toBe(10 + sheet.skills.INVESTIGATION.bonus);
|
||||
expect(info.passiveInsight).toBe(10 + sheet.skills.INSIGHT.bonus);
|
||||
|
||||
const quick = (await request(app).get(`/characters/${characterId}/quick-stats`).set(dmAuth)).body.quickStats;
|
||||
expect(quick.skills).toEqual(sheet.skills);
|
||||
expect(quick.saves).toEqual(sheet.saves);
|
||||
expect(quick.passiveInsight).toBe(sheet.passiveInsight);
|
||||
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters.find((c) => c.id === characterId);
|
||||
expect(listed.passives).toEqual({ perception: sheet.passivePerception, investigation: sheet.passiveInvestigation, insight: sheet.passiveInsight });
|
||||
expect(listed.spellSaveDC).toBe(sheet.spellcasting ? sheet.spellcasting.saveDC : null);
|
||||
// And what each rolls with, for a check called for from the whole party.
|
||||
expect(listed.checks).toEqual({ abilities: sheet.abilities, saves: sheet.saves, skills: sheet.skills });
|
||||
|
||||
// A held weapon says what its holder rolls with it.
|
||||
const weaponId = (await request(app).post(`/campaigns/${campaign.id}/weapons/from-library`).set(dmAuth).send({ key: 'dagger' })).body.weapon.id;
|
||||
await request(app).post(`/characters/${characterId}/weapons`).set(dmAuth).send({ weaponId });
|
||||
const held = (await request(app).get(`/characters/${characterId}`).set(dmAuth)).body.character.weapons[0];
|
||||
const finesse = Math.max(sheet.abilities.STR.modifier, sheet.abilities.DEX.modifier);
|
||||
expect(held).toMatchObject({ attackBonus: finesse + sheet.proficiencyBonus, damageBonus: finesse });
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing a password', () => {
|
||||
@@ -1718,13 +1750,31 @@ describe('ordering, archiving and deleting encounters', () => {
|
||||
}
|
||||
const names = async (auth) => (await request(app).get(`${c}/encounters`).set(auth)).body.encounters.map((e) => e.name);
|
||||
|
||||
// New encounters go to the top.
|
||||
expect(await names(dmAuth)).toEqual(['Three', 'Two', 'One']);
|
||||
// New encounters go to the bottom.
|
||||
expect(await names(dmAuth)).toEqual(['One', 'Two', 'Three']);
|
||||
|
||||
const order = (encounterIds, auth = dmAuth) => request(app).put(`${c}/encounters/order`).set(auth).send({ encounterIds });
|
||||
expect((await order([ids.One, ids.Two, ids.Three], playerAuth)).status).toBe(403);
|
||||
expect((await order([ids.Three, ids.Two, ids.One], playerAuth)).status).toBe(403);
|
||||
expect((await order([ids.One, ids.One])).status).toBe(400);
|
||||
expect((await order([ids.One, 'not-an-encounter'])).status).toBe(400);
|
||||
expect((await order([ids.Three, ids.One, ids.Two])).status).toBe(204);
|
||||
expect(await names(playerAuth)).toEqual(['Three', 'One', 'Two']);
|
||||
|
||||
// A new one, of either kind, still lands at the bottom of a rearranged list,
|
||||
// unless the DM says where: the top, or straight after another.
|
||||
const add = async (body) => (await request(app).post(`${c}/encounters`).set(dmAuth).send(body)).body.encounter.id;
|
||||
const extra = [await add({ name: 'Four', mode: 'QUEST' })];
|
||||
expect(await names(dmAuth)).toEqual(['Three', 'One', 'Two', 'Four']);
|
||||
extra.push(await add({ name: 'First', position: 'TOP' }));
|
||||
expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'Two', 'Four']);
|
||||
extra.push(await add({ name: 'After One', mode: 'QUEST', afterEncounterId: ids.One }));
|
||||
expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'After One', 'Two', 'Four']);
|
||||
extra.push(await add({ name: 'Last', position: 'BOTTOM' }));
|
||||
extra.push(await add({ name: 'After Last', position: 'TOP', afterEncounterId: extra[3] }));
|
||||
expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'After One', 'Two', 'Four', 'Last', 'After Last']);
|
||||
expect((await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Lost', afterEncounterId: 'not-an-encounter' })).status).toBe(400);
|
||||
expect((await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Lost', position: 'MIDDLE' })).status).toBe(400);
|
||||
for (const id of extra) await request(app).delete(`/encounters/${id}`).set(dmAuth);
|
||||
expect((await order([ids.One, ids.Two, ids.Three])).status).toBe(204);
|
||||
expect(await names(playerAuth)).toEqual(['One', 'Two', 'Three']);
|
||||
|
||||
@@ -2813,3 +2863,596 @@ describe('a campaign from a world seed', () => {
|
||||
expect((await request(app).get('/campaigns').set(auth)).body.campaigns).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// A built character's class is whatever the dice offered. These tests need a
|
||||
// particular one, at a particular level, so it is set straight on the row.
|
||||
async function buildAs(campaignId, auth, name, characterClass, level = 1) {
|
||||
const characterId = await buildCharacter(campaignId, auth, name);
|
||||
await prisma.character.update({ where: { id: characterId }, data: { characterClass, level, conScore: 14, specialAbilities: null } });
|
||||
return characterId;
|
||||
}
|
||||
|
||||
describe('spell slots', () => {
|
||||
itIfDb('come from class and level; the player spends one, only the DM gives one back', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('slots');
|
||||
const characterId = await buildAs(campaign.id, playerAuth, 'Merla', 'WIZARD', 3);
|
||||
const slots = (auth, body) => request(app).post(`/characters/${characterId}/spell-slots`).set(auth).send(body);
|
||||
const levels = (res) => res.body.character.sheet.spellSlots.levels;
|
||||
|
||||
const sheet = (await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.sheet;
|
||||
expect(sheet.spellSlots).toMatchObject({ kind: 'FULL', restoresOn: 'LONG' });
|
||||
expect(sheet.spellSlots.levels).toEqual([
|
||||
{ level: 1, max: 4, used: 0, remaining: 4 },
|
||||
{ level: 2, max: 2, used: 0, remaining: 2 },
|
||||
]);
|
||||
|
||||
// The player casts: one slot at a time.
|
||||
const cast = await slots(playerAuth, { action: 'use', level: 2 });
|
||||
expect(cast.status).toBe(200);
|
||||
expect(levels(cast)[1]).toEqual({ level: 2, max: 2, used: 1, remaining: 1 });
|
||||
expect((await slots(playerAuth, { action: 'use', level: 2 })).status).toBe(200);
|
||||
const none = await slots(playerAuth, { action: 'use', level: 2 });
|
||||
expect(none.status).toBe(400);
|
||||
expect(none.body.error).toMatch(/no 2nd-level spell slots left/);
|
||||
// A slot level the character does not have.
|
||||
expect((await slots(playerAuth, { action: 'use', level: 3 })).status).toBe(400);
|
||||
expect((await slots(playerAuth, { action: 'use', level: 0 })).status).toBe(400);
|
||||
|
||||
// Getting one back is the DM's.
|
||||
expect((await slots(playerAuth, { action: 'set', level: 2, used: 0 })).status).toBe(403);
|
||||
expect((await slots(playerAuth, { action: 'restore' })).status).toBe(403);
|
||||
const back = await slots(dmAuth, { action: 'set', level: 2, used: 1 });
|
||||
expect(levels(back)[1].remaining).toBe(1);
|
||||
// The DM can spend one for the player too, and never sets more than there are.
|
||||
expect(levels(await slots(dmAuth, { action: 'use', level: 1 }))[0].used).toBe(1);
|
||||
expect(levels(await slots(dmAuth, { action: 'set', level: 1, used: 9 }))[0].used).toBe(4);
|
||||
expect(levels(await slots(dmAuth, { action: 'restore' })).map((s) => s.used)).toEqual([0, 0]);
|
||||
|
||||
// The party list and the character panel show them too.
|
||||
await slots(playerAuth, { action: 'use', level: 1 });
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters.find((c) => c.id === characterId);
|
||||
expect(listed.spellSlots.levels[0].remaining).toBe(3);
|
||||
expect(listed.hitDice).toMatchObject({ size: 6, count: 3, remaining: 3, conModifier: expect.any(Number) });
|
||||
const quick = (await request(app).get(`/characters/${characterId}/quick-stats`).set(playerAuth)).body.quickStats;
|
||||
expect(quick.spellSlots.levels[0].remaining).toBe(3);
|
||||
|
||||
// Dropping a level takes the slots it gave with it, spent ones included.
|
||||
await slots(playerAuth, { action: 'use', level: 2 });
|
||||
const lower = (await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ level: 1 })).body.character;
|
||||
expect(lower.sheet.spellSlots.levels).toEqual([{ level: 1, max: 2, used: 1, remaining: 1 }]);
|
||||
const higher = (await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ level: 5 })).body.character;
|
||||
expect(higher.sheet.spellSlots.levels.map((s) => [s.max, s.used])).toEqual([[4, 1], [3, 0], [2, 0]]);
|
||||
});
|
||||
|
||||
itIfDb('are not there for a class without spells', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('noslots');
|
||||
const characterId = await buildAs(campaign.id, playerAuth, 'Brakk', 'FIGHTER', 5);
|
||||
expect((await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.sheet.spellSlots).toBeNull();
|
||||
const res = await request(app).post(`/characters/${characterId}/spell-slots`).set(dmAuth).send({ action: 'use', level: 1 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/no spell slots/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rests', () => {
|
||||
itIfDb('a long rest gives back HP, spell slots and half the Hit Dice', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('longrest');
|
||||
const wizardId = await buildAs(campaign.id, playerAuth, 'Merla', 'WIZARD', 4);
|
||||
const fighterId = await buildAs(campaign.id, dmAuth, 'Brakk', 'FIGHTER', 4);
|
||||
const get = async (id) => (await request(app).get(`/characters/${id}`).set(dmAuth)).body.character;
|
||||
|
||||
await request(app).patch(`/characters/${wizardId}`).set(dmAuth).send({ currentHp: 2, tempHp: 3, hitDiceUsed: 4 });
|
||||
for (const level of [1, 1, 2]) await request(app).post(`/characters/${wizardId}/spell-slots`).set(playerAuth).send({ action: 'use', level });
|
||||
await request(app).patch(`/characters/${fighterId}`).set(dmAuth).send({ currentHp: 5 });
|
||||
|
||||
const rest = (body) => request(app).post(`/campaigns/${campaign.id}/rest`).set(dmAuth).send(body);
|
||||
// Only the DM calls a rest, and only for this campaign's characters.
|
||||
expect((await request(app).post(`/campaigns/${campaign.id}/rest`).set(playerAuth).send({ type: 'LONG', characters: [{ characterId: wizardId }] })).status).toBe(403);
|
||||
expect((await rest({ type: 'LONG', characters: [{ characterId: 'nobody' }] })).status).toBe(400);
|
||||
expect((await rest({ type: 'NAP', characters: [{ characterId: wizardId }] })).status).toBe(400);
|
||||
|
||||
// Only the wizard rests.
|
||||
const res = await rest({ type: 'LONG', characters: [{ characterId: wizardId }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toHaveLength(1);
|
||||
const wizard = await get(wizardId);
|
||||
expect(res.body.results[0]).toMatchObject({ characterId: wizardId, name: 'Merla', skipped: null, hpBefore: 2, hpAfter: wizard.sheet.hp.max, slotsRestored: true });
|
||||
expect(res.body.results[0].hitDice).toMatchObject({ regained: 2, left: 2, of: 4 });
|
||||
expect(wizard.currentHp).toBe(wizard.sheet.hp.max);
|
||||
expect(wizard.tempHp).toBe(0);
|
||||
expect(wizard.sheet.hitDice).toMatchObject({ used: 2, remaining: 2 });
|
||||
expect(wizard.sheet.spellSlots.levels.every((s) => s.used === 0)).toBe(true);
|
||||
expect((await get(fighterId)).currentHp).toBe(5);
|
||||
|
||||
// The dead get nothing from a rest.
|
||||
await request(app).patch(`/characters/${fighterId}`).set(dmAuth).send({ currentHp: -999 });
|
||||
const both = await rest({ type: 'LONG', characters: [{ characterId: wizardId }, { characterId: fighterId }] });
|
||||
expect(both.body.results.find((r) => r.characterId === fighterId).skipped).toMatch(/dead/);
|
||||
expect((await get(fighterId)).deathSaves.state).toBe('DEAD');
|
||||
});
|
||||
|
||||
itIfDb('a short rest spends Hit Dice to heal, and gives a warlock their slots', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('shortrest');
|
||||
const fighterId = await buildAs(campaign.id, playerAuth, 'Brakk', 'FIGHTER', 4);
|
||||
const warlockId = await buildAs(campaign.id, dmAuth, 'Vex', 'WARLOCK', 4);
|
||||
const get = async (id) => (await request(app).get(`/characters/${id}`).set(dmAuth)).body.character;
|
||||
const rest = (characters) => request(app).post(`/campaigns/${campaign.id}/rest`).set(dmAuth).send({ type: 'SHORT', characters });
|
||||
|
||||
await request(app).patch(`/characters/${fighterId}`).set(dmAuth).send({ currentHp: 1 });
|
||||
await request(app).patch(`/characters/${warlockId}`).set(dmAuth).send({ currentHp: 1 });
|
||||
await request(app).post(`/characters/${warlockId}/spell-slots`).set(dmAuth).send({ action: 'use', level: 2 });
|
||||
const con = (await get(fighterId)).sheet.abilities.CON.modifier;
|
||||
|
||||
// More dice than there are, or a total the dice cannot show: nothing is written for anyone.
|
||||
expect((await rest([{ characterId: warlockId }, { characterId: fighterId, hitDice: 5, rolled: 20 }])).status).toBe(400);
|
||||
expect((await rest([{ characterId: fighterId, hitDice: 2, rolled: 21 }])).status).toBe(400);
|
||||
expect((await get(warlockId)).sheet.spellSlots.levels[0].used).toBe(1);
|
||||
|
||||
// Two d10s rolled at the table for 9; the warlock spends none.
|
||||
const res = await rest([{ characterId: fighterId, hitDice: 2, rolled: 9 }, { characterId: warlockId }]);
|
||||
expect(res.status).toBe(200);
|
||||
const fighterResult = res.body.results.find((r) => r.characterId === fighterId);
|
||||
expect(fighterResult).toMatchObject({ hpBefore: 1, hpAfter: 1 + 9 + 2 * con, slotsRestored: false });
|
||||
expect(fighterResult.hitDice).toMatchObject({ size: 10, spent: 2, total: 9, conBonus: 2 * con, left: 2, of: 4, rolls: null });
|
||||
const fighter = await get(fighterId);
|
||||
expect(fighter.currentHp).toBe(1 + 9 + 2 * con);
|
||||
expect(fighter.sheet.hitDice).toMatchObject({ used: 2, remaining: 2 });
|
||||
|
||||
const warlockResult = res.body.results.find((r) => r.characterId === warlockId);
|
||||
expect(warlockResult).toMatchObject({ hpBefore: 1, hpAfter: 1, slotsRestored: true });
|
||||
expect((await get(warlockId)).sheet.spellSlots.levels[0].used).toBe(0);
|
||||
|
||||
// With no total given the site rolls the dice itself.
|
||||
const rolled = (await rest([{ characterId: fighterId, hitDice: 1 }])).body.results[0];
|
||||
expect(rolled.hitDice.rolls).toHaveLength(1);
|
||||
expect(rolled.hitDice.rolls[0]).toBeGreaterThanOrEqual(1);
|
||||
expect(rolled.hitDice.rolls[0]).toBeLessThanOrEqual(10);
|
||||
expect(rolled.hitDice.left).toBe(1);
|
||||
expect((await get(fighterId)).currentHp).toBe(Math.min(fighter.sheet.hp.max, fighter.currentHp + rolled.hitDice.rolls[0] + con));
|
||||
});
|
||||
});
|
||||
|
||||
describe('levelling up', () => {
|
||||
itIfDb('raises the level, heals to the new maximum and writes down the new features', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('levelup');
|
||||
const characterId = await buildAs(campaign.id, playerAuth, 'Brakk', 'FIGHTER');
|
||||
await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ currentHp: 1 });
|
||||
const levelUp = (auth, body = {}) => request(app).post(`/characters/${characterId}/level-up`).set(auth).send(body);
|
||||
|
||||
// Levelling is the DM's, and so is looking at what it brings.
|
||||
expect((await levelUp(playerAuth)).status).toBe(403);
|
||||
expect((await request(app).get(`/characters/${characterId}/level-up`).set(playerAuth)).status).toBe(403);
|
||||
|
||||
const plan = (await request(app).get(`/characters/${characterId}/level-up`).set(dmAuth)).body.plan;
|
||||
expect(plan).toMatchObject({ fromLevel: 1, toLevel: 2, boon: null, spells: null, spellSlots: null });
|
||||
expect(plan.features.map((f) => f.name)).toEqual(['Action Surge']);
|
||||
expect(plan.maxHp.to).toBeGreaterThan(plan.maxHp.from);
|
||||
|
||||
const res = await levelUp(dmAuth);
|
||||
expect(res.status).toBe(200);
|
||||
const character = res.body.character;
|
||||
expect(character.level).toBe(2);
|
||||
expect(character.sheet.hp.max).toBe(plan.maxHp.to);
|
||||
expect(character.currentHp).toBe(plan.maxHp.to);
|
||||
expect(character.specialAbilities).toBe('Action Surge — Once per short rest, take one extra action on your turn.');
|
||||
|
||||
// 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((await levelUp(dmAuth)).status).toBe(400);
|
||||
expect((await levelUp(dmAuth, { subclass: 'LIFE' })).status).toBe(400);
|
||||
const champion = (await levelUp(dmAuth, { subclass: 'CHAMPION' })).body.character;
|
||||
expect(champion).toMatchObject({ level: 3, subclass: 'CHAMPION', subclassName: 'Champion' });
|
||||
|
||||
// Level 4 grants a Boon, which has to be picked. There is no second subclass to pick.
|
||||
expect((await levelUp(dmAuth, { boon: 'TOUGHENED', subclass: 'SAMURAI' })).status).toBe(400);
|
||||
expect((await levelUp(dmAuth)).status).toBe(400);
|
||||
expect((await levelUp(dmAuth, { boon: 'FLIGHT' })).status).toBe(400);
|
||||
const toughened = (await levelUp(dmAuth, { boon: 'TOUGHENED' })).body.character;
|
||||
expect(toughened.level).toBe(4);
|
||||
expect(toughened.armorBonus).toBe(character.armorBonus + 1);
|
||||
expect(toughened.specialAbilities).toMatch(/^Toughened — \+1 AC/m);
|
||||
expect(toughened.specialAbilities).toMatch(/^Improved Critical — /m);
|
||||
|
||||
// Level 5 has no Boon to give; level 6 (a fighter's third) will not give Toughened twice.
|
||||
expect((await levelUp(dmAuth, { boon: 'HARDY' })).status).toBe(400);
|
||||
await levelUp(dmAuth);
|
||||
const again = await levelUp(dmAuth, { boon: 'TOUGHENED' });
|
||||
expect(again.status).toBe(400);
|
||||
expect(again.body.error).toMatch(/only once/);
|
||||
const hardy = (await levelUp(dmAuth, { boon: 'HARDY' })).body.character;
|
||||
expect(hardy.level).toBe(6);
|
||||
expect(hardy.tempHp).toBe(5);
|
||||
|
||||
// A long rest takes temp HP away, but Hardy's comes back.
|
||||
await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ tempHp: 0 });
|
||||
const rested = await request(app).post(`/campaigns/${campaign.id}/rest`).set(dmAuth).send({ type: 'LONG', characters: [{ characterId }] });
|
||||
expect(rested.body.results[0].tempHp).toBe(5);
|
||||
});
|
||||
|
||||
itIfDb('gives the free weapon upgrade, new spells and fresh slots', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('levelgear');
|
||||
const characterId = await buildAs(campaign.id, playerAuth, 'Merla', 'WIZARD');
|
||||
const weaponId = (await request(app).post(`/campaigns/${campaign.id}/weapons/from-library`).set(dmAuth).send({ key: 'dagger' })).body.weapon.id;
|
||||
await request(app).post(`/characters/${characterId}/weapons`).set(dmAuth).send({ weaponId });
|
||||
const spell = (name, level) => request(app).post(`/campaigns/${campaign.id}/spells`).set(dmAuth).send({ name, level });
|
||||
const boltId = (await spell('Test Bolt', 1)).body.spell.id;
|
||||
const wardId = (await spell('Test Ward', 1)).body.spell.id;
|
||||
await request(app).post(`/characters/${characterId}/spells`).set(dmAuth).send({ spellId: boltId });
|
||||
await request(app).post(`/characters/${characterId}/spell-slots`).set(playerAuth).send({ action: 'use', level: 1 });
|
||||
const other = await setupCampaign('levelgear-other');
|
||||
const foreignSpellId = (await request(app).post(`/campaigns/${other.campaign.id}/spells`).set(other.dmAuth).send({ name: 'Elsewhere', level: 1 })).body.spell.id;
|
||||
|
||||
const plan = (await request(app).get(`/characters/${characterId}/level-up`).set(dmAuth)).body.plan;
|
||||
expect(plan.spells).toEqual({ maxSpellLevel: 1, unlocksSpellLevel: null, newSpells: 2, newCantrips: 0 });
|
||||
expect(plan.spellSlots.levels).toEqual([{ level: 1, max: 3, used: 0, remaining: 3 }]);
|
||||
expect(plan.weapons).toEqual([expect.objectContaining({ weaponId, level: 1, damageDice: '1d4', nextDamageDice: '1d6' })]);
|
||||
|
||||
const levelUp = (body) => request(app).post(`/characters/${characterId}/level-up`).set(dmAuth).send(body);
|
||||
expect((await levelUp({ weaponId: 'not-held' })).status).toBe(400);
|
||||
expect((await levelUp({ spellIds: [foreignSpellId] })).status).toBe(400);
|
||||
|
||||
// A wizard picks a school at level 2. A spell already known is simply kept.
|
||||
expect(plan.subclass.level).toBe(2);
|
||||
const character = (await levelUp({ weaponId, spellIds: [boltId, wardId], subclass: 'ILLUSION' })).body.character;
|
||||
expect(character.level).toBe(2);
|
||||
expect(character.specialAbilities).toMatch(/^Illusion Savant — School of Illusion/m);
|
||||
expect(character.weapons[0]).toMatchObject({ weaponId, level: 2, scaledDamageDice: '1d6' });
|
||||
expect(character.spells.map((s) => s.spell.name).sort()).toEqual(['Test Bolt', 'Test Ward']);
|
||||
expect(character.sheet.spellSlots.levels).toEqual([{ level: 1, max: 3, used: 0, remaining: 3 }]);
|
||||
|
||||
// The weapon is now as high as its holder, so it has no upgrade to take at level 3... until they get there.
|
||||
const next = (await request(app).get(`/characters/${characterId}/level-up`).set(dmAuth)).body.plan;
|
||||
expect(next.weapons).toHaveLength(1);
|
||||
expect(next.spells.unlocksSpellLevel).toBe(2);
|
||||
});
|
||||
|
||||
itIfDb('a level set by hand still writes down what the class gains', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('levelset');
|
||||
const characterId = await buildAs(campaign.id, playerAuth, 'Sly', 'ROGUE');
|
||||
await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ specialAbilities: 'Sneak Attack — Once per turn, deal an extra 1d6 damage.' });
|
||||
const patch = (body) => request(app).patch(`/characters/${characterId}`).set(dmAuth).send(body);
|
||||
|
||||
const lines = (await patch({ level: 4 })).body.character.specialAbilities.split('\n');
|
||||
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.');
|
||||
|
||||
// Going down takes nothing away, and coming back up adds nothing twice.
|
||||
await patch({ level: 1 });
|
||||
expect((await patch({ level: 4 })).body.character.specialAbilities.split('\n')).toEqual(lines);
|
||||
// Text sent along with the level is taken as it is.
|
||||
expect((await patch({ level: 5, specialAbilities: 'Mine.' })).body.character.specialAbilities).toBe('Mine.');
|
||||
|
||||
// Level 20 is the top.
|
||||
await patch({ level: 20 });
|
||||
expect((await request(app).post(`/characters/${characterId}/level-up`).set(dmAuth).send({})).status).toBe(400);
|
||||
});
|
||||
|
||||
itIfDb('the DM sets and changes a subclass on the sheet; its lines follow', async () => {
|
||||
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]);
|
||||
|
||||
// Too early, not theirs, and not the player's to set.
|
||||
expect((await patch(dmAuth, { subclass: 'THIEF' })).status).toBe(400);
|
||||
await patch(dmAuth, { level: 3 });
|
||||
expect((await patch(dmAuth, { subclass: 'CHAMPION' })).status).toBe(400);
|
||||
expect((await patch(playerAuth, { subclass: 'THIEF' })).status).toBe(403);
|
||||
|
||||
const thief = await patch(dmAuth, { subclass: 'THIEF' });
|
||||
expect(thief.body.character).toMatchObject({ subclass: 'THIEF', subclassName: 'Thief' });
|
||||
expect(names(thief)).toEqual(['Cunning Action', 'Sneak Attack', 'Fast Hands', 'Second-Story Work']);
|
||||
|
||||
// From here the Level field writes the subclass's features too.
|
||||
expect(names(await patch(dmAuth, { level: 9 }))).toContain('Supreme Sneak');
|
||||
|
||||
const assassin = await patch(dmAuth, { subclass: 'ASSASSIN' });
|
||||
expect(names(assassin)).toEqual(expect.arrayContaining(['Assassinate', 'Tools of the Trade', 'Infiltration Expertise', 'Uncanny Dodge']));
|
||||
expect(names(assassin)).not.toContain('Fast Hands');
|
||||
expect(names(assassin)).not.toContain('Supreme Sneak');
|
||||
|
||||
// Everyone who can see the character sees the subclass.
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters.find((c) => c.id === characterId);
|
||||
expect(listed.subclassName).toBe('Assassin');
|
||||
expect((await request(app).get(`/characters/${characterId}/quick-stats`).set(playerAuth)).body.quickStats.subclassName).toBe('Assassin');
|
||||
|
||||
// Cleared, the choice is owed again.
|
||||
const cleared = await patch(dmAuth, { subclass: null });
|
||||
expect(cleared.body.character.subclass).toBeNull();
|
||||
expect(names(cleared)).toContain('Subclass');
|
||||
expect(names(cleared)).not.toContain('Assassinate');
|
||||
});
|
||||
});
|
||||
|
||||
describe('character state', () => {
|
||||
itIfDb('class counters: the player spends, only the DM gives back, and rests refill them', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('counters');
|
||||
const characterId = await buildAs(campaign.id, playerAuth, 'Brakk', 'FIGHTER', 9);
|
||||
const resources = (auth, body) => request(app).post(`/characters/${characterId}/resources`).set(auth).send(body);
|
||||
const left = (res) => Object.fromEntries(res.body.character.sheet.resources.map((r) => [r.key, r.remaining]));
|
||||
|
||||
const sheet = (await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.sheet;
|
||||
expect(sheet.resources.map((r) => r.key)).toEqual(['SECOND_WIND', 'ACTION_SURGE', 'INDOMITABLE']);
|
||||
|
||||
const used = await resources(playerAuth, { action: 'use', key: 'SECOND_WIND' });
|
||||
expect(used.status).toBe(200);
|
||||
expect(left(used)).toEqual({ SECOND_WIND: 0, ACTION_SURGE: 1, INDOMITABLE: 1 });
|
||||
// None left, no such counter, and not the player's to give back.
|
||||
const again = await resources(playerAuth, { action: 'use', key: 'SECOND_WIND' });
|
||||
expect(again.status).toBe(400);
|
||||
expect(again.body.error).toMatch(/short or long rest/);
|
||||
expect((await resources(playerAuth, { action: 'use', key: 'RAGE' })).status).toBe(400);
|
||||
expect((await resources(playerAuth, { action: 'set', key: 'SECOND_WIND', used: 0 })).status).toBe(403);
|
||||
expect((await resources(playerAuth, { action: 'restore' })).status).toBe(403);
|
||||
|
||||
await resources(playerAuth, { action: 'use', key: 'INDOMITABLE' });
|
||||
expect(left(await resources(dmAuth, { action: 'set', key: 'SECOND_WIND', used: 0 }))).toMatchObject({ SECOND_WIND: 1, INDOMITABLE: 0 });
|
||||
await resources(playerAuth, { action: 'use', key: 'ACTION_SURGE' });
|
||||
|
||||
// A short rest gives back Action Surge, not Indomitable; a long rest everything.
|
||||
const rest = (type) => request(app).post(`/campaigns/${campaign.id}/rest`).set(dmAuth).send({ type, characters: [{ characterId }] });
|
||||
expect((await rest('SHORT')).body.results[0].notes).toContain('Back: Action Surge.');
|
||||
const after = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters.find((c) => c.id === characterId);
|
||||
expect(after.resources.find((r) => r.key === 'INDOMITABLE').remaining).toBe(0);
|
||||
expect((await rest('LONG')).body.results[0].notes).toContain('Back: Indomitable.');
|
||||
expect(left(await resources(dmAuth, { action: 'restore' }))).toEqual({ SECOND_WIND: 1, ACTION_SURGE: 1, INDOMITABLE: 1 });
|
||||
|
||||
// A pool is spent several at a time, never past what is left.
|
||||
const paladinId = await buildAs(campaign.id, playerAuth, 'Sera', 'PALADIN', 2);
|
||||
const pool = (body) => request(app).post(`/characters/${paladinId}/resources`).set(playerAuth).send(body);
|
||||
expect(left(await pool({ action: 'use', key: 'LAY_ON_HANDS', amount: 7 })).LAY_ON_HANDS).toBe(3);
|
||||
const tooMuch = await pool({ action: 'use', key: 'LAY_ON_HANDS', amount: 4 });
|
||||
expect(tooMuch.status).toBe(400);
|
||||
expect(tooMuch.body.error).toMatch(/only 3/);
|
||||
});
|
||||
|
||||
itIfDb('inspiration is given by the DM and spent by the player; exhaustion and conditions are for the DM', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('state');
|
||||
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const patch = (auth, body) => request(app).patch(`/characters/${characterId}`).set(auth).send(body);
|
||||
|
||||
expect((await patch(playerAuth, { inspiration: true })).status).toBe(403);
|
||||
expect((await patch(dmAuth, { inspiration: true })).body.character.inspiration).toBe(true);
|
||||
expect((await patch(playerAuth, { inspiration: false })).body.character.inspiration).toBe(false);
|
||||
|
||||
expect((await patch(playerAuth, { exhaustion: 0 })).status).toBe(403);
|
||||
expect((await patch(playerAuth, { conditions: [] })).status).toBe(403);
|
||||
expect((await patch(dmAuth, { exhaustion: 7 })).status).toBe(400);
|
||||
const state = (await patch(dmAuth, { exhaustion: 2, conditions: ['Poisoned', 'Poisoned', 'Prone'] })).body.character;
|
||||
expect(state).toMatchObject({ exhaustion: 2, conditions: ['Poisoned', 'Prone'] });
|
||||
|
||||
// A long rest takes one level of exhaustion off; conditions stay.
|
||||
await request(app).post(`/campaigns/${campaign.id}/rest`).set(dmAuth).send({ type: 'LONG', characters: [{ characterId }] });
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters.find((c) => c.id === characterId);
|
||||
expect(listed).toMatchObject({ exhaustion: 1, conditions: ['Poisoned', 'Prone'], inspiration: false });
|
||||
});
|
||||
|
||||
itIfDb('conditions belong to the character: an encounter shows and changes them, and they outlast it', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('conditions');
|
||||
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ conditions: ['Poisoned'] });
|
||||
await request(app).post(`/campaigns/${campaign.id}/monsters/from-library`).set(dmAuth).send({ key: 'kobold' });
|
||||
const monsterId = (await request(app).get(`/campaigns/${campaign.id}/monsters`).set(dmAuth)).body.monsters[0].id;
|
||||
|
||||
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Bog' })).body.encounter.id;
|
||||
const add = (body) => request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send(body);
|
||||
const hero = (await add({ characterId })).body.participant;
|
||||
const kobold = (await add({ monsterId })).body.participant;
|
||||
const view = async () => (await request(app).get(`/encounters/${encounterId}`).set(playerAuth)).body.encounter.participants;
|
||||
const find = (list, id) => list.find((p) => p.id === id);
|
||||
|
||||
// Poisoned before the fight, poisoned in it.
|
||||
expect(find(await view(), hero.id).conditions).toEqual(['Poisoned']);
|
||||
|
||||
const patch = (id, body) => request(app).patch(`/encounters/${encounterId}/participants/${id}`).set(dmAuth).send(body);
|
||||
await patch(hero.id, { conditions: ['Poisoned', 'Prone'] });
|
||||
await patch(kobold.id, { conditions: ['Frightened'] });
|
||||
const during = await view();
|
||||
expect(find(during, hero.id).conditions).toEqual(['Poisoned', 'Prone']);
|
||||
expect(find(during, kobold.id).conditions).toEqual(['Frightened']);
|
||||
|
||||
await request(app).delete(`/encounters/${encounterId}`).set(dmAuth);
|
||||
expect((await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.conditions).toEqual(['Poisoned', 'Prone']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('faster battles', () => {
|
||||
async function scene(prefix) {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign(prefix);
|
||||
await request(app).post(`/campaigns/${campaign.id}/monsters/from-library`).set(dmAuth).send({ key: 'kobold' });
|
||||
const monsterId = (await request(app).get(`/campaigns/${campaign.id}/monsters`).set(dmAuth)).body.monsters[0].id;
|
||||
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Warren' })).body.encounter.id;
|
||||
const add = (body) => request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send(body);
|
||||
return { dmAuth, playerAuth, campaign, monsterId, encounterId, add };
|
||||
}
|
||||
|
||||
itIfDb('adds several of a monster at once, numbered on from those already there', async () => {
|
||||
const { dmAuth, playerAuth, campaign, monsterId, encounterId, add } = await scene('several');
|
||||
const first = await add({ monsterId, count: 3 });
|
||||
expect(first.status).toBe(201);
|
||||
expect(first.body.participants.map((p) => p.displayName)).toEqual(['Kobold 1', 'Kobold 2', 'Kobold 3']);
|
||||
expect((await add({ monsterId, count: 2, initiativeRoll: 10 })).body.participants.map((p) => p.displayName)).toEqual(['Kobold 4', 'Kobold 5']);
|
||||
// One on its own keeps the plain name; more than twenty at once is refused.
|
||||
expect((await add({ monsterId })).body.participant.displayName).toBe('Kobold');
|
||||
expect((await add({ monsterId, count: 21 })).status).toBe(400);
|
||||
// Only monsters come in numbers.
|
||||
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
expect((await add({ characterId, count: 2 })).status).toBe(400);
|
||||
expect((await request(app).get(`/encounters/${encounterId}`).set(dmAuth)).body.encounter.participants).toHaveLength(6);
|
||||
});
|
||||
|
||||
itIfDb('rolls initiative for the monsters and NPCs that have not rolled, and leaves the rest alone', async () => {
|
||||
const { dmAuth, playerAuth, campaign, monsterId, encounterId, add } = await scene('rollall');
|
||||
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const hero = (await add({ characterId })).body.participant;
|
||||
const rolledAlready = (await add({ monsterId, initiativeRoll: 20 })).body.participant;
|
||||
await add({ monsterId, count: 3 });
|
||||
const npcId = (await request(app).post(`/campaigns/${campaign.id}/npcs`).set(dmAuth).send({ name: 'Guide' })).body.npc.id;
|
||||
await add({ npcId });
|
||||
|
||||
expect((await request(app).post(`/encounters/${encounterId}/roll-initiative`).set(playerAuth)).status).toBe(403);
|
||||
const res = await request(app).post(`/encounters/${encounterId}/roll-initiative`).set(dmAuth);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.rolled).toBe(4);
|
||||
for (const p of res.body.encounter.participants) {
|
||||
if (p.id === hero.id) expect(p.initiativeRoll).toBeNull();
|
||||
else {
|
||||
expect(p.initiativeRoll).toBeGreaterThanOrEqual(1);
|
||||
expect(p.initiativeRoll).toBeLessThanOrEqual(20);
|
||||
expect(p.initiative).toBe(p.initiativeRoll + p.initiativeBonus);
|
||||
}
|
||||
}
|
||||
expect(res.body.encounter.participants.find((p) => p.id === rolledAlready.id).initiativeRoll).toBe(20);
|
||||
// Nothing left to roll the second time.
|
||||
expect((await request(app).post(`/encounters/${encounterId}/roll-initiative`).set(dmAuth)).body.rolled).toBe(0);
|
||||
});
|
||||
|
||||
itIfDb('a condition given for a number of rounds counts down as rounds begin, and wears off', async () => {
|
||||
const { dmAuth, playerAuth, campaign, monsterId, encounterId, add } = await scene('timed');
|
||||
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const hero = (await add({ characterId, initiativeRoll: 15 })).body.participant;
|
||||
const kobold = (await add({ monsterId, initiativeRoll: 5 })).body.participant;
|
||||
await request(app).patch(`/encounters/${encounterId}`).set(dmAuth).send({ status: 'ACTIVE' });
|
||||
const patch = (id, body) => request(app).patch(`/encounters/${encounterId}/participants/${id}`).set(dmAuth).send(body);
|
||||
const find = (res, id) => res.body.encounter.participants.find((p) => p.id === id);
|
||||
|
||||
// The hero is frightened for a round and poisoned until cured; the kobold is prone for two rounds.
|
||||
const given = await patch(hero.id, { conditions: ['Frightened', 'Poisoned'], conditionRounds: { Frightened: 1, Blinded: 3 } });
|
||||
expect(find(given, hero.id).conditionRounds).toEqual({ Frightened: 1 }); // no countdown for a condition they are not under
|
||||
await patch(kobold.id, { conditions: ['Prone'], conditionRounds: { Prone: 2 } });
|
||||
|
||||
const turn = () => request(app).post(`/encounters/${encounterId}/next-turn`).set(dmAuth);
|
||||
// Within the round nothing moves.
|
||||
const within = await turn();
|
||||
expect(within.body.expired).toEqual([]);
|
||||
expect(find(within, kobold.id).conditionRounds).toEqual({ Prone: 2 });
|
||||
|
||||
// Round 2 begins: the fright is over, on the sheet too.
|
||||
const round2 = await turn();
|
||||
expect(round2.body.encounter.currentRound).toBe(2);
|
||||
expect(round2.body.expired).toEqual([{ name: 'Aria', condition: 'Frightened' }]);
|
||||
expect(find(round2, hero.id).conditions).toEqual(['Poisoned']);
|
||||
expect(find(round2, kobold.id).conditionRounds).toEqual({ Prone: 1 });
|
||||
expect((await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.conditions).toEqual(['Poisoned']);
|
||||
|
||||
// Skipping to round 3 counts too. Removing a condition by hand drops its countdown.
|
||||
const round3 = await request(app).post(`/encounters/${encounterId}/advance-round`).set(dmAuth);
|
||||
expect(round3.body.expired).toEqual([{ name: 'Kobold', condition: 'Prone' }]);
|
||||
expect(find(round3, kobold.id)).toMatchObject({ conditions: [], conditionRounds: {} });
|
||||
await patch(kobold.id, { conditions: ['Stunned'], conditionRounds: { Stunned: 4 } });
|
||||
expect(find(await patch(kobold.id, { conditions: [] }), kobold.id).conditionRounds).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('experience and difficulty', () => {
|
||||
itIfDb('the DM sees how hard an encounter is, and awards its XP once, in equal shares', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('xp');
|
||||
await request(app).post(`/campaigns/${campaign.id}/monsters/from-library`).set(dmAuth).send({ key: 'kobold' }); // CR 1/8: 25 XP
|
||||
const monsterId = (await request(app).get(`/campaigns/${campaign.id}/monsters`).set(dmAuth)).body.monsters[0].id;
|
||||
const ariaId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const boId = await buildCharacter(campaign.id, playerAuth, 'Bo');
|
||||
|
||||
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Warren' })).body.encounter.id;
|
||||
const add = (body) => request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send(body);
|
||||
const award = (auth, body = {}) => request(app).post(`/encounters/${encounterId}/award-xp`).set(auth).send(body);
|
||||
|
||||
// Nobody to give it to yet.
|
||||
await add({ monsterId, count: 3 });
|
||||
expect((await award(dmAuth)).status).toBe(400);
|
||||
await add({ characterId: ariaId });
|
||||
await add({ characterId: boId });
|
||||
|
||||
// Three kobolds against two level 1 characters: 75 XP, doubled for their number.
|
||||
const dmView = (await request(app).get(`/encounters/${encounterId}`).set(dmAuth)).body.encounter;
|
||||
expect(dmView.difficulty).toEqual({
|
||||
xp: 75,
|
||||
adjustedXp: 150,
|
||||
multiplier: 2,
|
||||
rating: 'HARD',
|
||||
thresholds: { easy: 50, medium: 100, hard: 150, deadly: 200 },
|
||||
monsters: 3,
|
||||
unrated: 0,
|
||||
});
|
||||
expect(dmView.xpAwarded).toBeNull();
|
||||
// It is the DM's to know and to award.
|
||||
expect((await request(app).get(`/encounters/${encounterId}`).set(playerAuth)).body.encounter.difficulty).toBeUndefined();
|
||||
expect((await award(playerAuth)).status).toBe(403);
|
||||
|
||||
const res = await award(dmAuth);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ xp: 75, share: 37, awardedTo: ['Aria', 'Bo'] });
|
||||
expect(res.body.encounter.xpAwarded).toBe(75);
|
||||
const aria = (await request(app).get(`/characters/${ariaId}`).set(playerAuth)).body.character;
|
||||
expect(aria).toMatchObject({ xp: 37, level: 1, xpProgress: { nextLevelAt: 300, levelUpDue: false } });
|
||||
|
||||
// Once only.
|
||||
const again = await award(dmAuth, { xp: 500 });
|
||||
expect(again.status).toBe(400);
|
||||
expect(again.body.error).toMatch(/already been awarded/);
|
||||
|
||||
// The DM can set XP on the sheet; enough of it says a level is due, and changes nothing else.
|
||||
expect((await request(app).patch(`/characters/${ariaId}`).set(playerAuth).send({ xp: 300 })).status).toBe(403);
|
||||
const due = (await request(app).patch(`/characters/${ariaId}`).set(dmAuth).send({ xp: 300 })).body.character;
|
||||
expect(due).toMatchObject({ xp: 300, level: 1, xpProgress: { nextLevelAt: 300, levelUpDue: true } });
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters.find((c) => c.id === ariaId);
|
||||
expect(listed).toMatchObject({ xp: 300, xpProgress: { levelUpDue: true } });
|
||||
});
|
||||
|
||||
itIfDb('an amount can be typed, for a scene with nothing rated to fight', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('xpquest');
|
||||
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
|
||||
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Riddle', mode: 'QUEST' })).body.encounter.id;
|
||||
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId });
|
||||
const award = (body) => request(app).post(`/encounters/${encounterId}/award-xp`).set(dmAuth).send(body);
|
||||
|
||||
expect((await request(app).get(`/encounters/${encounterId}`).set(dmAuth)).body.encounter.difficulty).toMatchObject({ xp: 0, rating: null, monsters: 0 });
|
||||
expect((await award({})).status).toBe(400);
|
||||
expect((await award({ xp: 0 })).status).toBe(400);
|
||||
expect((await award({ xp: 120 })).body).toMatchObject({ xp: 120, share: 120 });
|
||||
expect((await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.xp).toBe(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a campaign without subclasses', () => {
|
||||
itIfDb('nobody is asked: each class follows its first subclass, and none is named', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('nosubclass');
|
||||
expect(campaign.subclassesEnabled).toBe(true);
|
||||
const settings = await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ subclassesEnabled: false });
|
||||
expect(settings.body.campaign.subclassesEnabled).toBe(false);
|
||||
expect((await request(app).patch(`/campaigns/${campaign.id}`).set(playerAuth).send({ subclassesEnabled: true })).status).toBe(403);
|
||||
|
||||
const characterId = await buildAs(campaign.id, playerAuth, 'Brakk', 'FIGHTER', 2);
|
||||
const levelUp = (body = {}) => request(app).post(`/characters/${characterId}/level-up`).set(dmAuth).send(body);
|
||||
const names = (character) => character.specialAbilities.split('\n').map((line) => line.split(' — ')[0]);
|
||||
|
||||
// Level 3 asks for no subclass, and writes the Champion's feature all the same.
|
||||
const plan = (await request(app).get(`/characters/${characterId}/level-up`).set(dmAuth)).body.plan;
|
||||
expect(plan.subclass).toBeNull();
|
||||
expect(plan.features.map((f) => f.name)).toEqual(['Improved Critical']);
|
||||
expect((await levelUp({ subclass: 'SAMURAI' })).status).toBe(400);
|
||||
const character = (await levelUp()).body.character;
|
||||
expect(character.level).toBe(3);
|
||||
expect(names(character)).toEqual(['Improved Critical']);
|
||||
// Nothing names it or offers a choice, on the sheet, the party list or the panel.
|
||||
expect(character).toMatchObject({ subclass: null, subclassName: null, subclassLevel: null, subclassOptions: [] });
|
||||
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters.find((c) => c.id === characterId);
|
||||
expect(listed.subclassName).toBeNull();
|
||||
expect((await request(app).get(`/characters/${characterId}/quick-stats`).set(dmAuth)).body.quickStats.subclassName).toBeNull();
|
||||
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ subclass: 'SAMURAI' })).status).toBe(400);
|
||||
|
||||
// The Level field does the same, with no line saying a choice is owed.
|
||||
const rogueId = await buildAs(campaign.id, playerAuth, 'Sly', 'ROGUE');
|
||||
const rogue = (await request(app).patch(`/characters/${rogueId}`).set(dmAuth).send({ level: 9 })).body.character;
|
||||
expect(names(rogue)).toEqual(expect.arrayContaining(['Fast Hands', 'Second-Story Work', 'Supreme Sneak']));
|
||||
expect(names(rogue)).not.toContain('Subclass');
|
||||
|
||||
// Switched back on, what they were following shows, and can be changed.
|
||||
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ subclassesEnabled: true });
|
||||
const shown = (await request(app).get(`/characters/${characterId}`).set(dmAuth)).body.character;
|
||||
expect(shown).toMatchObject({ subclass: 'CHAMPION', subclassName: 'Champion', subclassLevel: 3 });
|
||||
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ subclass: 'SAMURAI' })).body.character.subclassName).toBe('Samurai');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,12 +4,15 @@ const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { requireCampaignMember, requireDM } = require('../middleware/campaignAccess');
|
||||
const { generateInviteCode } = require('../utils/inviteCode');
|
||||
const { computeCharacterSheet, GEAR_STATS, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
const { SHEET_INCLUDE, settleHp } = require('../utils/sheet');
|
||||
const { computeCharacterSheet, xpProgress, GEAR_STATS, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL, MAX_LEVEL } = require('../game/calculations');
|
||||
const { rollDie } = require('../game/buildFlow');
|
||||
const { SHEET_INCLUDE, sheetOf, settleHp } = require('../utils/sheet');
|
||||
const { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
|
||||
const { visitedLocationIds } = require('../utils/locations');
|
||||
const { deathSaveInfo } = require('../utils/hp');
|
||||
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 { findSeed, normalizeCode, CODE_LENGTH } = require('../seeds');
|
||||
const { createCampaignFromSeed } = require('../seeds/createCampaign');
|
||||
|
||||
@@ -32,6 +35,7 @@ function campaignSummary(campaign, role) {
|
||||
areaAttackAcPenalty: campaign.areaAttackAcPenalty,
|
||||
maxEquipped: campaign.maxEquipped,
|
||||
maxEquippedWeapons: campaign.maxEquippedWeapons,
|
||||
subclassesEnabled: campaign.subclassesEnabled,
|
||||
ownerId: campaign.ownerId,
|
||||
archivedAt: campaign.archivedAt,
|
||||
role,
|
||||
@@ -67,6 +71,8 @@ const createCampaignSchema = z.object({
|
||||
// they are checked when something is put on.
|
||||
maxEquipped: z.number().int().min(1).max(20).optional(),
|
||||
maxEquippedWeapons: z.number().int().min(0).max(20).optional(),
|
||||
// Off: nobody chooses a subclass, and each class follows its first one.
|
||||
subclassesEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// POST /campaigns — create, creator becomes DM/owner
|
||||
@@ -211,7 +217,8 @@ router.post('/:id/invite', requireCampaignMember(), requireDM(), async (req, res
|
||||
// Characters within a campaign
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function characterSummary(character) {
|
||||
// `subclasses` is the campaign's setting: off, no subclass is named.
|
||||
function characterSummary(character, subclasses = true) {
|
||||
const sheet = computeCharacterSheet(
|
||||
character,
|
||||
character.skillProficiencies || [],
|
||||
@@ -225,18 +232,38 @@ function characterSummary(character) {
|
||||
name: character.name,
|
||||
species: character.species,
|
||||
characterClass: character.characterClass,
|
||||
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
|
||||
level: character.level,
|
||||
xp: character.xp,
|
||||
xpProgress: xpProgress(character.xp, character.level),
|
||||
buildStage: character.buildStage,
|
||||
currentHp: character.currentHp,
|
||||
tempHp: character.tempHp,
|
||||
deathSaves: deathSaveInfo(character),
|
||||
inspiration: character.inspiration,
|
||||
exhaustion: character.exhaustion,
|
||||
conditions: character.conditions,
|
||||
gold: character.gold,
|
||||
silver: character.silver,
|
||||
copper: character.copper,
|
||||
cash: character.cash,
|
||||
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
|
||||
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
|
||||
// What the DM reads off without asking for a roll.
|
||||
passives:
|
||||
character.buildStage === 'COMPLETE'
|
||||
? { perception: sheet.passivePerception, investigation: sheet.passiveInvestigation, insight: sheet.passiveInsight }
|
||||
: null,
|
||||
spellSaveDC: character.buildStage === 'COMPLETE' && sheet.spellcasting ? sheet.spellcasting.saveDC : null,
|
||||
// What each rolls with, for a check the DM calls for from the whole party.
|
||||
checks: character.buildStage === 'COMPLETE' ? { abilities: sheet.abilities, saves: sheet.saves, skills: sheet.skills } : null,
|
||||
// What is left to spend before the next rest: the slot tracker (null for
|
||||
// a character with no spell slots) and Hit Dice, with the Constitution
|
||||
// modifier each die healed is rolled with.
|
||||
spellSlots: character.buildStage === 'COMPLETE' ? sheet.spellSlots : null,
|
||||
hitDice: character.buildStage === 'COMPLETE' ? { ...sheet.hitDice, conModifier: sheet.abilities.CON.modifier } : null,
|
||||
// The class counters (Rage, Ki...), with how many of each are left.
|
||||
resources: character.buildStage === 'COMPLETE' ? sheet.resources : [],
|
||||
currentLocationId: character.currentLocationId,
|
||||
currentLocationName: character.currentLocation ? character.currentLocation.name : null,
|
||||
};
|
||||
@@ -249,7 +276,7 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
|
||||
include: { ...SHEET_INCLUDE, currentLocation: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
const summaries = characters.map(characterSummary);
|
||||
const summaries = characters.map((character) => characterSummary(character, req.campaign.subclassesEnabled));
|
||||
if (req.membership.role === 'DM') return res.json({ characters: summaries });
|
||||
|
||||
// A player sees the party's headline numbers, but another character's
|
||||
@@ -315,6 +342,75 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rests — the DM rests any of the party at once (utils/rest.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const restSchema = z.object({
|
||||
type: z.enum(['SHORT', 'LONG']),
|
||||
characters: z
|
||||
.array(
|
||||
z.object({
|
||||
characterId: z.string(),
|
||||
// A short rest only: how many Hit Dice this character spends, and what
|
||||
// they added up to if they were rolled at the table. Left out, the
|
||||
// dice are rolled here.
|
||||
hitDice: z.number().int().min(0).max(MAX_LEVEL).default(0),
|
||||
rolled: z.number().int().min(0).max(1000).nullable().optional(),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.max(200),
|
||||
});
|
||||
|
||||
// A long rest puts HP, spell slots and half the Hit Dice back. A short rest
|
||||
// spends Hit Dice to heal and gives a warlock their slots. Nothing is written
|
||||
// unless every character asked for can take the rest as asked. The answer
|
||||
// says what happened to each, in the party's order.
|
||||
router.post('/:id/rest', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = restSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { type } = parsed.data;
|
||||
const asked = new Map(parsed.data.characters.map((entry) => [entry.characterId, entry]));
|
||||
|
||||
const characters = await prisma.character.findMany({
|
||||
where: { id: { in: [...asked.keys()] }, campaignId: req.campaign.id, buildStage: 'COMPLETE' },
|
||||
include: SHEET_INCLUDE,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
if (characters.length !== asked.size) {
|
||||
return res.status(400).json({ error: 'Pick finished characters from this campaign.' });
|
||||
}
|
||||
|
||||
const outcomes = [];
|
||||
for (const character of characters) {
|
||||
const who = { characterId: character.id, name: character.name };
|
||||
const sheet = sheetOf(character);
|
||||
if (lifeState(character) === 'DEAD') {
|
||||
outcomes.push({ ...who, skipped: `${character.name} is dead: a rest does nothing for them.` });
|
||||
} else if (type === 'LONG') {
|
||||
outcomes.push({ ...who, skipped: null, ...longRest(character, sheet) });
|
||||
} else {
|
||||
const { hitDice: dice, rolled = null } = asked.get(character.id);
|
||||
const problem = hitDiceProblem(character, sheet, dice, rolled);
|
||||
if (problem) return res.status(400).json({ error: problem });
|
||||
const rolls = dice > 0 && rolled == null ? Array.from({ length: dice }, () => rollDie(sheet.hitDice.size)) : null;
|
||||
outcomes.push({ ...who, skipped: null, ...shortRest(character, sheet, { dice, rolls, rolled }) });
|
||||
}
|
||||
}
|
||||
|
||||
const rested = outcomes.filter((outcome) => outcome.data);
|
||||
await prisma.$transaction(rested.map((outcome) => prisma.character.update({ where: { id: outcome.characterId }, data: outcome.data })));
|
||||
const io = req.app.get('io');
|
||||
for (const { characterId } of rested) io.to(`campaign:${req.campaign.id}`).emit('character:updated', { characterId });
|
||||
|
||||
res.json({ type, results: outcomes.map(({ data, result, ...who }) => ({ ...who, ...result })) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weapons (campaign-level pool; the DM manages it and sees all of it)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -630,6 +726,9 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(),
|
||||
// Encounters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The campaign's own order, which the DM arranges.
|
||||
const ENCOUNTER_ORDER = [{ sortOrder: 'asc' }, { createdAt: 'asc' }];
|
||||
|
||||
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
|
||||
try {
|
||||
const isDM = req.membership.role === 'DM';
|
||||
@@ -638,7 +737,7 @@ router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) =>
|
||||
const mine = { archivedAt: null, participants: { some: { character: { userId: req.user.id } } } };
|
||||
const encounters = await prisma.encounter.findMany({
|
||||
where: { campaignId: req.campaign.id, ...(isDM ? {} : mine) },
|
||||
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
orderBy: ENCOUNTER_ORDER,
|
||||
});
|
||||
// dmNotes never leaves the server for a player.
|
||||
res.json({ encounters: isDM ? encounters : encounters.map(({ dmNotes, ...encounter }) => encounter) });
|
||||
@@ -652,21 +751,48 @@ const createEncounterSchema = z.object({
|
||||
mode: z.enum(['BATTLE', 'QUEST']).optional(),
|
||||
description: z.string().max(4000).optional().nullable(),
|
||||
locationId: z.string().optional().nullable(),
|
||||
// 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.
|
||||
position: z.enum(['TOP', 'BOTTOM']).optional(),
|
||||
afterEncounterId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = createEncounterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (parsed.data.locationId) {
|
||||
const location = await prisma.location.findFirst({ where: { id: parsed.data.locationId, campaignId: req.campaign.id } });
|
||||
const { position, afterEncounterId, ...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.' });
|
||||
}
|
||||
|
||||
// A new encounter goes to the top of the campaign's order.
|
||||
const first = await prisma.encounter.aggregate({ where: { campaignId: req.campaign.id }, _min: { sortOrder: true } });
|
||||
const encounter = await prisma.encounter.create({
|
||||
data: { ...parsed.data, campaignId: req.campaign.id, sortOrder: (first._min.sortOrder ?? 1) - 1 },
|
||||
const order = await prisma.encounter.findMany({
|
||||
where: { campaignId: req.campaign.id },
|
||||
orderBy: ENCOUNTER_ORDER,
|
||||
select: { id: true, sortOrder: true },
|
||||
});
|
||||
let index = position === 'TOP' ? 0 : order.length;
|
||||
if (afterEncounterId) {
|
||||
const at = order.findIndex((e) => e.id === afterEncounterId);
|
||||
if (at === -1) return res.status(400).json({ error: 'The encounter to put this one after is not part of this campaign.' });
|
||||
index = at + 1;
|
||||
}
|
||||
|
||||
const encounter = await prisma.$transaction(async (tx) => {
|
||||
// At the bottom nothing else moves. Anywhere else, the whole order is
|
||||
// numbered afresh around the newcomer.
|
||||
if (index === order.length) {
|
||||
const last = order.length > 0 ? order[order.length - 1].sortOrder : -1;
|
||||
return tx.encounter.create({ data: { ...fields, campaignId: req.campaign.id, sortOrder: last + 1 } });
|
||||
}
|
||||
const created = await tx.encounter.create({ data: { ...fields, campaignId: req.campaign.id, sortOrder: index } });
|
||||
for (const [i, existing] of order.entries()) {
|
||||
const sortOrder = i < index ? i : i + 1;
|
||||
if (existing.sortOrder !== sortOrder) await tx.encounter.update({ where: { id: existing.id }, data: { sortOrder } });
|
||||
}
|
||||
return created;
|
||||
});
|
||||
res.status(201).json({ encounter });
|
||||
} catch (err) {
|
||||
|
||||
@@ -11,9 +11,16 @@ const {
|
||||
getClassSpellcastingAbility,
|
||||
scaledDamageDice,
|
||||
canUseGear,
|
||||
spellSlotState,
|
||||
xpProgress,
|
||||
MAX_GEAR_LEVEL,
|
||||
MAX_LEVEL,
|
||||
MAX_SLOT_LEVEL,
|
||||
ABILITIES,
|
||||
} = require('../game/calculations');
|
||||
const { LevelUpError, advance, levelUpPlan, switchSubclass, subclassByDefault } = require('../game/levelUp');
|
||||
const { resourceTally, MAX_EXHAUSTION } = require('../game/classResources');
|
||||
const { SUBCLASS_LEVEL, subclassesOf, findSubclass, subclassName } = require('../game/data/subclasses');
|
||||
const {
|
||||
rollDie,
|
||||
rollAbilityScore,
|
||||
@@ -31,7 +38,7 @@ const characterOptions = require('../game/data/characterOptions');
|
||||
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, settleHp } = require('../utils/sheet');
|
||||
const { SHEET_INCLUDE, sheetOf, settleHp } = require('../utils/sheet');
|
||||
const { absorbDamage, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -48,10 +55,15 @@ async function loadFullCharacter(id) {
|
||||
weapons: { include: { weapon: true } },
|
||||
spells: { include: { spell: true } },
|
||||
currentLocation: true,
|
||||
campaign: { select: { subclassesEnabled: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Whether a character's campaign uses subclasses. Takes a character loaded
|
||||
// with its campaign (loadFullCharacter).
|
||||
const usesSubclasses = (character) => !character.campaign || character.campaign.subclassesEnabled;
|
||||
|
||||
// privateNotes is a real privacy boundary, not a UI hide — it's only ever
|
||||
// included in the payload for the character's own owning player.
|
||||
//
|
||||
@@ -63,6 +75,8 @@ async function loadFullCharacter(id) {
|
||||
function serializeCharacter(character, isOwner, isDM = true) {
|
||||
const restricted = !isOwner && !isDM;
|
||||
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
|
||||
// With subclasses switched off for the campaign, none is named or offered.
|
||||
const subclasses = usesSubclasses(character);
|
||||
return {
|
||||
id: character.id,
|
||||
campaignId: character.campaignId,
|
||||
@@ -71,8 +85,14 @@ function serializeCharacter(character, isOwner, isDM = true) {
|
||||
name: character.name,
|
||||
species: character.species,
|
||||
characterClass: character.characterClass,
|
||||
subclass: subclasses ? character.subclass : null,
|
||||
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 })) : [],
|
||||
level: character.level,
|
||||
xp: character.xp,
|
||||
xpProgress: xpProgress(character.xp, character.level),
|
||||
motivation: character.motivation,
|
||||
halfElfBonusAbility1: character.halfElfBonusAbility1,
|
||||
halfElfBonusAbility2: character.halfElfBonusAbility2,
|
||||
@@ -87,6 +107,9 @@ function serializeCharacter(character, isOwner, isDM = true) {
|
||||
currentHp: character.currentHp,
|
||||
tempHp: character.tempHp,
|
||||
deathSaves: deathSaveInfo(character),
|
||||
inspiration: character.inspiration,
|
||||
exhaustion: character.exhaustion,
|
||||
conditions: character.conditions,
|
||||
armorBonus: character.armorBonus,
|
||||
specialAbilities: character.specialAbilities,
|
||||
background: character.background,
|
||||
@@ -113,11 +136,17 @@ function serializeCharacter(character, isOwner, isDM = true) {
|
||||
: null,
|
||||
usable: canUseGear(character.level, ii.level),
|
||||
})),
|
||||
weapons: character.weapons.map((cw) => ({
|
||||
...cw,
|
||||
scaledDamageDice: scaledDamageDice(cw.weapon.damageDice, cw.level, cw.weapon.damageScaling, cw.weapon.minLevel),
|
||||
usable: canUseGear(character.level, cw.level),
|
||||
})),
|
||||
weapons: character.weapons.map((cw) => {
|
||||
// What this character rolls with it: to hit, and on top of its dice.
|
||||
const { attackBonus, damageBonus } = computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true, cw.level);
|
||||
return {
|
||||
...cw,
|
||||
scaledDamageDice: scaledDamageDice(cw.weapon.damageDice, cw.level, cw.weapon.damageScaling, cw.weapon.minLevel),
|
||||
attackBonus,
|
||||
damageBonus,
|
||||
usable: canUseGear(character.level, cw.level),
|
||||
};
|
||||
}),
|
||||
spells: character.spells.map((cs) => ({ ...cs, usable: canUseGear(character.level, cs.spell.level) })),
|
||||
sheet: character.buildStage === 'COMPLETE' ? sheet : null,
|
||||
};
|
||||
@@ -158,8 +187,13 @@ const patchCharacterSchema = z.object({
|
||||
appearance: z.string().max(4000).nullable().optional(),
|
||||
privateNotes: z.string().max(4000).nullable().optional(),
|
||||
currentLocationId: z.string().nullable().optional(),
|
||||
level: z.number().int().min(1).max(20).optional(),
|
||||
xp: z.number().int().min(0).optional(),
|
||||
level: z.number().int().min(1).max(MAX_LEVEL).optional(),
|
||||
subclass: z.string().max(40).nullable().optional(), // a key from game/data/subclasses.js; null clears it
|
||||
xp: z.number().int().min(0).max(10000000).optional(),
|
||||
hitDiceUsed: z.number().int().min(0).max(MAX_LEVEL).optional(),
|
||||
inspiration: z.boolean().optional(),
|
||||
exhaustion: z.number().int().min(0).max(MAX_EXHAUSTION).optional(),
|
||||
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
|
||||
});
|
||||
|
||||
// Progression and placement are the DM's call, and so is rewriting what the
|
||||
@@ -168,8 +202,11 @@ const patchCharacterSchema = z.object({
|
||||
// a player cannot heal or hurt their own character. The owning player can
|
||||
// edit the rest of their own sheet. Gold is not patchable at all — it only
|
||||
// moves through the ledger (POST /:id/gold). The armor bonus is the DM's
|
||||
// adjustment on top of what equipped gear gives, so it is the DM's too.
|
||||
const DM_ONLY_PATCH_FIELDS = ['currentHp', 'tempHp', 'level', 'xp', 'currentLocationId', 'speedOverride', 'armorBonus', 'specialAbilities', 'background', 'appearance'];
|
||||
// adjustment on top of what equipped gear gives, so it is the DM's too. Hit
|
||||
// Dice are spent on a rest, which the DM runs. Exhaustion and conditions are
|
||||
// the DM's to give and take away. Inspiration is the DM's to give; the player
|
||||
// may spend it (checked below).
|
||||
const DM_ONLY_PATCH_FIELDS = ['currentHp', 'tempHp', 'level', 'subclass', 'xp', 'currentLocationId', 'speedOverride', 'armorBonus', 'specialAbilities', 'background', 'appearance', 'hitDiceUsed', 'exhaustion', 'conditions'];
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
@@ -184,7 +221,56 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
|
||||
if (!req.isDM && DM_ONLY_PATCH_FIELDS.some((field) => parsed.data[field] !== undefined)) {
|
||||
return res.status(403).json({ error: 'Only the DM can change HP, temp HP, level, XP, speed, armor bonus, location, special abilities, background or appearance.' });
|
||||
return res.status(403).json({ error: 'Only the DM can change HP, temp HP, Hit Dice, level, subclass, XP, speed, armor bonus, location, exhaustion, conditions, special abilities, background or appearance.' });
|
||||
}
|
||||
if (!req.isDM && parsed.data.inspiration === true) {
|
||||
return res.status(403).json({ error: 'Only the DM can give inspiration. You can spend it.' });
|
||||
}
|
||||
if (parsed.data.conditions) parsed.data.conditions = [...new Set(parsed.data.conditions)];
|
||||
|
||||
const newLevel = parsed.data.level ?? req.character.level;
|
||||
if (parsed.data.level !== undefined || parsed.data.hitDiceUsed !== undefined) {
|
||||
// A character has one Hit Die per level, so none can be spent beyond that.
|
||||
parsed.data.hitDiceUsed = Math.min(parsed.data.hitDiceUsed ?? req.character.hitDiceUsed, newLevel);
|
||||
}
|
||||
if (newLevel !== req.character.level) {
|
||||
// Spent slots of a level the character no longer has are forgotten.
|
||||
const slots = spellSlotState(req.character.characterClass, newLevel, req.character.spellSlotsUsed);
|
||||
const tally = [];
|
||||
for (const slot of slots ? slots.levels : []) tally[slot.level - 1] = slot.used;
|
||||
parsed.data.spellSlotsUsed = Array.from(tally, (used) => used || 0);
|
||||
}
|
||||
const characterClass = req.character.characterClass;
|
||||
const askedSubclass = parsed.data.subclass; // before a level change may take one by default
|
||||
let abilities = parsed.data.specialAbilities === undefined ? req.character.specialAbilities : null;
|
||||
const writesAbilities = req.character.buildStage === 'COMPLETE' && parsed.data.specialAbilities === undefined;
|
||||
if (writesAbilities && newLevel > req.character.level) {
|
||||
// Setting a higher level by hand still writes down what the class and
|
||||
// subclass give on the way there. A Boon, and a subclass not yet picked,
|
||||
// are left as a line saying one is owed: picking them is part of
|
||||
// levelling up properly (POST /:id/level-up).
|
||||
let key = req.character.subclass;
|
||||
for (let level = req.character.level + 1; level <= newLevel; level++) {
|
||||
// With subclasses switched off, the class's first one is taken at the
|
||||
// level it would have been chosen.
|
||||
const taken = req.campaign.subclassesEnabled ? null : subclassByDefault(characterClass, level, key);
|
||||
abilities = advance(abilities, characterClass, level, undefined, taken ? { key: taken, chosen: true } : { key }).specialAbilities;
|
||||
if (taken) key = taken;
|
||||
}
|
||||
parsed.data.specialAbilities = abilities;
|
||||
if (key !== req.character.subclass && askedSubclass === undefined) parsed.data.subclass = key;
|
||||
}
|
||||
if (askedSubclass !== undefined && askedSubclass !== req.character.subclass) {
|
||||
if (!req.campaign.subclassesEnabled) return res.status(400).json({ error: 'Subclasses are switched off for this campaign.' });
|
||||
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]}.` });
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Damage is soaked by temp HP before it reaches real HP — unless this
|
||||
@@ -307,6 +393,234 @@ router.post('/:id/death-saves', requireCharacterAccess({ write: true }), async (
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spell slots — the slot tracker. How many slots there are comes from class
|
||||
// and level (game/calculations.js); the character row keeps how many are spent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ORDINALS = ['1st', '2nd', '3rd', '4th', '5th', '6th', '7th', '8th', '9th'];
|
||||
const slotLevelSchema = z.number().int().min(1).max(MAX_SLOT_LEVEL);
|
||||
|
||||
const spellSlotSchema = z.discriminatedUnion('action', [
|
||||
// Casting a spell: one slot of that level is spent.
|
||||
z.object({ action: z.literal('use'), level: slotLevelSchema }),
|
||||
// The DM setting how many of a level are spent: giving one back, or fixing a mistake.
|
||||
z.object({ action: z.literal('set'), level: slotLevelSchema, used: z.number().int().min(0).max(9) }),
|
||||
// The DM giving every slot back.
|
||||
z.object({ action: z.literal('restore') }),
|
||||
]);
|
||||
|
||||
// The owning player spends their own slots, one at a time. Getting one back
|
||||
// is the DM's: by hand here, or for everyone with a rest
|
||||
// (POST /campaigns/:id/rest).
|
||||
router.post('/:id/spell-slots', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = spellSlotSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { character } = req;
|
||||
if (character.buildStage !== 'COMPLETE') return res.status(400).json({ error: 'Character build is not finished yet.' });
|
||||
|
||||
const input = parsed.data;
|
||||
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);
|
||||
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.
|
||||
const used = Array.from({ length: slots.levels[slots.levels.length - 1].level }, (_, i) => {
|
||||
const slot = slots.levels.find((s) => s.level === i + 1);
|
||||
return slot ? slot.used : 0;
|
||||
});
|
||||
if (input.action === 'restore') {
|
||||
used.fill(0);
|
||||
} else {
|
||||
const slot = slots.levels.find((s) => s.level === input.level);
|
||||
if (!slot) return res.status(400).json({ error: `${character.name} has no ${ORDINALS[input.level - 1]}-level spell slots.` });
|
||||
if (input.action === 'use') {
|
||||
if (slot.remaining === 0) {
|
||||
return res.status(400).json({
|
||||
error: `${character.name} has no ${ORDINALS[input.level - 1]}-level spell slots left. They come back on a ${slots.restoresOn === 'SHORT' ? 'short or long' : 'long'} rest.`,
|
||||
});
|
||||
}
|
||||
used[input.level - 1] = slot.used + 1;
|
||||
} else {
|
||||
used[input.level - 1] = Math.min(input.used, slot.max);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.character.update({ where: { id: character.id }, data: { spellSlotsUsed: used } });
|
||||
const full = await loadFullCharacter(character.id);
|
||||
emitCharacterUpdated(req);
|
||||
res.json({ character: serializeCharacter(full, req.isOwner) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Class counters — Rage, Ki, Channel Divinity and the like. Which a character
|
||||
// has, and how many, comes from class, subclass and level
|
||||
// (game/classResources.js); the character row keeps how many are spent.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resourceKey = z.string().min(1).max(40);
|
||||
|
||||
const resourceSchema = z.discriminatedUnion('action', [
|
||||
// Using it: one is spent, or `amount` from a pool (Lay on Hands, Ki).
|
||||
z.object({ action: z.literal('use'), key: resourceKey, amount: z.number().int().min(1).max(999).default(1) }),
|
||||
// The DM setting how many are spent: giving some back, or fixing a mistake.
|
||||
z.object({ action: z.literal('set'), key: resourceKey, used: z.number().int().min(0).max(999) }),
|
||||
// The DM giving every counter back.
|
||||
z.object({ action: z.literal('restore') }),
|
||||
]);
|
||||
|
||||
// The owning player spends their own. Getting any back is the DM's: by hand
|
||||
// here, or for everyone with a rest (POST /campaigns/:id/rest).
|
||||
router.post('/:id/resources', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = resourceSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (req.character.buildStage !== 'COMPLETE') return res.status(400).json({ error: 'Character build is not finished yet.' });
|
||||
const input = parsed.data;
|
||||
if (input.action !== 'use' && !req.isDM) return res.status(403).json({ error: 'Only the DM can give a use back.' });
|
||||
|
||||
const character = await loadFullCharacter(req.character.id);
|
||||
const resources = sheetOf(character).resources;
|
||||
if (input.action === 'restore') {
|
||||
for (const resource of resources) resource.used = 0;
|
||||
} else {
|
||||
const resource = resources.find((r) => r.key === input.key);
|
||||
if (!resource) return res.status(400).json({ error: `${character.name} has no such counter.` });
|
||||
if (input.action === 'use') {
|
||||
if (input.amount > resource.remaining) {
|
||||
const back = resource.restoresOn === 'SHORT' ? 'short or long' : 'long';
|
||||
return res.status(400).json({
|
||||
error:
|
||||
resource.remaining === 0
|
||||
? `${character.name} has no ${resource.name} left. It comes back on a ${back} rest.`
|
||||
: `${character.name} has only ${resource.remaining} ${resource.name} left.`,
|
||||
});
|
||||
}
|
||||
resource.used += input.amount;
|
||||
} else {
|
||||
resource.used = Math.min(input.used, resource.max);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.character.update({ where: { id: character.id }, data: { resourcesUsed: resourceTally(resources) } });
|
||||
const full = await loadFullCharacter(character.id);
|
||||
emitCharacterUpdated(req);
|
||||
res.json({ character: serializeCharacter(full, req.isOwner) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Levelling up — one level at a time, with the checklist that goes with it
|
||||
// (game/levelUp.js): the new level, full HP, the class's new special
|
||||
// abilities, a Boon where the level grants one, new spells, fresh spell slots
|
||||
// and the free weapon upgrade.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Answers the request and returns false when this character cannot be levelled up.
|
||||
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.'];
|
||||
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;
|
||||
}
|
||||
|
||||
// What the next level brings, for the DM to look over before saying yes.
|
||||
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 } });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const levelUpSchema = z.object({
|
||||
subclass: z.string().optional(), // which subclass, at the level the class chooses one
|
||||
boon: z.string().optional(), // which Boon, at a level that grants one
|
||||
weaponId: z.string().nullable().optional(), // the weapon that gets the free upgrade
|
||||
spellIds: z.array(z.string()).max(50).default([]), // spells newly granted
|
||||
});
|
||||
|
||||
router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req, res, next) => {
|
||||
try {
|
||||
const parsed = levelUpSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { subclass, boon, weaponId, spellIds } = parsed.data;
|
||||
const character = await loadFullCharacter(req.character.id);
|
||||
if (!canLevelUp(req, res, character)) return;
|
||||
|
||||
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character) });
|
||||
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)) {
|
||||
return res.status(400).json({ error: 'That is not a subclass of this character\'s class.' });
|
||||
}
|
||||
if (plan.boon && !boon) return res.status(400).json({ error: `Level ${plan.toLevel} grants a Boon: pick one.` });
|
||||
if (!plan.boon && boon) return res.status(400).json({ error: `Level ${plan.toLevel} does not grant a Boon.` });
|
||||
if (weaponId && !plan.weapons.some((w) => w.weaponId === weaponId)) {
|
||||
return res.status(400).json({ error: 'That weapon cannot take the free upgrade: it has to be one this character holds, of standard growth, below their new level.' });
|
||||
}
|
||||
const spellsFound = await prisma.spell.count({ where: { id: { in: spellIds }, campaignId: character.campaignId } });
|
||||
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." });
|
||||
}
|
||||
|
||||
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 });
|
||||
} 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;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (weaponId) {
|
||||
await tx.characterWeapon.update({
|
||||
where: { characterId_weaponId: { characterId: character.id, weaponId } },
|
||||
data: { level: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
await tx.characterSpell.createMany({ data: spellIds.map((spellId) => ({ characterId: character.id, spellId })), skipDuplicates: true });
|
||||
await tx.character.update({
|
||||
where: { id: character.id },
|
||||
data: {
|
||||
level: plan.toLevel,
|
||||
...(subclass || plan.subclassTaken ? { subclass: subclass || plan.subclassTaken } : {}),
|
||||
specialAbilities: gained.specialAbilities,
|
||||
armorBonus,
|
||||
tempHp: Math.max(character.tempHp, gained.tempHp),
|
||||
spellSlotsUsed: [],
|
||||
// The new maximum, and healed to it. Levelling does not raise the dead.
|
||||
...(lifeState(character) === 'DEAD' ? {} : { currentHp: maxHp, ...NO_DEATH_SAVES }),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const full = await loadFullCharacter(character.id);
|
||||
emitCharacterUpdated(req);
|
||||
res.json({ character: serializeCharacter(full, req.isOwner) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', requireCharacterAccess(), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
|
||||
@@ -414,6 +728,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
|
||||
name: source.name,
|
||||
species: source.species,
|
||||
characterClass: source.characterClass,
|
||||
subclass: source.subclass,
|
||||
level: source.level,
|
||||
xp: source.xp,
|
||||
motivation: source.motivation,
|
||||
@@ -946,6 +1261,7 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
|
||||
level: character.level,
|
||||
species: character.species,
|
||||
characterClass: character.characterClass,
|
||||
subclassName: usesSubclasses(character) ? subclassName(character.characterClass, character.subclass) : null,
|
||||
currentHp: character.currentHp,
|
||||
maxHp: sheet.hp.max,
|
||||
tempHp: character.tempHp,
|
||||
@@ -955,8 +1271,18 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
|
||||
speed: sheet.speed,
|
||||
proficiencyBonus: sheet.proficiencyBonus,
|
||||
spellcasting: sheet.spellcasting,
|
||||
spellSlots: sheet.spellSlots,
|
||||
hitDice: sheet.hitDice,
|
||||
resources: sheet.resources,
|
||||
inspiration: character.inspiration,
|
||||
exhaustion: character.exhaustion,
|
||||
conditions: character.conditions,
|
||||
passivePerception: sheet.passivePerception,
|
||||
passiveInvestigation: sheet.passiveInvestigation,
|
||||
passiveInsight: sheet.passiveInsight,
|
||||
abilities: sheet.abilities,
|
||||
saves: sheet.saves,
|
||||
skills: sheet.skills,
|
||||
specialAbilities: character.specialAbilities,
|
||||
// What equipped gear is adding and taking away, and what else it does.
|
||||
gear: sheet.gear,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const { z } = require('zod');
|
||||
const prisma = require('../db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { loadCampaignMembership } = require('../middleware/campaignAccess');
|
||||
const { computeCharacterSheet, computeWeaponStats, scaledDamageDice, canUseGear, abilityModifier, MAX_GEAR_LEVEL } = require('../game/calculations');
|
||||
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 { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
|
||||
const { subclassName } = require('../game/data/subclasses');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -104,6 +106,41 @@ function revivalSpell(character) {
|
||||
return character.spells.map((cs) => cs.spell).find((spell) => spell.revives && canUseGear(character.level, spell.level));
|
||||
}
|
||||
|
||||
// The rounds left on a combatant's timed conditions, squared up with the
|
||||
// conditions they are actually under: { condition: rounds }.
|
||||
function roundsFor(conditionRounds, conditions) {
|
||||
const rounds = conditionRounds && typeof conditionRounds === 'object' ? conditionRounds : {};
|
||||
return Object.fromEntries(conditions.filter((condition) => Number.isInteger(rounds[condition]) && rounds[condition] > 0).map((condition) => [condition, rounds[condition]]));
|
||||
}
|
||||
|
||||
// A new round: every timed condition has one round less to run, and those
|
||||
// that run out are removed (from the character too, when it is a character's).
|
||||
// Returns what wore off, as [{ name, condition }], for the DM to read out.
|
||||
async function tickConditions(req) {
|
||||
const participants = await prisma.encounterParticipant.findMany({
|
||||
where: { encounterId: req.encounter.id },
|
||||
include: { character: { select: { id: true, conditions: true } } },
|
||||
});
|
||||
const expired = [];
|
||||
for (const p of participants) {
|
||||
const conditions = p.character ? p.character.conditions : p.conditions;
|
||||
const before = roundsFor(p.conditionRounds, conditions);
|
||||
if (Object.keys(before).length === 0) continue;
|
||||
const after = {};
|
||||
for (const [condition, rounds] of Object.entries(before)) {
|
||||
if (rounds > 1) after[condition] = rounds - 1;
|
||||
else expired.push({ name: p.displayName, condition });
|
||||
}
|
||||
const left = conditions.filter((condition) => before[condition] === undefined || after[condition] !== undefined);
|
||||
await prisma.encounterParticipant.update({ where: { id: p.id }, data: { conditionRounds: after, conditions: left } });
|
||||
if (p.character && left.length !== conditions.length) {
|
||||
await prisma.character.update({ where: { id: p.character.id }, data: { conditions: left } });
|
||||
emitCharacterUpdated(req, p.character.id);
|
||||
}
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
|
||||
const initiativeRollSchema = z.number().int().min(1, 'An initiative roll is a d20: 1 to 20').max(20, 'An initiative roll is a d20: 1 to 20');
|
||||
|
||||
// What every campaign member sees about a character in an encounter: the
|
||||
@@ -113,7 +150,7 @@ const initiativeRollSchema = z.number().int().min(1, 'An initiative roll is a d2
|
||||
// used; what is still in the pack is in the character panel, for the
|
||||
// character's own player and the DM. Uses the
|
||||
// same calculation-engine functions as the sheet, never re-deriving formulas.
|
||||
function characterCombatInfo(character) {
|
||||
function characterCombatInfo(character, subclasses = true) {
|
||||
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
|
||||
// Gear and spells above the character's level are held but can't be used,
|
||||
// so they stay out of the fight; so does anything not equipped.
|
||||
@@ -149,6 +186,7 @@ function characterCombatInfo(character) {
|
||||
level: character.level,
|
||||
species: character.species,
|
||||
characterClass: character.characterClass,
|
||||
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
|
||||
armorClass: sheet.armorClass,
|
||||
maxHp: sheet.hp.max,
|
||||
tempHp: character.tempHp,
|
||||
@@ -157,9 +195,16 @@ function characterCombatInfo(character) {
|
||||
speed: sheet.speed,
|
||||
initiativeBonus: sheet.initiative,
|
||||
passivePerception: sheet.passivePerception,
|
||||
passiveInvestigation: sheet.passiveInvestigation,
|
||||
passiveInsight: sheet.passiveInsight,
|
||||
proficiencyBonus: sheet.proficiencyBonus,
|
||||
abilities: sheet.abilities,
|
||||
saves: sheet.saves,
|
||||
skills: sheet.skills,
|
||||
spellSlots: sheet.spellSlots,
|
||||
resources: sheet.resources,
|
||||
inspiration: character.inspiration,
|
||||
exhaustion: character.exhaustion,
|
||||
specialAbilities: character.specialAbilities,
|
||||
// What equipped gear is adding and taking away. The numbers above already include it.
|
||||
gear: sheet.gear,
|
||||
@@ -170,6 +215,16 @@ function characterCombatInfo(character) {
|
||||
};
|
||||
}
|
||||
|
||||
// How hard the scene is for the characters in it, and what its monsters are
|
||||
// worth: every monster counts, standing or not. Takes the encounter's
|
||||
// participants with their characters and monsters loaded.
|
||||
function difficultyOf(participants) {
|
||||
return encounterDifficulty(
|
||||
participants.filter((p) => p.character).map((p) => p.character.level),
|
||||
participants.filter((p) => p.monster).map((p) => p.monster.challengeRating)
|
||||
);
|
||||
}
|
||||
|
||||
// What is for sale, oldest first, with the weapon or item itself.
|
||||
const WARES = { include: { weapon: true, item: true }, orderBy: { createdAt: 'asc' } };
|
||||
|
||||
@@ -189,7 +244,7 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
const encounter = await prisma.encounter.findUnique({
|
||||
where: { id: encounterId },
|
||||
include: {
|
||||
campaign: { select: { showMonsterHp: true } },
|
||||
campaign: { select: { showMonsterHp: true, subclassesEnabled: true } },
|
||||
location: { select: { id: true, name: true, description: true } },
|
||||
participants: {
|
||||
include: {
|
||||
@@ -224,12 +279,15 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
if (character) {
|
||||
// A character combatant's HP is the character's own HP, not a
|
||||
// per-encounter copy, so the tracker and the sheet can never disagree.
|
||||
const { maxHp, ...combatInfo } = characterCombatInfo(character);
|
||||
const { maxHp, ...combatInfo } = characterCombatInfo(character, encounter.campaign.subclassesEnabled);
|
||||
out.currentHp = character.currentHp;
|
||||
out.maxHp = maxHp;
|
||||
// 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 };
|
||||
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;
|
||||
@@ -250,7 +308,7 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
|
||||
...scene,
|
||||
locationId: knowsPlace ? scene.locationId : null,
|
||||
location: knowsPlace ? location : null,
|
||||
...(isDM ? { dmNotes } : {}),
|
||||
...(isDM ? { dmNotes, difficulty: difficultyOf(encounter.participants) } : {}),
|
||||
// A player is not told that someone they cannot see is being targeted.
|
||||
targetParticipantIds: isDM ? scene.targetParticipantIds : scene.targetParticipantIds.filter((id) => !hiddenIds.has(id)),
|
||||
participants,
|
||||
@@ -361,16 +419,19 @@ const participantSchema = z
|
||||
npcId: z.string().optional(),
|
||||
displayName: z.string().min(1).max(80).optional(),
|
||||
initiativeRoll: initiativeRollSchema.optional(),
|
||||
// Several of one monster at once: "Goblin 1" to "Goblin 4".
|
||||
count: z.number().int().min(1).max(20).default(1),
|
||||
})
|
||||
.refine((data) => [data.characterId, data.monsterId, data.npcId].filter(Boolean).length === 1, {
|
||||
message: 'Provide exactly one of characterId, monsterId or npcId',
|
||||
});
|
||||
})
|
||||
.refine((data) => data.count === 1 || Boolean(data.monsterId), { message: 'Only monsters can be added several at a time' });
|
||||
|
||||
router.post('/:id/participants', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = participantSchema.safeParse(req.body);
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
const { characterId, monsterId, npcId, displayName, initiativeRoll } = parsed.data;
|
||||
const { characterId, monsterId, npcId, displayName, initiativeRoll, count } = parsed.data;
|
||||
|
||||
let currentHp, maxHp, resolvedName;
|
||||
let bonus = 0;
|
||||
@@ -411,22 +472,40 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
|
||||
resolvedName = displayName || npc.name;
|
||||
}
|
||||
|
||||
const participant = await prisma.encounterParticipant.create({
|
||||
data: {
|
||||
encounterId: req.encounter.id,
|
||||
characterId: characterId || null,
|
||||
monsterId: monsterId || null,
|
||||
npcId: npcId || null,
|
||||
displayName: resolvedName,
|
||||
initiativeRoll: initiativeRoll ?? null,
|
||||
initiative: initiativeRoll == null ? 0 : initiativeRoll + bonus,
|
||||
currentHp,
|
||||
maxHp,
|
||||
isMonster: Boolean(monsterId),
|
||||
},
|
||||
});
|
||||
// Several at once are numbered, carrying on from any of that name already
|
||||
// in the scene ("Goblin 3" after "Goblin 1" and "Goblin 2").
|
||||
let names = [resolvedName];
|
||||
if (count > 1) {
|
||||
const present = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id }, select: { displayName: true } });
|
||||
const numbered = present
|
||||
.filter((p) => p.displayName.startsWith(`${resolvedName} `))
|
||||
.map((p) => Number(p.displayName.slice(resolvedName.length + 1)))
|
||||
.filter((n) => Number.isInteger(n) && n > 0);
|
||||
const from = Math.max(0, ...numbered);
|
||||
names = Array.from({ length: count }, (_, i) => `${resolvedName} ${from + i + 1}`.slice(-80));
|
||||
}
|
||||
|
||||
await respondWithEncounter(req, res, 201, { participant });
|
||||
const participants = [];
|
||||
for (const name of names) {
|
||||
participants.push(
|
||||
await prisma.encounterParticipant.create({
|
||||
data: {
|
||||
encounterId: req.encounter.id,
|
||||
characterId: characterId || null,
|
||||
monsterId: monsterId || null,
|
||||
npcId: npcId || null,
|
||||
displayName: name,
|
||||
initiativeRoll: initiativeRoll ?? null,
|
||||
initiative: initiativeRoll == null ? 0 : initiativeRoll + bonus,
|
||||
currentHp,
|
||||
maxHp,
|
||||
isMonster: Boolean(monsterId),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await respondWithEncounter(req, res, 201, { participant: participants[0], participants });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -437,6 +516,9 @@ const updateParticipantSchema = z.object({
|
||||
maxHp: z.number().int().min(1).max(100000).optional(),
|
||||
initiativeRoll: initiativeRollSchema.nullable().optional(), // null clears the roll
|
||||
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
|
||||
// Rounds left on the conditions that wear off by themselves; one left out
|
||||
// lasts until the DM removes it.
|
||||
conditionRounds: z.record(z.string().max(40), z.number().int().min(1).max(100)).optional(),
|
||||
displayName: z.string().min(1).max(80).optional(),
|
||||
});
|
||||
|
||||
@@ -467,11 +549,22 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, async (
|
||||
if (data.initiativeRoll !== undefined) {
|
||||
data.initiative = data.initiativeRoll == null ? 0 : data.initiativeRoll + initiativeBonus(existing);
|
||||
}
|
||||
if (data.conditions) data.conditions = [...new Set(data.conditions)];
|
||||
if (data.conditions !== undefined || data.conditionRounds !== undefined) {
|
||||
// A condition that goes takes its countdown with it.
|
||||
const conditions = data.conditions ?? (existing.character ? existing.character.conditions : existing.conditions);
|
||||
data.conditionRounds = roundsFor(data.conditionRounds ?? existing.conditionRounds, conditions);
|
||||
}
|
||||
if (existing.character) {
|
||||
// Max HP comes from the sheet, and damage/healing lands on the character.
|
||||
const { character } = existing;
|
||||
const maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
|
||||
data.maxHp = maxHp;
|
||||
if (data.conditions !== undefined) {
|
||||
// So do conditions: they are the character's, and stay after the scene.
|
||||
await prisma.character.update({ where: { id: character.id }, data: { conditions: data.conditions } });
|
||||
emitCharacterUpdated(req, character.id);
|
||||
}
|
||||
if (data.currentHp !== undefined) {
|
||||
if (lifeState(character) === 'DEAD' && data.currentHp > 0) {
|
||||
return res.status(400).json({ error: `${character.name} is dead. Revive them before healing.` });
|
||||
@@ -563,6 +656,25 @@ router.post('/:id/participants/:participantId/revive', requireEncounterAccess, a
|
||||
}
|
||||
});
|
||||
|
||||
// Rolls initiative for every monster and NPC that has no roll yet: a d20 each,
|
||||
// with their bonus added as when a roll is typed in. Characters are left to
|
||||
// their players.
|
||||
router.post('/:id/roll-initiative', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const waiting = await prisma.encounterParticipant.findMany({
|
||||
where: { encounterId: req.encounter.id, characterId: null, initiativeRoll: null },
|
||||
include: { monster: true },
|
||||
});
|
||||
for (const p of waiting) {
|
||||
const roll = crypto.randomInt(1, 21);
|
||||
await prisma.encounterParticipant.update({ where: { id: p.id }, data: { initiativeRoll: roll, initiative: roll + initiativeBonus(p) } });
|
||||
}
|
||||
await respondWithEncounter(req, res, 200, { rolled: waiting.length });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Targets — who the combatant on turn is going for
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -635,7 +747,9 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
|
||||
...(wrapped ? { currentRound: { increment: 1 } } : {}),
|
||||
},
|
||||
});
|
||||
await respondWithEncounter(req, res);
|
||||
// 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 });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -654,12 +768,51 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
|
||||
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: standingFrom(order, 0)?.id ?? null } : {}),
|
||||
},
|
||||
});
|
||||
const expired = await tickConditions(req);
|
||||
await syncCharacterLocations(req);
|
||||
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
|
||||
encounterId: updated.id,
|
||||
currentRound: updated.currentRound,
|
||||
});
|
||||
res.json({ encounter: await serializeEncounter(updated.id, req.isDM, req.user.id) });
|
||||
res.json({ expired, encounter: await serializeEncounter(updated.id, req.isDM, req.user.id) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Experience — awarded once, shared evenly between the characters in the scene
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const awardXpSchema = z.object({ xp: z.number().int().min(1).max(10000000).optional() });
|
||||
|
||||
// The DM hands out the encounter's XP: what its monsters are worth unless
|
||||
// another amount is given, in equal shares (rounded down) to every character
|
||||
// in the scene. Nobody levels up by it; the sheet says when a level is due.
|
||||
router.post('/:id/award-xp', requireEncounterAccess, requireDM, async (req, res, next) => {
|
||||
try {
|
||||
const parsed = awardXpSchema.safeParse(req.body || {});
|
||||
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
|
||||
if (req.encounter.xpAwarded != null) {
|
||||
return res.status(400).json({ error: `XP has already been awarded for this encounter (${req.encounter.xpAwarded}). Change a character's XP on their sheet.` });
|
||||
}
|
||||
|
||||
const participants = await prisma.encounterParticipant.findMany({
|
||||
where: { encounterId: req.encounter.id },
|
||||
include: { character: { select: { id: true, name: true, level: true } }, monster: { select: { challengeRating: true } } },
|
||||
});
|
||||
const characters = participants.filter((p) => p.character).map((p) => p.character);
|
||||
if (characters.length === 0) return res.status(400).json({ error: 'There are no characters in this encounter to award XP to.' });
|
||||
const xp = parsed.data.xp ?? difficultyOf(participants).xp;
|
||||
if (!(xp > 0)) return res.status(400).json({ error: 'There is no XP to award: no monster here has a challenge rating. Type an amount.' });
|
||||
|
||||
const share = Math.floor(xp / characters.length);
|
||||
await prisma.$transaction([
|
||||
prisma.character.updateMany({ where: { id: { in: characters.map((c) => c.id) } }, data: { xp: { increment: share } } }),
|
||||
prisma.encounter.update({ where: { id: req.encounter.id }, data: { xpAwarded: xp } }),
|
||||
]);
|
||||
for (const character of characters) emitCharacterUpdated(req, character.id);
|
||||
await respondWithEncounter(req, res, 200, { xp, share, awardedTo: characters.map((c) => c.name) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ const seedSchema = z.strictObject({
|
||||
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
|
||||
maxEquipped: z.number().int().min(1).max(20).optional(),
|
||||
maxEquippedWeapons: z.number().int().min(0).max(20).optional(),
|
||||
subclassesEnabled: z.boolean().optional(),
|
||||
}),
|
||||
locations: z.array(z.strictObject({ name: text(80), description: optionalText(2000) })).default([]),
|
||||
weapons: poolSchema,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// Resting (the Hollowcrown book's Rules Cheat Sheet, "Resting"). Each function
|
||||
// takes a finished character and its sheet and returns the columns to write
|
||||
// plus an account of what happened, for the DM to read. Pure: no DB calls.
|
||||
//
|
||||
// Short rest (an hour): spend Hit Dice to heal, each one the class's die plus
|
||||
// the Constitution modifier. A warlock's spell slots come back.
|
||||
// Class counters that restore on a short rest come back.
|
||||
// Long rest (a night): all HP, all spell slots, every class counter, Hit Dice
|
||||
// up to half the level (at least one), one level of exhaustion less, and temp
|
||||
// HP is gone, except what the Hardy Boon gives.
|
||||
|
||||
const { changeHp, lifeState, NO_DEATH_SAVES } = require('./hp');
|
||||
const { hardyTempHp } = require('../game/levelUp');
|
||||
const { restResources } = require('../game/classResources');
|
||||
|
||||
// Once a day on a short rest a wizard or a Land druid (from level 2) gets some
|
||||
// spent slots back. Which ones is the player's choice, so the DM puts them
|
||||
// back on the sheet; this is the reminder, or null when it does not apply.
|
||||
function recoveryNote(character, sheet) {
|
||||
const feature = character.characterClass === 'WIZARD' ? 'Arcane Recovery' : character.characterClass === 'DRUID' && character.level >= 2 && character.subclass === 'LAND' ? 'Natural Recovery' : null;
|
||||
if (!feature || !sheet.spellSlots || !sheet.spellSlots.levels.some((slot) => slot.used > 0)) return null;
|
||||
const budget = Math.ceil(character.level / 2);
|
||||
return `${feature}: once per day, ${character.name} can regain spent slots adding up to ${budget} slot level${budget === 1 ? '' : 's'} (none of 6th level or higher). Put them back on the sheet.`;
|
||||
}
|
||||
|
||||
// The class counters a rest refills: the tally to store and a line for the DM.
|
||||
function refill(sheet, restType) {
|
||||
const { resourcesUsed, restored } = restResources(sheet.resources || [], restType);
|
||||
return { resourcesUsed, notes: restored.length > 0 ? [`Back: ${restored.join(', ')}.`] : [] };
|
||||
}
|
||||
|
||||
// A problem with the Hit Dice asked for, or null. `rolled` is the total the
|
||||
// dice showed, for dice rolled at the table.
|
||||
function hitDiceProblem(character, sheet, dice, rolled) {
|
||||
if (dice === 0) return null;
|
||||
const { size, remaining } = sheet.hitDice;
|
||||
if (lifeState(character) === 'DYING') return `${character.name} is dying: steady or heal them before they spend Hit Dice.`;
|
||||
if (dice > remaining) return `${character.name} has ${remaining} Hit ${remaining === 1 ? 'Die' : 'Dice'} left, not ${dice}.`;
|
||||
if (rolled != null && (rolled < dice || rolled > dice * size)) {
|
||||
return `${character.name}: ${dice}d${size} adds up to between ${dice} and ${dice * size}, not ${rolled}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// `rolls` is what each Hit Die showed, or `rolled` their total when they were
|
||||
// rolled at the table. The Constitution modifier is added once per die, and
|
||||
// the rest never takes HP away.
|
||||
function shortRest(character, sheet, { dice = 0, rolls = null, rolled = null } = {}) {
|
||||
const total = dice === 0 ? 0 : rolls ? rolls.reduce((sum, roll) => sum + roll, 0) : rolled;
|
||||
const conBonus = dice * sheet.abilities.CON.modifier;
|
||||
const healed = Math.max(0, total + conBonus);
|
||||
const pact = Boolean(sheet.spellSlots) && sheet.spellSlots.restoresOn === 'SHORT';
|
||||
const slotsRestored = pact && sheet.spellSlots.levels.some((slot) => slot.used > 0);
|
||||
|
||||
const counters = refill(sheet, 'SHORT');
|
||||
|
||||
const data = {
|
||||
...(healed > 0 ? changeHp(character, character.currentHp + healed, sheet.hp.max) : {}),
|
||||
hitDiceUsed: sheet.hitDice.used + dice,
|
||||
...(pact ? { spellSlotsUsed: [] } : {}),
|
||||
resourcesUsed: counters.resourcesUsed,
|
||||
};
|
||||
const hpAfter = data.currentHp ?? character.currentHp;
|
||||
return {
|
||||
data,
|
||||
result: {
|
||||
hpBefore: character.currentHp,
|
||||
hpAfter,
|
||||
maxHp: sheet.hp.max,
|
||||
tempHp: character.tempHp,
|
||||
hitDice: { size: sheet.hitDice.size, spent: dice, rolls, total, conBonus, healed: hpAfter - character.currentHp, left: sheet.hitDice.remaining - dice, of: sheet.hitDice.count, regained: 0 },
|
||||
slotsRestored,
|
||||
notes: [...counters.notes, recoveryNote(character, sheet)].filter(Boolean),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function longRest(character, sheet) {
|
||||
const regained = Math.min(sheet.hitDice.used, Math.max(1, Math.floor(character.level / 2)));
|
||||
const tempHp = hardyTempHp(character.specialAbilities);
|
||||
const counters = refill(sheet, 'LONG');
|
||||
const exhaustion = Math.max(0, (character.exhaustion || 0) - 1);
|
||||
const data = {
|
||||
currentHp: sheet.hp.max,
|
||||
tempHp,
|
||||
...NO_DEATH_SAVES,
|
||||
hitDiceUsed: sheet.hitDice.used - regained,
|
||||
spellSlotsUsed: [],
|
||||
resourcesUsed: counters.resourcesUsed,
|
||||
exhaustion,
|
||||
};
|
||||
return {
|
||||
data,
|
||||
result: {
|
||||
hpBefore: character.currentHp,
|
||||
hpAfter: sheet.hp.max,
|
||||
maxHp: sheet.hp.max,
|
||||
tempHp,
|
||||
hitDice: { size: sheet.hitDice.size, spent: 0, rolls: null, total: 0, conBonus: 0, healed: 0, left: sheet.hitDice.remaining + regained, of: sheet.hitDice.count, regained },
|
||||
slotsRestored: Boolean(sheet.spellSlots) && sheet.spellSlots.levels.some((slot) => slot.used > 0),
|
||||
notes: [
|
||||
...counters.notes,
|
||||
...(character.exhaustion > 0 ? [exhaustion > 0 ? `Exhaustion down to level ${exhaustion}.` : 'No longer exhausted.'] : []),
|
||||
...(tempHp > 0 ? [`Hardy: ${tempHp} Temp HP for the day.`] : []),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { hitDiceProblem, shortRest, longRest };
|
||||
@@ -0,0 +1,125 @@
|
||||
const { hitDiceProblem, shortRest, longRest } = require('./rest');
|
||||
const { computeCharacterSheet } = require('../game/calculations');
|
||||
|
||||
const CLEAR = { deathSaveSuccesses: 0, deathSaveFailures: 0, stable: false };
|
||||
// A level 5 fighter (d10) with CON 14 (+2): 10 + 2 + 4 × (6 + 2) = 44 HP.
|
||||
const hero = (over = {}) => ({
|
||||
name: 'Aria',
|
||||
characterClass: 'FIGHTER',
|
||||
level: 5,
|
||||
conScore: 14,
|
||||
currentHp: 10,
|
||||
tempHp: 0,
|
||||
hitDiceUsed: 0,
|
||||
spellSlotsUsed: [],
|
||||
specialAbilities: null,
|
||||
...CLEAR,
|
||||
...over,
|
||||
});
|
||||
const sheet = (character) => computeCharacterSheet(character);
|
||||
|
||||
describe('shortRest', () => {
|
||||
test('spent Hit Dice heal what they rolled plus Constitution for each', () => {
|
||||
const c = hero();
|
||||
const { data, result } = shortRest(c, sheet(c), { dice: 2, rolls: [7, 4] });
|
||||
expect(data).toMatchObject({ currentHp: 25, hitDiceUsed: 2 });
|
||||
expect(result).toMatchObject({ hpBefore: 10, hpAfter: 25, maxHp: 44 });
|
||||
expect(result.hitDice).toMatchObject({ size: 10, spent: 2, total: 11, conBonus: 4, healed: 15, left: 3, of: 5 });
|
||||
});
|
||||
|
||||
test('takes a total rolled at the table, stops at max HP, and never takes HP away', () => {
|
||||
const c = hero({ currentHp: 40 });
|
||||
expect(shortRest(c, sheet(c), { dice: 1, rolled: 9 }).data.currentHp).toBe(44);
|
||||
const frail = hero({ conScore: 3, currentHp: 10 }); // CON -4
|
||||
const rest = shortRest(frail, sheet(frail), { dice: 1, rolled: 2 });
|
||||
expect(rest.data.currentHp).toBeUndefined();
|
||||
expect(rest.data.hitDiceUsed).toBe(1);
|
||||
expect(rest.result.hitDice.healed).toBe(0);
|
||||
});
|
||||
|
||||
test('with no dice spent nothing changes for a fighter', () => {
|
||||
const c = hero({ hitDiceUsed: 3 });
|
||||
expect(shortRest(c, sheet(c)).data).toEqual({ hitDiceUsed: 3, resourcesUsed: {} });
|
||||
});
|
||||
|
||||
test('gives back the class counters that restore on a short rest, and only those', () => {
|
||||
const c = hero({ level: 9, resourcesUsed: { SECOND_WIND: 1, ACTION_SURGE: 1, INDOMITABLE: 1 } });
|
||||
const { data, result } = shortRest(c, sheet(c));
|
||||
expect(data.resourcesUsed).toEqual({ INDOMITABLE: 1 });
|
||||
expect(result.notes).toEqual(['Back: Second Wind, Action Surge.']);
|
||||
});
|
||||
|
||||
test('gives a warlock their slots back, and nobody else', () => {
|
||||
const warlock = hero({ characterClass: 'WARLOCK', spellSlotsUsed: [0, 0, 2] });
|
||||
const rest = shortRest(warlock, sheet(warlock));
|
||||
expect(rest.data.spellSlotsUsed).toEqual([]);
|
||||
expect(rest.result.slotsRestored).toBe(true);
|
||||
|
||||
const wizard = hero({ characterClass: 'WIZARD', spellSlotsUsed: [2, 1] });
|
||||
const wizardRest = shortRest(wizard, sheet(wizard));
|
||||
expect(wizardRest.data.spellSlotsUsed).toBeUndefined();
|
||||
expect(wizardRest.result.slotsRestored).toBe(false);
|
||||
// ...but a wizard is reminded of Arcane Recovery: half their level, rounded up.
|
||||
expect(wizardRest.result.notes).toEqual([expect.stringContaining('Arcane Recovery')]);
|
||||
expect(wizardRest.result.notes[0]).toContain('3 slot levels');
|
||||
const fresh = hero({ characterClass: 'WIZARD' });
|
||||
expect(shortRest(fresh, sheet(fresh)).result.notes).toEqual([]);
|
||||
});
|
||||
|
||||
test('wakes a stable character who spends a Hit Die', () => {
|
||||
const c = hero({ currentHp: 0, stable: true });
|
||||
expect(shortRest(c, sheet(c), { dice: 1, rolled: 5 }).data).toMatchObject({ currentHp: 7, ...CLEAR });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hitDiceProblem', () => {
|
||||
test('refuses more dice than are left, an impossible total, and the dying', () => {
|
||||
const c = hero({ hitDiceUsed: 4 });
|
||||
expect(hitDiceProblem(c, sheet(c), 0)).toBeNull();
|
||||
expect(hitDiceProblem(c, sheet(c), 1, 10)).toBeNull();
|
||||
expect(hitDiceProblem(c, sheet(c), 1)).toBeNull();
|
||||
expect(hitDiceProblem(c, sheet(c), 2)).toMatch(/1 Hit Die left/);
|
||||
expect(hitDiceProblem(c, sheet(c), 1, 11)).toMatch(/between 1 and 10/);
|
||||
expect(hitDiceProblem(c, sheet(c), 1, 0)).toMatch(/between 1 and 10/);
|
||||
const dying = hero({ currentHp: 0 });
|
||||
expect(hitDiceProblem(dying, sheet(dying), 1)).toMatch(/dying/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('longRest', () => {
|
||||
test('heals to full, gives back every slot and half the level in Hit Dice', () => {
|
||||
const c = hero({ characterClass: 'WIZARD', currentHp: 3, tempHp: 4, hitDiceUsed: 5, spellSlotsUsed: [4, 3, 2] });
|
||||
const { data, result } = longRest(c, sheet(c));
|
||||
expect(data).toEqual({ currentHp: sheet(c).hp.max, tempHp: 0, ...CLEAR, hitDiceUsed: 3, spellSlotsUsed: [], resourcesUsed: {}, exhaustion: 0 });
|
||||
expect(result.hitDice).toMatchObject({ regained: 2, left: 2, of: 5 });
|
||||
expect(result.slotsRestored).toBe(true);
|
||||
});
|
||||
|
||||
test('gives back at least one Hit Die, and no more than were spent', () => {
|
||||
const novice = hero({ level: 1, hitDiceUsed: 1 });
|
||||
expect(longRest(novice, sheet(novice)).data.hitDiceUsed).toBe(0);
|
||||
const rested = hero({ hitDiceUsed: 1 });
|
||||
expect(longRest(rested, sheet(rested)).result.hitDice).toMatchObject({ regained: 1, left: 5 });
|
||||
});
|
||||
|
||||
test('gets a dying character back on their feet', () => {
|
||||
const c = hero({ currentHp: 0, deathSaveFailures: 2, deathSaveSuccesses: 1 });
|
||||
expect(longRest(c, sheet(c)).data).toMatchObject({ currentHp: 44, ...CLEAR });
|
||||
});
|
||||
|
||||
test('gives every class counter back and takes one level of exhaustion off', () => {
|
||||
const c = hero({ level: 9, exhaustion: 2, resourcesUsed: { SECOND_WIND: 1, INDOMITABLE: 1 } });
|
||||
const { data, result } = longRest(c, sheet(c));
|
||||
expect(data).toMatchObject({ resourcesUsed: {}, exhaustion: 1 });
|
||||
expect(result.notes).toEqual(['Back: Second Wind, Indomitable.', 'Exhaustion down to level 1.']);
|
||||
const tired = hero({ exhaustion: 1 });
|
||||
expect(longRest(tired, sheet(tired)).result.notes).toEqual(['No longer exhausted.']);
|
||||
});
|
||||
|
||||
test('leaves the Hardy Boon its Temp HP', () => {
|
||||
const c = hero({ tempHp: 2, specialAbilities: 'Hardy — +10 maximum HP: gain 10 Temp HP after each long rest.' });
|
||||
const { data, result } = longRest(c, sheet(c));
|
||||
expect(data.tempHp).toBe(10);
|
||||
expect(result.notes).toEqual(['Hardy: 10 Temp HP for the day.']);
|
||||
});
|
||||
});
|
||||
+689
-23
@@ -7,7 +7,10 @@ This describes the code as it stands. The formulas live in
|
||||
`backend/src/game/calculations.js`; the build flow lives in
|
||||
`backend/src/game/buildFlow.js` and `backend/src/routes/characters.routes.js`;
|
||||
the lists the story steps pick from are in
|
||||
`backend/src/game/data/characterOptions.js`.
|
||||
`backend/src/game/data/characterOptions.js`. Levelling up is
|
||||
`backend/src/game/levelUp.js` (the class tables are in
|
||||
`backend/src/game/data/levelFeatures.js`), and resting is
|
||||
`backend/src/utils/rest.js`.
|
||||
|
||||
Related: [WEAPONS.md](WEAPONS.md), [SPELLS.md](SPELLS.md),
|
||||
[ITEMS.md](ITEMS.md), [ENCOUNTERS.md](ENCOUNTERS.md),
|
||||
@@ -39,6 +42,7 @@ is the current behaviour.
|
||||
18. [Deleting](#18-deleting)
|
||||
19. [What is not modelled](#19-what-is-not-modelled)
|
||||
20. [Everything the builder offers](#20-everything-the-builder-offers)
|
||||
21. [What each class gains as it levels](#21-what-each-class-gains-as-it-levels)
|
||||
|
||||
---
|
||||
|
||||
@@ -278,11 +282,151 @@ Ability scores cannot be changed after the build is finalized.
|
||||
## 6. Level and XP
|
||||
|
||||
- Every character starts at **level 1**. The range is 1 to 20.
|
||||
- Levelling is **manual**: the DM changes the Level field on the sheet. Only
|
||||
the DM can change level or XP.
|
||||
- **XP** is stored but no screen shows or edits it, and it does not drive level.
|
||||
- Only the DM can change level or XP. There are two ways to change a level,
|
||||
both on the sheet: the **⬆ Level Up…** button, and the Level field.
|
||||
- **XP** (experience points) is shown under the character's name on the sheet
|
||||
as "37 / 300 XP": what they have, and what their next level takes. The party
|
||||
overview shows the same under each level.
|
||||
- XP comes from encounters: when the DM awards an encounter's XP it is shared
|
||||
between the characters in it ([ENCOUNTERS.md](ENCOUNTERS.md), section 9).
|
||||
The DM can also type a character's XP into the **XP** field beside Level.
|
||||
- **XP does not level a character up.** When a character has the XP for
|
||||
their next level the sheet shows "⬆ Level up due" (the party overview shows
|
||||
⬆), and the DM levels them with "⬆ Level Up…" when the table is ready. A DM
|
||||
who levels by milestones can ignore XP altogether.
|
||||
|
||||
Level feeds two things:
|
||||
| Level | XP needed | Level | XP needed | Level | XP needed | Level | XP needed |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | 0 | 6 | 14,000 | 11 | 85,000 | 16 | 195,000 |
|
||||
| 2 | 300 | 7 | 23,000 | 12 | 100,000 | 17 | 225,000 |
|
||||
| 3 | 900 | 8 | 34,000 | 13 | 120,000 | 18 | 265,000 |
|
||||
| 4 | 2,700 | 9 | 48,000 | 14 | 140,000 | 19 | 305,000 |
|
||||
| 5 | 6,500 | 10 | 64,000 | 15 | 165,000 | 20 | 355,000 |
|
||||
|
||||
### Level Up: the checklist
|
||||
|
||||
"⬆ Level Up…" takes a character up **one level** and does everything that
|
||||
goes with it. It opens a pop-out that lists the five steps and asks for the
|
||||
choices; nothing changes until the DM presses "Level up to N".
|
||||
|
||||
| Step | What the site does | What the DM chooses |
|
||||
|---|---|---|
|
||||
| 1. Level | Raises the level by one. Proficiency bonus, saves, skills and spell DC follow | Nothing |
|
||||
| 2. Hit points | Works out the new max HP and heals to it (a dead character stays dead). One more Hit Die | Nothing |
|
||||
| 3. Special abilities | Writes the class's and the subclass's new features for that level into Special Abilities ([section 21](#21-what-each-class-gains-as-it-levels)) | The **subclass**, at the level the class chooses one; the **Boon**, at a level that grants one |
|
||||
| 4. Spells | Gives a caster a fresh set of spell slots for the new level, and grants the spells ticked | Which spells to grant, from the campaign's spell list |
|
||||
| 5. Gear | Raises one weapon by one gear level | Which weapon, or none |
|
||||
|
||||
**Special abilities.** Each feature is one line, "Name — what it does". A
|
||||
feature that grows (Sneak Attack gaining a die, Rage gaining a use) rewrites
|
||||
the line it already has; anything else is added at the end. Where a line says
|
||||
"choose" (a fighting style, metamagic, an invocation), the DM edits it once
|
||||
the player has chosen. From level 5 every class that casts also gets a
|
||||
"Cantrips" line: damaging cantrips roll double the dice shown.
|
||||
|
||||
The class and subclass tables run from **level 2 to level 10**. Above level 10
|
||||
the numbers still move but no feature is written; add them by hand.
|
||||
|
||||
**Subclasses.** Each class has three subclasses: the path it takes from early
|
||||
on. A cleric, a druid and a wizard choose at **level 2**; every other class at
|
||||
**level 3**.
|
||||
|
||||
| Class | Subclasses |
|
||||
|---|---|
|
||||
| Barbarian | Path of the Berserker, Path of the Totem Warrior, Path of the Zealot |
|
||||
| Bard | College of Lore, College of Valor, College of Swords |
|
||||
| Cleric | Life Domain, Light Domain, War Domain |
|
||||
| Druid | Circle of the Land, Circle of the Moon, Circle of Stars |
|
||||
| Fighter | Champion, Battle Master, Samurai |
|
||||
| Monk | Way of the Open Hand, Way of Shadow, Way of the Four Elements |
|
||||
| Paladin | Oath of Devotion, Oath of the Ancients, Oath of Vengeance |
|
||||
| Ranger | Hunter, Beast Master, Gloom Stalker |
|
||||
| Rogue | Thief, Assassin, Swashbuckler |
|
||||
| Sorcerer | Draconic Bloodline, Wild Magic, Storm Sorcery |
|
||||
| Warlock | The Fiend, The Archfey, The Great Old One |
|
||||
| Wizard | School of Evocation, School of Abjuration, School of Illusion |
|
||||
|
||||
- At the level the class chooses, the checklist lists the three with a line
|
||||
about each. Picking one shows the features it brings, and the button stays
|
||||
"Choose a subclass first" until one is picked.
|
||||
- From then on each level writes the subclass's features along with the
|
||||
class's.
|
||||
- The subclass is shown beside the class wherever the class is: the sheet, the
|
||||
character panel, encounter cards, the party overview and the campaign's
|
||||
character list ("Fighter (Champion)").
|
||||
- The DM can set or change it later with the **Subclass** menu beside the
|
||||
Level field (shown once the character is at the level their class chooses).
|
||||
The old subclass's lines are taken out of Special Abilities, found by their
|
||||
names, and the new one's are written, up to the character's level. A line
|
||||
the DM had edited ("choose three…") is replaced by the new text. "Not
|
||||
chosen" takes the subclass away and writes "Subclass — Choose one with your
|
||||
DM…" in its place.
|
||||
- Like every special ability, a subclass's features are written for the table
|
||||
to apply: none of them changes a number on the sheet.
|
||||
|
||||
**A campaign without subclasses.** "Characters choose a subclass", in the
|
||||
campaign's Settings, is ticked for a new campaign. Unticked:
|
||||
|
||||
- Nobody is asked. On reaching the level their class would choose at, a
|
||||
character is put on the **first subclass in the table above** (Champion
|
||||
fighter, Life cleric, Evocation wizard…), by Level Up and by the Level
|
||||
field alike, and its features are written with the class's. A character
|
||||
already past that level gets everything it has given so far the next time
|
||||
they level.
|
||||
- No subclass is named anywhere, the checklist has no subclass step, the sheet
|
||||
has no Subclass menu, and no "Subclass — Choose one" line is written.
|
||||
- A character who already had a subclass keeps following it, unnamed.
|
||||
- Class counters still follow the subclass a character is on (a druid's
|
||||
Natural Recovery, for one).
|
||||
- Ticking the setting again shows each character's subclass and lets the DM
|
||||
change it. Unticking takes nothing off any sheet.
|
||||
|
||||
The first subclass of each class in the table is the single path that class
|
||||
followed before version 1.16. Characters who were already at their class's
|
||||
choice level when subclasses arrived were given that one, since their special
|
||||
abilities had been written from it.
|
||||
|
||||
**Boons** stand in for ability score increases, which the site does not have
|
||||
(every number on the sheet comes from the scores rolled at the build). Most
|
||||
classes get one at levels 4 and 8; a fighter also at 6, a rogue also at 10.
|
||||
|
||||
| Boon | What it is | What the site does |
|
||||
|---|---|---|
|
||||
| Weapon Mastery | +1 to attack and damage rolls with weapons | Writes the line. The player adds the +1 to the numbers shown |
|
||||
| Arcane Focus | +1 to spell save DC and spell attack bonus | Writes the line. The player adds the +1 to the numbers shown |
|
||||
| Toughened | +1 AC | Writes the line and raises **Extra AC** by 1 |
|
||||
| Hardy | +5 maximum HP, as temp HP | Writes the line, gives 5 **temp HP** now and again after every long rest |
|
||||
|
||||
Toughened can be taken once; each of the others twice (its one line then
|
||||
reads +2, or +10).
|
||||
|
||||
**Spells.** The pop-out says what the book's unlock schedule allows at the new
|
||||
level, and lists the campaign's spells the character does not yet know, up to
|
||||
the highest slot level they now have. What is ticked is granted; the count is
|
||||
the DM's to keep.
|
||||
|
||||
| Class | New spells each level | Extra cantrip |
|
||||
|---|---|---|
|
||||
| Bard, Cleric, Druid, Sorcerer, Warlock | 1 | At levels 4 and 10 |
|
||||
| Wizard | 2 | At levels 4 and 10 |
|
||||
| Paladin, Ranger | 2 at level 2, then 1 at each odd level | None |
|
||||
|
||||
**Gear.** The free upgrade goes to one held weapon of **standard growth**
|
||||
whose gear level is below the character's new level (the equipped one is
|
||||
offered first). Powerful and legendary weapons never get it.
|
||||
|
||||
### The Level field
|
||||
|
||||
Typing a number into Level sets it outright: for a character who joins late,
|
||||
or to put a mistake right. Going **up** this way still writes down every
|
||||
feature the class gains on the way (and the subclass, when one is chosen), but
|
||||
it asks nothing: a Boon is left as a line saying one is owed ("Boon (level 4)
|
||||
— Choose one with your DM…"), and so is a subclass not yet picked ("Subclass —
|
||||
Choose one with your DM…", cleared when one is picked), HP is not healed, no
|
||||
spell is granted and no weapon is raised. Going **down** takes
|
||||
no special ability away; the DM edits the text.
|
||||
|
||||
Level feeds these things:
|
||||
|
||||
**Proficiency bonus** — `2 + (level − 1) ÷ 4`, rounded down.
|
||||
|
||||
@@ -294,7 +438,9 @@ Level feeds two things:
|
||||
| 13–16 | +5 |
|
||||
| 17–20 | +6 |
|
||||
|
||||
**Max HP** — see the next section.
|
||||
**Max HP** and **Hit Dice** — see the next section.
|
||||
|
||||
**Spell slots** — how many a caster has ([SPELLS.md](SPELLS.md), section 8).
|
||||
|
||||
## 7. Hit points
|
||||
|
||||
@@ -319,12 +465,57 @@ Example: a Fighter (d10) with CON 14 (+2) has 12 at level 1 and 28 at level 3.
|
||||
or in an encounter (section 15). Both change the same number. A player sees
|
||||
their HP but has no buttons to change it, and the server refuses the change.
|
||||
- Always kept between **0 and max**: healing stops at max and damage stops at 0.
|
||||
- A level-up raises max but leaves current HP where it was; the DM clicks
|
||||
"Recompute Max HP" to set current HP to the new max (a full heal). If a
|
||||
level change lowers max below current HP, current HP drops to the new max.
|
||||
- "⬆ Level Up…" heals to the new max. A level typed into the Level field
|
||||
raises max but leaves current HP where it was; the DM clicks "Recompute Max
|
||||
HP" to set current HP to the new max (a full heal). If a level change lowers
|
||||
max below current HP, current HP drops to the new max.
|
||||
|
||||
**Temp HP** is a tracked number shown beside HP, set by the DM. Damage is not taken from it
|
||||
automatically.
|
||||
**Temp HP** is a tracked number shown beside HP, set by the DM. Damage comes
|
||||
off it first. A long rest clears it.
|
||||
|
||||
### Hit Dice and rests
|
||||
|
||||
A character has **one Hit Die per level**, of their class's size (the same die
|
||||
as in the table above). The sheet, the character panel and the party overview
|
||||
show how many are left. Only the DM changes that number: with a rest, or by
|
||||
typing into "Hit Dice left" on the sheet.
|
||||
|
||||
The DM's **🛏 Rest…** button is on the campaign page (beside "Characters") and
|
||||
on the party overview. It opens a pop-out: tick who rests, pick short or long,
|
||||
and press the button. Everyone ticked rests at once, and the pop-out then says
|
||||
what it did for each of them. Dead characters start unticked, and a rest does
|
||||
nothing for them.
|
||||
|
||||
| | Short rest (1 hour) | Long rest (8 hours) |
|
||||
|---|---|---|
|
||||
| HP | Each Hit Die spent heals what it rolled + the CON modifier | Back to max; death saves cleared |
|
||||
| Hit Dice | The ones spent are used up | Spent ones come back, up to half the level (at least 1) |
|
||||
| Spell slots | A warlock gets all of theirs back | Everyone gets all of theirs back |
|
||||
| Temp HP | Unchanged | Cleared, then the Hardy Boon gives its 5 or 10 |
|
||||
| Class counters | Those marked short rest come back | All come back |
|
||||
| Exhaustion | Unchanged | One level less |
|
||||
| Inspiration and conditions | Unchanged | Unchanged |
|
||||
|
||||
**Spending Hit Dice.** On a short rest each resting character has a line that
|
||||
says what they can roll: "Spend [ ] of 3 d10". The DM types how many dice the
|
||||
player spends, and the pop-out shows what to roll ("Roll 2d10") and the
|
||||
Constitution that will be added. Then either:
|
||||
|
||||
- the player rolls real dice and the DM types **what they added up to**, or
|
||||
- the total is left empty and **the site rolls** them, and shows each die.
|
||||
|
||||
The healing is the total, plus the CON modifier once for each die, and never
|
||||
less than nothing. It stops at max HP. A dying character cannot spend Hit
|
||||
Dice until they are steadied or healed; a stable one can, and wakes up.
|
||||
|
||||
A short rest is refused as a whole, with nothing changed for anyone, if a
|
||||
character is asked to spend more dice than they have or the total typed is not
|
||||
one those dice could show.
|
||||
|
||||
After a short rest a wizard (Arcane Recovery) or a Circle of the Land druid
|
||||
(Natural Recovery) with spent slots gets a reminder: once per day they may
|
||||
regain slots adding up to half their level, rounded up. Which slots is the
|
||||
player's choice, so the DM puts them back on the sheet.
|
||||
|
||||
### At 0 HP: death saves
|
||||
|
||||
@@ -370,6 +561,76 @@ Dropping to 0 always starts from empty tallies. None of this changes any
|
||||
other number on the sheet, and nothing is added to the conditions in an
|
||||
encounter.
|
||||
|
||||
### Condition and counters
|
||||
|
||||
The sheet's "Condition and counters" card, also at the top of the character
|
||||
panel, is how a character is doing beyond hit points. All of it is a reminder
|
||||
for the table: **none of it changes a number on the sheet**.
|
||||
|
||||
- **Inspiration.** The DM gives it ("✨ Give inspiration") and can take it
|
||||
away. The character's own player sees "✨ Inspired" and a **Spend** button.
|
||||
A character has it or does not; it is not counted.
|
||||
- **Exhaustion**, level 0 to 6, set by the DM. The card lists what the level
|
||||
means (1: disadvantage on ability checks; 2: speed halved; 3: disadvantage
|
||||
on attacks and saves; 4: HP maximum halved; 5: speed 0; 6: death), each
|
||||
level adding to those before. A **long rest takes one level off**.
|
||||
- **Conditions** (Poisoned, Prone, Concentrating…), given and removed by the
|
||||
DM. Hovering over one shows its rule. They belong to the character, not to
|
||||
an encounter: a condition given in a battle is still there after it, on the
|
||||
sheet, in the panel and in the next encounter, until the DM removes it
|
||||
([ENCOUNTERS.md](ENCOUNTERS.md), section 8).
|
||||
- **Class counters**: the things a class does a set number of times before a
|
||||
rest. Each has a row of pips, filled while a use is left, and the rest that
|
||||
gives it back. The character's own player and the DM press **Use** to spend
|
||||
one. Only the DM gives one back: a click on a pip sets how many are left,
|
||||
and "All back" refills them all. A counter with more than ten uses (Lay on
|
||||
Hands, a high-level monk's Ki) has a box for how much to spend in place of
|
||||
pips, and "+ Back" for the DM.
|
||||
|
||||
| Class | Counter | How many | Back on |
|
||||
|---|---|---|---|
|
||||
| Barbarian | Rage | 2; 3 from level 3; 4 from level 6 | Long rest |
|
||||
| Bard | Bardic Inspiration | Charisma modifier, at least 1 | Long rest; short rest from level 5 |
|
||||
| Cleric | Healing Hands | 1 | Short rest |
|
||||
| Cleric | Channel Divinity (from level 2) | 1; 2 from level 6 | Short rest |
|
||||
| Druid | Wild Shape (from level 2) | 2 | Short rest |
|
||||
| Fighter | Second Wind | 1 | Short rest |
|
||||
| Fighter | Action Surge (from level 2) | 1 | Short rest |
|
||||
| Fighter | Indomitable (from level 9) | 1 | Long rest |
|
||||
| Monk | Ki (from level 2) | The character's level | Short rest |
|
||||
| Paladin | Lay on Hands (HP) | 5 × level | Long rest |
|
||||
| Paladin | Divine Sense | 1 + Charisma modifier, at least 1 | Long rest |
|
||||
| Paladin | Channel Divinity (from level 3) | 1 | Short rest |
|
||||
| Sorcerer | Wild Talent | 1 | Long rest |
|
||||
| Sorcerer | Sorcery Points (from level 2) | The character's level | Long rest |
|
||||
| Wizard | Arcane Recovery | 1 | Long rest |
|
||||
|
||||
A ranger, a rogue and a warlock have none of their own. Subclasses add theirs:
|
||||
|
||||
| Subclass | Counter | How many | Back on |
|
||||
|---|---|---|---|
|
||||
| Zealot (level 10) | Zealous Presence | 1 | Long rest |
|
||||
| Light Domain | Warding Flare | Wisdom modifier, at least 1 | Long rest |
|
||||
| War Domain | War Priest | Wisdom modifier, at least 1 | Long rest |
|
||||
| Circle of the Land | Natural Recovery | 1 | Long rest |
|
||||
| Circle of Stars | Star Map (Guiding Bolt) | Proficiency bonus | Long rest |
|
||||
| Circle of Stars (level 6) | Cosmic Omen | Proficiency bonus | Long rest |
|
||||
| Battle Master | Superiority Dice | 4; 5 from level 7 | Short rest |
|
||||
| Samurai | Fighting Spirit | 3 | Long rest |
|
||||
| Open Hand (level 6) | Wholeness of Body | 1 | Long rest |
|
||||
| Wild Magic | Tides of Chaos | 1 | Long rest |
|
||||
| The Archfey | Fey Presence; Misty Escape (level 6) | 1 each | Short rest |
|
||||
| The Fiend (level 6) | Patron's Luck | 1 | Short rest |
|
||||
| The Great Old One (level 6) | Entropic Ward | 1 | Short rest |
|
||||
| School of Illusion (level 10) | Illusory Self | 1 | Short rest |
|
||||
|
||||
A **short rest** gives back the counters marked short rest; a **long rest**
|
||||
gives back every counter. The rest pop-out names what came back. What a
|
||||
counter does is in the character's special abilities; the site only keeps
|
||||
count. Spending a counter does nothing else: a monk who spends Ki, or a
|
||||
sorcerer turning points into a slot, still presses the other buttons
|
||||
themselves.
|
||||
|
||||
## 8. Other derived stats
|
||||
|
||||
| Stat | Formula |
|
||||
@@ -378,6 +639,7 @@ encounter.
|
||||
| Initiative | DEX modifier + equipped gear |
|
||||
| Speed | Species speed, unless a speed override is set, + equipped gear |
|
||||
| Passive Perception | 10 + Perception skill bonus + equipped gear |
|
||||
| Passive Investigation, Passive Insight | 10 + that skill's bonus (gear that raises the skill raises it too) |
|
||||
| Hit dice | One per level, of the class's die size (shown only; not spent or tracked) |
|
||||
| Spell save DC | 8 + proficiency bonus + spellcasting ability modifier |
|
||||
| Spell attack bonus | Proficiency bonus + spellcasting ability modifier |
|
||||
@@ -419,6 +681,49 @@ Skill proficiencies are drawn at random during the build (step 5) and cannot
|
||||
be changed from the sheet afterwards. Expertise is supported by the
|
||||
calculations but is only ever set if the DM supplies it during step 5.
|
||||
|
||||
### Rolling from the sheet
|
||||
|
||||
Numbers with a dotted underline roll when clicked. The dice roller opens with
|
||||
the result under a label saying what it was ("Aria: Stealth"), the die that
|
||||
came up, the bonus and the total. A natural 20 or 1 is called out.
|
||||
|
||||
| Click | Rolls |
|
||||
|---|---|
|
||||
| An ability's modifier | d20 + the modifier (an ability check) |
|
||||
| A saving throw, a skill | d20 + its bonus |
|
||||
| A weapon's "to hit" | d20 + its attack bonus |
|
||||
| A weapon's damage | Its dice at its gear level + the damage bonus |
|
||||
| The spell attack bonus | d20 + the bonus |
|
||||
| A spell's dice | Those dice |
|
||||
| Initiative (on an encounter card) | d20 + the initiative bonus |
|
||||
|
||||
- The same numbers roll on the sheet, in the character panel, and on an
|
||||
encounter card's details and the spotlight. On an encounter a monster's or
|
||||
NPC's attack and damage, and a monster's ability scores, roll too (for the
|
||||
DM, who is the only one sent them).
|
||||
- **Advantage and disadvantage.** The roller has Disadv. / Normal / Adv.
|
||||
buttons. Whichever is lit applies to every d20 rolled until it is changed:
|
||||
two d20s are rolled and the higher or lower kept, and both are shown.
|
||||
- A roll is made in the browser of whoever clicked. It is not sent to the
|
||||
server, nobody else sees it, and it changes nothing: a damage roll is not
|
||||
applied, a spell slot is not spent, and an initiative roll still has to be
|
||||
typed into its field.
|
||||
- Of dice written with more after them ("2d8+3 fire") the dice are rolled.
|
||||
Text that does not start with dice (a DM may type anything as an item's or
|
||||
a spell's dice) is shown as plain text.
|
||||
- Boons that say "add it to the numbers the site shows" (Weapon Mastery,
|
||||
Arcane Focus) are not in the roll either: add them to the total.
|
||||
|
||||
**Calling for a roll (DM).** "🎲 Call for a roll…" on the party overview, and
|
||||
in an encounter's header, rolls one check for several characters at once.
|
||||
Pick a skill, a saving throw or an ability check; optionally a DC, and
|
||||
advantage or disadvantage; untick anyone who is not rolling. Each character
|
||||
gets their own d20 and their own bonus, and with a DC each is marked "made it"
|
||||
or "failed", with how many made it and whether a group check (at least half)
|
||||
succeeds. On the party overview it offers every finished character who is not
|
||||
dead; in an encounter, the characters in it who are still up. These rolls are
|
||||
the DM's own, like any other: the players do not see them.
|
||||
|
||||
## 10. Gear pools, starting options and gear levels
|
||||
|
||||
Each campaign has three **pools**, managed by the DM on the campaign page,
|
||||
@@ -564,7 +869,8 @@ A character knows spells, each with a prepared flag.
|
||||
- A spell's damage, if it has one, is shown beside it. It is fixed.
|
||||
- Spell save DC and attack bonus are shown only for classes with a
|
||||
spellcasting ability.
|
||||
- There are no spell slots.
|
||||
- A caster has **spell slots**, from class and level, tracked on the sheet
|
||||
and in the character panel ([SPELLS.md](SPELLS.md), section 8).
|
||||
|
||||
## 12. Inventory and money
|
||||
|
||||
@@ -669,10 +975,10 @@ character in the same campaign.
|
||||
| Screen | What it shows |
|
||||
|---|---|
|
||||
| Campaign page | On its "Overview". Character list: species, class, level, HP, AC, location. A player's own characters are under "Your Characters" and nowhere else; "Rest of the Party" is everyone else's. |
|
||||
| Party overview | One table of every character with an HP bar, AC, gold and location. A player's own characters are highlighted and marked "★ Yours". |
|
||||
| Party overview | One table of every character with an HP bar, badges for inspiration, exhaustion and conditions, AC, passive Perception / Investigation / Insight, spell save DC, Hit Dice left, spell slots left, gold and location. A 📋 button beside each name opens the character panel for that character without leaving the page. The DM's "🛏 Rest…" button is here too. A player's own characters are highlighted and marked "★ Yours". |
|
||||
| Character sheet | Everything in this document. |
|
||||
| Character builder | A character that is still being built (section 2). |
|
||||
| Character panel (the floating 🧙 button, on every page: bottom right, or in the top bar on a phone) | One character at a glance: HP, AC, initiative, speed, proficiency bonus, passive Perception, ability scores, what equipped gear is doing, spellcasting numbers and special abilities; then **everything the character holds**: every weapon with its attack and damage, every item, every spell and the purse, with what is equipped and what each thing does. Gear can be equipped and unequipped from here. A player gets their own characters in the campaign they are in; the DM gets every character, with a menu to switch. Another player's panel shows the numbers and weapons only. It replaced the quick stats drawer. |
|
||||
| Character panel (the floating 🧙 button, on every page: bottom right, or in the top bar on a phone) | One character at a glance: HP, AC, initiative, speed, proficiency bonus, passive Perception, ability scores, Hit Dice left, inspiration, exhaustion, conditions and class counters (section 7), **saving throws and every skill** (● proficient, ◆ expertise) with passive Perception, Investigation and Insight, special abilities, what equipped gear is doing, and spellcasting numbers with the spell slot tracker; then **everything the character holds**: every weapon with its attack and damage, every item, every spell and the purse, with what is equipped and what each thing does. Gear can be equipped and unequipped from here. The saves and skills, special abilities, inventory and spells each fold away with a click on their title. A player gets their own characters in the campaign they are in; the DM gets every character, with a menu to switch. Another player's panel shows the numbers, saves, skills and weapons only. It replaced the quick stats drawer. |
|
||||
| Encounter page | Characters taking part in a battle or quest (next section). |
|
||||
|
||||
Changes are pushed live to everyone who has the campaign open, so sheets and
|
||||
@@ -710,7 +1016,7 @@ players never receive.
|
||||
|
||||
| | DM | Players |
|
||||
|---|---|---|
|
||||
| A character | Level, species, class, HP, AC, speed, initiative bonus, passive perception, proficiency bonus, ability scores, saves, usable weapons and spells, special abilities | The same |
|
||||
| A character | Level, species, class, HP, AC, speed, initiative bonus, passive perception, investigation and insight, proficiency bonus, ability scores, saves, skills, usable weapons and spells, spell slots left, special abilities | The same |
|
||||
| A monster | The full stat block: AC, speed, attack, damage, challenge, ability scores, trait, notes | Name, HP and conditions only |
|
||||
| An NPC | Everything, including DM notes and stats | Name, role, attitude and description |
|
||||
| Rewards | All of them | Only the ones that have been awarded |
|
||||
@@ -780,6 +1086,18 @@ Another Campaign" card on its sheet.
|
||||
| Open the character panel with the full inventory | Yes | Yes | No (numbers and weapons only) |
|
||||
| Enter a death save, or stabilise | Yes | Yes | No |
|
||||
| Set death-save tallies by hand, revive from dead | No | Yes | No |
|
||||
| Spend a spell slot | Yes | Yes | No |
|
||||
| Give a spell slot back, or all of them | No | Yes | No |
|
||||
| Call a short or long rest | No | Yes | No |
|
||||
| Change the Hit Dice left | No | Yes | No |
|
||||
| Spend a class counter (Rage, Ki…) | Yes | Yes | No |
|
||||
| Give a class counter back, or all of them | No | Yes | No |
|
||||
| Give inspiration, or take it away | No | Yes | No |
|
||||
| Spend inspiration | Yes | Yes | No |
|
||||
| Set exhaustion; give or remove a condition | No | Yes | No |
|
||||
| Level a character up (the checklist), pick a subclass or a Boon | No | Yes | No |
|
||||
| Set or change the subclass on the sheet (in a campaign that uses subclasses) | No | Yes | No |
|
||||
| Switch subclasses on or off for the campaign | No | Yes | No |
|
||||
| Edit special abilities, background, appearance (after the build) | No | Yes | No |
|
||||
| Tick Equipped / Prepared | Yes | Yes | No |
|
||||
| Give, remove or re-count weapons, spells and items | No | Yes | No |
|
||||
@@ -790,6 +1108,7 @@ Another Campaign" card on its sheet.
|
||||
| Change gold and cash with an NPC, or buy what is for sale, in a quest in progress | Yes | Yes | No |
|
||||
| View the money log, gold and inventory | Yes | Yes | No |
|
||||
| Change level, XP, location or speed override | No | Yes | No |
|
||||
| See a character's XP | Yes | Yes | Yes |
|
||||
| Post a character note | Yes | Yes | Yes |
|
||||
| Copy to another campaign | Yes | Yes | No |
|
||||
| Delete the character | No | Yes | No |
|
||||
@@ -811,16 +1130,26 @@ calculations for them.
|
||||
|
||||
- The effects of special abilities: species traits and class features are
|
||||
written on the sheet for the DM to apply, but nothing is calculated from them
|
||||
- Class features beyond level 1 (the DM adds them to the sheet by hand),
|
||||
subclasses and multiclassing
|
||||
- Spell slots, spell preparation limits and concentration
|
||||
- Ability score increases and feats on level-up
|
||||
- Class and subclass features beyond level 10 (the DM adds them to the sheet
|
||||
by hand), subclasses beyond the three per class, the spellcasting
|
||||
subclasses of non-casters (Eldritch Knight, Arcane Trickster) and
|
||||
multiclassing
|
||||
- Spell preparation limits and concentration
|
||||
- Ability score increases and feats on level-up (a Boon is given in their
|
||||
place, and Weapon Mastery and Arcane Focus are added by the player, not by
|
||||
the site)
|
||||
- What a class counter or a condition does: the site counts Rages, Ki and the
|
||||
rest and lists conditions, but applies none of their effects
|
||||
- Counters for species traits, talents and Boons, and uses the special
|
||||
abilities give "a few times per day" beyond the ones in section 7
|
||||
- Armor rules beyond a flat bonus to AC (no "replaces 10 + DEX", no cap on DEX)
|
||||
- Weapon proficiency (everyone is proficient with everything)
|
||||
- Rolling attacks, damage, saves or checks for a character (the dice roller,
|
||||
bottom left of every page, or the last button of the bottom bar on a phone,
|
||||
is a plain roller that knows nothing about the sheet)
|
||||
- Conditions on the sheet, and resting
|
||||
- Shared rolls: a roll made from the sheet is seen only by whoever clicked,
|
||||
and is not recorded
|
||||
- Applying a roll: damage, healing and initiative rolled this way are still
|
||||
entered by hand
|
||||
- The effects of exhaustion (its level is recorded; speed, HP maximum and
|
||||
rolls are not changed)
|
||||
- Automatic levelling from XP
|
||||
- Alignment, languages, tool proficiencies and backgrounds-as-rules
|
||||
- Encumbrance and item weight
|
||||
@@ -1070,3 +1399,340 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
| Bright colours | — |
|
||||
| Plain, practical clothes | — |
|
||||
<!-- END GENERATED: builder-lists -->
|
||||
|
||||
## 21. What each class gains as it levels
|
||||
|
||||
What "⬆ Level Up…" writes into Special Abilities at each level (section 6),
|
||||
the Boons, and under each class what its three subclasses add. "Replaces"
|
||||
means the line the character already has by that name is rewritten.
|
||||
|
||||
This list is written from the app's own data by `npm run docs:lists` (in `/backend`). Re-run it after changing the data; do not edit the list by hand.
|
||||
|
||||
<!-- BEGIN GENERATED: level-features -->
|
||||
### Boons
|
||||
|
||||
| Boon | First time | Can be taken |
|
||||
|---|---|---|
|
||||
| Weapon Mastery | +1 to attack and damage rolls with weapons (add it to the numbers the site shows). | 2 times |
|
||||
| Arcane Focus | +1 to spell save DC and spell attack bonus (add it to the numbers the site shows). | 2 times |
|
||||
| Toughened | +1 AC (counted in your Extra AC). | Once |
|
||||
| Hardy | +5 maximum HP: gain 5 Temp HP after each long rest. | 2 times |
|
||||
|
||||
### Barbarian
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Reckless Attack**: On your first attack each turn you may attack with advantage; attacks against you have advantage until your next turn.<br>**Danger Sense**: Advantage on Dexterity saves against effects you can see (traps, breath, spells). |
|
||||
| 3 | **Rage** (replaces): Three times per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved. |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Fast Movement**: +10 ft speed when not in heavy armor (ask your DM to raise your speed). |
|
||||
| 6 | **Rage** (replaces): Four times per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved. |
|
||||
| 7 | **Feral Instinct**: Advantage on initiative; you can act normally in a surprise round if you rage first. |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | **Brutal Critical**: Roll one extra weapon damage die on a critical hit.<br>**Rage** (replaces): Four times per day, for one minute: +3 melee damage, advantage on Strength checks and saves, and physical damage against you is halved. |
|
||||
| 10 | No new feature |
|
||||
|
||||
**Barbarian subclasses** (chosen at level 3). What each adds to the table above:
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **Path of the Berserker**<br>Rage as pure fury: extra attacks now, exhaustion after. | 3 | **Frenzy**: 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 | **Mindless Rage**: You can’t be charmed or frightened while raging. |
|
||||
| — | 10 | **Intimidating Presence**: 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. |
|
||||
| **Path of the Totem Warrior**<br>A spirit animal walks with you and lends its strength. | 3 | **Spirit Seeker**: Path of the Totem Warrior. You can speak with beasts and sense what they sense, as a ritual with your DM.<br>**Totem Spirit**: 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 | **Aspect of the Beast**: 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 | **Spirit Walker**: As a ritual, ask the spirits of a place for what they know (the DM answers as a nature spirit would). |
|
||||
| **Path of the Zealot**<br>A god’s fury in your blows, and death has trouble keeping you. | 3 | **Divine Fury**: Path of the Zealot. While raging, the first creature you hit each turn takes an extra 1d6 + half your level radiant or necrotic damage.<br>**Warrior of the Gods**: Spells that bring you back from the dead cost nothing to cast on you. |
|
||||
| — | 6 | **Fanatical Focus**: Once per rage, reroll a saving throw you just failed. |
|
||||
| — | 10 | **Zealous Presence**: Once per long rest, bonus action: up to ten allies within 60 ft have advantage on attacks and saves until your next turn. |
|
||||
|
||||
### Bard
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Jack of All Trades**: Add half your proficiency bonus (round down) to any check you are not proficient in.<br>**Song of Rest**: During a short rest, allies who spend Hit Dice heal an extra 1d6. |
|
||||
| 3 | **Expertise**: Double proficiency on two proficient skills (add by hand). |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Bardic Inspiration** (replaces): A few times per day, as a bonus action, give an ally a d8 to add to one attack, check or save.<br>**Font of Inspiration**: Regain Bardic Inspiration on a short rest.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
|
||||
| 6 | **Countercharm**: Action: allies within 30 ft get advantage on saves against fear and charm until your next turn. |
|
||||
| 7 | No new feature |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | **Song of Rest** (replaces): During a short rest, allies who spend Hit Dice heal an extra 1d8. |
|
||||
| 10 | **Bardic Inspiration** (replaces): A few times per day, as a bonus action, give an ally a d10 to add to one attack, check or save.<br>**Expertise** (replaces): Double proficiency on four proficient skills (add by hand).<br>**Magical Secrets**: Learn two more spells from any class (the DM grants them within the unlock schedule). |
|
||||
|
||||
**Bard subclasses** (chosen at level 3). What each adds to the table above:
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **College of Lore**<br>Knows a bit of everything, and a word to trip anyone up. | 3 | **Cutting Words**: College of Lore. Reaction: spend a Bardic Inspiration to subtract the die from an enemy’s attack, check or damage roll.<br>**Bonus Proficiencies**: Treat three extra skills of your choice as proficient (add your proficiency bonus by hand). |
|
||||
| — | 6 | **Additional Magical Secrets**: Learn two spells from any class list (the DM grants them within the unlock schedule). |
|
||||
| **College of Valor**<br>A skald in the front line, inspiring by example. | 3 | **Combat Inspiration**: 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.<br>**Battle Training**: You fight in medium armor and with a shield (the DM raises Extra AC to match what you wear). |
|
||||
| — | 6 | **Extra Attack**: Attack twice when you take the Attack action. |
|
||||
| **College of Swords**<br>A blade dancer whose performance is the fight itself. | 3 | **Fighting Style**: 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).<br>**Blade Flourish**: 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 | **Extra Attack**: Attack twice when you take the Attack action. |
|
||||
|
||||
### Cleric
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Channel Divinity**: Once per short rest, use Turn Undead or your domain’s Channel Divinity.<br>**Turn Undead**: Channel Divinity. Action: undead within 30 ft make a Wisdom save or flee from you for 1 minute. |
|
||||
| 3 | No new feature |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Destroy Undead**: Undead of CR 1/2 or lower that fail against your Turn Undead are destroyed.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
|
||||
| 6 | **Channel Divinity** (replaces): Twice per short rest, use Turn Undead or your domain’s Channel Divinity. |
|
||||
| 7 | No new feature |
|
||||
| 8 | **Boon** (choose one)<br>**Destroy Undead** (replaces): Undead of CR 1 or lower that fail against your Turn Undead are destroyed. |
|
||||
| 9 | No new feature |
|
||||
| 10 | **Divine Intervention**: Once per week, call on your god: roll d100; if you roll your level or lower, the god helps (the DM decides how). |
|
||||
|
||||
**Cleric subclasses** (chosen at level 2). What each adds to the table above:
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **Life Domain**<br>The healer: more from every cure, and healing in a burst. | 2 | **Preserve Life**: Channel Divinity. Action: share out healing of 5 × your level among creatures within 30 ft (up to half their max HP each). |
|
||||
| — | 6 | **Blessed Healer**: When you heal someone else with a spell, you heal yourself 2 + the spell’s level. |
|
||||
| — | 8 | **Divine Strike**: Once per turn, a weapon hit deals +1d8 radiant damage. |
|
||||
| **Light Domain**<br>Fire and radiance: blinds attackers and burns the dark away. | 2 | **Warding Flare**: 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.<br>**Radiance of the Dawn**: 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 | **Improved Flare**: Use Warding Flare when a creature attacks someone else within 30 ft of you. |
|
||||
| — | 8 | **Potent Spellcasting**: Add your Wisdom modifier to the damage of your cleric cantrips. |
|
||||
| **War Domain**<br>A soldier of the faith: extra strikes and blows that land. | 2 | **War Priest**: 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.<br>**Guided Strike**: Channel Divinity. When you make an attack roll, add +10 to it after seeing the roll. |
|
||||
| — | 6 | **War God’s Blessing**: Channel Divinity. Reaction: give +10 to an attack roll made by a creature within 30 ft. |
|
||||
| — | 8 | **Divine Strike**: Once per turn, a weapon hit deals +1d8 damage of the weapon’s type. |
|
||||
|
||||
### Druid
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Wild Shape** (replaces): Twice per short rest, become a beast you have seen of CR 1/4 or less (no flying or swimming), for up to half your level in hours. Use the beast's HP; when it hits 0 you return to your own form. |
|
||||
| 3 | No new feature |
|
||||
| 4 | **Boon** (choose one)<br>**Wild Shape** (replaces): Twice per short rest, become a beast you have seen of CR 1/2 or less (it may swim, not fly), for up to half your level in hours. Use the beast's HP; when it hits 0 you return to your own form. |
|
||||
| 5 | **Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
|
||||
| 6 | No new feature |
|
||||
| 7 | No new feature |
|
||||
| 8 | **Boon** (choose one)<br>**Wild Shape** (replaces): Twice per short rest, become a beast you have seen of CR 1 or less (it may swim or fly), for up to half your level in hours. Use the beast's HP; when it hits 0 you return to your own form. |
|
||||
| 9 | No new feature |
|
||||
| 10 | No new feature |
|
||||
|
||||
**Druid subclasses** (chosen at level 2). What each adds to the table above:
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **Circle of the Land**<br>A keeper of old magic, at home in the wild places. | 2 | **Natural Recovery**: Once per day on a short rest, recover spent spell slots totalling up to half your level (rounded up).<br>**Bonus Cantrip**: Learn one extra cantrip (the DM grants it). |
|
||||
| — | 6 | **Land’s Stride**: Ordinary difficult terrain and plants do not slow you; advantage on saves against magical plants. |
|
||||
| — | 10 | **Nature’s Ward**: Immune to poison and disease; you can’t be charmed or frightened by fey or elementals. |
|
||||
| **Circle of the Moon**<br>A shapeshifter who fights as the beast. | 2 | **Combat Wild Shape**: 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.<br>**Circle Forms**: 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 | **Primal Strike**: Your attacks in beast form count as magical. |
|
||||
| — | 10 | **Elemental Wild Shape**: Spend both uses of Wild Shape at once to become an air, earth, fire or water elemental. |
|
||||
| **Circle of Stars**<br>Reads the night sky and wears its constellations. | 2 | **Star Map**: 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).<br>**Starry Form**: 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 | **Cosmic Omen**: 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 | **Twinkling Constellations**: 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
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Action Surge**: Once per short rest, take one extra action on your turn. |
|
||||
| 3 | No new feature |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action. |
|
||||
| 6 | **Boon** (choose one) |
|
||||
| 7 | No new feature |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | **Indomitable**: Once per long rest, reroll a failed saving throw. |
|
||||
| 10 | No new feature |
|
||||
|
||||
**Fighter subclasses** (chosen at level 3). What each adds to the table above:
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **Champion**<br>Simple and deadly: more critical hits, more athletic. | 3 | **Improved Critical**: Your weapon attacks score a critical hit on a roll of 19 or 20. |
|
||||
| — | 7 | **Remarkable Athlete**: Add half your proficiency bonus to Strength, Dexterity and Constitution checks you are not proficient in; long jump + Strength modifier feet. |
|
||||
| — | 10 | **Additional Fighting Style**: Choose a second option from your Fighting Style list. (If it is +1 AC in armor, the DM raises Extra AC by 1.) |
|
||||
| **Battle Master**<br>A tactician who turns each swing into a manoeuvre. | 3 | **Combat Superiority**: 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.<br>**Manoeuvres**: 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 | **Know Your Enemy**: 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.<br>**Combat Superiority** (replaces): 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.<br>**Manoeuvres**: Choose two more with your DM (five in all) from the list on your level 3 line. |
|
||||
| — | 10 | **Combat Superiority** (replaces): 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). |
|
||||
| **Samurai**<br>Unbreakable resolve: strikes with advantage and shrugs off blows. | 3 | **Fighting Spirit**: Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 5 Temp HP.<br>**Bonus Proficiency**: Treat one of History, Insight, Performance or Persuasion as proficient (add your proficiency bonus by hand). |
|
||||
| — | 7 | **Elegant Courtier**: Add your Wisdom modifier to Persuasion checks, and treat Wisdom saves as proficient (both added by hand). |
|
||||
| — | 10 | **Tireless Spirit**: If you roll initiative with no uses of Fighting Spirit left, you regain one.<br>**Fighting Spirit** (replaces): Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 10 Temp HP. |
|
||||
|
||||
### Monk
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Ki**: Points equal to your level; they come back on a short rest. Ki save DC is 8 + proficiency bonus + Wisdom modifier.<br>**Flurry of Blows**: 1 ki: two unarmed strikes as a bonus action.<br>**Patient Defense**: 1 ki: Dodge as a bonus action.<br>**Step of the Wind**: 1 ki: Dash or Disengage as a bonus action, jump distance doubled.<br>**Unarmored Movement**: +10 ft speed without armor (ask your DM to raise your speed). |
|
||||
| 3 | **Deflect Missiles**: Reaction: reduce ranged weapon damage by 1d10 + Dexterity modifier + your level. |
|
||||
| 4 | **Boon** (choose one)<br>**Slow Fall**: Reaction: reduce falling damage by 5 × your level. |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Stunning Strike**: 1 ki after a melee hit: the target makes a Constitution save or is stunned until your next turn.<br>**Martial Arts** (replaces): Your unarmed strikes deal 1d6 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action. |
|
||||
| 6 | **Ki-Empowered Strikes**: Your unarmed strikes count as magical.<br>**Unarmored Movement** (replaces): +15 ft speed without armor (ask your DM to raise your speed). |
|
||||
| 7 | **Evasion**: On a Dexterity save for half damage, take none on a success and half on a failure.<br>**Stillness of Mind**: Action: end charm or fear on yourself. |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | **Unarmored Movement** (replaces): +15 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
|
||||
| 10 | **Purity of Body**: Immune to poison and disease.<br>**Unarmored Movement** (replaces): +20 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
|
||||
|
||||
**Monk subclasses** (chosen at level 3). What each adds to the table above:
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **Way of the Open Hand**<br>Pure martial arts: knocks down, pushes away, heals itself. | 3 | **Open Hand Technique**: Flurry hits can knock a target prone (Dexterity save), push it 15 ft (Strength save) or stop its reactions. |
|
||||
| — | 6 | **Wholeness of Body**: Once per long rest, heal 3 × your level as an action. |
|
||||
| **Way of Shadow**<br>A ninja: steps between shadows and strikes unseen. | 3 | **Shadow Arts**: 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 | **Shadow Step**: 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. |
|
||||
| **Way of the Four Elements**<br>Bends fire, water, earth and air with ki. | 3 | **Elemental Attunement**: 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).<br>**Elemental Disciplines**: 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 | **Elemental Disciplines**: 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
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Divine Smite**: After a melee hit, spend a spell slot to deal +2d8 radiant (+1d8 per slot level above 1st; +1d8 more against undead and fiends).<br>**Fighting Style**: Choose one with your DM: +1 AC in armor (the DM raises Extra AC by 1), +2 damage with a one-handed weapon, or protect an adjacent ally (impose disadvantage on an attack as a reaction).<br>**Spellcasting**: Paladin spells unlock: you now have spell slots. |
|
||||
| 3 | **Divine Health**: Immune to disease. |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
|
||||
| 6 | **Aura of Protection**: You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious. |
|
||||
| 7 | No new feature |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | No new feature |
|
||||
| 10 | **Aura of Courage**: You and allies within 10 ft can’t be frightened. |
|
||||
|
||||
**Paladin subclasses** (chosen at level 3). What each adds to the table above:
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **Oath of Devotion**<br>The shining knight: honesty, courage, a blessed blade. | 3 | **Sacred Weapon**: Oath of Devotion, Channel Divinity. Add your Charisma modifier to attack rolls with your weapon for 1 minute; it sheds light.<br>**Turn the Unholy**: Channel Divinity. Fiends and undead within 30 ft make a Wisdom save or flee for 1 minute. |
|
||||
| — | 7 | **Aura of Devotion**: You and allies within 10 ft can’t be charmed. |
|
||||
| **Oath of the Ancients**<br>A green knight who guards life, light and laughter. | 3 | **Nature’s Wrath**: 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).<br>**Turn the Faithless**: Channel Divinity. Fey and fiends within 30 ft make a Wisdom save or flee for 1 minute. |
|
||||
| — | 7 | **Aura of Warding**: You and allies within 10 ft take half damage from spells. |
|
||||
| **Oath of Vengeance**<br>A relentless hunter of one sworn enemy at a time. | 3 | **Vow of Enmity**: Oath of Vengeance, Channel Divinity. Bonus action: for 1 minute you have advantage on attack rolls against one creature within 10 ft.<br>**Abjure Enemy**: 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 | **Relentless Avenger**: When you hit with an opportunity attack, move up to half your speed straight afterwards without provoking attacks. |
|
||||
|
||||
### Ranger
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Fighting Style**: Choose one with your DM: +2 to ranged attacks, +1 AC in armor (the DM raises Extra AC by 1), or add your ability modifier again to a paired weapon’s damage (added by hand).<br>**Spellcasting**: Ranger spells unlock: you now have spell slots. |
|
||||
| 3 | **Primeval Awareness**: Spend a spell slot: sense aberrations, dragons, fey, fiends, undead and elementals within 1 mile for 1 minute per slot level. |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
|
||||
| 6 | **Favored Enemy**: Choose a second favored enemy with your DM.<br>**Natural Explorer**: Choose a second favored terrain with your DM. |
|
||||
| 7 | No new feature |
|
||||
| 8 | **Boon** (choose one)<br>**Land’s Stride**: Ordinary difficult terrain and plants do not slow you. |
|
||||
| 9 | No new feature |
|
||||
| 10 | **Hide in Plain Sight**: Spend 1 minute camouflaging: +10 to Stealth while you stay still.<br>**Natural Explorer**: Choose a third favored terrain with your DM. |
|
||||
|
||||
**Ranger subclasses** (chosen at level 3). What each adds to the table above:
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **Hunter**<br>Brings down the biggest thing in the room. | 3 | **Colossus Slayer**: Hunter’s Prey. Once per turn, +1d8 damage to a creature that is already below its max HP. |
|
||||
| — | 7 | **Escape the Horde**: Defensive Tactics. Opportunity attacks against you have disadvantage. |
|
||||
| **Beast Master**<br>Fights beside an animal companion. | 3 | **Ranger’s Companion**: 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 | **Exceptional Training**: On a turn your companion does not attack, it can Dash, Disengage or Help as your bonus action. Its attacks count as magical. |
|
||||
| **Gloom Stalker**<br>An ambusher who owns the first round and the dark. | 3 | **Dread Ambusher**: 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.<br>**Umbral Sight**: You see in darkness to 60 ft, and creatures that rely on darkvision cannot see you in the dark. |
|
||||
| — | 7 | **Iron Mind**: Treat Wisdom saves as proficient (add your proficiency bonus by hand). |
|
||||
|
||||
### Rogue
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Cunning Action**: Bonus action: Dash, Disengage or Hide. |
|
||||
| 3 | **Sneak Attack** (replaces): Once per turn, deal an extra 2d6 damage to a target you have advantage against, or that has one of your allies next to it. |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Sneak Attack** (replaces): Once per turn, deal an extra 3d6 damage to a target you have advantage against, or that has one of your allies next to it.<br>**Uncanny Dodge**: Reaction: halve the damage of an attack that hits you. |
|
||||
| 6 | **Expertise**: Double proficiency on two more proficient skills (add by hand). |
|
||||
| 7 | **Sneak Attack** (replaces): Once per turn, deal an extra 4d6 damage to a target you have advantage against, or that has one of your allies next to it.<br>**Evasion**: On a Dexterity save for half damage, take none on a success and half on a failure. |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | **Sneak Attack** (replaces): Once per turn, deal an extra 5d6 damage to a target you have advantage against, or that has one of your allies next to it. |
|
||||
| 10 | **Boon** (choose one) |
|
||||
|
||||
**Rogue subclasses** (chosen at level 3). What each adds to the table above:
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **Thief**<br>Quick fingers, rooftops and anything that can be used. | 3 | **Fast Hands**: Bonus action: Sleight of Hand, pick a lock, disarm a trap or Use an Object.<br>**Second-Story Work**: Climbing costs no extra movement; running jumps + Dexterity modifier feet. |
|
||||
| — | 9 | **Supreme Sneak**: Advantage on Stealth if you move no more than half your speed. |
|
||||
| **Assassin**<br>Strikes first, and the first strike is the worst. | 3 | **Assassinate**: 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.<br>**Tools of the Trade**: You are practised with disguises and poisons: advantage on checks to make or use them. |
|
||||
| — | 9 | **Infiltration Expertise**: With a week and 25 gold you build a false identity with papers and a history; nobody doubts it without an obvious reason. |
|
||||
| **Swashbuckler**<br>A duellist: in, strike, out, and a smile. | 3 | **Fancy Footwork**: Swashbuckler. A creature you made a melee attack against this turn cannot make opportunity attacks against you until the turn ends.<br>**Rakish Audacity**: 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 | **Panache**: 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
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Font of Magic**: 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. |
|
||||
| 3 | **Metamagic**: Choose two with your DM: Quickened Spell (2 points: cast an action spell as a bonus action), Twinned Spell (points = spell level: hit a second target with a single-target spell), Careful Spell (1 point: allies auto-succeed on your spell’s save), Subtle Spell (1 point: cast without words or gestures), Extended Spell (1 point: double duration), Heightened Spell (3 points: one target has disadvantage on its first save). |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
|
||||
| 6 | No new feature |
|
||||
| 7 | No new feature |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | No new feature |
|
||||
| 10 | **Metamagic**: Choose a third option with your DM. |
|
||||
|
||||
**Sorcerer subclasses** (chosen at level 3). What each adds to the table above:
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **Draconic Bloodline**<br>Dragon blood: tougher skin and one element at your call. | 3 | **Dragon Ancestor**: Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier (ask your DM about Extra AC). |
|
||||
| — | 6 | **Elemental Affinity**: Draconic Bloodline. Add your Charisma modifier to one damage roll of your dragon’s element; spend 1 sorcery point to resist that element for 1 hour. |
|
||||
| **Wild Magic**<br>Raw chaos: luck you can bend, and surprises you cannot. | 3 | **Wild Magic Surge**: 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).<br>**Tides of Chaos**: 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 | **Bend Luck**: Reaction, 2 sorcery points: add or subtract 1d4 from another creature’s attack, check or save. |
|
||||
| **Storm Sorcery**<br>Wind and lightning: never stands still for long. | 3 | **Tempestuous Magic**: Storm Sorcery. Bonus action, just before or after casting a spell of 1st level or higher: fly 10 ft without provoking opportunity attacks.<br>**Wind Speaker**: You speak the tongue of air, water, earth and fire elementals. |
|
||||
| — | 6 | **Heart of the Storm**: 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.<br>**Storm Guide**: 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
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Eldritch Invocations**: Choose two with your DM: Agonizing Blast (add your Charisma modifier to each Eldritch Blast hit), Devil’s Sight (see normally in darkness, even magical darkness, to 120 ft), Repelling Blast (Eldritch Blast hits push the target 10 ft away), Mask of Many Faces (cast Disguise Self at will), Beguiling Influence (proficiency in Deception and Persuasion, added by hand), Eyes of the Rune Keeper (read any writing). |
|
||||
| 3 | **Pact Boon**: Choose one with your DM: Pact of the Blade (summon a weapon of your choice as an action; it counts as magical), Pact of the Chain (gain a small familiar spirit: an imp, a sprite or a cat-shaped shadow), or Pact of the Tome (a book of three extra cantrips from any class, which the DM grants). |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Eldritch Invocations**: Choose one more with your DM (three in all). From level 5 the list adds Thirsting Blade (Pact of the Blade: attack twice with your pact weapon) and One with Shadows (become invisible while standing still in dim light or darkness).<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
|
||||
| 6 | No new feature |
|
||||
| 7 | **Eldritch Invocations**: Choose one more with your DM (four in all). From level 5 the list adds Thirsting Blade (Pact of the Blade: attack twice with your pact weapon) and One with Shadows (become invisible while standing still in dim light or darkness). |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | **Eldritch Invocations**: Choose one more with your DM (five in all). From level 5 the list adds Thirsting Blade (Pact of the Blade: attack twice with your pact weapon) and One with Shadows (become invisible while standing still in dim light or darkness). |
|
||||
| 10 | No new feature |
|
||||
|
||||
**Warlock subclasses** (chosen at level 3). What each adds to the table above:
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **The Fiend**<br>A pact with something from the hells: power fed by the fallen. | 3 | **Dark One’s Blessing**: 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 | **Patron’s Luck**: Once per short rest, add a d10 to an ability check or saving throw after seeing the roll. |
|
||||
| — | 10 | **Patron’s Resilience**: After each rest, choose one damage type: you resist it until you choose another. |
|
||||
| **The Archfey**<br>A fey lord’s favourite: glamour, vanishing and beguilement. | 3 | **Fey Presence**: 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 | **Misty Escape**: 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 | **Beguiling Defenses**: 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. |
|
||||
| **The Great Old One**<br>Something vast and unknowable whispers through you. | 3 | **Awakened Mind**: 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 | **Entropic Ward**: Once per short rest, reaction: an attack against you has disadvantage. If it misses, your next attack against that creature has advantage. |
|
||||
| — | 10 | **Thought Shield**: Nobody reads your thoughts unless you allow it. You resist psychic damage, and whoever deals it to you takes the same amount. |
|
||||
|
||||
### Wizard
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | No new feature |
|
||||
| 3 | No new feature |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
|
||||
| 6 | No new feature |
|
||||
| 7 | No new feature |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | No new feature |
|
||||
| 10 | No new feature |
|
||||
|
||||
**Wizard subclasses** (chosen at level 2). What each adds to the table above:
|
||||
|
||||
| Subclass | Level | Written into Special Abilities |
|
||||
|---|---|---|
|
||||
| **School of Evocation**<br>Blasting, with friends left standing in the middle of it. | 2 | **Evocation Savant**: School of Evocation. Copying evocation spells costs half the time and gold.<br>**Sculpt Spells**: 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 | **Potent Cantrip**: Creatures that save against your damaging cantrips still take half damage. |
|
||||
| — | 10 | **Empowered Evocation**: Add your Intelligence modifier to the damage of your evocation spells. |
|
||||
| **School of Abjuration**<br>Wards and counters: a shield of magic that takes the hits. | 2 | **Abjuration Savant**: School of Abjuration. Copying abjuration spells costs half the time and gold.<br>**Arcane Ward**: 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 | **Projected Ward**: Reaction: when a creature within 30 ft takes damage, your Arcane Ward takes it instead. |
|
||||
| — | 10 | **Improved Abjuration**: Add your proficiency bonus to ability checks made as part of an abjuration spell (Counterspell, Dispel Magic). |
|
||||
| **School of Illusion**<br>Tricks of light and sound that hold up to a second look. | 2 | **Illusion Savant**: School of Illusion. Copying illusion spells costs half the time and gold.<br>**Improved Minor Illusion**: You know Minor Illusion (the DM grants it), and it makes a sound and an image with one casting. |
|
||||
| — | 6 | **Malleable Illusions**: Action: change what an illusion of yours looks like while it lasts, if it lasts a minute or more. |
|
||||
| — | 10 | **Illusory Self**: Once per short rest, reaction when a creature attacks you: a copy of you takes the blow and the attack misses. |
|
||||
<!-- END GENERATED: level-features -->
|
||||
|
||||
+174
-28
@@ -84,8 +84,10 @@ the encounter does not take a visited place away again.
|
||||
|
||||
**Order.** The campaign page lists encounters on its "Overview", under the
|
||||
characters, in the **campaign order**, which
|
||||
the DM arranges with the ▲ and ▼ buttons and everyone sees. A new encounter
|
||||
goes to the top. The "Sort by" menu re-sorts the list by name, status, type or
|
||||
the DM arranges with the ▲ and ▼ buttons and everyone sees. A new encounter,
|
||||
battle or quest, goes to the **bottom** unless the DM picks otherwise in the
|
||||
menu beside the name when adding it: at the bottom, at the top, or straight
|
||||
after any encounter on the list. Top or bottom is remembered per browser. The "Sort by" menu re-sorts the list by name, status, type or
|
||||
date instead; that choice is remembered per browser and changes nothing for
|
||||
anyone else. ▲ and ▼ only show while sorting by campaign order.
|
||||
|
||||
@@ -123,30 +125,70 @@ The DM adds people from the "Add to the scene" panel.
|
||||
| Monster | The campaign's monster list ([MONSTERS.md](MONSTERS.md)) | Yes |
|
||||
| NPC | The campaign's NPC list ([NPCS.md](NPCS.md)) | No |
|
||||
|
||||
**Finding your own character.** A player's own characters stand out: their
|
||||
cards have a blue border and background and say "★ Your character", and a row
|
||||
of buttons at the top of the page names each one with its HP ("★ Aria · 12/12
|
||||
HP", plus "your turn" when it is) and scrolls to its card. The DM sees the
|
||||
same for any character of their own.
|
||||
**Several monsters at once.** With "Monster" chosen there is a number beside
|
||||
Add, 1 to 20. More than one adds that many copies, numbered: "Goblin 1" to
|
||||
"Goblin 4". Adding more later carries on from the highest number in the scene
|
||||
("Goblin 5"). One on its own keeps the plain name. An initiative roll typed
|
||||
in is given to all of them; left empty, each can be rolled separately
|
||||
(section 7).
|
||||
|
||||
**Inventory.** A character's card has an "🎒 Inventory" button for the DM and
|
||||
for that character's own player. It opens the character panel on that
|
||||
character: everything they hold, what is equipped and what each thing does
|
||||
**The turn strip.** With two or more in the scene, a row of small buttons
|
||||
runs across the top of the page, one per combatant, in the order they act. It
|
||||
stays under the top bar while the cards scroll (on a phone it scrolls
|
||||
sideways). Each button has the name, HP where the viewer may see the numbers
|
||||
(otherwise the initiative total), a thin health bar and "⚑" with the number of
|
||||
conditions. Whoever is on turn is gold and marked ▶, a target is red and
|
||||
marked 🎯, the viewer's own characters are blue and marked ★, and in a battle
|
||||
those who have acted this round are dimmed. A click scrolls to that card. The
|
||||
fallen are not on it.
|
||||
|
||||
**Finding your own character.** A player's own characters stand out: their
|
||||
cards have a blue border and background and say "★ Your character", and their
|
||||
buttons on the turn strip are blue and starred. The DM sees the same for any
|
||||
character of their own.
|
||||
|
||||
**Sheet.** A character's card has a "📋 Sheet" button for the DM and for that
|
||||
character's own player. It opens the character panel on that character,
|
||||
beside the encounter: saving throws, every skill, passive scores, special
|
||||
abilities, everything they hold, what is equipped and what each thing does
|
||||
([CHARACTERS.md](CHARACTERS.md), section 14). The floating 🧙 button opens the
|
||||
same panel from anywhere. A card's details also list what the character's
|
||||
equipped gear is doing; the AC, speed and other numbers on the card already
|
||||
include it.
|
||||
|
||||
**The spotlight.** On a wide screen the column beside the cards starts with
|
||||
one combatant's whole stat block: whoever is **on turn** in a battle. It is
|
||||
everything their card has under "details", so the DM reads the attacker's
|
||||
numbers there while scrolling to a target. **📌 Pin** on any card with details
|
||||
puts that combatant there instead (in a quest too, where nobody is on turn);
|
||||
"✕ Unpin" or a second click on Pin goes back to whoever is on turn. Pinning is
|
||||
for the viewer's own screen only. The side column stays in view and scrolls
|
||||
on its own. On a screen narrower than 960px there is no spotlight and no Pin:
|
||||
the list is one column and the card on turn is at its top.
|
||||
|
||||
**Rolling.** The underlined numbers on a card and in the spotlight roll when
|
||||
clicked: a character's saves, skills, ability checks, initiative, attacks and
|
||||
damage, and for the DM a monster's or NPC's attack and damage and a monster's
|
||||
ability scores. "🎲 Call for a roll…" in the header (DM) rolls one check or
|
||||
save for all the characters in the scene at once
|
||||
([CHARACTERS.md](CHARACTERS.md), "Rolling from the sheet").
|
||||
|
||||
**Keys (DM, battle in progress).** **N** is Next Turn. **S** opens the sheet of
|
||||
the character on turn, or pins the monster or NPC on turn to the spotlight.
|
||||
Keys do nothing while the cursor is in a field.
|
||||
|
||||
Each has a card. The card's left edge is coloured by kind, and it shows:
|
||||
|
||||
- **Characters:** level, species and class; HP, AC, speed, initiative bonus,
|
||||
passive perception, proficiency bonus and spell save DC. Under "details":
|
||||
ability scores, saves, the weapon and items the character has **equipped**
|
||||
(with attack and damage numbers), the spells they can cast, 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 "🎒 Inventory", at any point in the scene. The name links to the
|
||||
character sheet.
|
||||
ability scores, the weapon and items the character has **equipped**
|
||||
(with attack and damage numbers), spellcasting numbers with the spell slots
|
||||
left, the spells they can cast, **saving throws and all eighteen skills**
|
||||
(● 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.
|
||||
- **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,
|
||||
@@ -256,19 +298,24 @@ Initiative is entered in battles and quests alike. Turns and rounds are for
|
||||
battles only.
|
||||
|
||||
- Each combatant has an **initiative roll** field. Type what the d20 showed,
|
||||
1 to 20; the field accepts nothing else. The app does not roll the die, but
|
||||
it adds the bonus for you and shows the total, which can go past 20 (a 20 on
|
||||
1 to 20; the field accepts nothing else. The app adds the bonus for you and
|
||||
shows the total, which can go past 20 (a 20 on
|
||||
the die with a +3 bonus is 23): a character's initiative
|
||||
bonus from their sheet, a monster's Dexterity modifier, nothing for an NPC.
|
||||
- The DM can enter the roll for anyone, when adding them or on the card. A
|
||||
player can enter the roll for **their own character** on its card.
|
||||
- **🎲 Roll initiative for N monsters and NPCs**, in "Add to the scene" while
|
||||
any of them has no roll, rolls a d20 for each of those and adds their
|
||||
bonus. Monsters added together each get their own roll. Rolls already
|
||||
entered are left alone, and characters are left to their players.
|
||||
- The list is ordered by the total, highest first; ties keep the order they
|
||||
were added in. Anyone who has not rolled yet waits at the bottom.
|
||||
- The bonus is worked out when the roll is entered. If a character's sheet
|
||||
changes afterwards, enter the roll again to pick up the new bonus.
|
||||
- **Begin Battle** puts the first combatant on turn. Their card is
|
||||
highlighted and the header names them.
|
||||
- **Next Turn** passes the turn to the next in the order. After the last
|
||||
highlighted, the header names them, and their stats fill the spotlight
|
||||
(section 5).
|
||||
- **Next Turn** (or the **N** key) passes the turn to the next in the order. After the last
|
||||
combatant it returns to the top and the **round** goes up by one.
|
||||
- **Next Round** skips straight to the top of the next round.
|
||||
- Anyone out of the fight (section 6, "The fallen") is passed over.
|
||||
@@ -340,14 +387,42 @@ the roll with the AC shown and types the damage.
|
||||
## 8. Conditions
|
||||
|
||||
The DM adds a condition to any card from the "+ Condition" list, and removes
|
||||
one by clicking its ✕. Everyone sees them.
|
||||
one by clicking its ✕. Everyone sees them. **Hovering over a condition shows
|
||||
its rule** (on the card, in the spotlight, on the sheet and on the party
|
||||
overview).
|
||||
|
||||
**A character's conditions are the character's own.** One given on an
|
||||
encounter card is on their sheet and in their panel too, is still there when
|
||||
the encounter ends, and shows on their card in the next one, until the DM
|
||||
removes it (from any of those places). A monster's or an NPC's conditions
|
||||
belong to that one card.
|
||||
|
||||
A character's card also shows **✨ Inspired** and **Exhaustion N** when they
|
||||
apply, and under "details" (and in the spotlight) their **class counters**:
|
||||
Rage, Ki, Channel Divinity and the rest, with a Use button for the DM and for
|
||||
that character's own player ([CHARACTERS.md](CHARACTERS.md), section 7).
|
||||
|
||||
The list: Blinded, Charmed, Deafened, Frightened, Grappled, Incapacitated,
|
||||
Invisible, Paralyzed, Petrified, Poisoned, Prone, Restrained, Stunned,
|
||||
Unconscious, Concentrating.
|
||||
|
||||
Conditions are labels to remind the table. They do not change any numbers,
|
||||
and they do not expire on their own.
|
||||
**Timed conditions.** The "rounds" box beside "+ Condition" says how long the
|
||||
next condition added lasts. Its chip shows what is left ("Frightened 2 rd").
|
||||
Each time the **round goes up** (Next Turn past the last combatant, or Next
|
||||
Round) every timed condition loses a round, and one that reaches 0 comes off
|
||||
by itself, from the character's sheet too. The DM is told what wore off
|
||||
("Frightened wore off Aria."). Given for 1 round, a condition lasts until the
|
||||
next round begins; a condition added with the box empty stays until the DM
|
||||
removes it. The countdown belongs to the encounter: it does not run outside
|
||||
one, and removing a condition by hand drops its countdown.
|
||||
|
||||
**Concentration.** "Concentrating" marks someone holding a spell. When the DM
|
||||
applies damage to them, from their card or the attack bar, a notice gives the
|
||||
save to call for: Constitution, DC 10 or half the damage if that is higher,
|
||||
with their Constitution save bonus (a monster's Constitution modifier). The
|
||||
site does not roll it or end the spell: on a failure the DM removes the chip.
|
||||
|
||||
Conditions are reminders for the table. They do not change any numbers.
|
||||
|
||||
## 9. Rewards
|
||||
|
||||
@@ -379,6 +454,64 @@ Each can carry a short note ("Goblin chief's purse").
|
||||
Until a reward is awarded only the DM can see it. Afterwards players see what
|
||||
it was and who received it.
|
||||
|
||||
### Experience and difficulty
|
||||
|
||||
The DM's **Experience** card, above Rewards, reads the fight and hands out
|
||||
its XP. Players see none of it.
|
||||
|
||||
**What the monsters are worth.** Each monster's challenge rating
|
||||
([MONSTERS.md](MONSTERS.md)) is worth a set amount of XP, and the card adds
|
||||
them up, standing or fallen. A monster whose challenge is empty or is not a
|
||||
rating ("boss") is not counted, and the card says how many are not. NPCs have
|
||||
no challenge rating and are never counted.
|
||||
|
||||
| Challenge | XP | Challenge | XP | Challenge | XP | Challenge | XP |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 0 | 10 | 3 | 700 | 9 | 5,000 | 15 | 13,000 |
|
||||
| 1/8 | 25 | 4 | 1,100 | 10 | 5,900 | 16 | 15,000 |
|
||||
| 1/4 | 50 | 5 | 1,800 | 11 | 7,200 | 17 | 18,000 |
|
||||
| 1/2 | 100 | 6 | 2,300 | 12 | 8,400 | 18 | 20,000 |
|
||||
| 1 | 200 | 7 | 2,900 | 13 | 10,000 | 19 | 22,000 |
|
||||
| 2 | 450 | 8 | 3,900 | 14 | 11,500 | 20 | 25,000 |
|
||||
|
||||
Ratings 21 to 30 run from 33,000 to 155,000. "0.5" is read as 1/2, and "CR 3"
|
||||
as 3.
|
||||
|
||||
**How hard it is.** Several monsters are harder than their XP says, so to
|
||||
judge the fight the total is multiplied for their number: ×1 for one, ×1.5
|
||||
for two, ×2 for 3 to 6, ×2.5 for 7 to 10, ×3 for 11 to 14, ×4 for 15 or more.
|
||||
That figure is held against what the characters in the scene can take, each
|
||||
adding their own level's thresholds:
|
||||
|
||||
| Level | Easy | Medium | Hard | Deadly |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 25 | 50 | 75 | 100 |
|
||||
| 2 | 50 | 100 | 150 | 200 |
|
||||
| 3 | 75 | 150 | 225 | 400 |
|
||||
| 4 | 125 | 250 | 375 | 500 |
|
||||
| 5 | 250 | 500 | 750 | 1,100 |
|
||||
| 6 | 300 | 600 | 900 | 1,400 |
|
||||
| 7 | 350 | 750 | 1,100 | 1,700 |
|
||||
| 8 | 450 | 900 | 1,400 | 2,100 |
|
||||
| 9 | 550 | 1,100 | 1,600 | 2,400 |
|
||||
| 10 | 600 | 1,200 | 1,900 | 2,800 |
|
||||
|
||||
(Levels 11 to 20 continue to 2,800 / 5,700 / 8,500 / 12,700 at level 20.) The
|
||||
fight is **Trivial** below the party's Easy figure, then **Easy**,
|
||||
**Medium**, **Hard**, and **Deadly** from the Deadly figure up. The rating is
|
||||
a badge on the card and in the page header, beside the round, and it moves as
|
||||
monsters and characters are added. There is no rating until the scene has at
|
||||
least one character and one monster with a challenge rating.
|
||||
|
||||
**Awarding it.** "Award N XP each" shares the XP evenly between **every
|
||||
character in the encounter**, rounded down, and adds it to each one's XP. The
|
||||
amount is what the monsters are worth (not the multiplied figure) unless the
|
||||
DM types another: for a quest, a clever escape, or a scene with nothing rated
|
||||
in it. It can be awarded at any point, and **once** per encounter; the card
|
||||
then reads "✓ 75 XP awarded: 37 each". A mistake is put right on the
|
||||
characters' sheets. XP never levels anyone up by itself
|
||||
([CHARACTERS.md](CHARACTERS.md), section 6).
|
||||
|
||||
## 10. Trade
|
||||
|
||||
In an NPC quest the party can **change money** and **buy things**. Both take
|
||||
@@ -427,12 +560,15 @@ when there is something to trade, and always for the DM in a quest.
|
||||
| Scene description | Yes | Yes |
|
||||
| DM notes | Yes | Never |
|
||||
| Characters | Full card | Full card |
|
||||
| A character's saves, skills and passive scores | Yes | Yes, as on the sheet |
|
||||
| A character's inspiration, exhaustion, conditions and class counters | Yes, and can change them | Yes; a player can spend their own character's counters |
|
||||
| A character's inventory, spells and purse | Yes, for everyone | Their own characters only |
|
||||
| Monsters | Full stat block | Name, HP, conditions |
|
||||
| NPCs | Everything | Name, role, attitude, description, HP bar |
|
||||
| Monsters and NPCs at 0 HP | With "Show dead" ticked, marked hidden | Nothing |
|
||||
| Dead characters | Full card with "Show dead" ticked, marked hidden | The name only, in the Fallen box |
|
||||
| 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 |
|
||||
|
||||
@@ -457,6 +593,11 @@ information cannot be dug out of the page.
|
||||
| Revive a dead character outright | Yes | No |
|
||||
| Try to revive a dead character | Yes, for the character on turn | On their own character's turn, if it has a reviving spell |
|
||||
| Advance turns and rounds | Yes | No |
|
||||
| Roll initiative for the monsters and NPCs | Yes | No |
|
||||
| Call for a roll from all the characters | Yes | No |
|
||||
| See the difficulty and what the monsters are worth; award XP | Yes | No |
|
||||
| Click a number to roll it | Yes | Yes, for what they can see |
|
||||
| Give a condition a number of rounds | Yes | No |
|
||||
| Add, remove or award rewards | Yes | No |
|
||||
| Set exchange rates, and put things up for sale | Yes | No |
|
||||
| Change money or buy, in a quest in progress | Yes, for any character in it | For their own character in it |
|
||||
@@ -465,15 +606,20 @@ People outside the campaign cannot open its encounters.
|
||||
|
||||
## 13. What is not modelled
|
||||
|
||||
- Rolling initiative, attacks, damage or saves (use the dice roller, bottom
|
||||
left of every page, or in the bottom bar on a phone)
|
||||
- Applying an attack's damage automatically
|
||||
- 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
|
||||
- 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
|
||||
- Conditions with durations or mechanical effects
|
||||
- The mechanical effects of conditions, durations counted in anything but
|
||||
rounds, and rolling a concentration save
|
||||
- Maps, positions, distances and areas of effect
|
||||
- Experience points and encounter difficulty
|
||||
- Adjusting difficulty for a very small or very large party, and awarding
|
||||
XP to some of the characters only or in unequal shares
|
||||
- Spells as rewards, and spells for sale
|
||||
- Selling to an NPC, haggling, and buying more than one of a thing in a
|
||||
single step
|
||||
|
||||
+2
-1
@@ -54,7 +54,8 @@ everything below and is where it is changed.
|
||||
|
||||
- **Adding:** by hand with "+ Add Item", or with "📚 From Library". Both open
|
||||
in a pop-out; an item added by hand opens its own pop-out straight away, to
|
||||
set what it does. The library offers 32 ready-made items: light, medium and heavy armor, shields,
|
||||
set what it does, and "+ Add another item" at its top goes straight back
|
||||
to an empty form. The library offers 32 ready-made items: light, medium and heavy armor, shields,
|
||||
worn gear such as a Ring of Protection, and common supplies. Each is copied
|
||||
into the pool with its effects already set (Chain Mail: +6 AC, −10 Speed,
|
||||
and a note about Stealth), at starting level 1 and not ticked as a starting
|
||||
|
||||
+21
-13
@@ -40,13 +40,14 @@ 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` |
|
||||
| Challenge rating | A rough danger rating, e.g. `1/4`, `2` |
|
||||
| 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 |
|
||||
|
||||
None of these are calculated or used in formulas. They are reference numbers
|
||||
shown to the DM during a fight. A monster's ability modifiers are worked out
|
||||
from its scores for display only.
|
||||
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
|
||||
modifiers are worked out from its scores for display, for its initiative
|
||||
bonus (Dexterity) and for the rolls a click makes (section 5).
|
||||
|
||||
## 3. Managing monsters
|
||||
|
||||
@@ -60,6 +61,8 @@ search box, shows ten to a page, and clicking a row opens the monster's
|
||||
challenge rating, AC, HP, speed, attack bonus, damage, the six ability
|
||||
scores, special trait and notes. Only name, AC and HP must be typed; speed
|
||||
starts at 30, attack bonus at 0 and each ability score at 10.
|
||||
- Once added by hand, the monster's own pop-out opens. "+ Add another
|
||||
monster" at its top goes straight back to an empty form.
|
||||
- **Adding from the bestiary:** "📚 From Library" opens the bestiary in a
|
||||
pop-out, and "+ Add" copies a complete stat block.
|
||||
Each monster can be added once: one the campaign already has by that name is
|
||||
@@ -134,11 +137,14 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
|
||||
## 5. Monsters in encounters
|
||||
|
||||
On an encounter page, the DM chooses "Monster" in the "Add to the scene"
|
||||
panel, picks one, and types its initiative.
|
||||
panel, picks one, says how many, and types its initiative or leaves it to be
|
||||
rolled ("🎲 Roll initiative for N monsters and NPCs" rolls a d20 plus the
|
||||
Dexterity modifier for each that has no roll).
|
||||
|
||||
- The same monster can be added **any number of times**. Each copy has its
|
||||
own HP and conditions. Copies share the monster's name; the screen has no
|
||||
way to rename one (the API allows it).
|
||||
- The same monster can be added **any number of times**, up to 20 in one go.
|
||||
Each copy has its own HP and conditions. Copies added together are numbered
|
||||
("Goblin 1" to "Goblin 4"); one added on its own has the monster's name. The
|
||||
screen has no way to rename one (the API allows it).
|
||||
- A copy starts at the monster's max HP. Its HP stays between 0 and its max.
|
||||
- At 0 HP a copy is marked **Down** and dimmed, but keeps its place in the
|
||||
initiative order until the DM removes it.
|
||||
@@ -146,7 +152,10 @@ panel, picks one, and types its initiative.
|
||||
|
||||
The DM's card for a monster shows AC, speed, attack bonus and damage at a
|
||||
glance, and under "details" its six ability scores with modifiers, its trait
|
||||
and its notes.
|
||||
and its notes. **Attack, Damage and each ability score roll when clicked**: a
|
||||
d20 plus the attack bonus, the damage dice, or a d20 plus the ability's
|
||||
modifier. Of damage written with more after the dice ("2d8+3 fire") the dice
|
||||
are rolled; damage that does not start with dice is shown but does not roll.
|
||||
|
||||
## 6. What players see
|
||||
|
||||
@@ -179,8 +188,7 @@ Then they get the health bar only.
|
||||
|
||||
- Multiple attacks, spells, legendary actions and resistances (use Notes)
|
||||
- Trade: monsters do not sell things or change money (NPCs do)
|
||||
- Rolling a monster's attacks or damage (use the dice roller)
|
||||
- Saving throw bonuses and skills
|
||||
- Experience points for defeating monsters
|
||||
- Encounter difficulty calculated from challenge ratings
|
||||
- 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)
|
||||
- XP typed in for a monster: it always comes from the challenge rating
|
||||
- Monster hit dice, and rolling HP rather than using the fixed value
|
||||
|
||||
+2
-1
@@ -61,7 +61,8 @@ have for sale. The list has a search box and shows ten to a page.
|
||||
above: description, DM notes, combat stats, and their trade.
|
||||
- **Adding:** "+ Add NPC" opens a pop-out with every field above except the
|
||||
exchange rate and wares. Only the name is required. Once added, the NPC's
|
||||
own pop-out opens, to set their trade.
|
||||
own pop-out opens, to set their trade; "+ Add another NPC" at its top goes
|
||||
straight back to an empty form.
|
||||
- **Trade:** the exchange rate and the wares are set in the NPC's pop-out,
|
||||
under "Changes money" and "For sale" (section 5).
|
||||
- **Changing:** there is no edit form for the other fields. To change them,
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ The file is ordinary JavaScript, so it can build its lists however it likes
|
||||
| Field | What it is |
|
||||
|---|---|
|
||||
| `code` | Six capital letters or digits. What a DM types |
|
||||
| `campaign` | The campaign's settings: `name`, and optionally `description`, `startingGold`, `startingItemCount`, `startingSpellCount`, `skipMotivationRoll`, `showMonsterHp`, `areaAttackAcPenalty`, `maxEquipped`, `maxEquippedWeapons`. Anything left out takes the app's default |
|
||||
| `campaign` | The campaign's settings: `name`, and optionally `description`, `startingGold`, `startingItemCount`, `startingSpellCount`, `skipMotivationRoll`, `showMonsterHp`, `areaAttackAcPenalty`, `maxEquipped`, `maxEquippedWeapons`, `subclassesEnabled`. Anything left out takes the app's default |
|
||||
| `locations` | `{ name, description }` |
|
||||
| `weapons`, `items`, `spells`, `monsters` | The pools. Each entry is a row or a library copy (below) |
|
||||
| `npcs` | NPC rows, each optionally with `wares` |
|
||||
|
||||
+95
-12
@@ -17,10 +17,11 @@ This describes the code as it stands. The built-in list is
|
||||
5. [Spell level and who can use it](#5-spell-level-and-who-can-use-it)
|
||||
6. [Damage](#6-damage)
|
||||
7. [Spellcasting numbers](#7-spellcasting-numbers)
|
||||
8. [Starting spells](#8-starting-spells)
|
||||
9. [Permissions](#9-permissions)
|
||||
10. [What is not modelled](#10-what-is-not-modelled)
|
||||
11. [The complete spell library](#11-the-complete-spell-library)
|
||||
8. [Spell slots](#8-spell-slots)
|
||||
9. [Starting spells](#9-starting-spells)
|
||||
10. [Permissions](#10-permissions)
|
||||
11. [What is not modelled](#11-what-is-not-modelled)
|
||||
12. [The complete spell library](#12-the-complete-spell-library)
|
||||
|
||||
---
|
||||
|
||||
@@ -48,7 +49,7 @@ has a search box, shows ten to a page, and clicking a row opens the spell's
|
||||
| School | Free text: Evocation, Illusion… |
|
||||
| What it does | One sentence. Shown wherever the spell is shown |
|
||||
| Damage | Picked from a list, or "No damage". Fixed; it does not grow |
|
||||
| Starting option | Whether a brand-new character may pick it in the builder (section 8) |
|
||||
| Starting option | Whether a brand-new character may pick it in the builder (section 9) |
|
||||
| Revives | Whether it brings back the dead. A character who can cast one may revive a dead ally on their turn in a battle ([ENCOUNTERS.md](ENCOUNTERS.md), section 6). Ticked already on the library's Revivify and Raise Dead |
|
||||
| Casting time, range, duration | Stored for library spells. Shown to the DM in the spell's pop-out on the campaign page, and nowhere else |
|
||||
|
||||
@@ -57,7 +58,8 @@ A cantrip is a spell of level 0. The app always calls it a **cantrip**, never
|
||||
|
||||
- **Adding:** by hand with "+ Add Spell" (name, level, school, damage, what
|
||||
it does and the two tick boxes), or from the library. Both open in a
|
||||
pop-out.
|
||||
pop-out. A spell added by hand opens its own pop-out, and "+ Add another
|
||||
spell" at its top goes straight back to an empty form.
|
||||
- **Changing:** in the spell's pop-out: damage (the drop-down) and the Revives
|
||||
and starting-option tick boxes, saved as they are changed. Name, level, school and description cannot be changed on screen
|
||||
once created (the API accepts it); remove and re-add instead.
|
||||
@@ -88,7 +90,7 @@ Before you add one, each row shows:
|
||||
- **what it does**, in a sentence.
|
||||
|
||||
Every library spell is listed in full in
|
||||
[section 11](#11-the-complete-spell-library).
|
||||
[section 12](#12-the-complete-spell-library).
|
||||
|
||||
35 of the library spells deal damage and come with a default, taken from the
|
||||
standard rules at the spell's own level. For example:
|
||||
@@ -181,7 +183,83 @@ Shown only for classes with a spellcasting ability:
|
||||
A character without a spellcasting class can still be granted spells, but has
|
||||
no save DC or attack bonus shown.
|
||||
|
||||
## 8. Starting spells
|
||||
## 8. Spell slots
|
||||
|
||||
A spell slot is one casting of a spell of that level or lower. Cantrips never
|
||||
use one. How many slots a character has is worked out from **class and
|
||||
level**; the site keeps count of how many are spent.
|
||||
|
||||
**Where.** The tracker is in the Spellcasting card on the sheet and in the
|
||||
character panel (the 🧙 button, on every page, so it is at hand in an
|
||||
encounter). The party overview has a "Spell slots" column for the whole party.
|
||||
Each slot level is a row of diamonds: filled is a slot still there to spend,
|
||||
empty is one that is spent.
|
||||
|
||||
**Who does what.**
|
||||
|
||||
- The character's **player** (and the DM) presses **Use** on a row when a
|
||||
spell is cast: one slot of that level is spent. That is all a player can do;
|
||||
a slot once used stays used.
|
||||
- Only the **DM** gives a slot back: by clicking a diamond (it sets how many
|
||||
are left; clicking the last filled one empties it), with "All back", or with
|
||||
a rest.
|
||||
- Casting a low spell with a higher slot is spending the higher slot. Nothing
|
||||
ties a slot to a spell: the site does not check which spell was cast.
|
||||
|
||||
**Getting them back.** The DM's 🛏 Rest… button ([CHARACTERS.md](CHARACTERS.md),
|
||||
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.
|
||||
|
||||
**Bard, Cleric, Druid, Sorcerer, Wizard** (back on a long rest):
|
||||
|
||||
| Character level | 1st | 2nd | 3rd | 4th | 5th |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | 2 | — | — | — | — |
|
||||
| 2 | 3 | — | — | — | — |
|
||||
| 3 | 4 | 2 | — | — | — |
|
||||
| 4 | 4 | 3 | — | — | — |
|
||||
| 5 | 4 | 3 | 2 | — | — |
|
||||
| 6 | 4 | 3 | 3 | — | — |
|
||||
| 7 | 4 | 3 | 3 | 1 | — |
|
||||
| 8 | 4 | 3 | 3 | 2 | — |
|
||||
| 9 | 4 | 3 | 3 | 3 | 1 |
|
||||
| 10 | 4 | 3 | 3 | 3 | 2 |
|
||||
|
||||
**Paladin, Ranger** (back on a long rest; none at level 1):
|
||||
|
||||
| Character level | 1st | 2nd | 3rd |
|
||||
|---|---|---|---|
|
||||
| 1 | — | — | — |
|
||||
| 2 | 2 | — | — |
|
||||
| 3–4 | 3 | — | — |
|
||||
| 5–6 | 4 | 2 | — |
|
||||
| 7–8 | 4 | 3 | — |
|
||||
| 9–10 | 4 | 3 | 2 |
|
||||
|
||||
**Warlock** (back on a short or long rest). All of a warlock's slots are the
|
||||
same level, and every spell cast with one is cast at that level:
|
||||
|
||||
| Character level | Slots | Slot level |
|
||||
|---|---|---|
|
||||
| 1 | 1 | 1st |
|
||||
| 2 | 2 | 1st |
|
||||
| 3–4 | 2 | 2nd |
|
||||
| 5–6 | 2 | 3rd |
|
||||
| 7–8 | 2 | 4th |
|
||||
| 9–10 | 2 | 5th |
|
||||
|
||||
Above level 10 the tables carry on as in standard 5e, up to 9th-level slots at
|
||||
character level 17 (a warlock has 3 slots from level 11 and 4 from level 17).
|
||||
Barbarians, fighters, monks and rogues have no slots, whatever spells they are
|
||||
granted.
|
||||
|
||||
Slots do not decide what a character may **know or prepare**: that is still
|
||||
section 5. A wizard of level 3 can be granted a 3rd-level spell and prepare it,
|
||||
but has no 3rd-level slot to cast it with until level 5. Holding the players
|
||||
to that is the DM's.
|
||||
|
||||
## 9. Starting spells
|
||||
|
||||
In the builder's equipment step a new character can pick spells that are:
|
||||
|
||||
@@ -192,7 +270,7 @@ In the builder's equipment step a new character can pick spells that are:
|
||||
The number allowed is the campaign's "starting spells" setting. Starting
|
||||
spells arrive unprepared.
|
||||
|
||||
## 9. Permissions
|
||||
## 10. Permissions
|
||||
|
||||
| Action | DM | Owning player | Other players |
|
||||
|---|---|---|---|
|
||||
@@ -202,11 +280,16 @@ spells arrive unprepared.
|
||||
| Grant a spell from the list, or remove one | Yes | No | No |
|
||||
| Give a known spell to another character | Yes | Yes | No |
|
||||
| Prepare or unprepare | Yes | Yes, if the character can use it | No |
|
||||
| Spend a spell slot | Yes | Yes | No |
|
||||
| Give a spell slot back | Yes | No | No |
|
||||
| See a character's spells | Yes | Yes | Yes |
|
||||
|
||||
## 10. What is not modelled
|
||||
## 11. What is not modelled
|
||||
|
||||
- Spell slots, and casting a spell at a higher level
|
||||
- Which spell a slot was spent on, and what casting at a higher level adds
|
||||
(the player adds the extra dice)
|
||||
- Sorcery points, and Arcane or Natural Recovery (the DM gives those slots
|
||||
back by hand)
|
||||
- Cantrip damage increasing at higher character levels
|
||||
- Limits on how many spells can be prepared or known
|
||||
- Concentration, components, rituals
|
||||
@@ -215,7 +298,7 @@ spells arrive unprepared.
|
||||
shown)
|
||||
- Applying spell damage to a target automatically
|
||||
|
||||
## 11. The complete spell library
|
||||
## 12. The complete spell library
|
||||
|
||||
Every spell "📚 From Library" offers, with the damage it arrives with.
|
||||
|
||||
|
||||
+2
-1
@@ -57,7 +57,8 @@ it is changed.
|
||||
|
||||
- **Adding:** by hand with "+ Add Weapon", or from the library. Both open in
|
||||
a pop-out. A weapon added by hand opens its own pop-out straight away, to
|
||||
set what it does.
|
||||
set what it does; "+ Add another weapon" at its top goes straight back to
|
||||
an empty form.
|
||||
- **Effects:** under "While equipped" in the weapon's pop-out, saved with
|
||||
"Save Effects". A defending blade can give +1 AC, a heavy maul −5 speed.
|
||||
They count only while the weapon is equipped, and do not change its own
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.13.0",
|
||||
"version": "1.21.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+120
-3
@@ -15,12 +15,15 @@ import type {
|
||||
GearEffect,
|
||||
GoldTransaction,
|
||||
Item,
|
||||
LevelUpPlan,
|
||||
Location,
|
||||
Monster,
|
||||
Note,
|
||||
Npc,
|
||||
SaleOffer,
|
||||
QuickStats,
|
||||
RestResult,
|
||||
RestType,
|
||||
Spell,
|
||||
Weapon,
|
||||
} from './types';
|
||||
@@ -166,7 +169,7 @@ export function useCharacter(characterId: string | undefined) {
|
||||
export function usePatchCharacter(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: Partial<Omit<CharacterDetail, 'gold'>>) =>
|
||||
mutationFn: async (data: Partial<Omit<CharacterDetail, 'gold'>> & { hitDiceUsed?: number }) =>
|
||||
(await apiClient.patch<{ character: CharacterDetail }>(`/characters/${characterId}`, data)).data.character,
|
||||
onSuccess: (character) => {
|
||||
qc.setQueryData(['character', characterId], character);
|
||||
@@ -197,6 +200,95 @@ export function useDeathSave(characterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// The slot tracker. Spending a slot is the player's (or the DM's); setting how
|
||||
// many are spent, and giving them all back, are the DM's alone.
|
||||
export type SpellSlotAction = { action: 'use'; level: number } | { action: 'set'; level: number; used: number } | { action: 'restore' };
|
||||
|
||||
export function useSpellSlots(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: SpellSlotAction) =>
|
||||
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/spell-slots`, data)).data.character,
|
||||
onSuccess: (character) => {
|
||||
invalidateCharacter(qc, characterId); // the sheet and its quick stats
|
||||
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// The class counters. Spending is for the character's own player and the DM,
|
||||
// one use at a time or `amount` from a pool; setting how many are spent, and
|
||||
// giving them all back, are the DM's alone.
|
||||
export type ResourceAction = { action: 'use'; key: string; amount?: number } | { action: 'set'; key: string; used: number } | { action: 'restore' };
|
||||
|
||||
export function useResources(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: ResourceAction) =>
|
||||
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/resources`, data)).data.character,
|
||||
onSuccess: (character) => {
|
||||
invalidateCharacter(qc, characterId);
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows its counters
|
||||
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Inspiration, exhaustion and conditions. The DM sets all three; the
|
||||
// character's own player may spend inspiration (set it to false).
|
||||
export function useCharacterState(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { inspiration?: boolean; exhaustion?: number; conditions?: string[] }) =>
|
||||
(await apiClient.patch<{ character: CharacterDetail }>(`/characters/${characterId}`, data)).data.character,
|
||||
onSuccess: (character) => {
|
||||
invalidateCharacter(qc, characterId);
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows all of it
|
||||
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// What a character's next level brings. DM only.
|
||||
export function useLevelUpPlan(characterId: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['character', characterId, 'level-up'],
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Takes the character up one level, with the choices that go with it.
|
||||
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) => {
|
||||
invalidateCharacter(qc, characterId);
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
|
||||
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// A short or long rest for any of the party at once. On a short rest each
|
||||
// character spends `hitDice` Hit Dice; `rolled` is what they added up to at
|
||||
// the table, and left out the site rolls them.
|
||||
export function useRest(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { type: RestType; characters: { characterId: string; hitDice?: number; rolled?: number | null }[] }) =>
|
||||
(await apiClient.post<{ type: RestType; results: RestResult[] }>(`/campaigns/${campaignId}/rest`, data)).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['encounter'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCharacter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -676,7 +768,8 @@ export function useCampaignEncounters(campaignId: string | undefined) {
|
||||
export function useCreateEncounter(campaignId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { name: string; mode?: EncounterMode }) =>
|
||||
// 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 }) =>
|
||||
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, data)).data.encounter,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }),
|
||||
});
|
||||
@@ -747,7 +840,7 @@ function keepEncounter(qc: QueryClient, encounterId: string, data: unknown) {
|
||||
export function useAddParticipant(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiativeRoll?: number }) =>
|
||||
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiativeRoll?: number; count?: number }) =>
|
||||
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
@@ -771,6 +864,15 @@ export function useRemoveParticipant(encounterId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Rolls a d20 for every monster and NPC that has no initiative roll yet. DM only.
|
||||
export function useRollInitiative(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => (await apiClient.post<{ rolled: number }>(`/encounters/${encounterId}/roll-initiative`)).data,
|
||||
onSuccess: (data) => keepEncounter(qc, encounterId, data),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdvanceRound(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -861,6 +963,21 @@ export function useDeleteReward(encounterId: string) {
|
||||
|
||||
// Awarding is what actually changes the character: a gold ledger entry, or
|
||||
// the weapon/item added to what they hold.
|
||||
// Hands out the encounter's XP, shared evenly between the characters in it:
|
||||
// what its monsters are worth, or the amount given. Once only. DM only.
|
||||
export function useAwardXp(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (xp?: number) =>
|
||||
(await apiClient.post<{ xp: number; share: number; awardedTo: string[] }>(`/encounters/${encounterId}/award-xp`, xp ? { xp } : {})).data,
|
||||
onSuccess: (data) => {
|
||||
keepEncounter(qc, encounterId, data);
|
||||
qc.invalidateQueries({ queryKey: ['character'] });
|
||||
qc.invalidateQueries({ queryKey: ['campaign'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAwardReward(encounterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
+178
-7
@@ -95,6 +95,8 @@ export interface Campaign {
|
||||
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
|
||||
// Added to each target's AC for every target beyond the first in an area attack; 0 is off.
|
||||
areaAttackAcPenalty: number;
|
||||
// Off: nobody chooses a subclass; each class follows its first one, unnamed.
|
||||
subclassesEnabled: boolean;
|
||||
// How many weapons and items a character can have equipped together, and
|
||||
// how many of those can be weapons. Only equipped gear is used in a fight.
|
||||
maxEquipped: number;
|
||||
@@ -127,7 +129,71 @@ export interface DeathSaves {
|
||||
failures: number;
|
||||
}
|
||||
|
||||
export interface CharacterSummary {
|
||||
// One slot level on the slot tracker: how many slots there are, and how many
|
||||
// are spent.
|
||||
export interface SpellSlotLevel {
|
||||
level: number; // 1 is a 1st-level slot
|
||||
max: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
// 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';
|
||||
restoresOn: 'SHORT' | 'LONG';
|
||||
levels: SpellSlotLevel[];
|
||||
}
|
||||
|
||||
// One Hit Die per level, of the class's size, spent on short rests to heal.
|
||||
export interface HitDice {
|
||||
size: number;
|
||||
count: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
// A class counter (Rage, Ki, Channel Divinity...): how many uses there are,
|
||||
// how many are spent, and the rest that gives them back.
|
||||
export interface ClassResource {
|
||||
key: string;
|
||||
name: string;
|
||||
max: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
restoresOn: 'SHORT' | 'LONG';
|
||||
}
|
||||
|
||||
// How a character is doing beyond hit points. Reminders for the table: none
|
||||
// of it changes a number on the sheet.
|
||||
export interface CharacterCondition {
|
||||
inspiration: boolean;
|
||||
exhaustion: number; // 0 to 6
|
||||
conditions: string[];
|
||||
}
|
||||
|
||||
// Where a character's XP puts them: what the next level takes (null at level
|
||||
// 20), and whether they have it. Levelling is still the DM's to do.
|
||||
export interface XpProgress {
|
||||
nextLevelAt: number | null;
|
||||
levelUpDue: boolean;
|
||||
}
|
||||
|
||||
// How hard an encounter is for the characters in it (DM only). `xp` is what
|
||||
// its monsters are worth; `adjustedXp` is that multiplied for their number,
|
||||
// and is what the rating is judged on.
|
||||
export interface EncounterDifficulty {
|
||||
xp: number;
|
||||
adjustedXp: number;
|
||||
multiplier: number;
|
||||
rating: 'TRIVIAL' | 'EASY' | 'MEDIUM' | 'HARD' | 'DEADLY' | null; // null with no party, or nothing rated to fight
|
||||
thresholds: { easy: number; medium: number; hard: number; deadly: number } | null; // the party's; null with no party
|
||||
monsters: number; // how many have a challenge rating
|
||||
unrated: number; // and how many do not, left out of the rest
|
||||
}
|
||||
|
||||
export interface CharacterSummary extends CharacterCondition {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
userId: string | null;
|
||||
@@ -135,8 +201,10 @@ export interface CharacterSummary {
|
||||
name: string;
|
||||
species: Species | null;
|
||||
characterClass: CharacterClass | null;
|
||||
subclassName: string | null;
|
||||
level: number;
|
||||
xp: number;
|
||||
xpProgress: XpProgress;
|
||||
buildStage: BuildStage;
|
||||
currentHp: number;
|
||||
tempHp: number;
|
||||
@@ -148,6 +216,15 @@ export interface CharacterSummary {
|
||||
cash: number | null;
|
||||
maxHp: number | null;
|
||||
armorClass: number | null;
|
||||
// Passive scores and the spell save DC; null until built (the DC also for a non-caster).
|
||||
passives: { perception: number; investigation: number; insight: number } | null;
|
||||
spellSaveDC: number | null;
|
||||
// What the character rolls with; null until built.
|
||||
checks: { abilities: Record<Ability, AbilityDetail>; saves: SaveBonuses; skills: SkillBonuses } | null;
|
||||
// What is left before the next rest; both null until the character is built.
|
||||
spellSlots: SpellSlots | null; // also null for a character with no spell slots
|
||||
hitDice: (HitDice & { conModifier: number }) | null; // each die heals its roll plus this
|
||||
resources: ClassResource[]; // the class counters; empty until built
|
||||
currentLocationId: string | null;
|
||||
currentLocationName: string | null;
|
||||
}
|
||||
@@ -181,6 +258,9 @@ export interface ActiveGear {
|
||||
notes: { source: string; note: string }[];
|
||||
}
|
||||
|
||||
export type SaveBonuses = Record<Ability, { proficient: boolean; bonus: number }>;
|
||||
export type SkillBonuses = Record<string, { ability: Ability; proficient: boolean; expertise: boolean; bonus: number }>;
|
||||
|
||||
export interface CharacterSheet {
|
||||
abilities: Record<Ability, AbilityDetail>;
|
||||
proficiencyBonus: number;
|
||||
@@ -188,11 +268,15 @@ export interface CharacterSheet {
|
||||
initiative: number;
|
||||
speed: number;
|
||||
hp: { max: number };
|
||||
hitDice: { size: number; count: number };
|
||||
saves: Record<Ability, { proficient: boolean; bonus: number }>;
|
||||
skills: Record<string, { ability: Ability; proficient: boolean; expertise: boolean; bonus: number }>;
|
||||
hitDice: HitDice;
|
||||
saves: SaveBonuses;
|
||||
skills: SkillBonuses;
|
||||
passivePerception: number;
|
||||
passiveInvestigation: number;
|
||||
passiveInsight: number;
|
||||
spellcasting: { ability: Ability; saveDC: number; attackBonus: 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;
|
||||
}
|
||||
|
||||
@@ -224,6 +308,8 @@ export interface CharacterWeapon {
|
||||
equipped: boolean;
|
||||
level: number;
|
||||
scaledDamageDice: string; // the dice at this copy's level
|
||||
attackBonus: number; // what this character adds to the d20 with it
|
||||
damageBonus: number; // and to its dice
|
||||
usable: boolean; // false while the character is below this copy's level
|
||||
weapon: Weapon;
|
||||
}
|
||||
@@ -300,7 +386,7 @@ export interface SaveProficiency {
|
||||
proficient: boolean;
|
||||
}
|
||||
|
||||
export interface CharacterDetail {
|
||||
export interface CharacterDetail extends CharacterCondition {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
userId: string | null;
|
||||
@@ -308,8 +394,15 @@ export interface CharacterDetail {
|
||||
name: string;
|
||||
species: Species | null;
|
||||
characterClass: CharacterClass | null;
|
||||
// The path within the class: its key and name, null until one is chosen;
|
||||
// what there is to choose from, and the level the class chooses at.
|
||||
subclass: string | null;
|
||||
subclassName: string | null;
|
||||
subclassLevel: number | null;
|
||||
subclassOptions: { key: string; name: string; summary: string }[];
|
||||
level: number;
|
||||
xp: number;
|
||||
xpProgress: XpProgress;
|
||||
motivation: string | null;
|
||||
halfElfBonusAbility1: Ability | null;
|
||||
halfElfBonusAbility2: Ability | null;
|
||||
@@ -459,6 +552,7 @@ export interface CharacterCombatInfo {
|
||||
level: number;
|
||||
species: Species | null;
|
||||
characterClass: CharacterClass | null;
|
||||
subclassName: string | null;
|
||||
armorClass: number;
|
||||
tempHp: number;
|
||||
deathSaves: DeathSaves;
|
||||
@@ -466,9 +560,16 @@ export interface CharacterCombatInfo {
|
||||
speed: number;
|
||||
initiativeBonus: number;
|
||||
passivePerception: number;
|
||||
passiveInvestigation: number;
|
||||
passiveInsight: number;
|
||||
proficiencyBonus: number;
|
||||
abilities: Record<Ability, AbilityDetail>;
|
||||
saves: Record<Ability, { proficient: boolean; bonus: number }>;
|
||||
saves: SaveBonuses;
|
||||
skills: SkillBonuses;
|
||||
spellSlots: SpellSlots | null;
|
||||
resources: ClassResource[];
|
||||
inspiration: boolean;
|
||||
exhaustion: number;
|
||||
specialAbilities: string | null;
|
||||
gear: ActiveGear;
|
||||
weapons: CombatWeapon[]; // the equipped ones: a weapon has to be in hand to be used
|
||||
@@ -494,6 +595,8 @@ export interface EncounterParticipant {
|
||||
maxHp: number | null;
|
||||
hpPercent?: number | null;
|
||||
conditions: string[];
|
||||
// Rounds left on the conditions that wear off by themselves.
|
||||
conditionRounds: Record<string, number>;
|
||||
isMonster: boolean;
|
||||
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
|
||||
// Out of the fight (a dead character, or a monster or NPC at 0 HP) and so
|
||||
@@ -525,6 +628,8 @@ export interface Encounter {
|
||||
location?: Pick<Location, 'id' | 'name' | 'description'> | null;
|
||||
archivedAt: string | null; // archived encounters only reach the DM's list
|
||||
sortOrder: number; // place in the campaign's own order, lowest first
|
||||
xpAwarded?: number | null; // the XP handed out for it, in all; null until awarded
|
||||
difficulty?: EncounterDifficulty; // DM only
|
||||
createdAt: string;
|
||||
participants?: EncounterParticipant[]; // a player's list leaves out the fallen
|
||||
// The dead characters in the scene. Players do not get their cards, only
|
||||
@@ -652,12 +757,13 @@ export interface QuickStatsSpell {
|
||||
// A character at a glance, with everything it holds. The pack, the spell
|
||||
// list and the purse are only sent to the owning player and the DM
|
||||
// (`restricted` is true for anyone else).
|
||||
export interface QuickStats {
|
||||
export interface QuickStats extends CharacterCondition {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
species: Species | null;
|
||||
characterClass: CharacterClass | null;
|
||||
subclassName: string | null;
|
||||
currentHp: number;
|
||||
maxHp: number;
|
||||
tempHp: number;
|
||||
@@ -667,8 +773,15 @@ export interface QuickStats {
|
||||
speed: number;
|
||||
proficiencyBonus: number;
|
||||
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
|
||||
spellSlots: SpellSlots | null;
|
||||
hitDice: HitDice;
|
||||
resources: ClassResource[];
|
||||
passivePerception: number;
|
||||
passiveInvestigation: number;
|
||||
passiveInsight: number;
|
||||
abilities: Record<Ability, AbilityDetail>;
|
||||
saves: SaveBonuses;
|
||||
skills: SkillBonuses;
|
||||
specialAbilities: string | null;
|
||||
gear: ActiveGear;
|
||||
weapons: QuickStatsWeapon[];
|
||||
@@ -677,3 +790,61 @@ export interface QuickStats {
|
||||
spells: QuickStatsSpell[];
|
||||
purse: { gold: number; silver: number; copper: number; cash: number } | null;
|
||||
}
|
||||
|
||||
// One of the four Boons that stand in for an ability score increase.
|
||||
export interface BoonOption {
|
||||
key: string;
|
||||
name: string;
|
||||
text: string; // the line taking it now would write
|
||||
taken: number; // how many times this character already has
|
||||
max: number;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
// The checklist for a character's next level (GET /characters/:id/level-up).
|
||||
export interface LevelUpPlan {
|
||||
fromLevel: number;
|
||||
toLevel: number;
|
||||
maxHp: { from: number; to: number };
|
||||
// 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;
|
||||
// In a campaign without subclasses: the one taken without asking at this
|
||||
// level, its features already among `features`. Null otherwise.
|
||||
subclassTaken: string | null;
|
||||
// What the class gains. An upgrade rewrites the line that has its name.
|
||||
features: { name: string; text: string; upgrade: boolean }[];
|
||||
boon: BoonOption[] | null; // null at a level that grants no Boon
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
|
||||
export type RestType = 'SHORT' | 'LONG';
|
||||
|
||||
// What a rest did for one character (POST /campaigns/:id/rest).
|
||||
export interface RestResult {
|
||||
characterId: string;
|
||||
name: string;
|
||||
skipped: string | null; // why the rest did nothing for them; the rest is absent then
|
||||
hpBefore: number;
|
||||
hpAfter: number;
|
||||
maxHp: number;
|
||||
tempHp: number;
|
||||
hitDice: {
|
||||
size: number;
|
||||
spent: number;
|
||||
rolls: number[] | null; // what each die showed, when the site rolled them
|
||||
total: number;
|
||||
conBonus: number;
|
||||
healed: number;
|
||||
left: number;
|
||||
of: number;
|
||||
regained: number;
|
||||
};
|
||||
slotsRestored: boolean;
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
@@ -3,12 +3,17 @@ import { useCampaign, useCampaignCharacters, usePatchCharacterWeapon, usePatchIn
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useLastCampaignId } from '../lib/lastCampaign';
|
||||
import { closeCharacterPanel, openCharacterPanel, useCharacterPanelId } from '../lib/characterPanel';
|
||||
import { CLASS_LABELS, SPECIES_LABELS, fmtMod, formatPurse, spellLevelLabel } from '../lib/format';
|
||||
import { SPECIES_LABELS, classLabel, fmtMod, formatPurse, spellLevelLabel } from '../lib/format';
|
||||
import { effectLabel, effectsSummary } from '../lib/gear';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
import { Spinner, ErrorBanner } from './Spinner';
|
||||
import { HpBar } from './HpBar';
|
||||
import { DeathSaves } from './DeathSaves';
|
||||
import { SpellSlotTracker } from './SpellSlots';
|
||||
import { SheetNumbers } from './SheetNumbers';
|
||||
import { CharacterState } from './CharacterState';
|
||||
import { Rollable } from './Rollable';
|
||||
import { withBonus } from '../lib/diceRoller';
|
||||
import { useState } from 'react';
|
||||
import { extractErrorMessage } from '../lib/format';
|
||||
import type { ActiveGear } from '../api/types';
|
||||
@@ -43,7 +48,7 @@ function Flags({ equipped, usable, level }: { equipped: boolean; usable: boolean
|
||||
// One character at a glance: the numbers for a fight, and everything they
|
||||
// hold. The pack, spells and purse are there for the character's own player
|
||||
// and the DM, who can also put gear on and take it off from here.
|
||||
function PanelBody({ characterId }: { characterId: string }) {
|
||||
function PanelBody({ characterId, isDM }: { characterId: string; isDM: boolean }) {
|
||||
const { data, isLoading, error } = useQuickStats(characterId, true);
|
||||
const patchWeapon = usePatchCharacterWeapon(characterId);
|
||||
const patchItem = usePatchInventoryItem(characterId);
|
||||
@@ -61,6 +66,7 @@ function PanelBody({ characterId }: { characterId: string }) {
|
||||
['Speed', data.speed],
|
||||
['Prof.', fmtMod(data.proficiencyBonus)],
|
||||
['Passive Perc.', data.passivePerception],
|
||||
['Hit Dice', `${data.hitDice.remaining}/${data.hitDice.count} d${data.hitDice.size}`],
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -71,7 +77,7 @@ function PanelBody({ characterId }: { characterId: string }) {
|
||||
<span aria-hidden="true">{speciesIcon(data.species)}</span> {data.name}
|
||||
</h3>
|
||||
<div className="combatant-sub">
|
||||
{data.species && SPECIES_LABELS[data.species]} {data.characterClass && CLASS_LABELS[data.characterClass]} · Level {data.level}
|
||||
{data.species && SPECIES_LABELS[data.species]} {classLabel(data.characterClass, data.subclassName)} · Level {data.level}
|
||||
</div>
|
||||
</div>
|
||||
<Link to={`/characters/${data.id}`} className="btn" onClick={closeCharacterPanel}>
|
||||
@@ -111,6 +117,37 @@ function PanelBody({ characterId }: { characterId: string }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-sm">
|
||||
<CharacterState
|
||||
characterId={characterId}
|
||||
name={data.name}
|
||||
inspiration={data.inspiration}
|
||||
exhaustion={data.exhaustion}
|
||||
conditions={data.conditions}
|
||||
resources={data.resources}
|
||||
canUse={!data.restricted}
|
||||
isDM={isDM}
|
||||
onError={(err) => setEquipError(extractErrorMessage(err))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<details className="card mt-md" open>
|
||||
<summary className="card-title">Saves and skills</summary>
|
||||
<SheetNumbers
|
||||
who={data.name}
|
||||
saves={data.saves}
|
||||
skills={data.skills}
|
||||
passives={{ perception: data.passivePerception, investigation: data.passiveInvestigation, insight: data.passiveInsight }}
|
||||
/>
|
||||
</details>
|
||||
|
||||
{data.specialAbilities && (
|
||||
<details className="card mt-md" open>
|
||||
<summary className="card-title">Special Abilities</summary>
|
||||
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{data.specialAbilities}</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{(data.gear.effects.length > 0 || data.gear.notes.length > 0) && (
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">From equipped gear</div>
|
||||
@@ -123,8 +160,22 @@ function PanelBody({ characterId }: { characterId: string }) {
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">Spellcasting</div>
|
||||
<p style={{ margin: 0 }}>
|
||||
Save DC {data.spellcasting.saveDC} · Attack {fmtMod(data.spellcasting.attackBonus)}
|
||||
Save DC {data.spellcasting.saveDC} ·{' '}
|
||||
<Rollable label={`${data.name}: spell attack`} modifier={data.spellcasting.attackBonus}>
|
||||
Attack {fmtMod(data.spellcasting.attackBonus)}
|
||||
</Rollable>
|
||||
</p>
|
||||
{/* The character's own player and the DM spend slots here; the DM can also give them back. */}
|
||||
{data.spellSlots && (
|
||||
<SpellSlotTracker
|
||||
characterId={characterId}
|
||||
name={data.name}
|
||||
slots={data.spellSlots}
|
||||
canUse={!data.restricted}
|
||||
isDM={isDM}
|
||||
onError={(err) => setEquipError(extractErrorMessage(err))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -145,7 +196,15 @@ function PanelBody({ characterId }: { characterId: string }) {
|
||||
{w.quantity > 1 && <span className="combatant-sub"> ×{w.quantity}</span>}
|
||||
<Flags equipped={w.equipped} usable={w.usable} level={w.level} />
|
||||
<div className="combatant-sub">
|
||||
Lv {w.level} · attack {fmtMod(w.attackBonus)} · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
|
||||
Lv {w.level} ·{' '}
|
||||
<Rollable label={`${data.name}: ${w.name} attack`} modifier={w.attackBonus}>
|
||||
attack {fmtMod(w.attackBonus)}
|
||||
</Rollable>{' '}
|
||||
·{' '}
|
||||
<Rollable label={`${data.name}: ${w.name} damage`} dice={withBonus(w.damageDice, w.damageBonus)}>
|
||||
{w.damageDice} {fmtMod(w.damageBonus)}
|
||||
</Rollable>{' '}
|
||||
{w.damageType}
|
||||
</div>
|
||||
{effectsSummary(w.effects, w.effectNote) && <div className="combatant-sub">✦ {effectsSummary(w.effects, w.effectNote)}</div>}
|
||||
</div>
|
||||
@@ -168,8 +227,8 @@ function PanelBody({ characterId }: { characterId: string }) {
|
||||
<p className="combatant-sub mt-md">Their pack, spells and purse are their own business.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">Inventory · {data.inventory.length}</div>
|
||||
<details className="card mt-md" open>
|
||||
<summary className="card-title">Inventory · {data.inventory.length}</summary>
|
||||
{data.inventory.length === 0 && <p className="combatant-sub" style={{ margin: 0 }}>Nothing carried.</p>}
|
||||
{data.inventory.map((item) => (
|
||||
<div key={item.inventoryItemId} className="reward-row">
|
||||
@@ -196,7 +255,7 @@ function PanelBody({ characterId }: { characterId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">
|
||||
@@ -206,32 +265,33 @@ function PanelBody({ characterId }: { characterId: string }) {
|
||||
</div>
|
||||
|
||||
{data.spells.length > 0 && (
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">Spells · {data.spells.length}</div>
|
||||
<details className="card mt-md" open>
|
||||
<summary className="card-title">Spells · {data.spells.length}</summary>
|
||||
{data.spells.map((s) => (
|
||||
<div key={s.characterSpellId} className="reward-row">
|
||||
<strong>{s.name}</strong>
|
||||
<span className="combatant-sub">
|
||||
{' '}
|
||||
{spellLevelLabel(s.level)}
|
||||
{s.damageDice && ` · ${s.damageDice}`}
|
||||
{s.damageDice && (
|
||||
<>
|
||||
{' · '}
|
||||
<Rollable label={`${data.name}: ${s.name}`} dice={s.damageDice}>
|
||||
{s.damageDice}
|
||||
</Rollable>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{s.prepared && <span className="badge badge-live" style={{ marginLeft: 6 }}>prepared</span>}
|
||||
{!s.usable && <span className="badge badge-hostile" style={{ marginLeft: 6 }}>needs level {s.level}</span>}
|
||||
{s.description && <div className="combatant-sub">{s.description}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{data.specialAbilities && (
|
||||
<div className="card mt-md">
|
||||
<div className="card-title">Special Abilities</div>
|
||||
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{data.specialAbilities}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -262,7 +322,7 @@ export function CharacterPanel() {
|
||||
<button
|
||||
className="fab-quickstats"
|
||||
onClick={() => openCharacterPanel(fallback.id)}
|
||||
title={isDM ? 'Characters: stats and inventory' : 'Your character: stats and inventory'}
|
||||
title={isDM ? 'Characters: stats, skills and inventory' : 'Your character: stats, skills and inventory'}
|
||||
aria-label="Open the character panel"
|
||||
>
|
||||
🧙
|
||||
@@ -289,7 +349,7 @@ export function CharacterPanel() {
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<PanelBody key={shown} characterId={shown} />
|
||||
<PanelBody key={shown} characterId={shown} isDM={isDM} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState } from 'react';
|
||||
import { useCharacterState, useResources } from '../api/queries';
|
||||
import { CONDITIONS, MAX_EXHAUSTION, conditionRule, exhaustionRule } from '../lib/conditions';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import type { ClassResource } from '../api/types';
|
||||
|
||||
// A counter with more uses than this is a pool (Lay on Hands, a high-level
|
||||
// monk's Ki): it gets a number to spend in place of a row of pips.
|
||||
const MOST_PIPS = 10;
|
||||
|
||||
// One condition, with its rule a hover away. `onRemove` adds the ✕.
|
||||
export function ConditionChip({ name, note, onRemove }: { name: string; note?: string; onRemove?: () => void }) {
|
||||
return (
|
||||
<span className="condition-chip" title={conditionRule(name) || undefined}>
|
||||
{name}
|
||||
{note && <span className="condition-note">{note}</span>}
|
||||
{onRemove && (
|
||||
<button aria-label={`Remove ${name}`} onClick={onRemove}>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// The "+ Condition" menu: every condition not already in force.
|
||||
export function ConditionPicker({ who, current, onAdd }: { who: string; current: string[]; onAdd: (condition: string) => void }) {
|
||||
return (
|
||||
<select aria-label={`Add a condition to ${who}`} value="" onChange={(e) => e.target.value && onAdd(e.target.value)} style={{ width: 'auto' }}>
|
||||
<option value="">+ Condition</option>
|
||||
{CONDITIONS.filter((c) => !current.includes(c)).map((c) => (
|
||||
<option key={c} value={c} title={conditionRule(c)}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// Inspiration and exhaustion as badges, for places that only show them.
|
||||
export function StateBadges({ inspiration, exhaustion }: { inspiration: boolean; exhaustion: number }) {
|
||||
return (
|
||||
<>
|
||||
{inspiration && (
|
||||
<span className="badge badge-live" title="Inspiration: spend it for advantage on one attack, check or save">
|
||||
✨ Inspired
|
||||
</span>
|
||||
)}
|
||||
{exhaustion > 0 && (
|
||||
<span className="badge badge-hostile" title={exhaustionRule(exhaustion)}>
|
||||
Exhaustion {exhaustion}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// The class counters (Rage, Ki, Channel Divinity...): pips that are filled
|
||||
// while a use is left, and "Use" to spend one. `canUse` is the character's own
|
||||
// player or the DM. Getting a use back is the DM's alone: for them each pip
|
||||
// sets how many are left, and "All back" refills the lot.
|
||||
export function ResourceTracker({
|
||||
characterId,
|
||||
name,
|
||||
resources,
|
||||
canUse,
|
||||
isDM,
|
||||
onError,
|
||||
}: {
|
||||
characterId: string;
|
||||
name: string;
|
||||
resources: ClassResource[];
|
||||
canUse: boolean;
|
||||
isDM: boolean;
|
||||
onError?: (err: unknown) => void;
|
||||
}) {
|
||||
const mutation = useResources(characterId);
|
||||
const send = (action: Parameters<typeof mutation.mutate>[0]) => mutation.mutate(action, { onError });
|
||||
// What is typed into a pool's box, by counter.
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({});
|
||||
|
||||
if (resources.length === 0) return null;
|
||||
return (
|
||||
<div className="resource-tracker">
|
||||
{isDM && resources.some((r) => r.used > 0) && (
|
||||
<div className="flex-between">
|
||||
<span />
|
||||
<button className="btn btn-ghost" onClick={() => send({ action: 'restore' })} disabled={mutation.isPending} title={`Give ${name} every use back`}>
|
||||
All back
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{resources.map((r) => {
|
||||
const rest = r.restoresOn === 'SHORT' ? 'short or long rest' : 'long rest';
|
||||
const isPool = r.max > MOST_PIPS;
|
||||
const amount = Math.max(1, Math.floor(Number(amounts[r.key])) || 1);
|
||||
return (
|
||||
<div key={r.key} className="slot-row">
|
||||
<span className="resource-name" title={`Back on a ${rest}`}>
|
||||
{r.name}
|
||||
<span className="resource-rest">{r.restoresOn === 'SHORT' ? 'short rest' : 'long rest'}</span>
|
||||
</span>
|
||||
{isPool ? (
|
||||
<span className="slot-pips" />
|
||||
) : (
|
||||
<span className="slot-pips" role="group" aria-label={`${r.remaining} of ${r.max} ${r.name} left`}>
|
||||
{Array.from({ length: r.max }, (_, i) => i + 1).map((n) => {
|
||||
const className = `slot-pip${n <= r.remaining ? ' is-filled' : ''}`;
|
||||
const left = n === r.remaining ? n - 1 : n;
|
||||
return isDM ? (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={className}
|
||||
aria-label={`Set ${r.name} left to ${left}`}
|
||||
disabled={mutation.isPending}
|
||||
onClick={() => send({ action: 'set', key: r.key, used: r.max - left })}
|
||||
/>
|
||||
) : (
|
||||
<span key={n} className={className} />
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span className="mono slot-count">
|
||||
{r.remaining}/{r.max}
|
||||
</span>
|
||||
{canUse && isPool && (
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={r.max}
|
||||
placeholder="1"
|
||||
aria-label={`How much ${r.name} to spend`}
|
||||
value={amounts[r.key] ?? ''}
|
||||
onChange={(e) => setAmounts({ ...amounts, [r.key]: e.target.value })}
|
||||
style={{ width: 60 }}
|
||||
/>
|
||||
)}
|
||||
{canUse && (
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => send({ action: 'use', key: r.key, amount: isPool ? amount : 1 })}
|
||||
disabled={r.remaining === 0 || mutation.isPending}
|
||||
title={r.remaining === 0 ? `None left. Back on a ${rest}` : `Spend ${isPool ? amount : 'one'}. Back on a ${rest}`}
|
||||
aria-label={`Use ${r.name}`}
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
)}
|
||||
{isDM && isPool && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => send({ action: 'set', key: r.key, used: Math.max(0, r.used - amount) })}
|
||||
disabled={r.used === 0 || mutation.isPending}
|
||||
title={`Give ${amount} back`}
|
||||
aria-label={`Give ${r.name} back`}
|
||||
>
|
||||
+ Back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{canUse && !isDM && <p className="combatant-sub" style={{ margin: '0.3rem 0 0' }}>Once used, a rest or the DM gives it back.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// How a character is doing beyond hit points: inspiration, exhaustion,
|
||||
// conditions and class counters. The DM gives and takes away all of it. The
|
||||
// character's own player spends inspiration and counters. Everyone else reads.
|
||||
// With `conditions` left out that row is not drawn (an encounter card has its
|
||||
// own).
|
||||
export function CharacterState({
|
||||
characterId,
|
||||
name,
|
||||
inspiration,
|
||||
exhaustion,
|
||||
conditions,
|
||||
resources,
|
||||
canUse,
|
||||
isDM,
|
||||
onError,
|
||||
}: {
|
||||
characterId: string;
|
||||
name: string;
|
||||
inspiration: boolean;
|
||||
exhaustion: number;
|
||||
conditions?: string[];
|
||||
resources: ClassResource[];
|
||||
canUse: boolean;
|
||||
isDM: boolean;
|
||||
onError?: (err: unknown) => void;
|
||||
}) {
|
||||
const state = useCharacterState(characterId);
|
||||
const set = (data: Parameters<typeof state.mutate>[0]) => state.mutate(data, { onError });
|
||||
|
||||
return (
|
||||
<div className="character-state">
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
{isDM ? (
|
||||
<button
|
||||
className={`btn btn-target${inspiration ? ' is-lit' : ''}`}
|
||||
aria-pressed={inspiration}
|
||||
disabled={state.isPending}
|
||||
title={inspiration ? `Take ${name}'s inspiration away` : `Give ${name} inspiration: advantage on one attack, check or save of their choice`}
|
||||
onClick={() => set({ inspiration: !inspiration })}
|
||||
>
|
||||
✨ {inspiration ? 'Inspired' : 'Give inspiration'}
|
||||
</button>
|
||||
) : inspiration ? (
|
||||
<>
|
||||
<StateBadges inspiration exhaustion={0} />
|
||||
{canUse && (
|
||||
<button className="btn btn-target" disabled={state.isPending} title="Spend it for advantage on one attack, check or save" onClick={() => set({ inspiration: false })}>
|
||||
Spend
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="combatant-sub">No inspiration</span>
|
||||
)}
|
||||
{isDM ? (
|
||||
<label className="flex-row gap-sm" title={exhaustionRule(exhaustion)}>
|
||||
Exhaustion
|
||||
<select
|
||||
value={exhaustion}
|
||||
disabled={state.isPending}
|
||||
onChange={(e) => set({ exhaustion: Number(e.target.value) })}
|
||||
style={{ width: 'auto' }}
|
||||
>
|
||||
{Array.from({ length: MAX_EXHAUSTION + 1 }, (_, level) => (
|
||||
<option key={level} value={level}>
|
||||
{level === 0 ? 'None' : `Level ${level}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<StateBadges inspiration={false} exhaustion={exhaustion} />
|
||||
)}
|
||||
</div>
|
||||
{exhaustion > 0 && <p className="combatant-sub" style={{ margin: '0.3rem 0 0', whiteSpace: 'pre-wrap' }}>{exhaustionRule(exhaustion)}</p>}
|
||||
|
||||
{conditions && (conditions.length > 0 || isDM) && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm">
|
||||
{conditions.map((c) => (
|
||||
<ConditionChip key={c} name={c} onRemove={isDM ? () => set({ conditions: conditions.filter((x) => x !== c) }) : undefined} />
|
||||
))}
|
||||
{isDM && <ConditionPicker who={name} current={conditions} onAdd={(c) => set({ conditions: [...conditions, c] })} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResourceTracker characterId={characterId} name={name} resources={resources} canUse={canUse} isDM={isDM} onError={onError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +1,88 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { fmtMod } from '../lib/format';
|
||||
import { onRollRequest, parseDice, rollD20, rollDie, type RollMode, type RollRequest } from '../lib/diceRoller';
|
||||
|
||||
const DICE = [4, 6, 8, 10, 12, 20, 100];
|
||||
const MAX_DICE = 20;
|
||||
const HISTORY_LENGTH = 8;
|
||||
|
||||
const MODES: { mode: RollMode; label: string; title: string }[] = [
|
||||
{ mode: 'DISADVANTAGE', label: 'Disadv.', title: 'Disadvantage: roll two d20s and keep the lower' },
|
||||
{ mode: 'NORMAL', label: 'Normal', title: 'One d20' },
|
||||
{ mode: 'ADVANTAGE', label: 'Adv.', title: 'Advantage: roll two d20s and keep the higher' },
|
||||
];
|
||||
|
||||
interface Roll {
|
||||
id: number;
|
||||
sides: 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;
|
||||
dice: number[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// One fair roll of an n-sided die. Uses the browser's cryptographic random
|
||||
// source, discarding values that would make some faces slightly likelier.
|
||||
function rollDie(sides: number): number {
|
||||
const limit = Math.floor(0x100000000 / sides) * sides;
|
||||
const buffer = new Uint32Array(1);
|
||||
do {
|
||||
crypto.getRandomValues(buffer);
|
||||
} while (buffer[0] >= limit);
|
||||
return (buffer[0] % sides) + 1;
|
||||
// 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 table-side dice roller, reachable from every page. These rolls are for
|
||||
// the player's own use at the table: they are not sent to the server and do
|
||||
// not feed the character builder, whose dice stay locked in server-side.
|
||||
// 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 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;
|
||||
}
|
||||
}
|
||||
return { label: request.label, notation: request.expression.replace(/\s+/g, ''), dice, modifier: parsed.flat, total, 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.
|
||||
export function DiceRoller() {
|
||||
const [open, setOpen] = useState(false);
|
||||
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 push = (roll: Omit<Roll, 'id'>) => setHistory((prev) => [{ ...roll, id: (prev[0]?.id ?? 0) + 1 }, ...prev].slice(0, HISTORY_LENGTH));
|
||||
|
||||
// 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);
|
||||
useEffect(() => {
|
||||
currentMode.current = mode;
|
||||
});
|
||||
useEffect(
|
||||
() =>
|
||||
onRollRequest((request) => {
|
||||
const roll = answer(request, currentMode.current);
|
||||
if (!roll) return;
|
||||
push(roll);
|
||||
setOpen(true);
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
|
||||
@@ -46,15 +91,16 @@ export function DiceRoller() {
|
||||
}, [open]);
|
||||
|
||||
const latest = history[0];
|
||||
const singleD20 = sides === 20 && count === 1;
|
||||
|
||||
function roll() {
|
||||
if (singleD20) return push(d20Roll(modifier, mode));
|
||||
const dice = Array.from({ length: count }, () => rollDie(sides));
|
||||
const total = dice.reduce((sum, d) => sum + d, 0) + modifier;
|
||||
setHistory((prev) => [{ id: (prev[0]?.id ?? 0) + 1, sides, modifier, dice, total }, ...prev].slice(0, HISTORY_LENGTH));
|
||||
push({ notation: notation(count, sides, modifier), dice, modifier, total: dice.reduce((sum, d) => sum + d, 0) + modifier, natural: null, mode: 'NORMAL' });
|
||||
}
|
||||
|
||||
// The natural 20 and natural 1 only mean something on a single d20.
|
||||
const natural = latest && latest.sides === 20 && latest.dice.length === 1 ? latest.dice[0] : null;
|
||||
const natural = latest?.natural ?? null;
|
||||
const modeNote = (r: Roll) => (r.mode === 'ADVANTAGE' ? 'advantage' : r.mode === 'DISADVANTAGE' ? 'disadvantage' : '');
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -113,29 +159,44 @@ export function DiceRoller() {
|
||||
</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.dice.length > 1 || latest.modifier !== 0 || latest.label) && (
|
||||
<div className="dice-breakdown mono">
|
||||
{latest.dice.join(' + ')}
|
||||
{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.
|
||||
Pick a die and roll, or click a skill, save or attack on a sheet.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -151,7 +212,8 @@ export function DiceRoller() {
|
||||
{history.slice(1).map((r) => (
|
||||
<div key={r.id} className="flex-between dice-history-row">
|
||||
<span>
|
||||
{notation(r.dice.length, r.sides, r.modifier)}
|
||||
{r.label ? `${r.label} · ` : ''}
|
||||
{r.notation}
|
||||
{r.dice.length > 1 && <span className="dice-history-dice"> ({r.dice.join(', ')})</span>}
|
||||
</span>
|
||||
<strong className="mono">{r.total}</strong>
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import { ABILITY_LABELS, SKILL_LABELS, fmtMod } from '../lib/format';
|
||||
import { rollD20, type RollMode } from '../lib/diceRoller';
|
||||
import type { Ability, AbilityDetail, SaveBonuses, SkillBonuses } from '../api/types';
|
||||
|
||||
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
|
||||
// What the modal needs to know about each character who might roll.
|
||||
export interface Checker {
|
||||
id: string;
|
||||
name: string;
|
||||
abilities: Record<Ability, AbilityDetail>;
|
||||
saves: SaveBonuses;
|
||||
skills: SkillBonuses;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
id: string;
|
||||
name: string;
|
||||
dice: number[];
|
||||
kept: number;
|
||||
bonus: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// A check is "SKILL:STEALTH", "SAVE:DEX" or "ABILITY:STR".
|
||||
function describe(check: string): string {
|
||||
const [kind, key] = check.split(':');
|
||||
if (kind === 'SKILL') return SKILL_LABELS[key];
|
||||
return `${ABILITY_LABELS[key]} ${kind === 'SAVE' ? 'save' : 'check'}`;
|
||||
}
|
||||
|
||||
function bonusFor(c: Checker, check: string): number {
|
||||
const [kind, key] = check.split(':');
|
||||
if (kind === 'SKILL') return c.skills[key].bonus;
|
||||
if (kind === 'SAVE') return c.saves[key as Ability].bonus;
|
||||
return c.abilities[key as Ability].modifier;
|
||||
}
|
||||
|
||||
// The DM calling for a roll from several characters at once: everyone makes
|
||||
// a Stealth check, or a Dexterity save against a fireball. Pick the roll and
|
||||
// who makes it; each gets their own d20 with their own bonus, and with a DC
|
||||
// the list says who made it. Rolled in the browser, like every table roll.
|
||||
export function GroupCheckModal({ characters, onClose }: { characters: Checker[]; onClose: () => void }) {
|
||||
const [check, setCheck] = useState('SKILL:PERCEPTION');
|
||||
const [dc, setDc] = useState('');
|
||||
const [mode, setMode] = useState<RollMode>('NORMAL');
|
||||
const [left, setLeft] = useState<Set<string>>(new Set()); // who sits this one out
|
||||
const [results, setResults] = useState<{ check: string; dc: number | null; rows: Result[] } | null>(null);
|
||||
|
||||
const rolling = characters.filter((c) => !left.has(c.id));
|
||||
const target = dc === '' ? null : Number(dc);
|
||||
|
||||
function roll() {
|
||||
setResults({
|
||||
check,
|
||||
dc: target,
|
||||
rows: rolling.map((c) => {
|
||||
const { kept, dice } = rollD20(mode);
|
||||
const bonus = bonusFor(c, check);
|
||||
return { id: c.id, name: c.name, dice, kept, bonus, total: kept + bonus };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const passed = results && results.dc != null ? results.rows.filter((r) => r.total >= results.dc!).length : 0;
|
||||
|
||||
return (
|
||||
<Modal title="Call for a roll" subtitle="One d20 each, with each character's own bonus" onClose={onClose}>
|
||||
<div className="flex-row flex-wrap gap-sm">
|
||||
<select aria-label="What to roll" value={check} onChange={(e) => setCheck(e.target.value)} style={{ flex: 1, minWidth: 180 }}>
|
||||
<optgroup label="Skills">
|
||||
{Object.keys(SKILL_LABELS).map((skill) => (
|
||||
<option key={skill} value={`SKILL:${skill}`}>
|
||||
{SKILL_LABELS[skill]}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Saving throws">
|
||||
{ABILITIES.map((a) => (
|
||||
<option key={a} value={`SAVE:${a}`}>
|
||||
{ABILITY_LABELS[a]} save
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Ability checks">
|
||||
{ABILITIES.map((a) => (
|
||||
<option key={a} value={`ABILITY:${a}`}>
|
||||
{ABILITY_LABELS[a]} check
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
<NumberInput
|
||||
min={1}
|
||||
max={40}
|
||||
placeholder="DC"
|
||||
title="The number to reach. Leave empty to just see the totals."
|
||||
aria-label="Difficulty class"
|
||||
value={dc}
|
||||
onChange={(e) => setDc(e.target.value)}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
<select aria-label="How the d20s are rolled" value={mode} onChange={(e) => setMode(e.target.value as RollMode)} style={{ width: 'auto' }}>
|
||||
<option value="NORMAL">Normal</option>
|
||||
<option value="ADVANTAGE">Advantage</option>
|
||||
<option value="DISADVANTAGE">Disadvantage</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="level-spells mt-sm">
|
||||
{characters.map((c) => (
|
||||
<label key={c.id} className="checkbox-row level-choice">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!left.has(c.id)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(left);
|
||||
if (e.target.checked) next.delete(c.id);
|
||||
else next.add(c.id);
|
||||
setLeft(next);
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{c.name} <span className="combatant-sub mono">{fmtMod(bonusFor(c, check))}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="btn btn-primary btn-block mt-sm" onClick={roll} disabled={rolling.length === 0}>
|
||||
🎲 Roll {describe(check)} for {rolling.length}
|
||||
</button>
|
||||
|
||||
{results && (
|
||||
<div className="mt-md" aria-live="polite">
|
||||
<div className="section-label">
|
||||
{describe(results.check)}
|
||||
{results.dc != null && ` · DC ${results.dc} · ${passed} of ${results.rows.length} made it`}
|
||||
</div>
|
||||
{results.rows.map((r) => {
|
||||
const made = results.dc == null ? null : r.total >= results.dc;
|
||||
return (
|
||||
<div key={r.id} className="detail-row">
|
||||
<span>
|
||||
{r.name}{' '}
|
||||
<span className="combatant-sub mono">
|
||||
{r.dice.length > 1 ? `${r.dice.join(' / ')} → ${r.kept}` : r.kept} {r.bonus >= 0 ? '+' : '−'} {Math.abs(r.bonus)}
|
||||
{r.kept === 20 && ' · natural 20'}
|
||||
{r.kept === 1 && ' · natural 1'}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<strong className="mono">{r.total}</strong>
|
||||
{made != null && (
|
||||
<span className={`badge ${made ? 'badge-live' : 'badge-hostile'}`} style={{ marginLeft: 6 }}>
|
||||
{made ? 'made it' : 'failed'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{results.dc != null && (
|
||||
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>
|
||||
A group check succeeds when at least half make it{passed * 2 >= results.rows.length ? ': this one does.' : ': this one does not.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,8 @@ const STAT_HELP: Record<string, string> = {
|
||||
DAMAGE_BONUS: 'Damage Bonus — added to the weapon’s damage dice on a hit. Same ability modifier as the attack bonus.',
|
||||
CHALLENGE_RATING: 'Challenge Rating — a rough measure of how dangerous a monster is on its own, used to gauge encounter difficulty.',
|
||||
HIT_DICE: 'Hit Dice — the die your class rolls (or uses the fixed average of) when your HP increases at each level.',
|
||||
HIT_DICE_LEFT: 'Hit Dice — you have one per level. On a short rest you can spend any you have left: roll each one, add your Constitution modifier, and heal that much. A long rest gives back half your level in spent dice (at least one).',
|
||||
SPELL_SLOTS: 'Spell slots — each one is one casting of a spell of that level or lower. Cantrips never use a slot. How many you have comes from your class and level. Spent slots come back on a long rest (a warlock’s on a short rest too).',
|
||||
// Character builder
|
||||
DICE_MODE: 'Digital dice are rolled by the app. Physical dice are rolled by you at the table, and you type in what came up. Either way a roll is final once it is made.',
|
||||
WILDCARD: 'Wildcard — the luckiest result. Instead of choosing between two options you may pick any of them.',
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useLevelUp, useLevelUpPlan } from '../api/queries';
|
||||
import { extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format';
|
||||
import { Modal } from './Modal';
|
||||
import { Spinner, ErrorBanner } from './Spinner';
|
||||
import type { CharacterDetail, LevelUpPlan, Spell } from '../api/types';
|
||||
|
||||
// One numbered step of the checklist.
|
||||
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="level-step">
|
||||
<h3 className="section-label" style={{ margin: 0 }}>
|
||||
{n}. {title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function allowance(spells: NonNullable<LevelUpPlan['spells']>): string {
|
||||
const parts = [];
|
||||
if (spells.newSpells > 0) parts.push(`${spells.newSpells} new spell${spells.newSpells === 1 ? '' : 's'} of up to ${slotLevelLabel(spells.maxSpellLevel)} level`);
|
||||
if (spells.newCantrips > 0) parts.push(`${spells.newCantrips} extra cantrip`);
|
||||
return parts.length > 0 ? `The book allows ${parts.join(' and ')}.` : 'No new spell at this level.';
|
||||
}
|
||||
|
||||
// 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);
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (isLoading || !plan) {
|
||||
return (
|
||||
<Modal title={`Level up ${character.name}`} onClose={onClose}>
|
||||
{planError ? <ErrorBanner message={extractErrorMessage(planError)} /> : <Spinner label="Reading the class tables…" />}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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 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;
|
||||
|
||||
function toggleSpell(id: string) {
|
||||
setSpellIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleLevelUp() {
|
||||
setError(null);
|
||||
try {
|
||||
await levelUp.mutateAsync({ subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title={`Level up ${character.name}`} subtitle={`Level ${plan.fromLevel} → ${plan.toLevel} · the levelling-up checklist`} wide onClose={onClose}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
<Step n={1} title="Level">
|
||||
<p>
|
||||
Level <strong>{plan.fromLevel}</strong> → <strong>{plan.toLevel}</strong>. 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>
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Special abilities">
|
||||
{plan.subclass && (
|
||||
<div role="radiogroup" aria-label="Subclass" style={{ marginBottom: '0.6rem' }}>
|
||||
<p style={{ margin: '0 0 0.3rem' }}>
|
||||
<strong>Subclass</strong>{' '}
|
||||
<span className="combatant-sub">
|
||||
{plan.subclass.level === plan.toLevel
|
||||
? `chosen at level ${plan.subclass.level}. Choose one:`
|
||||
: `owed since level ${plan.subclass.level}; everything it has given so far is written now. Choose one:`}
|
||||
</span>
|
||||
</p>
|
||||
{plan.subclass.options.map((option) => (
|
||||
<label key={option.key} className="checkbox-row level-choice">
|
||||
<input type="radio" name="subclass" value={option.key} checked={subclass === option.key} onChange={() => setSubclass(option.key)} />
|
||||
<span>
|
||||
<strong>{option.name}</strong>
|
||||
<span className="combatant-sub" style={{ display: 'block' }}>{option.summary}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{pickedSubclass?.features.map((feature) => (
|
||||
<div key={feature.name + feature.text} className="reward-row">
|
||||
<strong>{feature.name}</strong>
|
||||
<span className="badge" style={{ marginLeft: 6 }}>{pickedSubclass.name}</span>
|
||||
<div className="combatant-sub">{feature.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{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.'}
|
||||
</p>
|
||||
)}
|
||||
{plan.features.map((feature) => (
|
||||
<div key={feature.name + feature.text} className="reward-row">
|
||||
<strong>{feature.name}</strong>
|
||||
{feature.upgrade && <span className="badge" style={{ marginLeft: 6 }}>replaces the line it has now</span>}
|
||||
<div className="combatant-sub">{feature.text}</div>
|
||||
</div>
|
||||
))}
|
||||
{plan.boon && (
|
||||
<div className="mt-sm" role="radiogroup" aria-label="Boon">
|
||||
<p style={{ margin: '0 0 0.3rem' }}>
|
||||
<strong>Boon</strong> <span className="combatant-sub">in place of an ability score increase. Choose one:</span>
|
||||
</p>
|
||||
{plan.boon.map((option) => (
|
||||
<label key={option.key} className="checkbox-row level-choice" style={option.available ? undefined : { opacity: 0.55 }}>
|
||||
<input type="radio" name="boon" value={option.key} checked={boon === option.key} disabled={!option.available} onChange={() => setBoon(option.key)} />
|
||||
<span>
|
||||
<strong>{option.name}</strong>
|
||||
{option.taken > 0 && <span className="badge" style={{ marginLeft: 6 }}>{option.available ? `taken ${option.taken} of ${option.max}` : 'already taken'}</span>}
|
||||
<span className="combatant-sub" style={{ display: 'block' }}>{option.text}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{pickedBoon?.key === 'TOUGHENED' && <p className="combatant-sub">Extra AC goes up by 1 with it.</p>}
|
||||
{pickedBoon?.key === 'HARDY' && <p className="combatant-sub">The Temp HP is given now, and again after every long rest taken with the Rest button.</p>}
|
||||
</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>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
<Step n={4} title="Spells">
|
||||
{!plan.spells ? (
|
||||
<p className="combatant-sub">This class has no spells.</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
{plan.spellSlots ? (
|
||||
<>
|
||||
Fresh spell slots: <strong>{slotsSummary(plan.spellSlots)}</strong>.
|
||||
</>
|
||||
) : (
|
||||
'No spell slots yet.'
|
||||
)}{' '}
|
||||
{plan.spells.unlocksSpellLevel && <strong>{slotLevelLabel(plan.spells.unlocksSpellLevel)}-level spells unlock. </strong>}
|
||||
{allowance(plan.spells)}
|
||||
</p>
|
||||
{grantable.length === 0 ? (
|
||||
<p className="combatant-sub">Nothing in the campaign's spell list to grant that {character.name} does not already know. Spells can be granted on the sheet later.</p>
|
||||
) : (
|
||||
<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>
|
||||
</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.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
<Step n={5} title="Gear">
|
||||
{plan.weapons.length === 0 ? (
|
||||
<p className="combatant-sub">No weapon can take the free upgrade: it goes to a held weapon of standard growth that is below level {plan.toLevel}.</p>
|
||||
) : (
|
||||
<label className="flex-row flex-wrap gap-sm">
|
||||
Free upgrade for the main weapon
|
||||
<select value={weaponId} onChange={(e) => setWeaponChoice(e.target.value)} style={{ flex: 1, minWidth: 180 }}>
|
||||
{plan.weapons.map((w) => (
|
||||
<option key={w.weaponId} value={w.weaponId}>
|
||||
{w.name}: Lv {w.level} → {w.level + 1} ({w.damageDice} → {w.nextDamageDice}){w.equipped ? ' · equipped' : ''}
|
||||
</option>
|
||||
))}
|
||||
<option value="">No upgrade</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
<div className="modal-foot">
|
||||
<button className="btn btn-primary" onClick={handleLevelUp} disabled={levelUp.isPending || Boolean(owed)}>
|
||||
{levelUp.isPending ? 'Levelling…' : (owed ?? `Level up to ${plan.toLevel}`)}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState } from 'react';
|
||||
import { useRest } from '../api/queries';
|
||||
import { CLASS_LABELS, extractErrorMessage, fmtMod, slotsSummary } from '../lib/format';
|
||||
import { Modal } from './Modal';
|
||||
import { NumberInput } from './NumberInput';
|
||||
import { ErrorBanner } from './Spinner';
|
||||
import { LifeBadge } from './DeathSaves';
|
||||
import type { CharacterSummary, RestResult, RestType } from '../api/types';
|
||||
|
||||
const REST_LABELS: Record<RestType, string> = { SHORT: 'Short rest', LONG: 'Long rest' };
|
||||
|
||||
// What a rest did for one character, in a line or two.
|
||||
function ResultRow({ result, type }: { result: RestResult; type: RestType }) {
|
||||
if (result.skipped) {
|
||||
return (
|
||||
<div className="reward-row">
|
||||
<strong>{result.name}</strong>
|
||||
<div className="combatant-sub">{result.skipped}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const { hitDice } = result;
|
||||
const lines: string[] = [];
|
||||
if (hitDice.spent > 0) {
|
||||
const dice = `${hitDice.spent}d${hitDice.size}`;
|
||||
const rolled = hitDice.rolls ? `the site rolled ${hitDice.rolls.join(' + ')}` : `rolled ${hitDice.total}`;
|
||||
const con = hitDice.conBonus === 0 ? '' : ` ${hitDice.conBonus > 0 ? '+' : '−'} ${Math.abs(hitDice.conBonus)} Constitution`;
|
||||
lines.push(`Spent ${dice}: ${rolled}${con}, healing ${hitDice.healed}.`);
|
||||
}
|
||||
if (hitDice.regained > 0) lines.push(`${hitDice.regained} Hit ${hitDice.regained === 1 ? 'Die' : 'Dice'} back.`);
|
||||
lines.push(`Hit Dice left: ${hitDice.left} of ${hitDice.of}.`);
|
||||
if (result.slotsRestored) lines.push('All spell slots back.');
|
||||
if (type === 'SHORT' && hitDice.spent === 0 && !result.slotsRestored) lines.unshift('Rested, with no Hit Dice spent.');
|
||||
|
||||
return (
|
||||
<div className="reward-row">
|
||||
<div className="flex-between">
|
||||
<strong>{result.name}</strong>
|
||||
<span className="mono">
|
||||
HP {result.hpBefore === result.hpAfter ? result.hpAfter : `${result.hpBefore} → ${result.hpAfter}`}/{result.maxHp}
|
||||
{result.tempHp > 0 && ` +${result.tempHp}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="combatant-sub">{lines.join(' ')}</div>
|
||||
{result.notes.map((note) => (
|
||||
<div key={note} className="combatant-sub">
|
||||
✦ {note}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The DM's rest button: pick who rests and for how long. A long rest is
|
||||
// worked out by the site. A short rest says which Hit Dice each character can
|
||||
// roll; the DM types how many they spend and what they rolled (or leaves the
|
||||
// roll to the site), and the healing is added up from that.
|
||||
export function RestModal({ campaignId, characters, onClose }: { campaignId: string; characters: CharacterSummary[]; onClose: () => void }) {
|
||||
const rest = useRest(campaignId);
|
||||
const party = characters.filter((c) => c.buildStage === 'COMPLETE');
|
||||
const [type, setType] = useState<RestType>('SHORT');
|
||||
// The dead are left out to begin with: a rest does nothing for them.
|
||||
const [picked, setPicked] = useState<Set<string>>(() => new Set(party.filter((c) => c.deathSaves.state !== 'DEAD').map((c) => c.id)));
|
||||
const [dice, setDice] = useState<Record<string, string>>({});
|
||||
const [rolled, setRolled] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState<{ type: RestType; results: RestResult[] } | null>(null);
|
||||
|
||||
function toggle(id: string) {
|
||||
setPicked((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRest() {
|
||||
setError(null);
|
||||
try {
|
||||
const resting = party.filter((c) => picked.has(c.id));
|
||||
setDone(
|
||||
await rest.mutateAsync({
|
||||
type,
|
||||
characters: resting.map((c) => {
|
||||
const spent = type === 'SHORT' ? Number(dice[c.id]) || 0 : 0;
|
||||
return { characterId: c.id, hitDice: spent, rolled: spent > 0 && rolled[c.id] ? Number(rolled[c.id]) : null };
|
||||
}),
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<Modal title={`${REST_LABELS[done.type]} taken`} subtitle="What it did for each character" onClose={onClose}>
|
||||
{done.results.map((result) => (
|
||||
<ResultRow key={result.characterId} result={result} type={done.type} />
|
||||
))}
|
||||
{done.type === 'SHORT' && (
|
||||
<p className="combatant-sub mt-md" style={{ marginBottom: 0 }}>
|
||||
Also back on a short rest, for the players to note: a fighter's Second Wind and Action Surge, a monk's ki, a cleric's Channel
|
||||
Divinity, a druid's Wild Shape.
|
||||
</p>
|
||||
)}
|
||||
{done.type === 'LONG' && (
|
||||
<p className="combatant-sub mt-md" style={{ marginBottom: 0 }}>
|
||||
"Per day" abilities are back too. One long rest per 24 hours.
|
||||
</p>
|
||||
)}
|
||||
<div className="modal-foot">
|
||||
<button className="btn btn-primary" onClick={onClose}>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Rest" subtitle="Pick who rests, and for how long" wide onClose={onClose}>
|
||||
<div className="flex-row flex-wrap" role="radiogroup" aria-label="Rest type">
|
||||
{(['SHORT', 'LONG'] as const).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={type === option}
|
||||
className={`btn ${type === option ? 'btn-primary' : ''}`}
|
||||
onClick={() => setType(option)}
|
||||
>
|
||||
{option === 'SHORT' ? '☕ Short rest (1 hour)' : '🛏 Long rest (8 hours)'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="subtitle">
|
||||
{type === 'SHORT'
|
||||
? 'Each character can spend Hit Dice to heal: they roll each die and add their Constitution modifier. Type how many dice they spend and what the dice added up to, or leave the total empty and the site rolls them. A warlock gets every spell slot back.'
|
||||
: 'Everyone resting goes to full HP, gets every spell slot back, and regains spent Hit Dice up to half their level (at least one). Temp HP is gone, except what the Hardy Boon gives.'}
|
||||
</p>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
<div className="flex-between">
|
||||
<h3 className="section-label" style={{ margin: 0 }}>
|
||||
Who rests ({party.filter((c) => picked.has(c.id)).length} of {party.length})
|
||||
</h3>
|
||||
<span className="flex-row gap-sm">
|
||||
<button className="btn btn-ghost" onClick={() => setPicked(new Set(party.map((c) => c.id)))}>
|
||||
All
|
||||
</button>
|
||||
<button className="btn btn-ghost" onClick={() => setPicked(new Set())}>
|
||||
None
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
{party.length === 0 && <p className="combatant-sub">No finished characters in this campaign yet.</p>}
|
||||
{party.map((c) => {
|
||||
const isPicked = picked.has(c.id);
|
||||
const hitDice = c.hitDice;
|
||||
const spent = Math.min(Number(dice[c.id]) || 0, hitDice?.remaining ?? 0);
|
||||
const slots = slotsSummary(c.spellSlots);
|
||||
return (
|
||||
<div key={c.id} className={`reward-row${isPicked ? '' : ' is-awarded'}`}>
|
||||
<label className="checkbox-row" style={{ margin: 0 }}>
|
||||
<input type="checkbox" checked={isPicked} onChange={() => toggle(c.id)} />
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
<strong>{c.name}</strong>{' '}
|
||||
<span className="combatant-sub">
|
||||
{c.characterClass && CLASS_LABELS[c.characterClass]} · Level {c.level}
|
||||
</span>{' '}
|
||||
<LifeBadge deathSaves={c.deathSaves} />
|
||||
<span className="combatant-sub" style={{ display: 'block' }}>
|
||||
HP {c.currentHp}/{c.maxHp}
|
||||
{c.tempHp > 0 && ` +${c.tempHp}`}
|
||||
{hitDice && ` · Hit Dice ${hitDice.remaining} of ${hitDice.count} (d${hitDice.size}) left`}
|
||||
{slots && ` · Slots ${slots}`}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{type === 'SHORT' && isPicked && hitDice && (
|
||||
<div className="flex-row flex-wrap gap-sm mt-sm rest-dice">
|
||||
{hitDice.remaining === 0 ? (
|
||||
<span className="combatant-sub">No Hit Dice left to spend until a long rest.</span>
|
||||
) : (
|
||||
<>
|
||||
<label className="flex-row gap-sm">
|
||||
Spend
|
||||
<NumberInput
|
||||
min={0}
|
||||
max={hitDice.remaining}
|
||||
placeholder="0"
|
||||
aria-label={`Hit Dice ${c.name} spends`}
|
||||
value={dice[c.id] ?? ''}
|
||||
onChange={(e) => setDice((d) => ({ ...d, [c.id]: e.target.value }))}
|
||||
style={{ width: 64 }}
|
||||
/>
|
||||
of {hitDice.remaining} d{hitDice.size}
|
||||
</label>
|
||||
{spent > 0 && (
|
||||
<label className="flex-row gap-sm">
|
||||
Roll {spent}d{hitDice.size}:
|
||||
<NumberInput
|
||||
min={spent}
|
||||
max={spent * hitDice.size}
|
||||
placeholder="site rolls"
|
||||
aria-label={`What ${c.name}'s Hit Dice added up to`}
|
||||
title={`What the ${spent}d${hitDice.size} added up to (${spent} to ${spent * hitDice.size}). Leave empty and the site rolls.`}
|
||||
value={rolled[c.id] ?? ''}
|
||||
onChange={(e) => setRolled((r) => ({ ...r, [c.id]: e.target.value }))}
|
||||
style={{ width: 96 }}
|
||||
/>
|
||||
<span className="combatant-sub">
|
||||
{fmtMod(spent * hitDice.conModifier)} Constitution ({fmtMod(hitDice.conModifier)} a die)
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="modal-foot">
|
||||
<button className="btn btn-primary" onClick={handleRest} disabled={picked.size === 0 || rest.isPending}>
|
||||
{rest.isPending ? 'Resting…' : `Take the ${REST_LABELS[type].toLowerCase()}`}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { leadingDice, rollCheck, rollDice } from '../lib/diceRoller';
|
||||
|
||||
// A number that rolls when clicked: a d20 plus `modifier` (a check, a save,
|
||||
// an attack), or the dice in `dice` (damage). The roll opens in the dice
|
||||
// roller under `label`. Of dice written with more after them ("2d8+3 fire")
|
||||
// the dice are rolled; text that does not start with dice (a DM may type
|
||||
// anything) is shown as it is.
|
||||
export function Rollable({
|
||||
label,
|
||||
modifier,
|
||||
dice,
|
||||
className = '',
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
modifier?: number;
|
||||
dice?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const readable = dice === undefined ? null : leadingDice(dice);
|
||||
if (dice !== undefined && !readable) return <span className={className}>{children}</span>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`rollable ${className}`}
|
||||
title={readable ? `Roll ${readable}: ${label}` : `Roll a d20 for ${label}`}
|
||||
onClick={() => (readable ? rollDice(label, readable) : rollCheck(label, modifier ?? 0))}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ABILITY_LABELS, SKILL_LABELS, fmtMod } from '../lib/format';
|
||||
import type { Ability, SaveBonuses, SkillBonuses } from '../api/types';
|
||||
import { Rollable } from './Rollable';
|
||||
|
||||
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
|
||||
|
||||
// Saving throws, skills and passive scores in one compact block: what the DM
|
||||
// asks for mid-fight. Proficient lines are marked ● and expertise ◆. Used by
|
||||
// the character panel and the encounter screen, so they always agree. A click
|
||||
// on a line rolls it in the dice roller, under `who`'s name.
|
||||
export function SheetNumbers({
|
||||
who,
|
||||
saves,
|
||||
skills,
|
||||
passives,
|
||||
}: {
|
||||
who: string;
|
||||
saves: SaveBonuses;
|
||||
skills: SkillBonuses;
|
||||
passives: { perception: number; investigation: number; insight: number };
|
||||
}) {
|
||||
return (
|
||||
<div className="sheet-numbers">
|
||||
<div className="section-label">Saving throws</div>
|
||||
<div className="number-grid is-saves">
|
||||
{ABILITIES.map((a) => (
|
||||
<Rollable key={a} label={`${who}: ${ABILITY_LABELS[a]} save`} modifier={saves[a].bonus} className={`number-row${saves[a].proficient ? ' is-proficient' : ''}`}>
|
||||
<span>
|
||||
<span className="prof-mark" aria-hidden="true">{saves[a].proficient ? '●' : '○'}</span> {a}
|
||||
</span>
|
||||
<b className="mono">{fmtMod(saves[a].bonus)}</b>
|
||||
</Rollable>
|
||||
))}
|
||||
</div>
|
||||
<div className="section-label">Skills</div>
|
||||
<div className="number-grid">
|
||||
{Object.entries(skills).map(([skill, detail]) => (
|
||||
<Rollable key={skill} label={`${who}: ${SKILL_LABELS[skill]}`} modifier={detail.bonus} className={`number-row${detail.proficient ? ' is-proficient' : ''}`}>
|
||||
<span>
|
||||
<span className="prof-mark" aria-hidden="true">{detail.expertise ? '◆' : detail.proficient ? '●' : '○'}</span> {SKILL_LABELS[skill]}{' '}
|
||||
<span className="number-ability">{detail.ability}</span>
|
||||
</span>
|
||||
<b className="mono">{fmtMod(detail.bonus)}</b>
|
||||
</Rollable>
|
||||
))}
|
||||
</div>
|
||||
<div className="stat-chips mt-sm">
|
||||
<span className="stat-chip">
|
||||
<span>Passive Perception</span>
|
||||
<b>{passives.perception}</b>
|
||||
</span>
|
||||
<span className="stat-chip">
|
||||
<span>Passive Investigation</span>
|
||||
<b>{passives.investigation}</b>
|
||||
</span>
|
||||
<span className="stat-chip">
|
||||
<span>Passive Insight</span>
|
||||
<b>{passives.insight}</b>
|
||||
</span>
|
||||
</div>
|
||||
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>● proficient · ◆ expertise · click a line to roll it</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useSpellSlots } from '../api/queries';
|
||||
import { slotLevelLabel } from '../lib/format';
|
||||
import { InfoTip } from './InfoTip';
|
||||
import type { SpellSlots as SpellSlotsInfo } from '../api/types';
|
||||
|
||||
// The slot tracker: a row of pips for each level of slot the character has,
|
||||
// filled while the slot is still there to spend. `canUse` is the character's
|
||||
// own player or the DM: "Use" spends one. Getting a slot back is the DM's
|
||||
// alone: for them each pip is a button that sets how many are left (clicking
|
||||
// the last filled one takes it off), and "All back" refills the lot.
|
||||
export function SpellSlotTracker({
|
||||
characterId,
|
||||
name,
|
||||
slots,
|
||||
canUse,
|
||||
isDM,
|
||||
onError,
|
||||
}: {
|
||||
characterId: string;
|
||||
name: string;
|
||||
slots: SpellSlotsInfo;
|
||||
canUse: boolean;
|
||||
isDM: boolean;
|
||||
onError?: (err: unknown) => void;
|
||||
}) {
|
||||
const spellSlots = useSpellSlots(characterId);
|
||||
const send = (action: Parameters<typeof spellSlots.mutate>[0]) => spellSlots.mutate(action, { onError });
|
||||
const anySpent = slots.levels.some((slot) => slot.used > 0);
|
||||
|
||||
return (
|
||||
<div className="spell-slots">
|
||||
<div className="flex-between flex-wrap">
|
||||
<span className="death-tally">
|
||||
Spell slots <InfoTip term="SPELL_SLOTS" />
|
||||
</span>
|
||||
{isDM && anySpent && (
|
||||
<button className="btn btn-ghost" onClick={() => send({ action: 'restore' })} disabled={spellSlots.isPending} title={`Give ${name} every spell slot back`}>
|
||||
All back
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{slots.levels.map((slot) => {
|
||||
const label = slotLevelLabel(slot.level);
|
||||
return (
|
||||
<div key={slot.level} className="slot-row">
|
||||
<span className="slot-level">{label}</span>
|
||||
<span className="slot-pips" role="group" aria-label={`${slot.remaining} of ${slot.max} ${label}-level slots left`}>
|
||||
{Array.from({ length: slot.max }, (_, i) => i + 1).map((n) => {
|
||||
const className = `slot-pip${n <= slot.remaining ? ' is-filled' : ''}`;
|
||||
const left = n === slot.remaining ? n - 1 : n;
|
||||
return isDM ? (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={className}
|
||||
aria-label={`Set ${label}-level slots left to ${left}`}
|
||||
disabled={spellSlots.isPending}
|
||||
onClick={() => send({ action: 'set', level: slot.level, used: slot.max - left })}
|
||||
/>
|
||||
) : (
|
||||
<span key={n} className={className} />
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<span className="mono slot-count">
|
||||
{slot.remaining}/{slot.max}
|
||||
</span>
|
||||
{canUse && (
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => send({ action: 'use', level: slot.level })}
|
||||
disabled={slot.remaining === 0 || spellSlots.isPending}
|
||||
title={slot.remaining === 0 ? `No ${label}-level slots left` : `Spend one ${label}-level slot`}
|
||||
aria-label={`Use a ${label}-level slot`}
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>
|
||||
{slots.kind === 'PACT'
|
||||
? 'Every spell cast with one of these is cast at that level. They all come back on a short or long rest.'
|
||||
: 'A higher slot can cast a lower spell. Cantrips need no slot. They all come back on a long rest.'}
|
||||
{canUse && !isDM && ' Once used, only the DM can give one back.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,11 @@ import { ErrorBanner } from '../Spinner';
|
||||
import { EffectsEditor } from '../EffectsEditor';
|
||||
import { WaresEditor } from '../Wares';
|
||||
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
|
||||
import { abilityLine, rateLabel, type EntryRef } from '../../lib/campaignEntries';
|
||||
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';
|
||||
|
||||
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)' };
|
||||
|
||||
function Chip({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -33,7 +34,7 @@ function RemoveButton({ name, warning, pending, onRemove }: { name: string; warn
|
||||
}
|
||||
|
||||
// A weapon or an item: the two share levels, growth and effects.
|
||||
function GearDetail({ campaignId, pool, entry, onClose }: { campaignId: string; pool: 'weapons' | 'items'; entry: Weapon | Item; onClose: () => void }) {
|
||||
function GearDetail({ campaignId, pool, entry, added, onClose }: { added: ReactNode; campaignId: string; pool: 'weapons' | 'items'; entry: Weapon | Item; onClose: () => void }) {
|
||||
const patch = usePatchPoolEntry(campaignId, pool);
|
||||
const remove = useDeletePoolEntry(campaignId, pool);
|
||||
const [effects, setEffects] = useState<GearEffect[]>(entry.effects ?? []);
|
||||
@@ -45,6 +46,7 @@ function GearDetail({ campaignId, pool, entry, onClose }: { campaignId: string;
|
||||
|
||||
return (
|
||||
<Modal title={entry.name} subtitle={weapon ? 'Weapon' : 'Item'} onClose={onClose}>
|
||||
{added}
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="stat-chips">
|
||||
{weapon ? (
|
||||
@@ -117,7 +119,7 @@ function GearDetail({ campaignId, pool, entry, onClose }: { campaignId: string;
|
||||
);
|
||||
}
|
||||
|
||||
function SpellDetail({ campaignId, spell, onClose }: { campaignId: string; spell: Spell; onClose: () => void }) {
|
||||
function SpellDetail({ campaignId, spell, added, onClose }: { added: ReactNode; campaignId: string; spell: Spell; onClose: () => void }) {
|
||||
const patch = usePatchPoolEntry(campaignId, 'spells');
|
||||
const remove = useDeletePoolEntry(campaignId, 'spells');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -125,6 +127,7 @@ function SpellDetail({ campaignId, spell, onClose }: { campaignId: string; spell
|
||||
|
||||
return (
|
||||
<Modal title={spell.name} subtitle={[spellLevelLabel(spell.level), spell.school, spellRequirementLabel(spell.level)].filter(Boolean).join(' · ')} onClose={onClose}>
|
||||
{added}
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<div className="stat-chips">
|
||||
{spell.castingTime && <Chip label="Casting time">{spell.castingTime}</Chip>}
|
||||
@@ -152,7 +155,7 @@ function SpellDetail({ campaignId, spell, onClose }: { campaignId: string; spell
|
||||
);
|
||||
}
|
||||
|
||||
function MonsterDetail({ campaignId, monster, onClose }: { campaignId: string; monster: Monster; onClose: () => void }) {
|
||||
function MonsterDetail({ campaignId, monster, added, onClose }: { added: ReactNode; campaignId: string; monster: Monster; onClose: () => void }) {
|
||||
const update = useUpdateMonster(campaignId);
|
||||
const remove = useDeleteMonster(campaignId);
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -161,6 +164,7 @@ function MonsterDetail({ campaignId, monster, onClose }: { campaignId: string; m
|
||||
|
||||
return (
|
||||
<Modal title={editing ? `Editing ${monster.name}` : monster.name} subtitle="Monster" wide onClose={onClose}>
|
||||
{added}
|
||||
{error && <ErrorBanner message={error} />}
|
||||
{editing ? (
|
||||
<MonsterForm
|
||||
@@ -213,7 +217,7 @@ function MonsterDetail({ campaignId, monster, onClose }: { campaignId: string; m
|
||||
// An NPC: who they are, and what they trade — the rate they change gold and
|
||||
// cash at, and their wares. Players meet both on the encounter page, in a
|
||||
// quest the NPC is part of.
|
||||
function NpcDetail({ campaignId, npc, weapons, items, onClose }: { campaignId: string; npc: Npc; weapons: Weapon[]; items: Item[]; onClose: () => void }) {
|
||||
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 [gold, setGold] = useState(npc.exchangeGold ? String(npc.exchangeGold) : '');
|
||||
@@ -236,6 +240,7 @@ function NpcDetail({ campaignId, npc, weapons, items, onClose }: { campaignId: s
|
||||
wide
|
||||
onClose={onClose}
|
||||
>
|
||||
{added}
|
||||
{error && <ErrorBanner message={error} />}
|
||||
{npc.description ? <p style={{ whiteSpace: 'pre-wrap' }}>{npc.description}</p> : <p style={{ color: 'var(--text-muted)' }}>No description for the players.</p>}
|
||||
{npc.dmNotes && <p style={{ color: 'var(--text-muted)', whiteSpace: 'pre-wrap' }}>🔒 {npc.dmNotes}</p>}
|
||||
@@ -310,6 +315,7 @@ export function EntryDetail({
|
||||
spells,
|
||||
monsters,
|
||||
npcs,
|
||||
onAddAnother,
|
||||
onClose,
|
||||
}: {
|
||||
campaignId: string;
|
||||
@@ -319,29 +325,39 @@ export function EntryDetail({
|
||||
spells: Spell[];
|
||||
monsters: Monster[];
|
||||
npcs: Npc[];
|
||||
// Given for something added by hand a moment ago: opens the form it came from again.
|
||||
onAddAnother?: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const added = onAddAnother && (
|
||||
<div className="added-bar">
|
||||
<span>✓ Added to the campaign.</span>
|
||||
<button className="btn btn-primary" onClick={onAddAnother}>
|
||||
+ Add another {KIND_NOUNS[entry.kind]}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
const find = <T extends { id: string }>(list: T[]) => list.find((e) => e.id === entry.id);
|
||||
switch (entry.kind) {
|
||||
case 'weapon': {
|
||||
const weapon = find(weapons);
|
||||
return weapon ? <GearDetail key={weapon.id} campaignId={campaignId} pool="weapons" entry={weapon} onClose={onClose} /> : null;
|
||||
return weapon ? <GearDetail key={weapon.id} added={added} campaignId={campaignId} pool="weapons" entry={weapon} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'item': {
|
||||
const item = find(items);
|
||||
return item ? <GearDetail key={item.id} campaignId={campaignId} pool="items" entry={item} onClose={onClose} /> : null;
|
||||
return item ? <GearDetail key={item.id} added={added} campaignId={campaignId} pool="items" entry={item} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'spell': {
|
||||
const spell = find(spells);
|
||||
return spell ? <SpellDetail key={spell.id} campaignId={campaignId} spell={spell} onClose={onClose} /> : null;
|
||||
return spell ? <SpellDetail key={spell.id} added={added} campaignId={campaignId} spell={spell} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'monster': {
|
||||
const monster = find(monsters);
|
||||
return monster ? <MonsterDetail key={monster.id} campaignId={campaignId} monster={monster} onClose={onClose} /> : null;
|
||||
return monster ? <MonsterDetail key={monster.id} added={added} campaignId={campaignId} monster={monster} onClose={onClose} /> : null;
|
||||
}
|
||||
case 'npc': {
|
||||
const npc = find(npcs);
|
||||
return npc ? <NpcDetail key={npc.id} campaignId={campaignId} npc={npc} weapons={weapons} items={items} onClose={onClose} /> : null;
|
||||
return npc ? <NpcDetail key={npc.id} added={added} campaignId={campaignId} npc={npc} weapons={weapons} items={items} onClose={onClose} /> : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,35 @@ interface SectionProps<T> {
|
||||
campaignId: string;
|
||||
entries: T[] | undefined;
|
||||
onOpen: (entry: EntryRef) => void;
|
||||
// The "add by hand" pop-out is wanted from outside: "add another", pressed
|
||||
// in the pop-out of the one just added.
|
||||
formRequested: boolean;
|
||||
onFormClosed: () => void;
|
||||
}
|
||||
|
||||
// Which of a list's two "add" pop-outs is open: the form, or the library.
|
||||
type Adding = 'form' | 'library' | null;
|
||||
|
||||
// Which "add" pop-out a list has open, and what went wrong in it.
|
||||
function useAdding(formRequested: boolean, onFormClosed: () => void) {
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
return {
|
||||
adding: formRequested ? ('form' as const) : adding,
|
||||
error,
|
||||
setError,
|
||||
open: (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
},
|
||||
close: () => {
|
||||
setError(null);
|
||||
setAdding(null);
|
||||
onFormClosed();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
title,
|
||||
addLabel,
|
||||
@@ -117,15 +141,10 @@ const StartingOption = () => (
|
||||
|
||||
const startingBadge = (entry: { isStartingOption: boolean }) => entry.isStartingOption && <span className="badge">Starting</span>;
|
||||
|
||||
export function WeaponsSection({ campaignId, entries, onOpen }: SectionProps<Weapon>) {
|
||||
export function WeaponsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Weapon>) {
|
||||
const createWeapon = useCreateWeapon(campaignId);
|
||||
const addFromLibrary = useAddWeaponFromLibrary(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -146,8 +165,8 @@ export function WeaponsSection({ campaignId, entries, onOpen }: SectionProps<Wea
|
||||
effects: [],
|
||||
effectNote: null,
|
||||
});
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'weapon', id: weapon.id });
|
||||
close();
|
||||
onOpen({ kind: 'weapon', id: weapon.id, isNew: true });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
@@ -186,11 +205,11 @@ export function WeaponsSection({ campaignId, entries, onOpen }: SectionProps<Wea
|
||||
renderMeta={(w) => `${w.category} · ${w.damageDice} ${w.damageType} · starts at level 1`}
|
||||
renderDetail={(w) => effectsSummary(w.effects, w.effectNote)}
|
||||
error={error}
|
||||
onClose={() => setAdding(null)}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add a weapon" subtitle="What it does while equipped is set once it is added." onClose={() => setAdding(null)}>
|
||||
<Modal title="Add a weapon" subtitle="What it does while equipped is set once it is added." onClose={close}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2">
|
||||
@@ -220,15 +239,10 @@ export function WeaponsSection({ campaignId, entries, onOpen }: SectionProps<Wea
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemsSection({ campaignId, entries, onOpen }: SectionProps<Item>) {
|
||||
export function ItemsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Item>) {
|
||||
const createItem = useCreateItem(campaignId);
|
||||
const addFromLibrary = useAddItemFromLibrary(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -245,8 +259,8 @@ export function ItemsSection({ campaignId, entries, onOpen }: SectionProps<Item>
|
||||
effects: [],
|
||||
effectNote: null,
|
||||
});
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'item', id: item.id });
|
||||
close();
|
||||
onOpen({ kind: 'item', id: item.id, isNew: true });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
@@ -285,11 +299,11 @@ export function ItemsSection({ campaignId, entries, onOpen }: SectionProps<Item>
|
||||
renderMeta={(i) => [i.category, effectsSummary(i.effects, i.effectNote), i.damageDice].filter(Boolean).join(' · ')}
|
||||
renderDetail={(i) => i.description}
|
||||
error={error}
|
||||
onClose={() => setAdding(null)}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add an item" subtitle="What it does while equipped is set once it is added." onClose={() => setAdding(null)}>
|
||||
<Modal title="Add an item" subtitle="What it does while equipped is set once it is added." onClose={close}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2">
|
||||
@@ -312,15 +326,10 @@ export function ItemsSection({ campaignId, entries, onOpen }: SectionProps<Item>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpellsSection({ campaignId, entries, onOpen }: SectionProps<Spell>) {
|
||||
export function SpellsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Spell>) {
|
||||
const createSpell = useCreateSpell(campaignId);
|
||||
const addFromLibrary = useAddSpellFromLibrary(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -339,8 +348,8 @@ export function SpellsSection({ campaignId, entries, onOpen }: SectionProps<Spel
|
||||
isStartingOption: fd.get('isStartingOption') === 'on',
|
||||
revives: fd.get('revives') === 'on',
|
||||
});
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'spell', id: spell.id });
|
||||
close();
|
||||
onOpen({ kind: 'spell', id: spell.id, isNew: true });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
@@ -381,11 +390,11 @@ export function SpellsSection({ campaignId, entries, onOpen }: SectionProps<Spel
|
||||
}
|
||||
renderDetail={(s) => s.description}
|
||||
error={error}
|
||||
onClose={() => setAdding(null)}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add a spell" onClose={() => setAdding(null)}>
|
||||
<Modal title="Add a spell" onClose={close}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2">
|
||||
@@ -422,15 +431,10 @@ export function SpellsSection({ campaignId, entries, onOpen }: SectionProps<Spel
|
||||
);
|
||||
}
|
||||
|
||||
export function MonstersSection({ campaignId, entries, onOpen }: SectionProps<Monster>) {
|
||||
export function MonstersSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Monster>) {
|
||||
const createMonster = useCreateMonster(campaignId);
|
||||
const addFromLibrary = useAddMonsterFromLibrary(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
@@ -461,11 +465,11 @@ export function MonstersSection({ campaignId, entries, onOpen }: SectionProps<Mo
|
||||
}
|
||||
renderDetail={(m) => [m.specialTrait, m.notes].filter(Boolean).join(' — ') || null}
|
||||
error={error}
|
||||
onClose={() => setAdding(null)}
|
||||
onClose={close}
|
||||
/>
|
||||
)}
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add a monster" wide onClose={() => setAdding(null)}>
|
||||
<Modal title="Add a monster" wide onClose={close}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<MonsterForm
|
||||
saving={createMonster.isPending}
|
||||
@@ -473,8 +477,8 @@ export function MonstersSection({ campaignId, entries, onOpen }: SectionProps<Mo
|
||||
setError(null);
|
||||
try {
|
||||
const monster = await createMonster.mutateAsync(stats);
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'monster', id: monster.id });
|
||||
close();
|
||||
onOpen({ kind: 'monster', id: monster.id, isNew: true });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
throw err;
|
||||
@@ -487,14 +491,9 @@ export function MonstersSection({ campaignId, entries, onOpen }: SectionProps<Mo
|
||||
);
|
||||
}
|
||||
|
||||
export function NpcsSection({ campaignId, entries, onOpen }: SectionProps<Npc>) {
|
||||
export function NpcsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Npc>) {
|
||||
const createNpc = useCreateNpc(campaignId);
|
||||
const [adding, setAdding] = useState<Adding>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const open = (next: Adding) => {
|
||||
setError(null);
|
||||
setAdding(next);
|
||||
};
|
||||
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -514,8 +513,8 @@ export function NpcsSection({ campaignId, entries, onOpen }: SectionProps<Npc>)
|
||||
attackBonus: num('attackBonus'),
|
||||
damageDice: text('damageDice'),
|
||||
});
|
||||
setAdding(null);
|
||||
onOpen({ kind: 'npc', id: npc.id });
|
||||
close();
|
||||
onOpen({ kind: 'npc', id: npc.id, isNew: true });
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
}
|
||||
@@ -545,7 +544,7 @@ export function NpcsSection({ campaignId, entries, onOpen }: SectionProps<Npc>)
|
||||
onOpen={(id) => onOpen({ kind: 'npc', id })}
|
||||
/>
|
||||
{adding === 'form' && (
|
||||
<Modal title="Add an NPC" subtitle="Only the name is needed. What they sell is set once they are added." onClose={() => setAdding(null)}>
|
||||
<Modal title="Add an NPC" subtitle="Only the name is needed. What they sell is set once they are added." onClose={close}>
|
||||
{error && <ErrorBanner message={error} />}
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2">
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
// Single-key shortcuts for a page: { n: () => …, s: () => … }. A key does
|
||||
// nothing while the cursor is in a field, while a modifier is held, or when
|
||||
// its entry is undefined (so a page can switch one off by leaving it out).
|
||||
export function useHotkeys(keys: Record<string, (() => void) | undefined>) {
|
||||
const latest = useRef(keys);
|
||||
useEffect(() => {
|
||||
latest.current = keys;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey || e.altKey || e.repeat) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))) return;
|
||||
const action = latest.current[e.key.toLowerCase()];
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
action();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
}
|
||||
@@ -1911,6 +1911,99 @@ table.data-table tbody tr.is-mine:hover {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Spell slots, rests and levelling up */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
.spell-slots {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.6rem;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
.slot-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
.slot-level {
|
||||
width: 2.2rem;
|
||||
font-family: var(--font-display);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.slot-pips {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* A slot still there to spend is filled; a spent one is an empty ring. */
|
||||
.slot-pip {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0;
|
||||
border-radius: 3px;
|
||||
border: 2px solid var(--accent);
|
||||
background: transparent;
|
||||
transform: rotate(45deg);
|
||||
margin: 0.15rem;
|
||||
}
|
||||
.slot-pip.is-filled {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
button.slot-pip {
|
||||
cursor: pointer;
|
||||
}
|
||||
button.slot-pip:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
.slot-count {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.slot-row .btn {
|
||||
padding: 0.25rem 0.7rem;
|
||||
}
|
||||
|
||||
.rest-dice {
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
.level-step {
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
.level-step:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
.level-step p {
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
.level-choice {
|
||||
align-items: flex-start;
|
||||
margin: 0.35rem 0;
|
||||
}
|
||||
.level-choice input {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.level-spells {
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.slot-pip {
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
}
|
||||
.rest-dice {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination .pagination-status > select {
|
||||
min-width: 0 !important;
|
||||
padding: 0.3rem 0.5rem;
|
||||
@@ -1954,3 +2047,279 @@ table.data-table tbody tr.is-mine:hover {
|
||||
padding: 0.9rem 0.85rem calc(1.5rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
|
||||
/* At the top of the pop-out of something added a moment ago. */
|
||||
.added-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--success);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(95, 138, 79, 0.14);
|
||||
color: var(--success-strong);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
The battle at a glance: the turn strip, the spotlight, saves and skills
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
/* Everyone in the scene on one line that stays under the top bar. */
|
||||
.turn-strip {
|
||||
position: sticky;
|
||||
top: 3.6rem;
|
||||
z-index: 25;
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 -0.25rem 1rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.turn-chip {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 5.5rem;
|
||||
max-width: 10rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-muted);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.turn-chip:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.turn-chip-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.turn-chip-sub {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.turn-chip .hp-bar-track {
|
||||
margin: 0;
|
||||
}
|
||||
.turn-chip.is-mine {
|
||||
border-color: var(--arcane-strong);
|
||||
}
|
||||
.turn-chip.is-targeted {
|
||||
border-color: var(--danger-strong);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.turn-chip.is-active {
|
||||
border-color: var(--accent-strong);
|
||||
background: var(--accent-soft);
|
||||
box-shadow: 0 0 0 1px var(--accent-strong);
|
||||
}
|
||||
.turn-chip.is-active .turn-chip-name {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.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);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
padding-right: 2px;
|
||||
}
|
||||
}
|
||||
.spotlight {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.link-button {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link-button:hover {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.sheet-numbers .section-label {
|
||||
margin: 0.6rem 0 0.25rem;
|
||||
}
|
||||
.number-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
|
||||
column-gap: 1rem;
|
||||
}
|
||||
.number-grid.is-saves {
|
||||
grid-template-columns: repeat(auto-fill, minmax(5rem, 1fr));
|
||||
}
|
||||
.number-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.12rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px dotted var(--border-soft);
|
||||
}
|
||||
.number-row.is-proficient {
|
||||
color: var(--text);
|
||||
}
|
||||
.number-row.is-proficient b {
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
.prof-mark {
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.number-ability {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* A card that folds away: the title is the switch. */
|
||||
summary.card-title {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
summary.card-title::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
summary.card-title::after {
|
||||
content: '▾';
|
||||
color: var(--text-dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
details:not([open]) > summary.card-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
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,
|
||||
.btn-pin {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Character state: inspiration, exhaustion, conditions, class counters
|
||||
--------------------------------------------------------------------------- */
|
||||
.btn-target.is-lit {
|
||||
border-color: var(--accent-strong);
|
||||
color: var(--accent-strong);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.condition-chip[title] {
|
||||
cursor: help;
|
||||
}
|
||||
.condition-note {
|
||||
opacity: 0.8;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
.resource-tracker {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.resource-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 7.5rem;
|
||||
max-width: 11rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.resource-rest {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.notice-card {
|
||||
border-color: var(--arcane-strong);
|
||||
background: rgba(92, 127, 176, 0.12);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Numbers that roll when clicked
|
||||
--------------------------------------------------------------------------- */
|
||||
.rollable {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: underline dotted var(--border);
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.rollable:hover,
|
||||
.rollable:focus-visible {
|
||||
color: var(--accent-strong);
|
||||
text-decoration-color: var(--accent);
|
||||
}
|
||||
/* A whole row or chip that rolls keeps its own shape. */
|
||||
.rollable.number-row {
|
||||
width: 100%;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--border-soft);
|
||||
}
|
||||
.rollable.number-row:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.rollable:has(> .stat-chip) {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
}
|
||||
.rollable:hover > .stat-chip {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.rollable.stat-sub {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.roll-label {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ export type EntryKind = 'weapon' | 'item' | 'spell' | 'monster' | 'npc';
|
||||
export interface EntryRef {
|
||||
kind: EntryKind;
|
||||
id: string;
|
||||
// It was added by hand a moment ago: its pop-out offers to add another.
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export type MonsterInput = Omit<Monster, 'id' | 'campaignId'>;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// The conditions a combatant can be under, each with its rule in a sentence
|
||||
// or two. They are reminders for the table: the site changes no number for
|
||||
// them. "Concentrating" is not a condition in the rules, but it is tracked the
|
||||
// same way.
|
||||
export const CONDITION_RULES: Record<string, string> = {
|
||||
Blinded: 'Cannot see: fails any check that needs sight. Attacks against them have advantage; their own attacks have disadvantage.',
|
||||
Charmed: 'Cannot attack the charmer or target them with anything harmful. The charmer has advantage on social checks with them.',
|
||||
Deafened: 'Cannot hear: fails any check that needs hearing.',
|
||||
Frightened: 'Disadvantage on ability checks and attacks while the source of the fear is in sight, and cannot willingly move closer to it.',
|
||||
Grappled: 'Speed is 0. Ends if the grappler is incapacitated or the two are forced apart.',
|
||||
Incapacitated: 'Cannot take actions or reactions.',
|
||||
Invisible: 'Cannot be seen without magic or a special sense. Attacks against them have disadvantage; their own attacks have advantage.',
|
||||
Paralyzed: 'Incapacitated; cannot move or speak. Fails Strength and Dexterity saves. Attacks against them have advantage, and a hit from within 5 ft is a critical hit.',
|
||||
Petrified: 'Turned to stone: incapacitated, cannot move or speak, unaware. Attacks against them have advantage; fails Strength and Dexterity saves; resists all damage; immune to poison and disease.',
|
||||
Poisoned: 'Disadvantage on attack rolls and ability checks.',
|
||||
Prone: 'Can only crawl, and standing up costs half their speed. Disadvantage on their attacks. Attacks from within 5 ft have advantage against them; from further away, disadvantage.',
|
||||
Restrained: 'Speed is 0. Attacks against them have advantage; their own have disadvantage. Disadvantage on Dexterity saves.',
|
||||
Stunned: 'Incapacitated; cannot move and can barely speak. Fails Strength and Dexterity saves. Attacks against them have advantage.',
|
||||
Unconscious: 'Incapacitated; cannot move or speak, unaware, drops what they hold and falls prone. Fails Strength and Dexterity saves. Attacks against them have advantage, and a hit from within 5 ft is a critical hit.',
|
||||
Concentrating: 'Holding a spell. Taking damage calls for a Constitution save, DC 10 or half the damage if that is higher; a failure ends the spell. So does casting another concentration spell, or being incapacitated.',
|
||||
};
|
||||
|
||||
export const CONDITIONS = Object.keys(CONDITION_RULES);
|
||||
|
||||
// What a condition does; empty for one the DM typed in by another name.
|
||||
export function conditionRule(name: string): string {
|
||||
return CONDITION_RULES[name] ?? '';
|
||||
}
|
||||
|
||||
// Exhaustion has six levels, each adding to the ones before it.
|
||||
export const EXHAUSTION_LEVELS = [
|
||||
'Disadvantage on ability checks.',
|
||||
'Speed halved.',
|
||||
'Disadvantage on attack rolls and saving throws.',
|
||||
'Hit point maximum halved.',
|
||||
'Speed reduced to 0.',
|
||||
'Death.',
|
||||
];
|
||||
export const MAX_EXHAUSTION = EXHAUSTION_LEVELS.length;
|
||||
|
||||
// Everything a level of exhaustion means, one effect per line.
|
||||
export function exhaustionRule(level: number): string {
|
||||
if (level <= 0) return 'Not exhausted.';
|
||||
return EXHAUSTION_LEVELS.slice(0, level)
|
||||
.map((effect, i) => `${i + 1}: ${effect}`)
|
||||
.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Rolls asked for from elsewhere on the page: a skill on a sheet, an attack
|
||||
// on an encounter card. Kept outside React, like the character panel's
|
||||
// (lib/characterPanel.ts), so anything can ask without being wired to the
|
||||
// layout. The dice roller picks each request up, rolls it and shows it. The
|
||||
// rolls themselves never leave the browser.
|
||||
|
||||
// A d20 with a bonus (a check, a save, an attack), or a dice expression (damage).
|
||||
export type RollRequest = { id: number; label: string } & ({ kind: 'check'; modifier: number } | { kind: 'dice'; expression: string });
|
||||
|
||||
let nextId = 1;
|
||||
const listeners = new Set<(request: RollRequest) => void>();
|
||||
|
||||
function ask(request: RollRequest) {
|
||||
listeners.forEach((listener) => listener(request));
|
||||
}
|
||||
|
||||
// The dice roller listens here; returns the way to stop listening.
|
||||
export function onRollRequest(listener: (request: RollRequest) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
// Rolls a d20 and adds `modifier`, under a label such as "Aria: Stealth".
|
||||
export function rollCheck(label: string, modifier: number) {
|
||||
ask({ id: nextId++, label, kind: 'check', modifier });
|
||||
}
|
||||
|
||||
// Rolls a dice expression such as "1d8+3", under a label such as "Aria: Longsword damage".
|
||||
export function rollDice(label: string, expression: string) {
|
||||
ask({ id: nextId++, label, kind: 'dice', expression });
|
||||
}
|
||||
|
||||
// One fair roll of an n-sided die. Uses the browser's cryptographic random
|
||||
// source, discarding values that would make some faces slightly likelier.
|
||||
export function rollDie(sides: number): number {
|
||||
const limit = Math.floor(0x100000000 / sides) * sides;
|
||||
const buffer = new Uint32Array(1);
|
||||
do {
|
||||
crypto.getRandomValues(buffer);
|
||||
} while (buffer[0] >= limit);
|
||||
return (buffer[0] % sides) + 1;
|
||||
}
|
||||
|
||||
export interface ParsedDice {
|
||||
dice: { count: number; sides: number; sign: 1 | -1 }[];
|
||||
flat: number;
|
||||
}
|
||||
|
||||
// Reads the dice expressions the app writes: "1d8", "2d6", "1d6+1d8",
|
||||
// "2d4+2", "1d8 - 1". Null for anything else (free text is allowed where a
|
||||
// DM types dice), and for something too big to be a real roll.
|
||||
export function parseDice(expression: string): ParsedDice | null {
|
||||
const terms = expression
|
||||
.replace(/\s+/g, '')
|
||||
.replace(/[−–]/g, '-')
|
||||
.toLowerCase()
|
||||
.replace(/-/g, '+-')
|
||||
.split('+')
|
||||
.filter(Boolean);
|
||||
if (terms.length === 0) return null;
|
||||
const parsed: ParsedDice = { dice: [], flat: 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]);
|
||||
if (count > 100 || sides < 2 || sides > 1000) 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.
|
||||
export function leadingDice(text: string): string | null {
|
||||
const match = /^\s*((?:\d*d\d+|\d+)(?:\s*[+\-−–]\s*(?:\d*d\d+|\d+))*)/i.exec(text);
|
||||
return match && parseDice(match[1]) ? match[1].replace(/\s+/g, '') : null;
|
||||
}
|
||||
|
||||
// A dice expression from its parts: "1d8" and +3 make "1d8+3".
|
||||
export function withBonus(dice: string, bonus: number): string {
|
||||
return bonus === 0 ? dice : `${dice}${bonus > 0 ? '+' : '-'}${Math.abs(bonus)}`;
|
||||
}
|
||||
|
||||
export type RollMode = 'NORMAL' | 'ADVANTAGE' | 'DISADVANTAGE';
|
||||
|
||||
// A d20 as the mode has it: one die, or two with the higher or lower kept.
|
||||
export function rollD20(mode: RollMode): { kept: number; dice: number[] } {
|
||||
if (mode === 'NORMAL') {
|
||||
const die = rollDie(20);
|
||||
return { kept: die, dice: [die] };
|
||||
}
|
||||
const dice = [rollDie(20), rollDie(20)];
|
||||
return { kept: mode === 'ADVANTAGE' ? Math.max(...dice) : Math.min(...dice), dice };
|
||||
}
|
||||
@@ -58,6 +58,18 @@ export const ABILITY_LABELS: Record<string, string> = {
|
||||
CHA: 'Charisma',
|
||||
};
|
||||
|
||||
// "37 / 300 XP": what a character has and what the next level takes.
|
||||
export function xpLabel(xp: number, progress: { nextLevelAt: number | null }): string {
|
||||
return progress.nextLevelAt == null ? `${xp.toLocaleString()} XP` : `${xp.toLocaleString()} / ${progress.nextLevelAt.toLocaleString()} XP`;
|
||||
}
|
||||
|
||||
// "Fighter" or, with a subclass chosen, "Fighter (Champion)".
|
||||
export function classLabel(characterClass: string | null, subclassName?: string | null): string {
|
||||
if (!characterClass) return '';
|
||||
const label = CLASS_LABELS[characterClass] ?? characterClass;
|
||||
return subclassName ? `${label} (${subclassName})` : label;
|
||||
}
|
||||
|
||||
export function fmtMod(mod: number): string {
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
}
|
||||
@@ -127,6 +139,17 @@ export function spellLevelLabel(level: number): string {
|
||||
return level === 0 ? 'Cantrip' : `Level ${level}`;
|
||||
}
|
||||
|
||||
// A spell slot's level: "1st", "2nd"…
|
||||
const ORDINALS = ['1st', '2nd', '3rd', '4th', '5th', '6th', '7th', '8th', '9th'];
|
||||
export function slotLevelLabel(level: number): string {
|
||||
return ORDINALS[level - 1] ?? `${level}th`;
|
||||
}
|
||||
|
||||
// A character's spell slots in a few words: "1st 3/4 · 2nd 2/2" (left / in all).
|
||||
export function slotsSummary(slots: { levels: { level: number; max: number; remaining: number }[] } | null): string {
|
||||
return slots ? slots.levels.map((s) => `${slotLevelLabel(s.level)} ${s.remaining}/${s.max}`).join(' · ') : '';
|
||||
}
|
||||
|
||||
// Who can use a spell: a character of at least its level; cantrips, anyone.
|
||||
export function spellRequirementLabel(level: number): string {
|
||||
return level === 0 ? 'any character can use it' : `needs character level ${level}`;
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { NumberInput } from '../components/NumberInput';
|
||||
import { SectionNav, type Section } from '../components/SectionNav';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { RestModal } from '../components/RestModal';
|
||||
import { Pagination } from '../components/Paginated';
|
||||
import { usePagination } from '../hooks/usePagination';
|
||||
import { CampaignSearch, type SearchGroup } from '../components/campaign/CampaignSearch';
|
||||
@@ -61,6 +62,15 @@ function storedEncounterSort(): EncounterSort {
|
||||
return stored && stored in ENCOUNTER_SORT_LABELS ? (stored as EncounterSort) : 'custom';
|
||||
}
|
||||
|
||||
// Where a new encounter goes in the campaign's order: the bottom, the top, or
|
||||
// straight after another one ("after:<id>"). Top or bottom is remembered.
|
||||
const ENCOUNTER_PLACE_KEY = 'dnd_encounter_place';
|
||||
const AFTER = 'after:';
|
||||
|
||||
function storedEncounterPlace(): string {
|
||||
return localStorage.getItem(ENCOUNTER_PLACE_KEY) === 'TOP' ? 'TOP' : 'BOTTOM';
|
||||
}
|
||||
|
||||
// `encounters` arrives in campaign order, and the sort is stable, so ties
|
||||
// (same status, same type) stay in that order.
|
||||
function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter[] {
|
||||
@@ -90,7 +100,7 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
|
||||
</div>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>
|
||||
{character.species ? `${character.species} ` : ''}
|
||||
{character.characterClass || (inProgress ? 'Not yet built' : '')} · Level {character.level}
|
||||
{character.characterClass || (inProgress ? 'Not yet built' : '')}{character.subclassName && ` (${character.subclassName})`} · Level {character.level}
|
||||
{character.currentLocationName && ` · 📍 ${character.currentLocationName}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -155,9 +165,12 @@ export function CampaignHomePage() {
|
||||
const [query, setQuery] = useState('');
|
||||
// The weapon, item, spell, monster or NPC whose pop-out is open.
|
||||
const [opened, setOpened] = useState<EntryRef | null>(null);
|
||||
// The list whose "add by hand" pop-out was asked for from the pop-out of the one just added.
|
||||
const [addingKind, setAddingKind] = useState<EntryKind | null>(null);
|
||||
const [newCharName, setNewCharName] = useState('');
|
||||
const [newEncounterName, setNewEncounterName] = useState('');
|
||||
const [newEncounterMode, setNewEncounterMode] = useState<EncounterMode>('BATTLE');
|
||||
const [newEncounterPlace, setNewEncounterPlace] = useState(storedEncounterPlace);
|
||||
const [encounterSort, setEncounterSort] = useState<EncounterSort>(storedEncounterSort);
|
||||
const [showArchivedEncounters, setShowArchivedEncounters] = useState(false);
|
||||
const [noteBody, setNoteBody] = useState('');
|
||||
@@ -166,9 +179,12 @@ export function CampaignHomePage() {
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [resting, setResting] = useState(false);
|
||||
const [deleteConfirmName, setDeleteConfirmName] = useState('');
|
||||
|
||||
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
|
||||
// An encounter picked to go after may have been archived or deleted since.
|
||||
const encounterPlace = newEncounterPlace.startsWith(AFTER) && !liveEncounters.some((e) => AFTER + e.id === newEncounterPlace) ? 'BOTTOM' : newEncounterPlace;
|
||||
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
|
||||
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
|
||||
const encountersPage = usePagination(shownEncounters, 10);
|
||||
@@ -204,6 +220,9 @@ export function CampaignHomePage() {
|
||||
const encounter = await createEncounter.mutateAsync({
|
||||
name: newEncounterName || (newEncounterMode === 'QUEST' ? 'New Quest' : 'New Encounter'),
|
||||
mode: newEncounterMode,
|
||||
...(encounterPlace.startsWith(AFTER)
|
||||
? { afterEncounterId: encounterPlace.slice(AFTER.length) }
|
||||
: { position: encounterPlace === 'TOP' ? ('TOP' as const) : ('BOTTOM' as const) }),
|
||||
});
|
||||
navigate(`/encounters/${encounter.id}`);
|
||||
} catch (err) {
|
||||
@@ -430,6 +449,11 @@ export function CampaignHomePage() {
|
||||
<div className="section-block">
|
||||
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
|
||||
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
|
||||
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
|
||||
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
|
||||
🛏 Rest…
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handleCreateCharacter} className="flex-row flex-wrap mt-sm" style={{ marginBottom: '0.75rem' }}>
|
||||
<input
|
||||
@@ -492,6 +516,28 @@ export function CampaignHomePage() {
|
||||
<option value="BATTLE">Battle</option>
|
||||
<option value="QUEST">NPC quest</option>
|
||||
</select>
|
||||
{liveEncounters.length > 0 && (
|
||||
<select
|
||||
aria-label="Where the new encounter goes in the list"
|
||||
title="Where it goes in the campaign's order. ▲ and ▼ move it afterwards."
|
||||
value={encounterPlace}
|
||||
onChange={(e) => {
|
||||
setNewEncounterPlace(e.target.value);
|
||||
if (!e.target.value.startsWith(AFTER)) localStorage.setItem(ENCOUNTER_PLACE_KEY, e.target.value);
|
||||
}}
|
||||
style={{ maxWidth: 220 }}
|
||||
>
|
||||
<option value="BOTTOM">At the bottom of the list</option>
|
||||
<option value="TOP">At the top of the list</option>
|
||||
<optgroup label="Straight after…">
|
||||
{liveEncounters.map((e) => (
|
||||
<option key={e.id} value={AFTER + e.id}>
|
||||
After {e.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
</select>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={createEncounter.isPending}>
|
||||
{newEncounterMode === 'QUEST' ? '📜 Start Quest' : '⚔ Start Encounter'}
|
||||
</button>
|
||||
@@ -652,11 +698,11 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDM && show('npcs') && <NpcsSection campaignId={campaignId} entries={npcs} onOpen={setOpened} />}
|
||||
{isDM && show('weapons') && <WeaponsSection campaignId={campaignId} entries={weapons} onOpen={setOpened} />}
|
||||
{isDM && show('items') && <ItemsSection campaignId={campaignId} entries={items} onOpen={setOpened} />}
|
||||
{isDM && show('spells') && <SpellsSection campaignId={campaignId} entries={spells} onOpen={setOpened} />}
|
||||
{isDM && show('monsters') && <MonstersSection campaignId={campaignId} entries={monsters} onOpen={setOpened} />}
|
||||
{isDM && show('npcs') && <NpcsSection campaignId={campaignId} entries={npcs} onOpen={setOpened} formRequested={addingKind === 'npc'} onFormClosed={() => setAddingKind(null)} />}
|
||||
{isDM && show('weapons') && <WeaponsSection campaignId={campaignId} entries={weapons} onOpen={setOpened} formRequested={addingKind === 'weapon'} onFormClosed={() => setAddingKind(null)} />}
|
||||
{isDM && show('items') && <ItemsSection campaignId={campaignId} entries={items} onOpen={setOpened} formRequested={addingKind === 'item'} onFormClosed={() => setAddingKind(null)} />}
|
||||
{isDM && show('spells') && <SpellsSection campaignId={campaignId} entries={spells} onOpen={setOpened} formRequested={addingKind === 'spell'} onFormClosed={() => setAddingKind(null)} />}
|
||||
{isDM && show('monsters') && <MonstersSection campaignId={campaignId} entries={monsters} onOpen={setOpened} formRequested={addingKind === 'monster'} onFormClosed={() => setAddingKind(null)} />}
|
||||
|
||||
{show('notes') && (
|
||||
<div className="card">
|
||||
@@ -742,6 +788,7 @@ export function CampaignHomePage() {
|
||||
startingSpellCount: Number(fd.get('startingSpellCount')),
|
||||
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
|
||||
showMonsterHp: fd.get('showMonsterHp') === 'on',
|
||||
subclassesEnabled: fd.get('subclassesEnabled') === 'on',
|
||||
areaAttackAcPenalty: Number(fd.get('areaAttackAcPenalty')),
|
||||
maxEquipped: Number(fd.get('maxEquipped')),
|
||||
maxEquippedWeapons: Number(fd.get('maxEquippedWeapons')),
|
||||
@@ -770,6 +817,10 @@ export function CampaignHomePage() {
|
||||
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
|
||||
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
|
||||
</label>
|
||||
<label className="checkbox-row mt-sm">
|
||||
<input type="checkbox" name="subclassesEnabled" defaultChecked={campaign.subclassesEnabled} />
|
||||
Characters choose a subclass (untick and every class follows one path, with nothing to choose)
|
||||
</label>
|
||||
<label className="flex-row flex-wrap gap-sm mt-sm">
|
||||
Area attack AC penalty
|
||||
<NumberInput
|
||||
@@ -874,6 +925,8 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
|
||||
|
||||
{isDM && opened && (
|
||||
<EntryDetail
|
||||
campaignId={campaignId}
|
||||
@@ -883,6 +936,14 @@ export function CampaignHomePage() {
|
||||
spells={spells ?? []}
|
||||
monsters={monsters ?? []}
|
||||
npcs={npcs ?? []}
|
||||
onAddAnother={
|
||||
opened.isNew
|
||||
? () => {
|
||||
setAddingKind(opened.kind);
|
||||
setOpened(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onClose={() => setOpened(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -36,6 +36,11 @@ import { JumpNav } from '../components/JumpNav';
|
||||
import { useRememberCampaign } from '../lib/lastCampaign';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { DeathSaves } from '../components/DeathSaves';
|
||||
import { SpellSlotTracker } from '../components/SpellSlots';
|
||||
import { CharacterState } from '../components/CharacterState';
|
||||
import { Rollable } from '../components/Rollable';
|
||||
import { withBonus } from '../lib/diceRoller';
|
||||
import { LevelUpModal } from '../components/LevelUpModal';
|
||||
import { GearInForce } from '../components/CharacterPanel';
|
||||
import { effectsSummary } from '../lib/gear';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
@@ -43,7 +48,8 @@ import { Pagination } from '../components/Paginated';
|
||||
import { usePagination } from '../hooks/usePagination';
|
||||
import {
|
||||
ABILITY_LABELS,
|
||||
CLASS_LABELS,
|
||||
classLabel,
|
||||
xpLabel,
|
||||
COINS,
|
||||
COIN_FIELD,
|
||||
COIN_LABELS,
|
||||
@@ -280,6 +286,7 @@ export function CharacterSheetPage() {
|
||||
const [pickedSpellId, setPickedSpellId] = useState('');
|
||||
const [copyTargetId, setCopyTargetId] = useState('');
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [levellingUp, setLevellingUp] = useState(false);
|
||||
|
||||
const weaponsPage = usePagination(character?.weapons, 8);
|
||||
const spellsPage = usePagination(character?.spells, 8);
|
||||
@@ -421,8 +428,13 @@ export function CharacterSheetPage() {
|
||||
<span aria-hidden="true">{speciesIcon(character.species)}</span> {character.name}
|
||||
</h1>
|
||||
<p className="subtitle">
|
||||
{character.species && SPECIES_LABELS[character.species]} {character.characterClass && CLASS_LABELS[character.characterClass]} · Level{' '}
|
||||
{character.level}
|
||||
{character.species && SPECIES_LABELS[character.species]} {classLabel(character.characterClass, character.subclassName)} · Level{' '}
|
||||
{character.level} · {xpLabel(character.xp, character.xpProgress)}
|
||||
{character.xpProgress.levelUpDue && (
|
||||
<span className="badge badge-live" style={{ marginLeft: 6 }} title="They have the XP for the next level. Levelling up is the DM's to do.">
|
||||
⬆ Level up due
|
||||
</span>
|
||||
)}
|
||||
{character.motivation && ` · "${character.motivation}"`}
|
||||
{character.currentLocation && ` · 📍 ${character.currentLocation.name}`}
|
||||
</p>
|
||||
@@ -485,6 +497,10 @@ export function CharacterSheetPage() {
|
||||
isDM={isDM}
|
||||
onError={(err) => setFormError(extractErrorMessage(err))}
|
||||
/>
|
||||
<p className="subtitle mt-sm" style={{ marginBottom: 0 }}>
|
||||
Hit Dice: <strong>{sheet.hitDice.remaining}</strong> of {sheet.hitDice.count} left (d{sheet.hitDice.size}{' '}
|
||||
{fmtMod(sheet.abilities.CON.modifier)} each) <InfoTip term="HIT_DICE_LEFT" />
|
||||
</p>
|
||||
|
||||
{/* HP is the DM's to change; a player reads it. */}
|
||||
{isDM && (
|
||||
@@ -534,7 +550,8 @@ export function CharacterSheetPage() {
|
||||
|
||||
{isDM && (
|
||||
<div className="flex-row mt-sm flex-wrap">
|
||||
<label className="flex-row gap-sm">
|
||||
{/* Typing a level sets it outright; the button walks the checklist, one level at a time. */}
|
||||
<label className="flex-row gap-sm" title="Set the level by hand. A higher level still adds the class's new special abilities; use Level Up for the full checklist.">
|
||||
Level
|
||||
<NumberInput
|
||||
key={`level:${character.level}`}
|
||||
@@ -543,7 +560,63 @@ export function CharacterSheetPage() {
|
||||
keepValue
|
||||
defaultValue={character.level}
|
||||
style={{ width: 60 }}
|
||||
onBlur={(e) => patchCharacter.mutate({ level: Number(e.target.value) })}
|
||||
onBlur={(e) => Number(e.target.value) !== character.level && patchCharacter.mutate({ level: Number(e.target.value) })}
|
||||
/>
|
||||
</label>
|
||||
{character.subclassLevel != null && character.level >= character.subclassLevel && (
|
||||
<label
|
||||
className="flex-row gap-sm"
|
||||
title="The path within the class. Changing it takes the old subclass's lines out of Special Abilities and writes the new one's, up to this level."
|
||||
>
|
||||
Subclass
|
||||
<select
|
||||
value={character.subclass ?? ''}
|
||||
disabled={patchCharacter.isPending}
|
||||
onChange={(e) => patchCharacter.mutate({ subclass: e.target.value || null }, { onError: (err) => setFormError(extractErrorMessage(err)) })}
|
||||
style={{ width: 'auto' }}
|
||||
>
|
||||
<option value="">Not chosen</option>
|
||||
{character.subclassOptions.map((option) => (
|
||||
<option key={option.key} value={option.key} title={option.summary}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label className="flex-row gap-sm" title="Experience points. An encounter's XP is added here when you award it. Enough for the next level marks the level as due; it does not level the character up.">
|
||||
XP
|
||||
<NumberInput
|
||||
key={`xp:${character.xp}`}
|
||||
min={0}
|
||||
max={10000000}
|
||||
keepValue
|
||||
defaultValue={character.xp}
|
||||
style={{ width: 90 }}
|
||||
onBlur={(e) => Number(e.target.value) !== character.xp && patchCharacter.mutate({ xp: Math.max(0, Math.floor(Number(e.target.value)) || 0) })}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setLevellingUp(true)}
|
||||
disabled={character.level >= 20}
|
||||
title="Go up one level with the levelling-up checklist: HP, new special abilities, spells and the free weapon upgrade"
|
||||
>
|
||||
⬆ Level Up…
|
||||
</button>
|
||||
<label className="flex-row gap-sm" title="Hit Dice this character still has to spend on a short rest. The Rest button on the campaign page keeps count for you.">
|
||||
Hit Dice left
|
||||
<NumberInput
|
||||
key={`hitdice:${sheet.hitDice.remaining}:${sheet.hitDice.count}`}
|
||||
min={0}
|
||||
max={sheet.hitDice.count}
|
||||
keepValue
|
||||
defaultValue={sheet.hitDice.remaining}
|
||||
style={{ width: 60 }}
|
||||
onBlur={(e) => {
|
||||
const left = Number(e.target.value);
|
||||
if (left !== sheet.hitDice.remaining) patchCharacter.mutate({ hitDiceUsed: sheet.hitDice.count - left });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button className="btn btn-ghost" onClick={recomputeMaxHp} title="Recompute max HP from the current level and Constitution">
|
||||
@@ -581,6 +654,21 @@ export function CharacterSheetPage() {
|
||||
|
||||
<JumpNav />
|
||||
|
||||
<div data-jump="Condition" className="card mt-md">
|
||||
<div className="card-title">Condition and counters</div>
|
||||
<CharacterState
|
||||
characterId={characterId}
|
||||
name={character.name}
|
||||
inspiration={character.inspiration}
|
||||
exhaustion={character.exhaustion}
|
||||
conditions={character.conditions}
|
||||
resources={sheet.resources}
|
||||
canUse={canEdit}
|
||||
isDM={isDM}
|
||||
onError={(err) => setFormError(extractErrorMessage(err))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div data-jump="Abilities" className="card mt-md">
|
||||
<div className="card-title">Ability Scores</div>
|
||||
<div className="grid grid-cols-3">
|
||||
@@ -590,7 +678,9 @@ export function CharacterSheetPage() {
|
||||
{ABILITY_LABELS[ability]} <InfoTip term="ABILITY_MODIFIER" />
|
||||
</span>
|
||||
<span className="stat-value">{sheet.abilities[ability].final}</span>
|
||||
<span className="stat-sub">{fmtMod(sheet.abilities[ability].modifier)}</span>
|
||||
<Rollable label={`${character.name}: ${ABILITY_LABELS[ability]} check`} modifier={sheet.abilities[ability].modifier} className="stat-sub">
|
||||
{fmtMod(sheet.abilities[ability].modifier)}
|
||||
</Rollable>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -606,7 +696,9 @@ export function CharacterSheetPage() {
|
||||
<span>
|
||||
{ABILITY_LABELS[ability]} {sheet.saves[ability].proficient && <span className="badge">prof</span>}
|
||||
</span>
|
||||
<span className="mono">{fmtMod(sheet.saves[ability].bonus)}</span>
|
||||
<Rollable label={`${character.name}: ${ABILITY_LABELS[ability]} save`} modifier={sheet.saves[ability].bonus} className="mono">
|
||||
{fmtMod(sheet.saves[ability].bonus)}
|
||||
</Rollable>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -625,7 +717,9 @@ export function CharacterSheetPage() {
|
||||
<span className="badge">prof</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="mono">{fmtMod(detail.bonus)}</span>
|
||||
<Rollable label={`${character.name}: ${SKILL_LABELS[skill]}`} modifier={detail.bonus} className="mono">
|
||||
{fmtMod(detail.bonus)}
|
||||
</Rollable>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -633,14 +727,29 @@ export function CharacterSheetPage() {
|
||||
</div>
|
||||
|
||||
{sheet.spellcasting && (
|
||||
<div className="card mt-md">
|
||||
<div data-jump="Spellcasting" className="card mt-md">
|
||||
<div className="card-title">
|
||||
Spellcasting ({ABILITY_LABELS[sheet.spellcasting.ability]}) <InfoTip term="SPELL_SAVE_DC" />
|
||||
</div>
|
||||
<p style={{ margin: 0 }}>
|
||||
Save DC <strong>{sheet.spellcasting.saveDC}</strong> · Attack Bonus{' '}
|
||||
<strong>{fmtMod(sheet.spellcasting.attackBonus)}</strong> <InfoTip term="SPELL_ATTACK_BONUS" />
|
||||
<Rollable label={`${character.name}: spell attack`} modifier={sheet.spellcasting.attackBonus}>
|
||||
<strong>{fmtMod(sheet.spellcasting.attackBonus)}</strong>
|
||||
</Rollable>{' '}
|
||||
<InfoTip term="SPELL_ATTACK_BONUS" />
|
||||
</p>
|
||||
{sheet.spellSlots ? (
|
||||
<SpellSlotTracker
|
||||
characterId={characterId}
|
||||
name={character.name}
|
||||
slots={sheet.spellSlots}
|
||||
canUse={canEdit}
|
||||
isDM={isDM}
|
||||
onError={(err) => setFormError(extractErrorMessage(err))}
|
||||
/>
|
||||
) : (
|
||||
<p className="subtitle" style={{ margin: '0.4rem 0 0' }}>No spell slots yet: they come at level 2.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -653,7 +762,15 @@ export function CharacterSheetPage() {
|
||||
<div>
|
||||
<strong>{cw.weapon.name}</strong>
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
|
||||
Lv {cw.level} · {cw.scaledDamageDice} {cw.weapon.damageType} · x{cw.quantity}
|
||||
Lv {cw.level} ·{' '}
|
||||
<Rollable label={`${character.name}: ${cw.weapon.name} attack`} modifier={cw.attackBonus}>
|
||||
{fmtMod(cw.attackBonus)} to hit
|
||||
</Rollable>{' '}
|
||||
·{' '}
|
||||
<Rollable label={`${character.name}: ${cw.weapon.name} damage`} dice={withBonus(cw.scaledDamageDice, cw.damageBonus)}>
|
||||
{cw.scaledDamageDice} {fmtMod(cw.damageBonus)}
|
||||
</Rollable>{' '}
|
||||
{cw.weapon.damageType} · x{cw.quantity}
|
||||
</span>
|
||||
{!cw.usable && <RequiresLevel level={cw.level} />}
|
||||
{effectsSummary(cw.weapon.effects, cw.weapon.effectNote) && (
|
||||
@@ -718,7 +835,15 @@ export function CharacterSheetPage() {
|
||||
<strong>{cs.spell.name}</strong>
|
||||
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
|
||||
{spellLevelLabel(cs.spell.level)}
|
||||
{cs.spell.damageDice && ` · ${cs.spell.damageDice} damage`}
|
||||
{cs.spell.damageDice && (
|
||||
<>
|
||||
{' · '}
|
||||
<Rollable label={`${character.name}: ${cs.spell.name}`} dice={cs.spell.damageDice}>
|
||||
{cs.spell.damageDice}
|
||||
</Rollable>{' '}
|
||||
damage
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{!cs.usable && <RequiresLevel level={cs.spell.level} />}
|
||||
{cs.spell.description && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{cs.spell.description}</div>}
|
||||
@@ -1062,6 +1187,8 @@ export function CharacterSheetPage() {
|
||||
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} onJump={notesPage.setPage} />
|
||||
</div>
|
||||
|
||||
{isDM && levellingUp && <LevelUpModal character={character} campaignSpells={campaignSpells ?? []} onClose={() => setLevellingUp(false)} />}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/useAuth';
|
||||
import { useCampaign, useCampaignCharacters } from '../api/queries';
|
||||
@@ -6,10 +7,14 @@ import { Spinner, ErrorBanner } from '../components/Spinner';
|
||||
import { Pagination } from '../components/Paginated';
|
||||
import { usePagination } from '../hooks/usePagination';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
import { CLASS_LABELS, SPECIES_LABELS, formatPurse } from '../lib/format';
|
||||
import { SPECIES_LABELS, classLabel, formatPurse, slotsSummary, xpLabel } from '../lib/format';
|
||||
import { RestModal } from '../components/RestModal';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { ConditionChip, StateBadges } from '../components/CharacterState';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
import { openCharacterPanel } from '../lib/characterPanel';
|
||||
import { GroupCheckModal } from '../components/GroupCheckModal';
|
||||
|
||||
// A DM's one-screen view of every character's stats at a glance; a player
|
||||
// sees the same table, scoped to their own character(s) plus a read-only
|
||||
@@ -23,11 +28,15 @@ export function PartyOverviewPage() {
|
||||
const { data, isLoading: campaignLoading, error: campaignError } = useCampaign(campaignId);
|
||||
const { data: characters, isLoading: charsLoading, error: charsError } = useCampaignCharacters(campaignId);
|
||||
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(characters, 12);
|
||||
const [resting, setResting] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
|
||||
if (campaignLoading || charsLoading) return <Spinner label="Gathering the party…" />;
|
||||
if (campaignError || charsError || !data) return <ErrorBanner message="Could not load the party overview." />;
|
||||
|
||||
const isDM = data.campaign.role === 'DM';
|
||||
// Everyone alive and built, for a roll the DM calls for from the whole party.
|
||||
const checkers = (characters ?? []).flatMap((c) => (c.checks && c.deathSaves.state !== 'DEAD' ? [{ id: c.id, name: c.name, ...c.checks }] : []));
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -36,9 +45,21 @@ export function PartyOverviewPage() {
|
||||
<h1>Party Overview</h1>
|
||||
<p className="subtitle">{isDM ? 'Every character in this campaign, at a glance.' : 'Your party.'}</p>
|
||||
</div>
|
||||
<Link to={`/campaigns/${campaignId}`} className="btn btn-ghost">
|
||||
← Back to Campaign
|
||||
</Link>
|
||||
<div className="flex-row">
|
||||
{isDM && checkers.length > 0 && (
|
||||
<button className="btn" onClick={() => setChecking(true)} title="Call for a check or a save from the party, and roll it for all of them at once">
|
||||
🎲 Call for a roll…
|
||||
</button>
|
||||
)}
|
||||
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
|
||||
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
|
||||
🛏 Rest…
|
||||
</button>
|
||||
)}
|
||||
<Link to={`/campaigns/${campaignId}`} className="btn btn-ghost">
|
||||
← Back to Campaign
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{characters?.length === 0 && <div className="empty-state">No characters yet.</div>}
|
||||
@@ -58,6 +79,14 @@ export function PartyOverviewPage() {
|
||||
<th>
|
||||
AC <InfoTip term="AC" />
|
||||
</th>
|
||||
<th title="Passive Perception, Investigation and Insight: 10 + the skill's bonus, used without a roll">Passive P / Inv / Ins</th>
|
||||
<th title="What a target rolls against to resist this character's spells">Spell DC</th>
|
||||
<th>
|
||||
Hit Dice <InfoTip term="HIT_DICE_LEFT" />
|
||||
</th>
|
||||
<th>
|
||||
Spell slots <InfoTip term="SPELL_SLOTS" />
|
||||
</th>
|
||||
<th>Coins</th>
|
||||
<th>Location</th>
|
||||
</tr>
|
||||
@@ -73,10 +102,38 @@ export function PartyOverviewPage() {
|
||||
<span aria-hidden="true">{speciesIcon(c.species)}</span> {c.name}
|
||||
</Link>
|
||||
{isMine && <span className="badge badge-mine" style={{ marginLeft: 6 }}>★ Yours</span>}
|
||||
{!inProgress && (c.inspiration || c.exhaustion > 0 || c.conditions.length > 0) && (
|
||||
<span className="flex-row flex-wrap gap-sm" style={{ display: 'inline-flex', marginLeft: 6 }}>
|
||||
<StateBadges inspiration={c.inspiration} exhaustion={c.exhaustion} />
|
||||
{c.conditions.map((condition) => (
|
||||
<ConditionChip key={condition} name={condition} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{!inProgress && (
|
||||
<button
|
||||
className="btn btn-target"
|
||||
style={{ marginLeft: 6 }}
|
||||
title={`${c.name} at a glance, without leaving this page: saves, skills, attacks and special abilities`}
|
||||
onClick={() => openCharacterPanel(c.id)}
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Species">{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
|
||||
<td data-label="Class">{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
|
||||
<td data-label="Level">{c.level}</td>
|
||||
<td data-label="Class">{c.characterClass ? classLabel(c.characterClass, c.subclassName) : inProgress ? 'Building…' : '—'}</td>
|
||||
<td data-label="Level">
|
||||
{c.level}
|
||||
{!inProgress && (
|
||||
<div className="combatant-sub mono" style={{ whiteSpace: 'nowrap' }}>
|
||||
{xpLabel(c.xp, c.xpProgress)}
|
||||
{c.xpProgress.levelUpDue && (
|
||||
<span title="They have the XP for the next level"> ⬆</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td data-label="HP">
|
||||
{c.maxHp != null ? (
|
||||
<div style={{ minWidth: 90 }}>
|
||||
@@ -92,6 +149,12 @@ export function PartyOverviewPage() {
|
||||
)}
|
||||
</td>
|
||||
<td data-label="AC">{c.armorClass ?? '—'}</td>
|
||||
<td data-label="Passive P / Inv / Ins" className="mono">
|
||||
{c.passives ? `${c.passives.perception} / ${c.passives.investigation} / ${c.passives.insight}` : '—'}
|
||||
</td>
|
||||
<td data-label="Spell DC" className="mono">{c.spellSaveDC ?? '—'}</td>
|
||||
<td data-label="Hit Dice" className="mono">{c.hitDice ? `${c.hitDice.remaining}/${c.hitDice.count} d${c.hitDice.size}` : '—'}</td>
|
||||
<td data-label="Spell slots" className="mono">{slotsSummary(c.spellSlots) || '—'}</td>
|
||||
<td data-label="Coins" className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
|
||||
<td data-label="Location">{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
|
||||
</tr>
|
||||
@@ -102,6 +165,8 @@ export function PartyOverviewPage() {
|
||||
</div>
|
||||
)}
|
||||
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
|
||||
{isDM && checking && <GroupCheckModal characters={checkers} onClose={() => setChecking(false)} />}
|
||||
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+25
-107
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.13.0",
|
||||
"version": "1.21.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.13.0",
|
||||
"version": "1.21.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.13.0",
|
||||
"version": "1.21.0",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.13.0",
|
||||
"version": "1.21.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
@@ -1411,9 +1411,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1431,9 +1428,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1451,9 +1445,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1471,9 +1462,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1491,9 +1479,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1511,9 +1496,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1531,9 +1513,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1551,9 +1530,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1768,9 +1744,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1792,9 +1765,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1816,9 +1786,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1840,9 +1807,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1864,9 +1828,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1888,9 +1849,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2451,9 +2409,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2471,9 +2426,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2491,9 +2443,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2511,9 +2460,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2531,9 +2477,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2551,9 +2494,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2994,7 +2934,7 @@
|
||||
"version": "19.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
|
||||
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.3.0"
|
||||
@@ -3061,6 +3001,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3077,6 +3018,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3093,6 +3035,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3109,6 +3052,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3125,6 +3069,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3141,6 +3086,7 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3157,6 +3103,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3173,6 +3120,7 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3189,6 +3137,7 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3205,6 +3154,7 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3221,6 +3171,7 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3237,6 +3188,7 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3253,6 +3205,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3269,6 +3222,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3285,6 +3239,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3301,6 +3256,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3317,6 +3273,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3333,6 +3290,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3349,6 +3307,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3365,6 +3324,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3487,9 +3447,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3504,9 +3461,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3521,9 +3475,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3538,9 +3489,6 @@
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3555,9 +3503,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3572,9 +3517,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3589,9 +3531,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3606,9 +3545,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3623,9 +3559,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3640,9 +3573,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7200,9 +7130,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7224,9 +7151,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7248,9 +7172,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -7272,9 +7193,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.13.0",
|
||||
"version": "1.21.0",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user