Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9f8169286 |
@@ -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 |
|
||||
@@ -63,6 +63,15 @@ including a character sheet or an encounter) and **Account**.
|
||||
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.
|
||||
- **⬆ 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
|
||||
@@ -165,7 +174,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.1",
|
||||
"version": "1.14.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;
|
||||
@@ -283,6 +283,12 @@ 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)
|
||||
armorBonus Int @default(0) // a flat AC adjustment the DM sets by hand, on top of what equipped gear gives
|
||||
speedOverride Int?
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ 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 DOCS = path.join(__dirname, '..', '..', 'docs');
|
||||
|
||||
@@ -114,12 +116,32 @@ function builderLists() {
|
||||
].join('\n\n');
|
||||
}
|
||||
|
||||
// What "Level Up" writes for each class at each level, 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']);
|
||||
}
|
||||
return `### ${CLASSES[key].name}\n\n${table(['Level', 'Written into Special Abilities'], rows)}`;
|
||||
});
|
||||
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,7 @@
|
||||
const { SPECIES } = require('./data/species');
|
||||
const { CLASSES } = require('./data/classes');
|
||||
const { SKILLS } = require('./data/skills');
|
||||
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'];
|
||||
|
||||
@@ -121,6 +122,60 @@ 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 };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 +248,8 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
halfElfBonusAbility2 = null,
|
||||
armorBonus = 0,
|
||||
speedOverride = null,
|
||||
spellSlotsUsed = [],
|
||||
hitDiceUsed = 0,
|
||||
} = character;
|
||||
|
||||
const halfElfBonusAbilities = [halfElfBonusAbility1, halfElfBonusAbility2].filter(Boolean);
|
||||
@@ -246,7 +303,7 @@ 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'),
|
||||
@@ -259,6 +316,8 @@ 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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -405,6 +464,12 @@ module.exports = {
|
||||
getClassSaveProficiencies,
|
||||
getClassHitDieSize,
|
||||
getClassSpellcastingAbility,
|
||||
MAX_LEVEL,
|
||||
MAX_SLOT_LEVEL,
|
||||
spellSlotsFor,
|
||||
spellSlotState,
|
||||
maxSpellLevel,
|
||||
hitDiceState,
|
||||
GEAR_STATS,
|
||||
activeGear,
|
||||
computeCharacterSheet,
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// 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. The site has no subclasses, so each class follows one simple path (the
|
||||
// Champion fighter, the Life cleric...). 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: [
|
||||
{ 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.' },
|
||||
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: [{ name: 'Mindless Rage', text: 'You can’t be charmed or frightened while raging.' }, 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)],
|
||||
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.' }],
|
||||
},
|
||||
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: '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).' },
|
||||
{ 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.' },
|
||||
{ name: 'Additional Magical Secrets', text: 'Learn two spells from any class list (the DM grants them within the unlock schedule).' },
|
||||
],
|
||||
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 Preserve Life.' },
|
||||
{ name: 'Turn Undead', text: 'Channel Divinity. Action: undead within 30 ft make a Wisdom save or flee from you for 1 minute.' },
|
||||
{ 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).' },
|
||||
],
|
||||
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 Preserve Life.', upgrade: true },
|
||||
{ name: 'Blessed Healer', text: 'When you heal someone else with a spell, you heal yourself 2 + the spell’s level.' },
|
||||
],
|
||||
8: [
|
||||
BOON,
|
||||
{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 radiant damage.' },
|
||||
{ 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)'),
|
||||
{ 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).' },
|
||||
],
|
||||
4: [BOON, wildShape('CR 1/2 or less (it may swim, not fly)')],
|
||||
6: [{ name: 'Land’s Stride', text: 'Ordinary difficult terrain and plants do not slow you; advantage on saves against magical plants.' }],
|
||||
8: [BOON, wildShape('CR 1 or less (it may swim or fly)')],
|
||||
10: [{ name: 'Nature’s Ward', text: 'Immune to poison and disease; you can’t be charmed or frightened by fey or elementals.' }],
|
||||
},
|
||||
FIGHTER: {
|
||||
2: [{ name: 'Action Surge', text: 'Once per short rest, take one extra action on your turn.' }],
|
||||
3: [{ name: 'Improved Critical', text: 'Your weapon attacks score a critical hit on a roll of 19 or 20.' }],
|
||||
4: [BOON],
|
||||
5: [EXTRA_ATTACK],
|
||||
6: [BOON],
|
||||
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.' }],
|
||||
8: [BOON],
|
||||
9: [{ name: 'Indomitable', text: 'Once per long rest, reroll a failed saving throw.' }],
|
||||
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.)' }],
|
||||
},
|
||||
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: 'Open Hand Technique', text: 'Flurry hits can knock a target prone (Dexterity save), push it 15 ft (Strength save) or stop its reactions.' },
|
||||
{ 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: 'Wholeness of Body', text: 'Once per long rest, heal 3 × your level as an action.' },
|
||||
{ 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.' },
|
||||
{ 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.' },
|
||||
],
|
||||
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.' }],
|
||||
7: [{ name: 'Aura of Devotion', text: 'You and allies within 10 ft can’t be charmed.' }],
|
||||
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: 'Colossus Slayer', text: 'Hunter’s Prey. Once per turn, +1d8 damage to a creature that is already below its max HP.' },
|
||||
{ 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.' },
|
||||
],
|
||||
7: [{ name: 'Escape the Horde', text: 'Defensive Tactics. Opportunity attacks against you have disadvantage.' }],
|
||||
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'),
|
||||
{ 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.' },
|
||||
],
|
||||
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'), { name: 'Supreme Sneak', text: 'Advantage on Stealth if you move no more than half your speed.' }],
|
||||
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],
|
||||
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.' }],
|
||||
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')],
|
||||
6: [{ name: 'Patron’s Luck', text: 'Once per short rest, add a d10 to an ability check or saving throw after seeing the roll.' }],
|
||||
7: [invocations('four')],
|
||||
8: [BOON],
|
||||
9: [invocations('five')],
|
||||
10: [{ name: 'Patron’s Resilience', text: 'After each rest, choose one damage type: you resist it until you choose another.' }],
|
||||
},
|
||||
WIZARD: {
|
||||
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.' },
|
||||
],
|
||||
4: [BOON],
|
||||
6: [{ name: 'Potent Cantrip', text: 'Creatures that save against your damaging cantrips still take half damage.' }],
|
||||
8: [BOON],
|
||||
10: [{ name: 'Empowered Evocation', text: 'Add your Intelligence modifier to the damage of your evocation spells.' }],
|
||||
},
|
||||
};
|
||||
|
||||
// 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,144 @@
|
||||
// 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 { 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.
|
||||
// An entry is { name, text, upgrade? } or { boon: true }.
|
||||
function featuresAt(characterClass, level) {
|
||||
const features = [...((LEVEL_FEATURES[characterClass] || {})[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);
|
||||
}
|
||||
|
||||
// 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. 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) {
|
||||
const entries = featuresAt(characterClass, level);
|
||||
let text = addFeatures(specialAbilities, entries.filter((entry) => !entry.boon));
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// The checklist for a character's next level. Takes the character with its
|
||||
// weapons loaded (each with its pool entry).
|
||||
function levelUpPlan(character) {
|
||||
const toLevel = character.level + 1;
|
||||
const entries = featuresAt(character.characterClass, toLevel);
|
||||
return {
|
||||
fromLevel: character.level,
|
||||
toLevel,
|
||||
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, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
|
||||
@@ -0,0 +1,130 @@
|
||||
const { featuresAt, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan, LevelUpError } = require('./levelUp');
|
||||
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);
|
||||
|
||||
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);
|
||||
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')).toEqual({ specialAbilities: 'Toughened — +1 AC (counted in your Extra AC).', armorBonus: 1, tempHp: 0 });
|
||||
const hardy = advance(null, 'FIGHTER', 4, 'HARDY');
|
||||
expect(hardy).toMatchObject({ armorBonus: 0, tempHp: 5 });
|
||||
// Taken again, its one line grows.
|
||||
const twice = advance(hardy.specialAbilities, 'FIGHTER', 6, 'HARDY');
|
||||
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').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);
|
||||
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.features.map((f) => f.name)).toEqual(['Action Surge']);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -2813,3 +2813,259 @@ 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 4 grants a Boon, which has to be picked.
|
||||
await levelUp(dmAuth);
|
||||
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 spell already known is simply kept.
|
||||
const character = (await levelUp({ weaponId, spellIds: [boltId, wardId] })).body.character;
|
||||
expect(character.level).toBe(2);
|
||||
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/));
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,13 @@ 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, 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 { findSeed, normalizeCode, CODE_LENGTH } = require('../seeds');
|
||||
const { createCampaignFromSeed } = require('../seeds/createCampaign');
|
||||
@@ -237,6 +239,11 @@ function characterSummary(character) {
|
||||
cash: character.cash,
|
||||
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
|
||||
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : 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,
|
||||
currentLocationId: character.currentLocationId,
|
||||
currentLocationName: character.currentLocation ? character.currentLocation.name : null,
|
||||
};
|
||||
@@ -315,6 +322,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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -11,9 +11,13 @@ const {
|
||||
getClassSpellcastingAbility,
|
||||
scaledDamageDice,
|
||||
canUseGear,
|
||||
spellSlotState,
|
||||
MAX_GEAR_LEVEL,
|
||||
MAX_LEVEL,
|
||||
MAX_SLOT_LEVEL,
|
||||
ABILITIES,
|
||||
} = require('../game/calculations');
|
||||
const { LevelUpError, advance, levelUpPlan } = require('../game/levelUp');
|
||||
const {
|
||||
rollDie,
|
||||
rollAbilityScore,
|
||||
@@ -31,7 +35,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();
|
||||
@@ -158,8 +162,9 @@ 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(),
|
||||
level: z.number().int().min(1).max(MAX_LEVEL).optional(),
|
||||
xp: z.number().int().min(0).optional(),
|
||||
hitDiceUsed: z.number().int().min(0).max(MAX_LEVEL).optional(),
|
||||
});
|
||||
|
||||
// Progression and placement are the DM's call, and so is rewriting what the
|
||||
@@ -168,8 +173,9 @@ 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.
|
||||
const DM_ONLY_PATCH_FIELDS = ['currentHp', 'tempHp', 'level', 'xp', 'currentLocationId', 'speedOverride', 'armorBonus', 'specialAbilities', 'background', 'appearance', 'hitDiceUsed'];
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
@@ -184,7 +190,30 @@ 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, XP, speed, armor bonus, location, special abilities, background or appearance.' });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
if (req.character.buildStage === 'COMPLETE' && newLevel > req.character.level && parsed.data.specialAbilities === undefined) {
|
||||
// Setting a higher level by hand still writes down what the class gains
|
||||
// on the way there. A Boon is left as a line saying one is owed: picking
|
||||
// it is part of levelling up properly (POST /:id/level-up).
|
||||
let abilities = req.character.specialAbilities;
|
||||
for (let level = req.character.level + 1; level <= newLevel; level++) {
|
||||
abilities = advance(abilities, req.character.characterClass, level).specialAbilities;
|
||||
}
|
||||
parsed.data.specialAbilities = abilities;
|
||||
}
|
||||
|
||||
// Damage is soaked by temp HP before it reaches real HP — unless this
|
||||
@@ -307,6 +336,166 @@ 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);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
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({
|
||||
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 { boon, weaponId, spellIds } = parsed.data;
|
||||
const character = await loadFullCharacter(req.character.id);
|
||||
if (!canLevelUp(req, res, character)) return;
|
||||
|
||||
const plan = levelUpPlan(character);
|
||||
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 {
|
||||
gained = advance(character.specialAbilities, character.characterClass, plan.toLevel, boon);
|
||||
} 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,
|
||||
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' });
|
||||
@@ -955,6 +1144,8 @@ 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,
|
||||
passivePerception: sheet.passivePerception,
|
||||
abilities: sheet.abilities,
|
||||
specialAbilities: character.specialAbilities,
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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.
|
||||
// Long rest (a night): all HP, all spell slots, Hit Dice up to half the level
|
||||
// (at least one), and temp HP is gone, except what the Hardy Boon gives.
|
||||
|
||||
const { changeHp, lifeState, NO_DEATH_SAVES } = require('./hp');
|
||||
const { hardyTempHp } = require('../game/levelUp');
|
||||
|
||||
// Once a day on a short rest a wizard or a 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 ? '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.`;
|
||||
}
|
||||
|
||||
// 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 data = {
|
||||
...(healed > 0 ? changeHp(character, character.currentHp + healed, sheet.hp.max) : {}),
|
||||
hitDiceUsed: sheet.hitDice.used + dice,
|
||||
...(pact ? { spellSlotsUsed: [] } : {}),
|
||||
};
|
||||
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: [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 data = { currentHp: sheet.hp.max, tempHp, ...NO_DEATH_SAVES, hitDiceUsed: sheet.hitDice.used - regained, spellSlotsUsed: [] };
|
||||
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: tempHp > 0 ? [`Hardy: ${tempHp} Temp HP for the day.`] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { hitDiceProblem, shortRest, longRest };
|
||||
@@ -0,0 +1,109 @@
|
||||
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 });
|
||||
});
|
||||
|
||||
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: [] });
|
||||
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('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.']);
|
||||
});
|
||||
});
|
||||
+329
-18
@@ -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,77 @@ 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.
|
||||
- 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** is stored but no screen shows or edits it, and it does not drive level.
|
||||
|
||||
Level feeds two things:
|
||||
### 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 new features for that level into Special Abilities ([section 21](#21-what-each-class-gains-as-it-levels)) | 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 tables run from **level 2 to level 10** and follow one path per
|
||||
class (there are no subclasses): Berserker barbarian, Lore bard, Life cleric,
|
||||
Land druid, Champion fighter, Open Hand monk, Devotion paladin, Hunter ranger,
|
||||
Thief rogue, Draconic sorcerer, a fiend-like warlock patron, Evocation wizard.
|
||||
Above level 10 the numbers still move but no feature is written; add them by
|
||||
hand.
|
||||
|
||||
**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, but it asks nothing: a Boon is left as a
|
||||
line saying one is owed ("Boon (level 4) — Choose one with your DM…"), 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 +364,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 +391,54 @@ 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 |
|
||||
|
||||
**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 druid of level 2 or more
|
||||
(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
|
||||
|
||||
@@ -564,7 +678,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 +784,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, AC, Hit Dice left, spell slots left, gold and location. 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, what equipped gear is doing, spellcasting numbers with the spell slot tracker, 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. |
|
||||
| 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
|
||||
@@ -780,6 +895,11 @@ 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 |
|
||||
| Level a character up (the checklist), pick a Boon | 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 |
|
||||
@@ -811,16 +931,20 @@ 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 features beyond level 10 (the DM adds them to the sheet by hand), a
|
||||
choice of subclass (each class follows one path) 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)
|
||||
- Ki, sorcery points, rages and other class counters (the lines say how many;
|
||||
the player keeps count)
|
||||
- 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
|
||||
- Conditions on the sheet, and exhaustion
|
||||
- Automatic levelling from XP
|
||||
- Alignment, languages, tool proficiencies and backgrounds-as-rules
|
||||
- Encumbrance and item weight
|
||||
@@ -1070,3 +1194,190 @@ 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),
|
||||
and the Boons. "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 | **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.<br>**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 | **Mindless Rage**: You can’t be charmed or frightened while raging.<br>**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 | **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. |
|
||||
|
||||
### 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 | **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).<br>**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.<br>**Additional Magical Secrets**: Learn two spells from any class list (the DM grants them within the unlock schedule). |
|
||||
| 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). |
|
||||
|
||||
### Cleric
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Channel Divinity**: Once per short rest, use Turn Undead or Preserve Life.<br>**Turn Undead**: Channel Divinity. Action: undead within 30 ft make a Wisdom save or flee from you for 1 minute.<br>**Preserve Life**: Channel Divinity. Action: share out healing of 5 × your level among creatures within 30 ft (up to half their max HP each). |
|
||||
| 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 Preserve Life.<br>**Blessed Healer**: When you heal someone else with a spell, you heal yourself 2 + the spell’s level. |
|
||||
| 7 | No new feature |
|
||||
| 8 | **Boon** (choose one)<br>**Divine Strike**: Once per turn, a weapon hit deals +1d8 radiant damage.<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). |
|
||||
|
||||
### 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.<br>**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). |
|
||||
| 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 | **Land’s Stride**: Ordinary difficult terrain and plants do not slow you; advantage on saves against magical plants. |
|
||||
| 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 | **Nature’s Ward**: Immune to poison and disease; you can’t be charmed or frightened by fey or elementals. |
|
||||
|
||||
### Fighter
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 2 | **Action Surge**: Once per short rest, take one extra action on your turn. |
|
||||
| 3 | **Improved Critical**: Your weapon attacks score a critical hit on a roll of 19 or 20. |
|
||||
| 4 | **Boon** (choose one) |
|
||||
| 5 | **Extra Attack**: Attack twice when you take the Attack action. |
|
||||
| 6 | **Boon** (choose one) |
|
||||
| 7 | **Remarkable Athlete**: Add half your proficiency bonus to Strength, Dexterity and Constitution checks you are not proficient in; long jump + Strength modifier feet. |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | **Indomitable**: Once per long rest, reroll a failed saving throw. |
|
||||
| 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.) |
|
||||
|
||||
### 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 | **Open Hand Technique**: Flurry hits can knock a target prone (Dexterity save), push it 15 ft (Strength save) or stop its reactions.<br>**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>**Wholeness of Body**: Once per long rest, heal 3 × your level as an action.<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. |
|
||||
|
||||
### 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.<br>**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. |
|
||||
| 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 | **Aura of Devotion**: You and allies within 10 ft can’t be charmed. |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | No new feature |
|
||||
| 10 | **Aura of Courage**: You and allies within 10 ft can’t be frightened. |
|
||||
|
||||
### 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 | **Colossus Slayer**: Hunter’s Prey. Once per turn, +1d8 damage to a creature that is already below its max HP.<br>**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 | **Escape the Horde**: Defensive Tactics. Opportunity attacks against you have disadvantage. |
|
||||
| 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. |
|
||||
|
||||
### 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.<br>**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. |
|
||||
| 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.<br>**Supreme Sneak**: Advantage on Stealth if you move no more than half your speed. |
|
||||
| 10 | **Boon** (choose one) |
|
||||
|
||||
### 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 | **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. |
|
||||
| 7 | No new feature |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | No new feature |
|
||||
| 10 | **Metamagic**: Choose a third option with your DM. |
|
||||
|
||||
### 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 | **Patron’s Luck**: Once per short rest, add a d10 to an ability check or saving throw after seeing the roll. |
|
||||
| 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 | **Patron’s Resilience**: After each rest, choose one damage type: you resist it until you choose another. |
|
||||
|
||||
### Wizard
|
||||
|
||||
| Level | Written into Special Abilities |
|
||||
|---|---|
|
||||
| 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. |
|
||||
| 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 | **Potent Cantrip**: Creatures that save against your damaging cantrips still take half damage. |
|
||||
| 7 | No new feature |
|
||||
| 8 | **Boon** (choose one) |
|
||||
| 9 | No new feature |
|
||||
| 10 | **Empowered Evocation**: Add your Intelligence modifier to the damage of your evocation spells. |
|
||||
<!-- END GENERATED: level-features -->
|
||||
|
||||
+93
-11
@@ -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 |
|
||||
|
||||
@@ -89,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:
|
||||
@@ -182,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:
|
||||
|
||||
@@ -193,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 |
|
||||
|---|---|---|---|
|
||||
@@ -203,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
|
||||
@@ -216,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.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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,62 @@ 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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: { 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({
|
||||
|
||||
@@ -127,6 +127,31 @@ export interface DeathSaves {
|
||||
failures: number;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
export interface CharacterSummary {
|
||||
id: string;
|
||||
campaignId: string;
|
||||
@@ -148,6 +173,9 @@ export interface CharacterSummary {
|
||||
cash: number | null;
|
||||
maxHp: number | null;
|
||||
armorClass: number | 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
|
||||
currentLocationId: string | null;
|
||||
currentLocationName: string | null;
|
||||
}
|
||||
@@ -188,11 +216,12 @@ export interface CharacterSheet {
|
||||
initiative: number;
|
||||
speed: number;
|
||||
hp: { max: number };
|
||||
hitDice: { size: number; count: number };
|
||||
hitDice: HitDice;
|
||||
saves: Record<Ability, { proficient: boolean; bonus: number }>;
|
||||
skills: Record<string, { ability: Ability; proficient: boolean; expertise: boolean; bonus: number }>;
|
||||
passivePerception: number;
|
||||
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
|
||||
spellSlots: SpellSlots | null; // null for a character with no spell slots
|
||||
gear: ActiveGear;
|
||||
}
|
||||
|
||||
@@ -667,6 +696,8 @@ export interface QuickStats {
|
||||
speed: number;
|
||||
proficiencyBonus: number;
|
||||
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
|
||||
spellSlots: SpellSlots | null;
|
||||
hitDice: HitDice;
|
||||
passivePerception: number;
|
||||
abilities: Record<Ability, AbilityDetail>;
|
||||
specialAbilities: string | null;
|
||||
@@ -677,3 +708,55 @@ 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 };
|
||||
// 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[];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { speciesIcon } from '../lib/speciesIcons';
|
||||
import { Spinner, ErrorBanner } from './Spinner';
|
||||
import { HpBar } from './HpBar';
|
||||
import { DeathSaves } from './DeathSaves';
|
||||
import { SpellSlotTracker } from './SpellSlots';
|
||||
import { useState } from 'react';
|
||||
import { extractErrorMessage } from '../lib/format';
|
||||
import type { ActiveGear } from '../api/types';
|
||||
@@ -43,7 +44,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 +62,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 (
|
||||
@@ -125,6 +127,17 @@ function PanelBody({ characterId }: { characterId: string }) {
|
||||
<p style={{ margin: 0 }}>
|
||||
Save DC {data.spellcasting.saveDC} · Attack {fmtMod(data.spellcasting.attackBonus)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -289,7 +302,7 @@ export function CharacterPanel() {
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<PanelBody key={shown} characterId={shown} />
|
||||
<PanelBody key={shown} characterId={shown} isDM={isDM} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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,188 @@
|
||||
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 (and a Boon where the level
|
||||
// grants 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 [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);
|
||||
|
||||
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({ 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.features.length === 0 && !plan.boon && (
|
||||
<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) && (
|
||||
<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(plan.boon && !boon)}>
|
||||
{levelUp.isPending ? 'Levelling…' : plan.boon && !boon ? 'Choose a Boon first' : `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,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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -127,6 +127,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';
|
||||
@@ -168,6 +169,7 @@ 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) || [];
|
||||
@@ -432,6 +434,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
|
||||
@@ -876,6 +883,8 @@ export function CampaignHomePage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
|
||||
|
||||
{isDM && opened && (
|
||||
<EntryDetail
|
||||
campaignId={campaignId}
|
||||
|
||||
@@ -36,6 +36,8 @@ 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 { LevelUpModal } from '../components/LevelUpModal';
|
||||
import { GearInForce } from '../components/CharacterPanel';
|
||||
import { effectsSummary } from '../lib/gear';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
@@ -280,6 +282,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);
|
||||
@@ -485,6 +488,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 +541,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 +551,30 @@ 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>
|
||||
<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">
|
||||
@@ -633,7 +664,7 @@ 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>
|
||||
@@ -641,6 +672,18 @@ export function CharacterSheetPage() {
|
||||
Save DC <strong>{sheet.spellcasting.saveDC}</strong> · Attack Bonus{' '}
|
||||
<strong>{fmtMod(sheet.spellcasting.attackBonus)}</strong> <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>
|
||||
)}
|
||||
|
||||
@@ -1062,6 +1105,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,7 +7,8 @@ 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 { CLASS_LABELS, SPECIES_LABELS, formatPurse, slotsSummary } from '../lib/format';
|
||||
import { RestModal } from '../components/RestModal';
|
||||
import { HpBar } from '../components/HpBar';
|
||||
import { LifeBadge } from '../components/DeathSaves';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
@@ -23,6 +25,7 @@ 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);
|
||||
|
||||
if (campaignLoading || charsLoading) return <Spinner label="Gathering the party…" />;
|
||||
if (campaignError || charsError || !data) return <ErrorBanner message="Could not load the party overview." />;
|
||||
@@ -36,9 +39,16 @@ 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 && 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 +68,12 @@ export function PartyOverviewPage() {
|
||||
<th>
|
||||
AC <InfoTip term="AC" />
|
||||
</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>
|
||||
@@ -92,6 +108,8 @@ export function PartyOverviewPage() {
|
||||
)}
|
||||
</td>
|
||||
<td data-label="AC">{c.armorClass ?? '—'}</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 +120,7 @@ export function PartyOverviewPage() {
|
||||
</div>
|
||||
)}
|
||||
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
|
||||
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.13.1",
|
||||
"version": "1.14.0",
|
||||
"private": true,
|
||||
"description": "D&D 5e campaign manager — monorepo root",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user