Compare commits

...
6 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 61221f262c V1.14.1: choose where a new encounter goes
- A new encounter, battle or quest, goes to the bottom of the
  campaign's encounter list. It used to go to the top.
- A menu beside the name when adding one says where it goes instead:
  at the bottom, at the top, or straight after any encounter on the
  list. Top or bottom is remembered per browser.
- All packages at version 1.14.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 11:50:41 +02:00
joshuaandClaude Opus 5.5 e9f8169286 V1.14.0: spell slots, rests, and a level-up checklist
- Spell slots. Every class that casts has a slot tracker, worked out
  from class and level: the full-caster table for bards, clerics,
  druids, sorcerers and wizards, the half-caster table for paladins and
  rangers (from level 2), and a warlock's few slots of one level. It is
  in the Spellcasting card on the sheet and in the character panel, and
  the party overview has a column for it. A player presses Use to spend
  a slot; only the DM gives one back.
- Rests. The DM's "Rest…" button, beside the character list and on the
  party overview, rests any of the party at once. A short rest shows
  which Hit Dice each character can roll and heals from what they
  rolled (typed in, or rolled by the site), and gives a warlock their
  slots. A long rest puts back HP, every spell slot and half the level
  in Hit Dice, and clears temp HP.
- Hit Dice are counted: one per level, shown on the sheet, the panel
  and the party overview.
- Level Up. "Level Up…" on a sheet takes a character up one level with
  the whole checklist: the new max HP and a full heal, the class's new
  special abilities for levels 2 to 10, a Boon where the level grants
  one (Toughened raises Extra AC, Hardy gives temp HP now and after
  each long rest), fresh spell slots and new spells, and the free
  upgrade of one standard weapon.
- A level typed into the Level field still writes down what the class
  gains on the way up, and leaves a line where a Boon is owed.
- The docs cover all of it, with a generated list of what each class
  gains at each level. The Hollowcrown seed's note no longer says to
  track spell slots on paper.
- New migration: spent spell slots and Hit Dice on a character.
- All packages at version 1.14.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:47:56 +02:00
joshuaandClaude Opus 5.5 3a867fd1a1 V1.13.1: add another, straight from the one just added
- A weapon, item, spell, monster or NPC added by hand opens its own
  pop-out, as before. That pop-out now has "+ Add another weapon" (or
  item, spell, monster, NPC) at its top, which goes straight back to an
  empty form: no closing it and pressing "+ Add" again.
- All packages at version 1.13.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:38:53 +02:00
joshuaandClaude Opus 5.5 37c561c5d3 V1.13.0: a campaign page in parts, pop-outs, and a search
- The campaign page shows one part at a time, picked from a row of
  buttons under its name: Overview (characters and encounters), NPCs,
  Locations, Weapons, Items, Spells, Monsters, Notes and Settings for
  the DM; Overview, Places and Notes for a player. Each button counts
  what is in its part. On a phone the row scrolls sideways. The part
  showing is in the address (?view=weapons), so the back button steps
  through the parts.
- NPCs, weapons, items, spells and monsters are lists of short rows.
  Clicking a row opens a pop-out with everything about it and
  everything that can be changed. Adding one, by hand or from the
  library, is a pop-out too: a window on a wide screen, the whole
  screen on a phone.
- "Search this campaign" looks through everything added to the
  campaign at once: characters, encounters, locations and notes, and
  for the DM also NPCs, weapons, items, spells and monsters. Each of
  the DM's lists has a search box of its own as well.
- With three pages or more, the page number of a paged list is a menu
  to jump straight to any page.
- The invite code, the party and archive or delete are under Settings.
- A weapon's properties and range, and a spell's casting time, range
  and duration, are shown in its pop-out.
- All packages at version 1.13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:26:11 +02:00
joshuaandClaude Opus 5.5 7b3d121151 V1.12.3: live updates behind any proxy, fewer requests
- Live updates start on plain requests and move up to a websocket when
  one gets through. The site used to ask for the websocket first, with
  no way back: behind a proxy that does not pass the upgrade, nothing
  live arrived at all.
- A browser asks the API's leave before a request, the site and the API
  being on different hosts. It now remembers the answer for two hours,
  where it used to ask before nearly every request.
- A built file is kept by the browser for good: its name changes when
  its contents do. Each used to be checked again on every page load.
- For a real websocket through nginx, the blocks that pass requests to
  the API need "proxy_http_version 1.1", "proxy_set_header Upgrade
  $http_upgrade" and "proxy_set_header Connection "upgrade"".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:04:45 +02:00
joshuaandClaude Opus 5.5 f04e3497f4 V1.12.2: fewer reloads, smaller downloads
- An action in an encounter no longer loads the encounter again: the
  answer to the action already holds it. Live updates that arrive
  together are answered with one reload, of the encounter or character
  that changed, where each used to reload every one on screen.
- Everything the site sends is compressed. Nothing in front of it did
  that (frp only tunnels), so the API and the built site now do it
  themselves: the main script is 100 kB on the wire, down from 330 kB.
- Each page is its own download, fetched the first time it is opened,
  so signing in does not wait on the encounter tracker or the builder.
- The library is fetched when one of its pickers is opened, where the
  campaign page used to fetch it on every visit by the DM.
- The database has indexes on the columns every list is looked up by,
  and checking that someone is in a campaign takes one query, not two.
- On a server: "npm install", then "npm run deploy", then build the
  frontend again and restart both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:33:39 +02:00
54 changed files with 5674 additions and 1566 deletions
+37 -6
View File
@@ -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 |
@@ -40,9 +40,40 @@ including a character sheet or an encounter) and **Account**.
- The **🧙 character button** is on every page (floating at the bottom right
on a wide screen). It opens a panel with your character's numbers and
everything they hold; the DM can open it for any character.
- The campaign page and the character sheet are long, so each starts with a
row of **section links** (Characters, Encounters, NPCs, Monsters...) that
jump straight to that part of the page.
- The **campaign page shows one part at a time**, picked from a row of
buttons under its name: Overview (characters and encounters), NPCs,
Locations, Weapons, Items, Spells, Monsters, Notes and Settings for the DM;
Overview, Places and Notes for a player. Each button shows how many things
are in that part. On a phone the row is one line that scrolls sideways. The
part showing is in the address (`?view=weapons`), so it can be bookmarked
and the back button steps through the parts.
- **Search this campaign**, the box above that row, looks through everything
that has been added to the campaign at once: characters, encounters,
locations and notes, and for the DM also NPCs, weapons, items, spells and
monsters. It matches names and descriptions, every word typed, in any
order. A result opens that thing. Each of the DM's lists also has a search
box of its own.
- The DM's lists (NPCs, weapons, items, spells, monsters) are short rows: a
name and a few words. **Clicking a row opens a pop-out** with everything
about it and everything that can be changed. Adding one, by hand or from the
library, is a pop-out too. One added by hand opens its own pop-out, with an
**"+ Add another"** button at the top that goes straight back to an empty
form. A pop-out is a window over the page on a wide
screen and the whole screen on a phone; ✕, Escape or a click outside it
closes it.
- Every paged list has Prev and Next, and with three pages or more the page
number is a menu to **jump straight to any page**.
- The DM's **🛏 Rest…** button, beside "Characters" on the campaign page and
on the party overview, rests any of the party at once: a short rest says
which Hit Dice each character can roll and heals from what they rolled; a
long rest puts back HP, spell slots and Hit Dice.
- A caster's **spell slots** are on their sheet and in the character panel:
the player presses Use when they cast, and only the DM gives a slot back.
- **⬆ 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
overview) is shown as a stack of cards, one per row, with each figure
labelled, and rows of controls wrap rather than run off the side.
@@ -143,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
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.12.1",
"version": "1.14.1",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -20,6 +20,7 @@
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"bcrypt": "^6.0.0",
"compression": "^1.8.2",
"cors": "^2.8.5",
"dotenv": "^18.0.6",
"express": "^5.2.1",
@@ -0,0 +1,20 @@
-- CreateIndex: the columns every list is looked up by. Postgres does not
-- index a foreign key on its own.
CREATE INDEX "locations_campaignId_idx" ON "locations"("campaignId");
CREATE INDEX "campaign_members_userId_idx" ON "campaign_members"("userId");
CREATE INDEX "characters_campaignId_idx" ON "characters"("campaignId");
CREATE INDEX "characters_userId_idx" ON "characters"("userId");
CREATE INDEX "weapons_campaignId_idx" ON "weapons"("campaignId");
CREATE INDEX "items_campaignId_idx" ON "items"("campaignId");
CREATE INDEX "spells_campaignId_idx" ON "spells"("campaignId");
CREATE INDEX "monsters_campaignId_idx" ON "monsters"("campaignId");
CREATE INDEX "npcs_campaignId_idx" ON "npcs"("campaignId");
CREATE INDEX "encounters_campaignId_idx" ON "encounters"("campaignId");
CREATE INDEX "encounter_rewards_encounterId_idx" ON "encounter_rewards"("encounterId");
CREATE INDEX "sale_offers_campaignId_idx" ON "sale_offers"("campaignId");
CREATE INDEX "sale_offers_npcId_idx" ON "sale_offers"("npcId");
CREATE INDEX "sale_offers_encounterId_idx" ON "sale_offers"("encounterId");
CREATE INDEX "encounter_participants_encounterId_idx" ON "encounter_participants"("encounterId");
CREATE INDEX "encounter_participants_characterId_idx" ON "encounter_participants"("characterId");
CREATE INDEX "notes_campaignId_idx" ON "notes"("campaignId");
CREATE INDEX "notes_characterId_idx" ON "notes"("characterId");
@@ -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;
+24
View File
@@ -213,6 +213,7 @@ model Location {
encounters Encounter[]
visits CharacterLocationVisit[]
@@index([campaignId])
@@map("locations")
}
@@ -243,6 +244,7 @@ model CampaignMember {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([campaignId, userId])
@@index([userId])
@@map("campaign_members")
}
@@ -281,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?
@@ -323,6 +331,8 @@ model Character {
encounterEntries EncounterParticipant[]
locationVisits CharacterLocationVisit[]
@@index([campaignId])
@@index([userId])
@@map("characters")
}
@@ -386,6 +396,7 @@ model Weapon {
rewards EncounterReward[]
offers SaleOffer[]
@@index([campaignId])
@@map("weapons")
}
@@ -425,6 +436,7 @@ model Item {
rewards EncounterReward[]
offers SaleOffer[]
@@index([campaignId])
@@map("items")
}
@@ -486,6 +498,7 @@ model Spell {
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
known CharacterSpell[]
@@index([campaignId])
@@map("spells")
}
@@ -528,6 +541,7 @@ model Monster {
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
encounterAppearances EncounterParticipant[]
@@index([campaignId])
@@map("monsters")
}
@@ -555,6 +569,7 @@ model Npc {
encounterAppearances EncounterParticipant[]
wares SaleOffer[]
@@index([campaignId])
@@map("npcs")
}
@@ -590,6 +605,7 @@ model Encounter {
rewards EncounterReward[]
wares SaleOffer[]
@@index([campaignId])
@@map("encounters")
}
@@ -615,6 +631,7 @@ model EncounterReward {
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@index([encounterId])
@@map("encounter_rewards")
}
@@ -642,6 +659,9 @@ model SaleOffer {
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@index([campaignId])
@@index([npcId])
@@index([encounterId])
@@map("sale_offers")
}
@@ -672,6 +692,8 @@ model EncounterParticipant {
monster Monster? @relation(fields: [monsterId], references: [id], onDelete: SetNull)
npc Npc? @relation(fields: [npcId], references: [id], onDelete: SetNull)
@@index([encounterId])
@@index([characterId])
@@map("encounter_participants")
}
@@ -692,5 +714,7 @@ model Note {
character Character? @relation(fields: [characterId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id])
@@index([campaignId])
@@index([characterId])
@@map("notes")
}
+23 -1
View File
@@ -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)) {
+1 -1
View File
@@ -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.",
+8 -1
View File
@@ -1,5 +1,6 @@
const express = require('express');
const cors = require('cors');
const compression = require('compression');
const authRoutes = require('./routes/auth.routes');
const campaignsRoutes = require('./routes/campaigns.routes');
@@ -10,7 +11,13 @@ const notesRoutes = require('./routes/notes.routes');
function createApp() {
const app = express();
app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173' }));
// The site and the API are on different hosts, so a browser asks leave
// before each kind of request. maxAge lets it remember the answer (browsers
// cap it at two hours) and not ask again every time.
app.use(cors({ origin: process.env.CORS_ORIGIN || 'http://localhost:5173', maxAge: 7200 }));
// Nothing in front of the API compresses for it (frp only tunnels), so the
// JSON is gzipped here.
app.use(compression());
app.use(express.json());
app.get('/health', (req, res) => res.json({ ok: true }));
+66 -1
View File
@@ -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,
+288
View File
@@ -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 };
+92
View File
@@ -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 };
+144
View File
@@ -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 };
+130
View File
@@ -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']);
});
});
+78
View File
@@ -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();
});
});
+12 -5
View File
@@ -12,11 +12,18 @@ function requireCampaignMember() {
return async (req, res, next) => {
const campaignId = req.params.campaignId || req.params.id;
try {
const campaign = await prisma.campaign.findUnique({ where: { id: campaignId } });
if (!campaign) return res.status(404).json({ error: 'Campaign not found' });
const membership = await loadCampaignMembership(req.user.id, campaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
// One query for a member, which is nearly every request. Only when it
// finds nothing is the campaign looked up, to tell a 404 from a 403.
const found = await prisma.campaignMember.findUnique({
where: { campaignId_userId: { campaignId, userId: req.user.id } },
include: { campaign: true },
});
if (!found) {
const exists = await prisma.campaign.findUnique({ where: { id: campaignId }, select: { id: true } });
if (!exists) return res.status(404).json({ error: 'Campaign not found' });
return res.status(403).json({ error: 'Not a member of this campaign' });
}
const { campaign, ...membership } = found;
req.campaign = campaign;
req.membership = membership;
+278 -3
View File
@@ -1442,6 +1442,7 @@ describe('default content library', () => {
expect((await request(app).get(`/campaigns/${campaign.id}/library`).set(playerAuth)).status).toBe(403);
const listRes = await request(app).get(`/campaigns/${campaign.id}/library`).set(dmAuth);
expect(listRes.status).toBe(200);
expect(listRes.headers['content-encoding']).toBe('gzip'); // a big answer goes out compressed
expect(listRes.body.weapons.length).toBeGreaterThan(20);
expect(listRes.body.spells.length).toBeGreaterThan(50);
expect(listRes.body.monsters.length).toBeGreaterThan(20);
@@ -1717,13 +1718,31 @@ describe('ordering, archiving and deleting encounters', () => {
}
const names = async (auth) => (await request(app).get(`${c}/encounters`).set(auth)).body.encounters.map((e) => e.name);
// New encounters go to the top.
expect(await names(dmAuth)).toEqual(['Three', 'Two', 'One']);
// New encounters go to the bottom.
expect(await names(dmAuth)).toEqual(['One', 'Two', 'Three']);
const order = (encounterIds, auth = dmAuth) => request(app).put(`${c}/encounters/order`).set(auth).send({ encounterIds });
expect((await order([ids.One, ids.Two, ids.Three], playerAuth)).status).toBe(403);
expect((await order([ids.Three, ids.Two, ids.One], playerAuth)).status).toBe(403);
expect((await order([ids.One, ids.One])).status).toBe(400);
expect((await order([ids.One, 'not-an-encounter'])).status).toBe(400);
expect((await order([ids.Three, ids.One, ids.Two])).status).toBe(204);
expect(await names(playerAuth)).toEqual(['Three', 'One', 'Two']);
// A new one, of either kind, still lands at the bottom of a rearranged list,
// unless the DM says where: the top, or straight after another.
const add = async (body) => (await request(app).post(`${c}/encounters`).set(dmAuth).send(body)).body.encounter.id;
const extra = [await add({ name: 'Four', mode: 'QUEST' })];
expect(await names(dmAuth)).toEqual(['Three', 'One', 'Two', 'Four']);
extra.push(await add({ name: 'First', position: 'TOP' }));
expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'Two', 'Four']);
extra.push(await add({ name: 'After One', mode: 'QUEST', afterEncounterId: ids.One }));
expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'After One', 'Two', 'Four']);
extra.push(await add({ name: 'Last', position: 'BOTTOM' }));
extra.push(await add({ name: 'After Last', position: 'TOP', afterEncounterId: extra[3] }));
expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'After One', 'Two', 'Four', 'Last', 'After Last']);
expect((await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Lost', afterEncounterId: 'not-an-encounter' })).status).toBe(400);
expect((await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Lost', position: 'MIDDLE' })).status).toBe(400);
for (const id of extra) await request(app).delete(`/encounters/${id}`).set(dmAuth);
expect((await order([ids.One, ids.Two, ids.Three])).status).toBe(204);
expect(await names(playerAuth)).toEqual(['One', 'Two', 'Three']);
@@ -2812,3 +2831,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);
});
});
+116 -10
View File
@@ -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)
// ---------------------------------------------------------------------------
@@ -630,6 +706,9 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(),
// Encounters
// ---------------------------------------------------------------------------
// The campaign's own order, which the DM arranges.
const ENCOUNTER_ORDER = [{ sortOrder: 'asc' }, { createdAt: 'asc' }];
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
try {
const isDM = req.membership.role === 'DM';
@@ -638,7 +717,7 @@ router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) =>
const mine = { archivedAt: null, participants: { some: { character: { userId: req.user.id } } } };
const encounters = await prisma.encounter.findMany({
where: { campaignId: req.campaign.id, ...(isDM ? {} : mine) },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
orderBy: ENCOUNTER_ORDER,
});
// dmNotes never leaves the server for a player.
res.json({ encounters: isDM ? encounters : encounters.map(({ dmNotes, ...encounter }) => encounter) });
@@ -652,21 +731,48 @@ const createEncounterSchema = z.object({
mode: z.enum(['BATTLE', 'QUEST']).optional(),
description: z.string().max(4000).optional().nullable(),
locationId: z.string().optional().nullable(),
// Where it goes in the campaign's order: the top, the bottom, or straight
// after another encounter (which wins over `position`). The bottom if
// neither is given.
position: z.enum(['TOP', 'BOTTOM']).optional(),
afterEncounterId: z.string().optional().nullable(),
});
router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = createEncounterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (parsed.data.locationId) {
const location = await prisma.location.findFirst({ where: { id: parsed.data.locationId, campaignId: req.campaign.id } });
const { position, afterEncounterId, ...fields } = parsed.data;
if (fields.locationId) {
const location = await prisma.location.findFirst({ where: { id: fields.locationId, campaignId: req.campaign.id } });
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
}
// A new encounter goes to the top of the campaign's order.
const first = await prisma.encounter.aggregate({ where: { campaignId: req.campaign.id }, _min: { sortOrder: true } });
const encounter = await prisma.encounter.create({
data: { ...parsed.data, campaignId: req.campaign.id, sortOrder: (first._min.sortOrder ?? 1) - 1 },
const order = await prisma.encounter.findMany({
where: { campaignId: req.campaign.id },
orderBy: ENCOUNTER_ORDER,
select: { id: true, sortOrder: true },
});
let index = position === 'TOP' ? 0 : order.length;
if (afterEncounterId) {
const at = order.findIndex((e) => e.id === afterEncounterId);
if (at === -1) return res.status(400).json({ error: 'The encounter to put this one after is not part of this campaign.' });
index = at + 1;
}
const encounter = await prisma.$transaction(async (tx) => {
// At the bottom nothing else moves. Anywhere else, the whole order is
// numbered afresh around the newcomer.
if (index === order.length) {
const last = order.length > 0 ? order[order.length - 1].sortOrder : -1;
return tx.encounter.create({ data: { ...fields, campaignId: req.campaign.id, sortOrder: last + 1 } });
}
const created = await tx.encounter.create({ data: { ...fields, campaignId: req.campaign.id, sortOrder: index } });
for (const [i, existing] of order.entries()) {
const sortOrder = i < index ? i : i + 1;
if (existing.sortOrder !== sortOrder) await tx.encounter.update({ where: { id: existing.id }, data: { sortOrder } });
}
return created;
});
res.status(201).json({ encounter });
} catch (err) {
+196 -5
View File
@@ -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,
+84
View File
@@ -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 };
+109
View File
@@ -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.']);
});
});
+333 -20
View File
@@ -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
@@ -421,7 +535,9 @@ calculations but is only ever set if the DM supplies it during step 5.
## 10. Gear pools, starting options and gear levels
Each campaign has three **pools**, managed by the DM on the campaign page:
Each campaign has three **pools**, managed by the DM on the campaign page,
each in a part of its own ("Weapons", "Items", "Spells") where every entry
opens in a pop-out:
| Pool | What an entry holds | How entries are added |
|---|---|---|
@@ -562,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
@@ -666,11 +783,11 @@ character in the same campaign.
| Screen | What it shows |
|---|---|
| Campaign page | 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". |
| 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, 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
@@ -778,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 |
@@ -809,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
@@ -1068,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 -->
+11 -7
View File
@@ -77,18 +77,22 @@ characters are at that location: beginning it, changing its location, or
adding a character to it moves them there. Each place a character is moved to
(this way or by the DM on the sheet) is remembered, and a player can see only
the locations one of their own characters has been to — on the campaign page
under "Places You've Been", and on the encounter itself. Archiving or deleting
under "Places", and on the encounter itself. Archiving or deleting
the encounter does not take a visited place away again.
### Order, archive and delete
**Order.** The campaign page lists encounters in the **campaign order**, which
the DM arranges with the ▲ and ▼ buttons and everyone sees. A new encounter
goes to the top. The "Sort by" menu re-sorts the list by name, status, type or
**Order.** The campaign page lists encounters on its "Overview", under the
characters, in the **campaign order**, which
the DM arranges with the ▲ and ▼ buttons and everyone sees. A new encounter,
battle or quest, goes to the **bottom** unless the DM picks otherwise in the
menu beside the name when adding it: at the bottom, at the top, or straight
after any encounter on the list. Top or bottom is remembered per browser. The "Sort by" menu re-sorts the list by name, status, type or
date instead; that choice is remembered per browser and changes nothing for
anyone else. ▲ and ▼ only show while sorting by campaign order.
The list shows **ten encounters a page**, with Prev and Next beneath it, and
The list shows **ten encounters a page**, with Prev and Next beneath it (and,
with three pages or more, a menu to jump to any page), and
the DM's archived list is paged the same way. The order runs on across pages:
moving the last encounter on a page down carries it to the top of the next.
@@ -410,8 +414,8 @@ when there is something to trade, and always for the DM in a quest.
holds at the level it is sold at, and uses up stock. A character below that
level holds it but cannot use it yet.
- Every trade is written to the character's money log.
- **An NPC's rate and wares are set on the campaign page**, under "Trade" on
the NPC's row, and follow the NPC into every quest they are in. **An
- **An NPC's rate and wares are set on the campaign page**, in the NPC's
pop-out, and follow the NPC into every quest they are in. **An
encounter's own wares are set here**, in the DM's part of the card, and
exist only in this encounter: a market stall, a vending machine, a chest
with a price tag.
+18 -11
View File
@@ -36,7 +36,11 @@ level up the same way weapons do), [SPELLS.md](SPELLS.md),
## 2. The item pool
The pool is the "Items Pool" card on the campaign page (DM only).
The pool is the "Items" part of the campaign page (DM only). It lists each
item as a short row: its name, then what it does, its dice and its
description, as far as they fit on a line. The list has a search box, shows
ten to a page, and clicking a row opens the item's **pop-out**, which shows
everything below and is where it is changed.
| Field | What it is |
|---|---|
@@ -48,21 +52,24 @@ The pool is the "Items Pool" card on the campaign page (DM only).
| Starting option | Whether a brand-new character may pick it in the builder (section 6) |
| Effects | What it does to whoever has it equipped: bonuses and penalties to the sheet's numbers, and a note (section 5) |
- **Adding:** by hand with the "+ Add Item" form, or with "📚 From Library",
which offers 32 ready-made items: light, medium and heavy armor, shields,
- **Adding:** by hand with "+ Add Item", or with "📚 From Library". Both open
in a pop-out; an item added by hand opens its own pop-out straight away, to
set what it does, and "+ Add another item" at its top goes straight back
to an empty form. The library offers 32 ready-made items: light, medium and heavy armor, shields,
worn gear such as a Ring of Protection, and common supplies. Each is copied
into the pool with its effects already set (Chain Mail: +6 AC, −10 Speed,
and a note about Stealth), at starting level 1 and not ticked as a starting
option. The copy is yours to change. The full list is in section 10. Each
can be added once: an item the pool already has by that name is left out of
the library list, and comes back if the pool's item is renamed or deleted.
- **Effects:** the "Effects" button on an item's row. The row shows what the
item does under its name.
- **Effects:** under "While equipped" in the item's pop-out, saved with "Save
Effects". The item's row shows what it does under its name.
- **Changing:** starting level, growth (when the item has dice) and the
starting-option tick box can be changed in the table. Name, description and
dice cannot be changed on screen once created (the API accepts it); remove
and re-add instead.
- **Removing:** the ✕ button. This takes the item out of **every inventory**
starting-option tick box are changed in the pop-out, and saved as they are
changed. Name, description and dice cannot be changed on screen once
created (the API accepts it); remove and re-add instead.
- **Removing:** "Remove from campaign" at the foot of the pop-out, after a
confirmation. This takes the item out of **every inventory**
and off every encounter's reward list. There is no undo.
## 3. A character's inventory
@@ -188,8 +195,8 @@ plus a **note** for what is not a number ("Disadvantage on Stealth",
card lists every item in force with what it is doing, and its note.
- **Max HP can move.** If taking something off lowers a character's maximum
HP below their current HP, current HP comes down to the new maximum.
- **Only the DM sets effects**, with the "Effects" button on the item's row in
the pool. Changing them changes the sheet of everyone wearing the item,
- **Only the DM sets effects**, under "While equipped" in the item's pop-out
on the campaign page. Changing them changes the sheet of everyone wearing the item,
straight away.
- The DM also has an **Extra AC** number on each sheet, added on top of gear,
for anything an item does not cover. It can be negative.
+16 -7
View File
@@ -50,22 +50,31 @@ from its scores for display only.
## 3. Managing monsters
The list is the "Monsters" card on the campaign page (DM only).
The list is the "Monsters" part of the campaign page (DM only). Each monster
is a short row: its name, challenge rating, AC, HP and attack. The list has a
search box, shows ten to a page, and clicking a row opens the monster's
**pop-out** with its whole stat block.
- **Adding by hand:** "+ Add Monster" opens the whole stat block: name,
- **Adding by hand:** "+ Add Monster" opens a pop-out with the whole stat
block: name,
challenge rating, AC, HP, speed, attack bonus, damage, the six ability
scores, special trait and notes. Only name, AC and HP must be typed; speed
starts at 30, attack bonus at 0 and each ability score at 10.
- **Adding from the bestiary:** "📚 From Library" copies a complete stat block.
- Once added by hand, the monster's own pop-out opens. "+ Add another
monster" at its top goes straight back to an empty form.
- **Adding from the bestiary:** "📚 From Library" opens the bestiary in a
pop-out, and "+ Add" copies a complete stat block.
Each monster can be added once: one the campaign already has by that name is
left out of the list, and comes back if the campaign's monster is renamed or
deleted.
- **Changing:** "Edit" on a monster's row opens the same form filled in.
- **Changing:** "Edit stat block" in a monster's pop-out opens the same form
filled in.
Saving changes the template, and copies already in encounters show the new
stat block straight away; their current HP is left alone.
- The list shows each monster's full stat block: the numbers in columns, and
its ability scores, trait and notes under its name.
- **Removing:** the ✕ button, after a confirmation. Copies
- The pop-out shows the full stat block: the numbers, the six ability scores,
the trait and the notes.
- **Removing:** "Remove from campaign" at the foot of the pop-out, after a
confirmation. Copies
already in encounters stay there with their name and HP, but lose their
stat block.
+16 -9
View File
@@ -52,18 +52,24 @@ A new NPC is Neutral unless you choose otherwise.
## 3. Managing NPCs
The NPC list is the "NPCs" card on the campaign page (DM only).
The NPC list is the "NPCs" part of the campaign page (DM only). Each NPC is a
short row: name, role, the start of their description, and badges for their
attitude, a 💱 with their exchange rate and a 🛒 with how many things they
have for sale. The list has a search box and shows ten to a page.
- **Adding:** "+ Add NPC" opens a form with every field above except the
exchange rate and wares. Only the name is required.
- **Trade:** the "Trade" button on an NPC's row opens their exchange rate and
their wares (section 5). The row shows a 💱 badge with the rate and a 🛒
badge with how many things are for sale.
- **The pop-out:** clicking a row opens the NPC's pop-out, with everything
above: description, DM notes, combat stats, and their trade.
- **Adding:** "+ Add NPC" opens a pop-out with every field above except the
exchange rate and wares. Only the name is required. Once added, the NPC's
own pop-out opens, to set their trade; "+ Add another NPC" at its top goes
straight back to an empty form.
- **Trade:** the exchange rate and the wares are set in the NPC's pop-out,
under "Changes money" and "For sale" (section 5).
- **Changing:** there is no edit form for the other fields. To change them,
remove the NPC and add it again (the API accepts changes, but no screen
offers them). Removing an NPC removes their wares too.
- **Removing:** the ✕ button, after a confirmation. This also **removes the
NPC from every encounter** it was in.
- **Removing:** "Remove from campaign" at the foot of the pop-out, after a
confirmation. This also **removes the NPC from every encounter** it was in.
## 4. NPCs in encounters
@@ -113,7 +119,8 @@ another.
- Both sides go on the character's money log, naming the NPC: "Changed with
Vex for $45" and "Changed with Vex from 6 gold".
- An NPC never runs out of gold or cash to change.
- To set a rate, type both numbers under "Trade" and press "Save rate".
- To set a rate, type both numbers under "Changes money" in the NPC's pop-out
and press "Save rate".
"Stop changing money" clears it. An NPC with no rate does not change money.
### Selling
+104 -17
View File
@@ -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)
---
@@ -36,7 +37,10 @@ This describes the code as it stands. The built-in list is
## 2. The spell list
The list is the "Spell List" card on the campaign page (DM only).
The list is the "Spells" part of the campaign page (DM only). Each spell is a
short row: its name, level, damage and the start of what it does. The list
has a search box, shows ten to a page, and clicking a row opens the spell's
**pop-out**, which shows everything below and is where it is changed.
| Field | What it is |
|---|---|
@@ -45,17 +49,19 @@ The list is the "Spell List" card on the campaign page (DM only).
| 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 but not shown on any screen |
| 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 |
A cantrip is a spell of level 0. The app always calls it a **cantrip**, never
"level 0" or "level 1".
- **Adding:** by hand with the "+ Add Spell" form (name, level, school,
damage, what it does and the two tick boxes), or from the library.
- **Changing:** damage (the drop-down in the table) and the Revives and
starting-option tick boxes. Name, level, school and description cannot be changed on screen
- **Adding:** by hand with "+ Add Spell" (name, level, school, damage, what
it does and the two tick boxes), or from the library. Both open in a
pop-out. A spell added by hand opens its own pop-out, and "+ Add another
spell" at its top goes straight back to an empty form.
- **Changing:** in the spell's pop-out: damage (the drop-down) and the Revives
and starting-option tick boxes, saved as they are changed. Name, level, school and description cannot be changed on screen
once created (the API accepts it); remove and re-add instead.
- **Damage choices:** the drop-down offers "No damage", then 1 to 6, 8 or 10
dice of d4, d6, d8, d10 or d12 (1d4 up to 10d12), plus the three unusual
@@ -84,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:
@@ -177,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:
@@ -188,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 |
|---|---|---|---|
@@ -198,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
@@ -211,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.
+20 -10
View File
@@ -37,7 +37,11 @@ This describes the code as it stands. The formulas live in
## 2. The weapon pool
The pool is the "Weapons Pool" card on the campaign page (DM only).
The pool is the "Weapons" part of the campaign page (DM only). It lists each
weapon as a short row: its name, base damage, starting level and what it does
while equipped. The list has a search box, shows ten to a page, and clicking a
row opens the weapon's **pop-out**, which shows everything below and is where
it is changed.
| Field | What it is |
|---|---|
@@ -48,17 +52,23 @@ The pool is the "Weapons Pool" card on the campaign page (DM only).
| Starts at level | 1 to 9. The lowest level this weapon can be (section 5) |
| Growth | Standard, Powerful or Legendary: how fast the dice climb per level (section 6) |
| Starting option | Whether a brand-new character may pick it in the builder (section 8) |
| Properties, range | Stored for library weapons (light, thrown, 20/60 ft…) but not shown or used anywhere |
| Properties, range | Stored for library weapons (light, thrown, 20/60 ft…). Shown in the weapon's pop-out, and not used anywhere |
| Effects | What it does to whoever has it equipped: bonuses and penalties to the sheet's numbers, and a note. Works exactly as for items ([ITEMS.md](ITEMS.md), section 5) |
- **Adding:** by hand with the "+ Add Weapon" form, or from the library.
- **Effects:** the "Effects" button on a weapon's row. A defending blade can
give +1 AC, a heavy maul −5 speed. They count only while the weapon is
equipped, and do not change its own attack or damage.
- **Changing:** starting level, growth and the starting-option tick box can be
changed in the table. Name, damage, type and ability cannot be changed on
screen once created (the API accepts it); remove and re-add instead.
- **Removing:** the ✕ button. This takes the weapon away from **every
- **Adding:** by hand with "+ Add Weapon", or from the library. Both open in
a pop-out. A weapon added by hand opens its own pop-out straight away, to
set what it does; "+ Add another weapon" at its top goes straight back to
an empty form.
- **Effects:** under "While equipped" in the weapon's pop-out, saved with
"Save Effects". A defending blade can give +1 AC, a heavy maul −5 speed.
They count only while the weapon is equipped, and do not change its own
attack or damage.
- **Changing:** starting level, growth and the starting-option tick box are
changed in the pop-out, and saved as they are changed. Name, damage, type
and ability cannot be changed on screen once created (the API accepts it);
remove and re-add instead.
- **Removing:** "Remove from campaign" at the foot of the pop-out, after a
confirmation. This takes the weapon away from **every
character holding it** and off every encounter's reward list. There is no
undo.
- The same weapon can be added twice; nothing stops duplicate names.
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.12.1",
"version": "1.14.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -18,10 +18,12 @@
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@types/compression": "^1.8.1",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"compression": "^1.8.2",
"oxlint": "^1.81.0",
"typescript": "~7.0.2",
"vite": "^8.3.0"
+30 -24
View File
@@ -1,35 +1,41 @@
import { lazy, Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Layout } from './components/Layout';
import { ProtectedRoute } from './components/ProtectedRoute';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { CampaignListPage } from './pages/CampaignListPage';
import { CampaignHomePage } from './pages/CampaignHomePage';
import { PartyOverviewPage } from './pages/PartyOverviewPage';
import { CharacterSheetPage } from './pages/CharacterSheetPage';
import { CharacterBuilderPage } from './pages/CharacterBuilderPage';
import { EncounterPage } from './pages/EncounterPage';
import { AccountPage } from './pages/AccountPage';
// Each page is its own file to download, fetched the first time it is opened,
// so signing in does not wait on the encounter tracker or the character builder.
const RegisterPage = lazy(() => import('./pages/RegisterPage').then((m) => ({ default: m.RegisterPage })));
const CampaignListPage = lazy(() => import('./pages/CampaignListPage').then((m) => ({ default: m.CampaignListPage })));
const CampaignHomePage = lazy(() => import('./pages/CampaignHomePage').then((m) => ({ default: m.CampaignHomePage })));
const PartyOverviewPage = lazy(() => import('./pages/PartyOverviewPage').then((m) => ({ default: m.PartyOverviewPage })));
const CharacterSheetPage = lazy(() => import('./pages/CharacterSheetPage').then((m) => ({ default: m.CharacterSheetPage })));
const CharacterBuilderPage = lazy(() => import('./pages/CharacterBuilderPage').then((m) => ({ default: m.CharacterBuilderPage })));
const EncounterPage = lazy(() => import('./pages/EncounterPage').then((m) => ({ default: m.EncounterPage })));
const AccountPage = lazy(() => import('./pages/AccountPage').then((m) => ({ default: m.AccountPage })));
function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/campaigns" replace />} />
<Route path="/campaigns" element={<CampaignListPage />} />
<Route path="/campaigns/:campaignId" element={<CampaignHomePage />} />
<Route path="/campaigns/:campaignId/party" element={<PartyOverviewPage />} />
<Route path="/characters/:characterId" element={<CharacterSheetPage />} />
<Route path="/characters/:characterId/build" element={<CharacterBuilderPage />} />
<Route path="/encounters/:encounterId" element={<EncounterPage />} />
<Route path="/account" element={<AccountPage />} />
<Suspense fallback={null}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/campaigns" replace />} />
<Route path="/campaigns" element={<CampaignListPage />} />
<Route path="/campaigns/:campaignId" element={<CampaignHomePage />} />
<Route path="/campaigns/:campaignId/party" element={<PartyOverviewPage />} />
<Route path="/characters/:characterId" element={<CharacterSheetPage />} />
<Route path="/characters/:characterId/build" element={<CharacterBuilderPage />} />
<Route path="/encounters/:encounterId" element={<EncounterPage />} />
<Route path="/account" element={<AccountPage />} />
</Route>
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
);
}
+91 -22
View File
@@ -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({
@@ -676,7 +735,8 @@ export function useCampaignEncounters(campaignId: string | undefined) {
export function useCreateEncounter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; mode?: EncounterMode }) =>
// Where it goes in the campaign's order: the top, the bottom (the default), or straight after another.
mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string }) =>
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, data)).data.encounter,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }),
});
@@ -735,12 +795,21 @@ export function useEncounter(encounterId: string | undefined) {
});
}
// Every change to an encounter is answered with the encounter as this viewer
// sees it, so the cache takes that as it is: no second request for what just
// came back.
function keepEncounter(qc: QueryClient, encounterId: string, data: unknown) {
const encounter = (data as { encounter?: Encounter } | undefined)?.encounter;
if (encounter) qc.setQueryData(['encounter', encounterId], encounter);
else qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
}
export function useAddParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiativeRoll?: number }) =>
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
}
@@ -749,7 +818,7 @@ export function usePatchParticipant(encounterId: string) {
return useMutation({
mutationFn: async ({ participantId, ...data }: Partial<EncounterParticipant> & { participantId: string }) =>
(await apiClient.patch(`/encounters/${encounterId}/participants/${participantId}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
}
@@ -757,8 +826,8 @@ export function useRemoveParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (participantId: string) =>
apiClient.delete(`/encounters/${encounterId}/participants/${participantId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
(await apiClient.delete(`/encounters/${encounterId}/participants/${participantId}`)).data,
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
}
@@ -766,7 +835,7 @@ export function useAdvanceRound(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await apiClient.post(`/encounters/${encounterId}/advance-round`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
}
@@ -781,8 +850,8 @@ export function usePatchEncounter(encounterId: string) {
dmNotes?: string | null;
locationId?: string | null;
}) => (await apiClient.patch(`/encounters/${encounterId}`, data)).data,
onSuccess: (_data, variables) => {
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
onSuccess: (data, variables) => {
keepEncounter(qc, encounterId, data);
// The campaign page lists encounters by name, mode and status, and an
// encounter in progress carries its characters to its location.
if (variables.status || variables.mode || variables.name || variables.locationId !== undefined) {
@@ -800,7 +869,7 @@ export function useSetTargets(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (participantIds: string[]) => (await apiClient.put(`/encounters/${encounterId}/targets`, { participantIds })).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
}
@@ -810,8 +879,8 @@ export function useReviveParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (participantId: string) => (await apiClient.post(`/encounters/${encounterId}/participants/${participantId}/revive`)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
onSuccess: (data) => {
keepEncounter(qc, encounterId, data);
qc.invalidateQueries({ queryKey: ['character'] }); // the revived character's sheet
},
});
@@ -821,7 +890,7 @@ export function useNextTurn(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await apiClient.post(`/encounters/${encounterId}/next-turn`)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
}
@@ -838,15 +907,15 @@ export function useAddReward(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: NewReward) => (await apiClient.post(`/encounters/${encounterId}/rewards`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
}
export function useDeleteReward(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (rewardId: string) => apiClient.delete(`/encounters/${encounterId}/rewards/${rewardId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
mutationFn: async (rewardId: string) => (await apiClient.delete(`/encounters/${encounterId}/rewards/${rewardId}`)).data,
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
}
@@ -857,8 +926,8 @@ export function useAwardReward(encounterId: string) {
return useMutation({
mutationFn: async ({ rewardId, ...target }: { rewardId: string } & ({ characterId: string } | { split: true })) =>
(await apiClient.post(`/encounters/${encounterId}/rewards/${rewardId}/award`, target)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
onSuccess: (data) => {
keepEncounter(qc, encounterId, data);
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['campaign'] });
},
@@ -927,8 +996,8 @@ export function useDeleteWare(campaignId: string) {
}
// Both change what a character holds, so the sheet and the party list refresh too.
function invalidateAfterTrade(qc: QueryClient, encounterId: string) {
qc.invalidateQueries({ queryKey: ['encounter', encounterId] });
function invalidateAfterTrade(qc: QueryClient, encounterId: string, data: unknown) {
keepEncounter(qc, encounterId, data);
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['campaign'] });
}
@@ -939,7 +1008,7 @@ export function useExchangeWithNpc(encounterId: string) {
return useMutation({
mutationFn: async (data: { characterId: string; npcId: string; from: 'GOLD' | 'CASH'; amount: number }) =>
(await apiClient.post<{ received: number; currency: Currency }>(`/encounters/${encounterId}/exchange`, data)).data,
onSuccess: () => invalidateAfterTrade(qc, encounterId),
onSuccess: (data) => invalidateAfterTrade(qc, encounterId, data),
});
}
@@ -948,7 +1017,7 @@ export function useBuyWare(encounterId: string) {
return useMutation({
mutationFn: async ({ wareId, ...data }: { wareId: string; characterId: string; quantity?: number }) =>
(await apiClient.post(`/encounters/${encounterId}/wares/${wareId}/buy`, data)).data,
onSuccess: () => invalidateAfterTrade(qc, encounterId),
onSuccess: (data) => invalidateAfterTrade(qc, encounterId, data),
});
}
+84 -1
View File
@@ -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[];
}
+15 -2
View File
@@ -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>
</>
)}
+2
View File
@@ -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.',
+5 -1
View File
@@ -1,3 +1,4 @@
import { Suspense } from 'react';
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
import { useAuth } from '../auth/useAuth';
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
@@ -57,7 +58,10 @@ export function Layout() {
)}
</header>
<main className="page">
<Outlet />
{/* The bars stay put while a page that has not been opened before downloads. */}
<Suspense fallback={null}>
<Outlet />
</Suspense>
</main>
{user && (
<nav className="bottom-nav" aria-label="Main">
+188
View File
@@ -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>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
// A pop-out over the page: a centred window on a wide screen, the whole
// screen on a phone. Escape, the ✕ and a click outside all close it, and the
// page behind stays where it was.
export function Modal({
title,
subtitle,
wide,
onClose,
children,
}: {
title: ReactNode;
subtitle?: ReactNode;
wide?: boolean;
onClose: () => void;
children: ReactNode;
}) {
const titleId = useId();
const windowRef = useRef<HTMLDivElement>(null);
// A click only closes if it both started and ended outside the window, so
// dragging to select text in a field never does.
const pressedOutside = useRef(false);
const close = useRef(onClose);
useEffect(() => {
close.current = onClose;
});
useEffect(() => {
const opener = document.activeElement as HTMLElement | null;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close.current();
};
document.addEventListener('keydown', onKey);
const scroll = document.body.style.overflow;
document.body.style.overflow = 'hidden';
windowRef.current?.focus();
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = scroll;
opener?.focus?.();
};
}, []);
return createPortal(
<div
className="modal-backdrop"
onMouseDown={(e) => {
pressedOutside.current = e.target === e.currentTarget;
}}
onClick={(e) => {
if (pressedOutside.current && e.target === e.currentTarget) onClose();
}}
>
<div ref={windowRef} className={`modal${wide ? ' is-wide' : ''}`} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
<div className="modal-head">
<div style={{ minWidth: 0 }}>
<h2 id={titleId} className="modal-title">
{title}
</h2>
{subtitle && <div className="combatant-sub">{subtitle}</div>}
</div>
<button type="button" className="btn btn-ghost" aria-label="Close" onClick={onClose}>
✕
</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>,
document.body
);
}
+20 -3
View File
@@ -4,11 +4,14 @@ export function Pagination({
pageCount,
onPrev,
onNext,
onJump,
}: {
page: number;
pageCount: number;
onPrev: () => void;
onNext: () => void;
// Given this, the page number is a list to pick any page from.
onJump?: (page: number) => void;
}) {
if (pageCount <= 1) return null;
return (
@@ -16,9 +19,23 @@ export function Pagination({
<button type="button" className="btn btn-ghost" onClick={onPrev} disabled={page <= 1}>
← Prev
</button>
<span className="pagination-status">
Page {page} of {pageCount}
</span>
{onJump && pageCount > 2 ? (
<label className="pagination-status flex-row gap-sm">
Page
<select aria-label="Go to page" value={page} onChange={(e) => onJump(Number(e.target.value))}>
{Array.from({ length: pageCount }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
of {pageCount}
</label>
) : (
<span className="pagination-status">
Page {page} of {pageCount}
</span>
)}
<button type="button" className="btn btn-ghost" onClick={onNext} disabled={page >= pageCount}>
Next →
</button>
+234
View File
@@ -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>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useRef } from 'react';
export interface Section {
id: string;
label: string;
icon: string;
// How many things are in it, shown beside its name.
count?: number;
}
// The parts of a page that shows one part at a time. A row of buttons on a
// wide screen; on a phone a single line that scrolls sideways, with the
// current part kept in view.
export function SectionNav({ sections, current, onSelect }: { sections: Section[]; current: string; onSelect: (id: string) => void }) {
const currentRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
currentRef.current?.scrollIntoView({ block: 'nearest', inline: 'center' });
}, [current]);
return (
<nav className="section-nav" aria-label="Parts of this campaign">
{sections.map((section) => (
<button
key={section.id}
ref={section.id === current ? currentRef : undefined}
type="button"
className={`jump-link${section.id === current ? ' is-active' : ''}`}
aria-current={section.id === current ? 'page' : undefined}
onClick={() => onSelect(section.id)}
>
<span aria-hidden>{section.icon}</span> {section.label}
{section.count !== undefined && <span className="section-count">{section.count}</span>}
</button>
))}
</nav>
);
}
+90
View File
@@ -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>
);
}
@@ -0,0 +1,55 @@
import type { ReactNode } from 'react';
import { EntryRow } from './parts';
export interface SearchHit {
key: string;
title: string;
detail?: ReactNode;
badges?: ReactNode;
// Where it goes: a page of its own, or whatever `onPick` opens.
to?: string;
onPick?: () => void;
}
export interface SearchGroup {
label: string;
icon: string;
hits: SearchHit[];
}
// More than this of one kind and the rest are counted, not listed.
const SHOWN_PER_GROUP = 12;
// Everything in the campaign that a search found, a kind at a time.
export function CampaignSearch({ query, groups }: { query: string; groups: SearchGroup[] }) {
const found = groups.filter((g) => g.hits.length > 0);
const total = found.reduce((sum, g) => sum + g.hits.length, 0);
return (
<div className="card" aria-live="polite">
<div className="card-title">
<span>
{total} {total === 1 ? 'result' : 'results'} for "{query}"
</span>
</div>
{total === 0 && <p style={{ color: 'var(--text-muted)', margin: 0 }}>Nothing in this campaign matches. Try fewer words, or part of a name.</p>}
{found.map((group) => (
<div key={group.label} className="search-group">
<div className="section-label">
<span aria-hidden>{group.icon}</span> {group.label} ({group.hits.length})
</div>
<div className="entry-list">
{group.hits.slice(0, SHOWN_PER_GROUP).map((hit) => (
<EntryRow key={hit.key} name={hit.title} meta={hit.detail} badges={hit.badges} to={hit.to} onOpen={hit.onPick} />
))}
</div>
{group.hits.length > SHOWN_PER_GROUP && (
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>
…and {group.hits.length - SHOWN_PER_GROUP} more. Add a word to narrow it down.
</p>
)}
</div>
))}
</div>
);
}
@@ -0,0 +1,363 @@
import { useState, type ReactNode } from 'react';
import { useDeleteMonster, useDeletePoolEntry, usePatchPoolEntry, useUpdateMonster } from '../../api/queries';
import { Modal } from '../Modal';
import { NumberInput } from '../NumberInput';
import { ErrorBanner } from '../Spinner';
import { EffectsEditor } from '../EffectsEditor';
import { WaresEditor } from '../Wares';
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
import { abilityLine, rateLabel, type EntryKind, type EntryRef } from '../../lib/campaignEntries';
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, StartLevelSelect } from './parts';
import type { GearEffect, Item, Monster, Npc, Spell, Weapon } from '../../api/types';
const KIND_NOUNS: Record<EntryKind, string> = { weapon: 'weapon', item: 'item', spell: 'spell', monster: 'monster', npc: 'NPC' };
const ATTACK_ABILITY_LABELS: Record<Weapon['attackAbility'], string> = { STR: 'STR', DEX: 'DEX', FINESSE: 'Finesse (the better of STR and DEX)' };
function Chip({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="stat-chip">
<span>{label}</span>
<b>{children}</b>
</div>
);
}
// The foot of every pop-out: taking the thing out of the campaign.
function RemoveButton({ name, warning, pending, onRemove }: { name: string; warning: string; pending: boolean; onRemove: () => void }) {
return (
<div className="modal-foot">
<button className="btn btn-danger" disabled={pending} onClick={() => window.confirm(`Remove ${name}? ${warning}`) && onRemove()}>
Remove from campaign
</button>
</div>
);
}
// A weapon or an item: the two share levels, growth and effects.
function GearDetail({ campaignId, pool, entry, added, onClose }: { added: ReactNode; campaignId: string; pool: 'weapons' | 'items'; entry: Weapon | Item; onClose: () => void }) {
const patch = usePatchPoolEntry(campaignId, pool);
const remove = useDeletePoolEntry(campaignId, pool);
const [effects, setEffects] = useState<GearEffect[]>(entry.effects ?? []);
const [note, setNote] = useState(entry.effectNote ?? '');
const [effectsSaved, setEffectsSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const onError = (err: unknown) => setError(extractErrorMessage(err));
const weapon = 'damageType' in entry ? entry : null;
return (
<Modal title={entry.name} subtitle={weapon ? 'Weapon' : 'Item'} onClose={onClose}>
{added}
{error && <ErrorBanner message={error} />}
<div className="stat-chips">
{weapon ? (
<>
<Chip label="Base damage">
{weapon.damageDice} {weapon.damageType}
</Chip>
<Chip label="Attacks with">{ATTACK_ABILITY_LABELS[weapon.attackAbility]}</Chip>
{weapon.rangeNormal && (
<Chip label="Range">
{weapon.rangeNormal}
{weapon.rangeLong ? `/${weapon.rangeLong}` : ''} ft
</Chip>
)}
{weapon.properties.length > 0 && <Chip label="Properties">{weapon.properties.join(', ')}</Chip>}
</>
) : (
entry.damageDice && <Chip label="Base dice">{entry.damageDice}</Chip>
)}
</div>
{!weapon && 'description' in entry && entry.description && <p className="mt-sm" style={{ whiteSpace: 'pre-wrap' }}>{entry.description}</p>}
<div className="grid grid-cols-2 mt-md">
<Field label="Level it starts at">
{(id) => <StartLevelSelect id={id} value={entry.minLevel} onChange={(minLevel) => patch.mutate({ id: entry.id, minLevel }, { onError })} />}
</Field>
{entry.damageDice && (
<Field label="Growth per level">
{(id) => <ScalingSelect id={id} value={entry.damageScaling} onChange={(damageScaling) => patch.mutate({ id: entry.id, damageScaling }, { onError })} />}
</Field>
)}
</div>
<p className="combatant-sub mt-sm">
A character must be at least its level to use it.
{entry.damageDice &&
' The dice above are what it does at the level it starts at. When you level up a character\'s copy they grow one small step per level (1d4 → 1d6 → 1d8 → 1d10 → 1d12 → 2d6 → …): two steps for Powerful gear, three for Legendary.'}
</p>
<label className="checkbox-row mt-md">
<input type="checkbox" checked={entry.isStartingOption} onChange={(e) => patch.mutate({ id: entry.id, isStartingOption: e.target.checked }, { onError })} />
Starting option: a player may pick it while building a character
</label>
<div className="section-label mt-md">While equipped</div>
<p className="combatant-sub" style={{ marginTop: 0 }}>
These count for a character only while it is equipped and they are high enough level to use it. Armor is an item with
+AC; a penalty is a negative number.
</p>
<EffectsEditor
effects={effects}
note={note}
onChange={(nextEffects, nextNote) => {
setEffects(nextEffects);
setNote(nextNote);
setEffectsSaved(false);
}}
/>
<div className="flex-row mt-sm">
<button
className="btn btn-primary"
disabled={patch.isPending}
onClick={() => patch.mutate({ id: entry.id, effects, effectNote: note.trim() || null }, { onError, onSuccess: () => setEffectsSaved(true) })}
>
Save Effects
</button>
{effectsSaved && <span className="combatant-sub">Saved.</span>}
</div>
<RemoveButton name={entry.name} warning="Any character holding it loses it." pending={remove.isPending} onRemove={() => remove.mutate(entry.id, { onError, onSuccess: onClose })} />
</Modal>
);
}
function SpellDetail({ campaignId, spell, added, onClose }: { added: ReactNode; campaignId: string; spell: Spell; onClose: () => void }) {
const patch = usePatchPoolEntry(campaignId, 'spells');
const remove = useDeletePoolEntry(campaignId, 'spells');
const [error, setError] = useState<string | null>(null);
const onError = (err: unknown) => setError(extractErrorMessage(err));
return (
<Modal title={spell.name} subtitle={[spellLevelLabel(spell.level), spell.school, spellRequirementLabel(spell.level)].filter(Boolean).join(' · ')} onClose={onClose}>
{added}
{error && <ErrorBanner message={error} />}
<div className="stat-chips">
{spell.castingTime && <Chip label="Casting time">{spell.castingTime}</Chip>}
{spell.range && <Chip label="Range">{spell.range}</Chip>}
{spell.duration && <Chip label="Duration">{spell.duration}</Chip>}
</div>
<p className="mt-sm" style={{ whiteSpace: 'pre-wrap' }}>{spell.description || 'No description.'}</p>
<div className="grid grid-cols-2 mt-md">
<Field label="Damage">
{(id) => <SpellDamageSelect id={id} spellName={spell.name} value={spell.damageDice} onChange={(damageDice) => patch.mutate({ id: spell.id, damageDice }, { onError })} />}
</Field>
</div>
<label className="checkbox-row mt-md">
<input type="checkbox" checked={spell.isStartingOption} onChange={(e) => patch.mutate({ id: spell.id, isStartingOption: e.target.checked }, { onError })} />
Starting option: a player may pick it while building a character
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" checked={spell.revives} onChange={(e) => patch.mutate({ id: spell.id, revives: e.target.checked }, { onError })} />
Revives the dead: a character who can cast it may revive a dead ally on their turn in a battle
</label>
<RemoveButton name={spell.name} warning="Any character holding it loses it." pending={remove.isPending} onRemove={() => remove.mutate(spell.id, { onError, onSuccess: onClose })} />
</Modal>
);
}
function MonsterDetail({ campaignId, monster, added, onClose }: { added: ReactNode; campaignId: string; monster: Monster; onClose: () => void }) {
const update = useUpdateMonster(campaignId);
const remove = useDeleteMonster(campaignId);
const [editing, setEditing] = useState(false);
const [error, setError] = useState<string | null>(null);
const onError = (err: unknown) => setError(extractErrorMessage(err));
return (
<Modal title={editing ? `Editing ${monster.name}` : monster.name} subtitle="Monster" wide onClose={onClose}>
{added}
{error && <ErrorBanner message={error} />}
{editing ? (
<MonsterForm
monster={monster}
saving={update.isPending}
onCancel={() => setEditing(false)}
onSave={async (stats) => {
setError(null);
try {
await update.mutateAsync({ id: monster.id, ...stats });
setEditing(false);
} catch (err) {
onError(err);
throw err;
}
}}
/>
) : (
<>
<div className="stat-chips">
<Chip label="CR">{monster.challengeRating || '—'}</Chip>
<Chip label="AC">{monster.ac}</Chip>
<Chip label="HP">{monster.maxHp}</Chip>
<Chip label="Speed">{monster.speed}</Chip>
<Chip label="Attack">{fmtMod(monster.attackBonus)}</Chip>
<Chip label="Damage">{monster.damageDice || '—'}</Chip>
</div>
<p className="mono mt-sm" style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{abilityLine(monster)}</p>
{monster.specialTrait && (
<p>
<strong>Trait:</strong> {monster.specialTrait}
</p>
)}
{monster.notes && <p style={{ color: 'var(--text-muted)', whiteSpace: 'pre-wrap' }}>{monster.notes}</p>}
<button className="btn" onClick={() => setEditing(true)}>
Edit stat block
</button>
<RemoveButton
name={monster.name}
warning="Copies already in encounters stay, but lose their stat block."
pending={remove.isPending}
onRemove={() => remove.mutate(monster.id, { onError, onSuccess: onClose })}
/>
</>
)}
</Modal>
);
}
// An NPC: who they are, and what they trade — the rate they change gold and
// cash at, and their wares. Players meet both on the encounter page, in a
// quest the NPC is part of.
function NpcDetail({ campaignId, npc, weapons, items, added, onClose }: { added: ReactNode; campaignId: string; npc: Npc; weapons: Weapon[]; items: Item[]; onClose: () => void }) {
const patch = usePatchPoolEntry(campaignId, 'npcs');
const remove = useDeletePoolEntry(campaignId, 'npcs');
const [gold, setGold] = useState(npc.exchangeGold ? String(npc.exchangeGold) : '');
const [cash, setCash] = useState(npc.exchangeCash ? String(npc.exchangeCash) : '');
const [error, setError] = useState<string | null>(null);
const onError = (err: unknown) => setError(extractErrorMessage(err));
const rate = rateLabel(npc);
const hasStats = npc.ac !== null || npc.maxHp !== null || npc.attackBonus !== null || Boolean(npc.damageDice);
return (
<Modal
title={npc.name}
subtitle={
<span className="flex-row flex-wrap gap-sm">
{npc.role || 'NPC'}
<span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</span>
{rate && <span className="badge">💱 {rate}</span>}
</span>
}
wide
onClose={onClose}
>
{added}
{error && <ErrorBanner message={error} />}
{npc.description ? <p style={{ whiteSpace: 'pre-wrap' }}>{npc.description}</p> : <p style={{ color: 'var(--text-muted)' }}>No description for the players.</p>}
{npc.dmNotes && <p style={{ color: 'var(--text-muted)', whiteSpace: 'pre-wrap' }}>🔒 {npc.dmNotes}</p>}
{hasStats && (
<div className="stat-chips">
{npc.ac !== null && <Chip label="AC">{npc.ac}</Chip>}
{npc.maxHp !== null && <Chip label="HP">{npc.maxHp}</Chip>}
{npc.attackBonus !== null && <Chip label="Attack">{fmtMod(npc.attackBonus)}</Chip>}
{npc.damageDice && <Chip label="Damage">{npc.damageDice}</Chip>}
</div>
)}
<div className="section-label mt-md">Changes money</div>
<p className="combatant-sub" style={{ marginTop: 0 }}>
Set a rate and the party can change gold into cash, or cash into gold, with {npc.name}. Silver and copper are not
taken. Leave it empty for someone who does not change money.
</p>
<div className="flex-row flex-wrap gap-sm">
<NumberInput min={1} max={1000000} aria-label="Gold side of the rate" placeholder="Gold" value={gold} onChange={(e) => setGold(e.target.value)} style={{ width: 90 }} />
<span>gold =</span>
<span>$</span>
<NumberInput min={1} max={1000000} aria-label="Cash side of the rate" placeholder="Cash" value={cash} onChange={(e) => setCash(e.target.value)} style={{ width: 90 }} />
<button
className="btn"
disabled={!gold || !cash || patch.isPending}
onClick={() => patch.mutate({ id: npc.id, exchangeGold: Number(gold), exchangeCash: Number(cash) }, { onError })}
>
Save rate
</button>
{rate && (
<button
className="btn btn-ghost"
disabled={patch.isPending}
onClick={() =>
patch.mutate(
{ id: npc.id, exchangeGold: null, exchangeCash: null },
{
onError,
onSuccess: () => {
setGold('');
setCash('');
},
}
)
}
>
Stop changing money
</button>
)}
</div>
<div className="section-label mt-md">For sale</div>
<WaresEditor campaignId={campaignId} seller={{ npcId: npc.id }} wares={npc.wares ?? []} weapons={weapons} items={items} onError={onError} />
<RemoveButton
name={npc.name}
warning="They leave every encounter they are in, and their wares go with them."
pending={remove.isPending}
onRemove={() => remove.mutate(npc.id, { onError, onSuccess: onClose })}
/>
</Modal>
);
}
// The pop-out for whichever weapon, item, spell, monster or NPC is open.
// Nothing shows if it is no longer in the campaign.
export function EntryDetail({
campaignId,
entry,
weapons,
items,
spells,
monsters,
npcs,
onAddAnother,
onClose,
}: {
campaignId: string;
entry: EntryRef;
weapons: Weapon[];
items: Item[];
spells: Spell[];
monsters: Monster[];
npcs: Npc[];
// Given for something added by hand a moment ago: opens the form it came from again.
onAddAnother?: () => void;
onClose: () => void;
}) {
const added = onAddAnother && (
<div className="added-bar">
<span>✓ Added to the campaign.</span>
<button className="btn btn-primary" onClick={onAddAnother}>
+ Add another {KIND_NOUNS[entry.kind]}
</button>
</div>
);
const find = <T extends { id: string }>(list: T[]) => list.find((e) => e.id === entry.id);
switch (entry.kind) {
case 'weapon': {
const weapon = find(weapons);
return weapon ? <GearDetail key={weapon.id} added={added} campaignId={campaignId} pool="weapons" entry={weapon} onClose={onClose} /> : null;
}
case 'item': {
const item = find(items);
return item ? <GearDetail key={item.id} added={added} campaignId={campaignId} pool="items" entry={item} onClose={onClose} /> : null;
}
case 'spell': {
const spell = find(spells);
return spell ? <SpellDetail key={spell.id} added={added} campaignId={campaignId} spell={spell} onClose={onClose} /> : null;
}
case 'monster': {
const monster = find(monsters);
return monster ? <MonsterDetail key={monster.id} added={added} campaignId={campaignId} monster={monster} onClose={onClose} /> : null;
}
case 'npc': {
const npc = find(npcs);
return npc ? <NpcDetail key={npc.id} added={added} campaignId={campaignId} npc={npc} weapons={weapons} items={items} onClose={onClose} /> : null;
}
}
}
@@ -0,0 +1,584 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import {
useAddItemFromLibrary,
useAddMonsterFromLibrary,
useAddSpellFromLibrary,
useAddWeaponFromLibrary,
useCampaignLibrary,
useCreateItem,
useCreateMonster,
useCreateNpc,
useCreateSpell,
useCreateWeapon,
} from '../../api/queries';
import { Modal } from '../Modal';
import { NumberInput } from '../NumberInput';
import { ErrorBanner, Spinner } from '../Spinner';
import { effectsSummary } from '../../lib/gear';
import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } from '../../lib/format';
import { MAX_GEAR_LEVEL } from '../../lib/previewCalc';
import { abilityLine, rateLabel, type EntryRef } from '../../lib/campaignEntries';
import { EntryList, Field, LibraryPicker, MonsterForm, ScalingSelect, SpellDamageSelect, StartLevelSelect } from './parts';
import type { CampaignLibrary, Item, LibraryItem, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Spell, Weapon } from '../../api/types';
interface SectionProps<T> {
campaignId: string;
entries: T[] | undefined;
onOpen: (entry: EntryRef) => void;
// The "add by hand" pop-out is wanted from outside: "add another", pressed
// in the pop-out of the one just added.
formRequested: boolean;
onFormClosed: () => void;
}
// Which of a list's two "add" pop-outs is open: the form, or the library.
type Adding = 'form' | 'library' | null;
// Which "add" pop-out a list has open, and what went wrong in it.
function useAdding(formRequested: boolean, onFormClosed: () => void) {
const [adding, setAdding] = useState<Adding>(null);
const [error, setError] = useState<string | null>(null);
return {
adding: formRequested ? ('form' as const) : adding,
error,
setError,
open: (next: Adding) => {
setError(null);
setAdding(next);
},
close: () => {
setError(null);
setAdding(null);
onFormClosed();
},
};
}
function SectionCard({
title,
addLabel,
hasLibrary = true,
onAdd,
intro,
children,
}: {
title: string;
addLabel: string;
hasLibrary?: boolean;
onAdd: (adding: Adding) => void;
intro: ReactNode;
children: ReactNode;
}) {
return (
<div className="card">
<div className="card-title">
<span>{title}</span>
<div className="flex-row gap-sm">
{hasLibrary && (
<button className="btn" onClick={() => onAdd('library')}>
📚 From Library
</button>
)}
<button className="btn btn-primary" onClick={() => onAdd('form')}>
{addLabel}
</button>
</div>
</div>
<p className="subtitle" style={{ marginTop: 0 }}>
{intro}
</p>
{children}
</div>
);
}
// The built-in library in a pop-out. It is a large download, so it is only
// asked for once one of these is opened.
function LibraryModal<T extends { key: string; name: string }>({
campaignId,
title,
pick,
taken,
adding,
onAdd,
placeholder,
renderMeta,
renderDetail,
error,
onClose,
}: {
campaignId: string;
title: string;
pick: (library: CampaignLibrary) => T[];
taken: { name: string }[] | undefined;
adding: boolean;
onAdd: (key: string) => void;
placeholder: string;
renderMeta: (item: T) => string;
renderDetail?: (item: T) => string | null;
error: string | null;
onClose: () => void;
}) {
const { data: library, isError } = useCampaignLibrary(campaignId);
return (
<Modal title={title} subtitle="Each entry can be added once. This stays open so you can add several." wide onClose={onClose}>
{error && <ErrorBanner message={error} />}
{isError && <ErrorBanner message="Could not load the library." />}
{!library && !isError && <Spinner label="Fetching the library…" />}
{library && (
<LibraryPicker<T> items={pick(library)} taken={taken} adding={adding} onAdd={onAdd} placeholder={placeholder} renderMeta={renderMeta} renderDetail={renderDetail} />
)}
</Modal>
);
}
const StartingOption = () => (
<label className="checkbox-row" style={{ alignSelf: 'end', paddingBottom: '0.6rem' }}>
<input type="checkbox" name="isStartingOption" />
Starting option (a player may pick it while building a character)
</label>
);
const startingBadge = (entry: { isStartingOption: boolean }) => entry.isStartingOption && <span className="badge">Starting</span>;
export function WeaponsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Weapon>) {
const createWeapon = useCreateWeapon(campaignId);
const addFromLibrary = useAddWeaponFromLibrary(campaignId);
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
setError(null);
try {
const weapon = await createWeapon.mutateAsync({
name: String(fd.get('name')).trim(),
damageDice: String(fd.get('damageDice')).trim(),
damageType: String(fd.get('damageType')).trim(),
attackAbility: fd.get('attackAbility') as Weapon['attackAbility'],
properties: [],
rangeNormal: null,
rangeLong: null,
damageScaling: Number(fd.get('damageScaling')),
minLevel: Number(fd.get('minLevel')),
isStartingOption: fd.get('isStartingOption') === 'on',
effects: [],
effectNote: null,
});
close();
onOpen({ kind: 'weapon', id: weapon.id, isNew: true });
} catch (err) {
setError(extractErrorMessage(err));
}
}
return (
<SectionCard
title="Weapons"
addLabel="+ Add Weapon"
onAdd={open}
intro={
<>
The weapons you hand out on each character's sheet. Open one to set the level it starts at, how fast its damage grows
(levels 1–{MAX_GEAR_LEVEL}), whether a new character may start with it, and what it does while equipped.
</>
}
>
<EntryList
entries={entries}
noun="weapons"
emptyText="No weapons yet. Add one by hand, or copy some from the library."
searchText={(w) => [w.name, w.damageDice, w.damageType, effectsSummary(w.effects, w.effectNote)]}
renderMeta={(w) => [`${w.damageDice} ${w.damageType}`, `Lv ${w.minLevel}`, effectsSummary(w.effects, w.effectNote) && `✦ ${effectsSummary(w.effects, w.effectNote)}`].filter(Boolean).join(' · ')}
renderBadges={startingBadge}
onOpen={(id) => onOpen({ kind: 'weapon', id })}
/>
{adding === 'library' && (
<LibraryModal<LibraryWeapon>
campaignId={campaignId}
title="Weapon library"
pick={(library) => library.weapons}
taken={entries}
adding={addFromLibrary.isPending}
onAdd={(key) => addFromLibrary.mutate(key, { onError: (err) => setError(extractErrorMessage(err)) })}
placeholder="Search the weapon library…"
renderMeta={(w) => `${w.category} · ${w.damageDice} ${w.damageType} · starts at level 1`}
renderDetail={(w) => effectsSummary(w.effects, w.effectNote)}
error={error}
onClose={close}
/>
)}
{adding === 'form' && (
<Modal title="Add a weapon" subtitle="What it does while equipped is set once it is added." onClose={close}>
{error && <ErrorBanner message={error} />}
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-2">
<Field label="Name">{(id) => <input id={id} name="name" required maxLength={80} />}</Field>
<Field label="Damage at its starting level">{(id) => <input id={id} name="damageDice" placeholder="e.g. 1d8" required />}</Field>
<Field label="Damage type">{(id) => <input id={id} name="damageType" placeholder="e.g. slashing" required />}</Field>
<Field label="Attacks with">
{(id) => (
<select id={id} name="attackAbility" defaultValue="STR">
<option value="STR">STR</option>
<option value="DEX">DEX</option>
<option value="FINESSE">Finesse (the better of the two)</option>
</select>
)}
</Field>
<Field label="Level it starts at">{(id) => <StartLevelSelect id={id} name="minLevel" />}</Field>
<Field label="Growth per level">{(id) => <ScalingSelect id={id} name="damageScaling" />}</Field>
<StartingOption />
</div>
<button type="submit" className="btn btn-primary mt-md" disabled={createWeapon.isPending}>
Add Weapon
</button>
</form>
</Modal>
)}
</SectionCard>
);
}
export function ItemsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Item>) {
const createItem = useCreateItem(campaignId);
const addFromLibrary = useAddItemFromLibrary(campaignId);
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
setError(null);
try {
const item = await createItem.mutateAsync({
name: String(fd.get('name')).trim(),
description: String(fd.get('description') || '').trim() || null,
damageDice: String(fd.get('damageDice') || '').trim() || null,
damageScaling: Number(fd.get('damageScaling')),
minLevel: Number(fd.get('minLevel')),
isStartingOption: fd.get('isStartingOption') === 'on',
effects: [],
effectNote: null,
});
close();
onOpen({ kind: 'item', id: item.id, isNew: true });
} catch (err) {
setError(extractErrorMessage(err));
}
}
return (
<SectionCard
title="Items"
addLabel="+ Add Item"
onAdd={open}
intro={
<>
Rope, potions, armor, quest items. Armor, shields, rings and the like are items with effects: open the item and set
what it does while equipped (chain mail: +6 AC, −10 Speed).
</>
}
>
<EntryList
entries={entries}
noun="items"
emptyText="No items yet. Add rope, potions, armor, quest items…"
searchText={(i) => [i.name, i.description, i.damageDice, effectsSummary(i.effects, i.effectNote)]}
renderMeta={(i) => [effectsSummary(i.effects, i.effectNote) && `✦ ${effectsSummary(i.effects, i.effectNote)}`, i.damageDice, i.description].filter(Boolean).join(' · ') || `Lv ${i.minLevel}`}
renderBadges={startingBadge}
onOpen={(id) => onOpen({ kind: 'item', id })}
/>
{adding === 'library' && (
<LibraryModal<LibraryItem>
campaignId={campaignId}
title="Item library"
pick={(library) => library.items}
taken={entries}
adding={addFromLibrary.isPending}
onAdd={(key) => addFromLibrary.mutate(key, { onError: (err) => setError(extractErrorMessage(err)) })}
placeholder="Search the item library: armor, shields, worn gear, supplies…"
renderMeta={(i) => [i.category, effectsSummary(i.effects, i.effectNote), i.damageDice].filter(Boolean).join(' · ')}
renderDetail={(i) => i.description}
error={error}
onClose={close}
/>
)}
{adding === 'form' && (
<Modal title="Add an item" subtitle="What it does while equipped is set once it is added." onClose={close}>
{error && <ErrorBanner message={error} />}
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-2">
<Field label="Name">{(id) => <input id={id} name="name" required maxLength={80} />}</Field>
<Field label="Dice (optional)">{(id) => <input id={id} name="damageDice" placeholder="e.g. 2d4+2" maxLength={20} />}</Field>
<Field label="Description (optional)" style={{ gridColumn: '1 / -1' }}>
{(id) => <input id={id} name="description" />}
</Field>
<Field label="Level it starts at">{(id) => <StartLevelSelect id={id} name="minLevel" />}</Field>
<Field label="Growth of its dice per level">{(id) => <ScalingSelect id={id} name="damageScaling" />}</Field>
<StartingOption />
</div>
<button type="submit" className="btn btn-primary mt-md" disabled={createItem.isPending}>
Add Item
</button>
</form>
</Modal>
)}
</SectionCard>
);
}
export function SpellsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Spell>) {
const createSpell = useCreateSpell(campaignId);
const addFromLibrary = useAddSpellFromLibrary(campaignId);
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
setError(null);
try {
const spell = await createSpell.mutateAsync({
name: String(fd.get('name')).trim(),
level: Number(fd.get('level')),
school: String(fd.get('school') || '').trim() || null,
castingTime: null,
range: null,
duration: null,
description: String(fd.get('description') || '').trim() || null,
damageDice: String(fd.get('damageDice') || '') || null,
isStartingOption: fd.get('isStartingOption') === 'on',
revives: fd.get('revives') === 'on',
});
close();
onOpen({ kind: 'spell', id: spell.id, isNew: true });
} catch (err) {
setError(extractErrorMessage(err));
}
}
return (
<SectionCard
title="Spells"
addLabel="+ Add Spell"
onAdd={open}
intro="The spells you hand out on each character's sheet. A character must be at least a spell's level to cast it. Open one to change its damage, or whether a new character may start with it."
>
<EntryList
entries={entries}
noun="spells"
emptyText="No spells yet. Add one by hand, or copy some from the library."
searchText={(s) => [s.name, s.school, s.description, spellLevelLabel(s.level)]}
renderMeta={(s) => [spellLevelLabel(s.level), s.damageDice && `${s.damageDice} damage`, s.description].filter(Boolean).join(' · ')}
renderBadges={(s) => (
<>
{s.revives && <span className="badge">Revives</span>}
{startingBadge(s)}
</>
)}
onOpen={(id) => onOpen({ kind: 'spell', id })}
/>
{adding === 'library' && (
<LibraryModal<LibrarySpell>
campaignId={campaignId}
title="Spell library"
pick={(library) => library.spells}
taken={entries}
adding={addFromLibrary.isPending}
onAdd={(key) => addFromLibrary.mutate(key, { onError: (err) => setError(extractErrorMessage(err)) })}
placeholder="Search the spell library…"
renderMeta={(s) =>
[spellLevelLabel(s.level), spellRequirementLabel(s.level), s.damageDice ? `${s.damageDice} damage` : 'no damage', s.school].filter(Boolean).join(' · ')
}
renderDetail={(s) => s.description}
error={error}
onClose={close}
/>
)}
{adding === 'form' && (
<Modal title="Add a spell" onClose={close}>
{error && <ErrorBanner message={error} />}
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-2">
<Field label="Name">{(id) => <input id={id} name="name" required maxLength={80} />}</Field>
<Field label="Level">
{(id) => (
<select id={id} name="level" defaultValue={0}>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((level) => (
<option key={level} value={level}>
{spellLevelLabel(level)}
</option>
))}
</select>
)}
</Field>
<Field label="School (optional)">{(id) => <input id={id} name="school" />}</Field>
<Field label="Damage">{(id) => <SpellDamageSelect id={id} name="damageDice" />}</Field>
<Field label="What it does (one sentence)" style={{ gridColumn: '1 / -1' }}>
{(id) => <input id={id} name="description" maxLength={4000} required />}
</Field>
<StartingOption />
<label className="checkbox-row" style={{ alignSelf: 'end', paddingBottom: '0.6rem' }}>
<input type="checkbox" name="revives" />
Revives the dead (its caster may revive a dead ally on their turn in a battle)
</label>
</div>
<button type="submit" className="btn btn-primary mt-md" disabled={createSpell.isPending}>
Add Spell
</button>
</form>
</Modal>
)}
</SectionCard>
);
}
export function MonstersSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Monster>) {
const createMonster = useCreateMonster(campaignId);
const addFromLibrary = useAddMonsterFromLibrary(campaignId);
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
return (
<SectionCard
title="Monsters"
addLabel="+ Add Monster"
onAdd={open}
intro="The stat blocks you bring into battles. Players never see them. Open one for the whole block, or to change it."
>
<EntryList
entries={entries}
noun="monsters"
emptyText="No monsters yet. Add one by hand, or copy some from the library."
searchText={(m) => [m.name, m.challengeRating && `CR ${m.challengeRating}`, m.specialTrait, m.notes]}
renderMeta={(m) => `CR ${m.challengeRating || '—'} · AC ${m.ac} · HP ${m.maxHp} · attack ${fmtMod(m.attackBonus)}${m.damageDice ? ` for ${m.damageDice}` : ''}`}
onOpen={(id) => onOpen({ kind: 'monster', id })}
/>
{adding === 'library' && (
<LibraryModal<LibraryMonster>
campaignId={campaignId}
title="Monster library"
pick={(library) => library.monsters}
taken={entries}
adding={addFromLibrary.isPending}
onAdd={(key) => addFromLibrary.mutate(key, { onError: (err) => setError(extractErrorMessage(err)) })}
placeholder="Search the monster library…"
renderMeta={(m) =>
`CR ${m.challengeRating ?? '—'} · AC ${m.ac} · HP ${m.maxHp} · speed ${m.speed} · attack ${fmtMod(m.attackBonus)}${m.damageDice ? ` for ${m.damageDice}` : ''} · ${abilityLine(m)}`
}
renderDetail={(m) => [m.specialTrait, m.notes].filter(Boolean).join(' — ') || null}
error={error}
onClose={close}
/>
)}
{adding === 'form' && (
<Modal title="Add a monster" wide onClose={close}>
{error && <ErrorBanner message={error} />}
<MonsterForm
saving={createMonster.isPending}
onSave={async (stats) => {
setError(null);
try {
const monster = await createMonster.mutateAsync(stats);
close();
onOpen({ kind: 'monster', id: monster.id, isNew: true });
} catch (err) {
setError(extractErrorMessage(err));
throw err;
}
}}
/>
</Modal>
)}
</SectionCard>
);
}
export function NpcsSection({ campaignId, entries, onOpen, formRequested, onFormClosed }: SectionProps<Npc>) {
const createNpc = useCreateNpc(campaignId);
const { adding, error, setError, open, close } = useAdding(formRequested, onFormClosed);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
const text = (key: string) => String(fd.get(key) || '').trim() || null;
const num = (key: string) => (fd.get(key) ? Number(fd.get(key)) : null);
setError(null);
try {
const npc = await createNpc.mutateAsync({
name: String(fd.get('name')).trim(),
role: text('role'),
disposition: fd.get('disposition') as NpcDisposition,
description: text('description'),
dmNotes: text('dmNotes'),
ac: num('ac'),
maxHp: num('maxHp'),
attackBonus: num('attackBonus'),
damageDice: text('damageDice'),
});
close();
onOpen({ kind: 'npc', id: npc.id, isNew: true });
} catch (err) {
setError(extractErrorMessage(err));
}
}
return (
<SectionCard
title="NPCs"
addLabel="+ Add NPC"
hasLibrary={false}
onAdd={open}
intro="The people your party meets. Add them to an encounter to bring them into a scene. Players never see your notes or an NPC's stats. Open one to set what they sell and the rate they change gold and cash at; players see both when they meet the NPC in a quest."
>
<EntryList
entries={entries}
noun="NPCs"
emptyText="No NPCs yet."
searchText={(n) => [n.name, n.role, n.disposition, n.description, n.dmNotes]}
renderMeta={(n) => [n.role, n.description].filter(Boolean).join(' · ')}
renderBadges={(n) => (
<>
{rateLabel(n) && <span className="badge">💱 {rateLabel(n)}</span>}
{(n.wares?.length ?? 0) > 0 && <span className="badge">🛒 {n.wares?.length}</span>}
<span className={`badge badge-${n.disposition.toLowerCase()}`}>{n.disposition}</span>
</>
)}
onOpen={(id) => onOpen({ kind: 'npc', id })}
/>
{adding === 'form' && (
<Modal title="Add an NPC" subtitle="Only the name is needed. What they sell is set once they are added." onClose={close}>
{error && <ErrorBanner message={error} />}
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-2">
<Field label="Name">{(id) => <input id={id} name="name" required maxLength={80} />}</Field>
<Field label="Role">{(id) => <input id={id} name="role" placeholder="innkeeper, captain…" maxLength={80} />}</Field>
<Field label="Attitude toward the party">
{(id) => (
<select id={id} name="disposition" defaultValue="NEUTRAL">
<option value="FRIENDLY">Friendly</option>
<option value="NEUTRAL">Neutral</option>
<option value="HOSTILE">Hostile</option>
</select>
)}
</Field>
</div>
<Field label="What the party sees and knows (shown to players)" style={{ marginTop: '0.75rem' }}>
{(id) => <textarea id={id} name="description" rows={2} className="textarea-block" />}
</Field>
<Field label="Secrets and motives (only you see this)" style={{ marginTop: '0.75rem' }}>
{(id) => <textarea id={id} name="dmNotes" rows={2} className="textarea-block" />}
</Field>
<div className="section-label mt-md">Combat stats (optional)</div>
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))' }}>
<Field label="AC">{(id) => <NumberInput id={id} name="ac" min={0} max={50} />}</Field>
<Field label="HP">{(id) => <NumberInput id={id} name="maxHp" min={1} max={100000} />}</Field>
<Field label="Attack bonus">{(id) => <NumberInput id={id} name="attackBonus" min={-20} max={50} />}</Field>
<Field label="Damage">{(id) => <input id={id} name="damageDice" placeholder="e.g. 1d6" maxLength={20} />}</Field>
</div>
<button type="submit" className="btn btn-primary mt-md" disabled={createNpc.isPending}>
Add NPC
</button>
</form>
</Modal>
)}
</SectionCard>
);
}
+363
View File
@@ -0,0 +1,363 @@
import { useId, useState, type CSSProperties, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { NumberInput } from '../NumberInput';
import { Pagination } from '../Paginated';
import { usePagination } from '../../hooks/usePagination';
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS } from '../../lib/format';
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../../lib/previewCalc';
import { matchesSearch, scoreField, type MonsterInput } from '../../lib/campaignEntries';
import type { Monster } from '../../api/types';
// A labelled field in a pop-out form. The child is handed the id its label points at.
export function Field({ label, style, children }: { label: string; style?: CSSProperties; children: (id: string) => ReactNode }) {
const id = useId();
return (
<div className="field" style={{ marginBottom: 0, ...style }}>
<label htmlFor={id}>{label}</label>
{children(id)}
</div>
);
}
// How fast a weapon's or item's dice grow as the DM levels a held copy up.
export function ScalingSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: number; onChange?: (scaling: number) => void }) {
return (
<select
id={id}
name={name}
aria-label="How fast the dice grow per level"
title="How fast the dice grow each time a held copy is levelled up"
{...(onChange ? { value, onChange: (e) => onChange(Number(e.target.value)) } : { defaultValue: 1 })}
>
{Object.entries(DAMAGE_SCALING_LABELS).map(([scaling, label]) => (
<option key={scaling} value={scaling}>
{label}
</option>
))}
</select>
);
}
const GEAR_LEVELS = Array.from({ length: MAX_GEAR_LEVEL }, (_, i) => i + 1);
// The level a weapon or item starts at. A held copy is never below it, and a
// character must be at least this level to use it.
export function StartLevelSelect({ id, name, value, onChange }: { id?: string; name?: string; value?: number; onChange?: (minLevel: number) => void }) {
return (
<select
id={id}
name={name}
aria-label="Level it starts at"
title="The level this starts at — a character must be at least this level to use it"
{...(onChange ? { value, onChange: (e) => onChange(Number(e.target.value)) } : { defaultValue: 1 })}
>
{GEAR_LEVELS.map((level) => (
<option key={level} value={level}>
Starts at Lv {level}
</option>
))}
</select>
);
}
// A spell's damage is picked from a list rather than typed. A value that is
// not in the list (set through the API, say) is still shown and kept.
export function SpellDamageSelect({
id,
name,
spellName,
value,
onChange,
}: {
id?: string;
name?: string;
spellName?: string;
value?: string | null;
onChange?: (damageDice: string | null) => void;
}) {
const known = [...SPELL_DAMAGE_GROUPS.flatMap((g) => g.options), ...SPELL_DAMAGE_SPECIALS];
const others = value && !known.includes(value) ? [...SPELL_DAMAGE_SPECIALS, value] : SPELL_DAMAGE_SPECIALS;
return (
<select
id={id}
name={name}
aria-label={spellName ? `${spellName} damage` : 'Damage'}
{...(onChange ? { value: value ?? '', onChange: (e) => onChange(e.target.value || null) } : { defaultValue: '' })}
>
<option value="">No damage</option>
{SPELL_DAMAGE_GROUPS.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.options.map((dice) => (
<option key={dice} value={dice}>
{dice}
</option>
))}
</optgroup>
))}
<optgroup label="Other">
{others.map((dice) => (
<option key={dice} value={dice}>
{dice}
</option>
))}
</optgroup>
</select>
);
}
// The whole stat block, field for field as a printed one gives it. Adds a
// monster, or edits one when `monster` is passed (its values fill the form).
export function MonsterForm({
monster,
saving,
onSave,
onCancel,
}: {
monster?: Monster;
saving: boolean;
// Rejects if the save failed, so the form keeps what was typed.
onSave: (data: MonsterInput) => Promise<void>;
onCancel?: () => void;
}) {
const field = (name: string, label: string, input: ReactNode, style?: CSSProperties) => (
<div className="field" style={{ marginBottom: 0, ...style }}>
<label htmlFor={`monster-${name}`}>{label}</label>
{input}
</div>
);
const number = (name: keyof MonsterInput, label: string, min: number, max: number, fallback?: number) =>
field(
name,
label,
<NumberInput
id={`monster-${name}`}
name={name}
min={min}
max={max}
defaultValue={(monster?.[name] as number | undefined) ?? fallback}
keepValue={fallback !== undefined}
required
/>
);
return (
<form
onSubmit={async (e) => {
e.preventDefault();
const fd = new FormData(e.target as HTMLFormElement);
const text = (key: string) => String(fd.get(key) || '').trim() || null;
const num = (key: string) => Number(fd.get(key));
try {
await onSave({
name: String(fd.get('name')).trim(),
challengeRating: text('challengeRating'),
ac: num('ac'),
maxHp: num('maxHp'),
speed: num('speed'),
attackBonus: num('attackBonus'),
damageDice: text('damageDice'),
strScore: num('strScore'),
dexScore: num('dexScore'),
conScore: num('conScore'),
intScore: num('intScore'),
wisScore: num('wisScore'),
chaScore: num('chaScore'),
specialTrait: text('specialTrait'),
notes: text('notes'),
});
} catch {
// Whoever opened the form shows the error; what was typed stays.
}
}}
>
<div className="grid grid-cols-3">
{field('name', 'Name', <input id="monster-name" name="name" defaultValue={monster?.name} required maxLength={80} />)}
{field(
'challengeRating',
'CR (Challenge Rating)',
<input id="monster-challengeRating" name="challengeRating" defaultValue={monster?.challengeRating ?? ''} placeholder="e.g. 1/4 or 3" maxLength={10} />
)}
{number('ac', 'AC (Armor Class)', 0, 50)}
{number('maxHp', 'HP (Hit Points)', 1, 100000)}
{number('speed', 'Speed (feet)', 0, 1000, 30)}
{number('attackBonus', 'Attack bonus', -20, 50, 0)}
{field(
'damageDice',
'Damage',
<input id="monster-damageDice" name="damageDice" defaultValue={monster?.damageDice ?? ''} placeholder="e.g. 1d6+2 necrotic" maxLength={60} />
)}
</div>
<div className="grid mt-sm" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(80px, 1fr))' }}>
{ABILITY_ORDER.map((a) => number(scoreField(a), a, 1, 30, 10))}
</div>
{field(
'specialTrait',
'Special trait',
<input
id="monster-specialTrait"
name="specialTrait"
defaultValue={monster?.specialTrait ?? ''}
placeholder="The one thing that makes it different"
maxLength={500}
/>,
{ marginTop: '0.5rem' }
)}
{field(
'notes',
'Notes',
<textarea
id="monster-notes"
name="notes"
rows={3}
className="textarea-block"
defaultValue={monster?.notes ?? ''}
placeholder="Everything else: other speeds (fly 30, swim 40), extra attacks, spells, tactics, resistances, senses, what it drops"
maxLength={2000}
/>,
{ marginTop: '0.5rem' }
)}
<div className="flex-row mt-md">
<button type="submit" className="btn btn-primary" disabled={saving}>
{monster ? 'Save Changes' : 'Add Monster'}
</button>
{onCancel && (
<button type="button" className="btn btn-ghost" onClick={onCancel}>
Cancel
</button>
)}
</div>
</form>
);
}
// The built-in library, searched by name, with what the campaign already
// holds left out. It stays open after an add, for adding several in a row.
export function LibraryPicker<T extends { key: string; name: string }>({
items,
taken,
onAdd,
adding,
renderMeta,
renderDetail,
placeholder,
}: {
items: T[];
// What the campaign already holds: a library entry by one of these names is
// left out, since each can be added once.
taken: { name: string }[] | undefined;
onAdd: (key: string) => void;
adding: boolean;
renderMeta: (item: T) => string;
// A sentence on what the entry is or does, shown under its name.
renderDetail?: (item: T) => string | null;
placeholder: string;
}) {
const [query, setQuery] = useState('');
const takenNames = new Set((taken ?? []).map((t) => t.name.toLowerCase()));
const available = items.filter((i) => !takenNames.has(i.name.toLowerCase()));
const filtered = available.filter((i) => matchesSearch(query, i.name));
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(filtered, 8);
return (
<div>
<input type="search" placeholder={placeholder} aria-label={placeholder} value={query} onChange={(e) => setQuery(e.target.value)} style={{ width: '100%' }} />
<div className="mt-sm">
{pageItems.map((item) => (
<div key={item.key} className="flex-between" style={{ padding: '0.45rem 0', borderBottom: '1px solid var(--border-soft)', flexWrap: 'nowrap' }}>
<div style={{ minWidth: 0 }}>
<strong>{item.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6, fontSize: '0.85rem' }}>{renderMeta(item)}</span>
{renderDetail?.(item) && <div style={{ fontSize: '0.9rem' }}>{renderDetail(item)}</div>}
</div>
<button className="btn" style={{ flex: 'none' }} disabled={adding} aria-label={`Add ${item.name}`} onClick={() => onAdd(item.key)}>
+ Add
</button>
</div>
))}
{filtered.length === 0 && (
<p style={{ color: 'var(--text-muted)' }}>
{available.length === 0 ? 'Everything in the library is already in this campaign.' : 'No matches.'}
</p>
)}
</div>
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
</div>
);
}
// One line of a list: what it is and a few words about it. The rest is in
// the pop-out it opens, or on the page it links to when given `to`.
export function EntryRow({ name, meta, badges, onOpen, to }: { name: string; meta?: ReactNode; badges?: ReactNode; onOpen?: () => void; to?: string }) {
const inner = (
<>
<span className="entry-row-main">
<span className="entry-row-name">{name}</span>
{meta && <span className="entry-row-meta">{meta}</span>}
</span>
{badges && <span className="entry-row-badges">{badges}</span>}
<span className="entry-row-chevron" aria-hidden>
›
</span>
</>
);
return to ? (
<Link to={to} className="entry-row">
{inner}
</Link>
) : (
<button type="button" className="entry-row" onClick={onOpen}>
{inner}
</button>
);
}
// A campaign list as short rows: searched by a box of its own, paged, and
// each row opening its pop-out.
export function EntryList<T extends { id: string; name: string }>({
entries,
noun,
emptyText,
searchText,
renderMeta,
renderBadges,
onOpen,
}: {
entries: T[] | undefined;
// What the list holds, in the plural: "weapons".
noun: string;
emptyText: string;
// Everything about an entry that a search should find it by.
searchText: (entry: T) => (string | number | null | undefined)[];
renderMeta: (entry: T) => ReactNode;
renderBadges?: (entry: T) => ReactNode;
onOpen: (id: string) => void;
}) {
const [query, setQuery] = useState('');
const filtered = (entries ?? []).filter((entry) => matchesSearch(query, ...searchText(entry)));
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(filtered, 10);
if (entries && entries.length === 0) return <p className="mt-sm" style={{ color: 'var(--text-muted)' }}>{emptyText}</p>;
return (
<>
<input
type="search"
className="mt-sm"
style={{ width: '100%' }}
placeholder={`Search the ${noun} in this campaign…`}
aria-label={`Search the ${noun} in this campaign`}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
/>
<div className="entry-list mt-sm">
{pageItems.map((entry) => (
<EntryRow key={entry.id} name={entry.name} meta={renderMeta(entry)} badges={renderBadges?.(entry)} onOpen={() => onOpen(entry.id)} />
))}
</div>
{entries && filtered.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No {noun} match "{query}".</p>}
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
</>
);
}
+43 -15
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useQueryClient, type QueryKey } from '@tanstack/react-query';
import { io, type Socket } from 'socket.io-client';
import { API_URL } from '../api/client';
import { useAuth } from '../auth/useAuth';
@@ -8,11 +8,18 @@ let sharedSocket: Socket | null = null;
function getSocket(token: string): Socket {
if (!sharedSocket) {
sharedSocket = io(API_URL, { auth: { token }, transports: ['websocket', 'polling'] });
// Starts on plain requests and moves up to a websocket when one gets
// through. Asking for the websocket first leaves no way back: behind a
// proxy that does not pass the upgrade, nothing live arrives at all.
sharedSocket = io(API_URL, { auth: { token } });
}
return sharedSocket;
}
// One change on the server often sends two or three events together. They are
// gathered for this long and answered with one refetch per query.
const FLUSH_MS = 50;
// Joins the Socket.io room for a campaign and keeps React Query's cache in
// sync with server-pushed events (HP/condition changes, encounter rounds,
// new characters) so every open tab in the campaign updates live.
@@ -25,27 +32,44 @@ export function useCampaignSocket(campaignId: string | undefined) {
const socket = getSocket(token);
socket.emit('campaign:join', campaignId);
const onCharacterUpdated = () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet HP
const pending = new Map<string, QueryKey>();
let timer: ReturnType<typeof setTimeout> | undefined;
const flush = () => {
timer = undefined;
const keys = [...pending.values()];
pending.clear();
// A wider key covers a narrower one waiting beside it: ['encounter'] takes in ['encounter', id].
const covered = (key: QueryKey) => keys.some((other) => other.length < key.length && other.every((part, i) => part === key[i]));
for (const queryKey of keys) if (!covered(queryKey)) qc.invalidateQueries({ queryKey });
};
const invalidate = (queryKey: QueryKey) => {
pending.set(JSON.stringify(queryKey), queryKey);
timer ??= setTimeout(flush, FLUSH_MS);
};
// An event names what changed when it is one thing, and nothing when it may be many.
const one = (root: string, id?: string) => invalidate(id ? [root, id] : [root]);
const onCharacterUpdated = ({ characterId }: { characterId?: string } = {}) => {
invalidate(['campaign', campaignId, 'characters']);
one('character', characterId);
invalidate(['encounter']); // a character combatant shows the sheet HP
// A character arriving somewhere new adds it to the places their player can see.
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'locations'] });
invalidate(['campaign', campaignId, 'locations']);
};
const onCharacterCreated = () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
invalidate(['campaign', campaignId, 'characters']);
};
const onParticipantUpdated = () => {
qc.invalidateQueries({ queryKey: ['encounter'] });
const onParticipantUpdated = ({ encounterId }: { encounterId?: string } = {}) => {
one('encounter', encounterId);
// A player's list only holds the encounters their characters are in.
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] });
invalidate(['campaign', campaignId, 'encounters']);
};
const onRoundAdvanced = () => {
qc.invalidateQueries({ queryKey: ['encounter'] });
const onRoundAdvanced = ({ encounterId }: { encounterId?: string } = {}) => {
one('encounter', encounterId);
};
const onCampaignDeleted = () => {
qc.invalidateQueries({ queryKey: ['campaigns'] });
qc.invalidateQueries({ queryKey: ['campaign', campaignId] });
invalidate(['campaigns']);
invalidate(['campaign', campaignId]);
};
socket.on('character:updated', onCharacterUpdated);
@@ -55,6 +79,10 @@ export function useCampaignSocket(campaignId: string | undefined) {
socket.on('campaign:deleted', onCampaignDeleted);
return () => {
if (timer) {
clearTimeout(timer);
flush();
}
socket.emit('campaign:leave', campaignId);
socket.off('character:updated', onCharacterUpdated);
socket.off('character:created', onCharacterCreated);
+309
View File
@@ -1753,3 +1753,312 @@ table.data-table tbody tr.is-mine:hover {
color: var(--success-strong);
background: rgba(95, 138, 79, 0.18);
}
/* ---------------------------------------------------------------------- */
/* The campaign page: one part at a time, picked from a row of buttons; */
/* lists as short rows; and the pop-out a row opens */
/* ---------------------------------------------------------------------- */
.campaign-search {
width: 100%;
margin-bottom: 0.75rem;
}
.section-nav {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 1.25rem;
}
.section-nav .jump-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
white-space: nowrap;
}
.jump-link.is-active {
border-color: var(--accent);
color: var(--accent-strong);
background: var(--accent-soft);
}
.section-count {
font-family: var(--font-body);
font-size: 0.75rem;
color: var(--text-dim);
}
.section-block + .section-block {
margin-top: 2rem;
}
.search-group + .search-group {
margin-top: 1.25rem;
}
.entry-list {
border: 1px solid var(--border-soft);
border-radius: var(--radius);
overflow: hidden;
}
.entry-list:empty {
display: none;
}
.entry-row {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.6rem 0.75rem;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-soft);
color: var(--text);
text-align: left;
cursor: pointer;
}
.entry-row:last-child {
border-bottom: none;
}
.entry-row:hover,
.entry-row:focus-visible {
background: var(--bg-card-hover);
text-decoration: none;
outline: none;
}
.entry-row-main {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
}
.entry-row-name {
font-family: var(--font-display);
color: var(--accent-strong);
}
/* A few words only: the whole of it is in the pop-out. */
.entry-row-meta {
color: var(--text-muted);
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entry-row-badges {
flex: none;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.3rem;
max-width: 45%;
}
.entry-row-chevron {
flex: none;
color: var(--text-dim);
font-size: 1.3rem;
line-height: 1;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 96;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 5vh 1rem;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(2px);
}
.modal {
display: flex;
flex-direction: column;
width: min(620px, 100%);
max-height: 90dvh;
background: linear-gradient(180deg, #1c160d 0%, #120e08 100%);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-deep);
}
.modal:focus {
outline: none;
}
.modal.is-wide {
width: min(860px, 100%);
}
.modal-head {
flex: none;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 0.9rem 0.6rem 0.75rem 1.2rem;
border-bottom: 1px solid var(--border-soft);
}
.modal-title {
font-size: 1.2rem;
margin: 0;
overflow-wrap: anywhere;
}
.modal-body {
overflow-y: auto;
padding: 1rem 1.2rem 1.25rem;
}
.modal-body .field input,
.modal-body .field select {
width: 100%;
}
.modal-foot {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-soft);
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;
}
@media (max-width: 900px) {
/* The parts of the campaign are one line that scrolls sideways, edge to edge. */
.section-nav {
flex-wrap: nowrap;
overflow-x: auto;
margin: 0 -0.85rem 1rem;
padding: 0 0.85rem 0.35rem;
scrollbar-width: none;
}
.section-nav::-webkit-scrollbar {
display: none;
}
.section-nav .jump-link {
flex: none;
}
.entry-row {
padding: 0.75rem 0.7rem;
}
/* A pop-out is the whole screen, over the bottom bar too. */
.modal-backdrop {
padding: 0;
}
.modal,
.modal.is-wide {
width: 100%;
height: 100dvh;
max-height: none;
border: none;
border-radius: 0;
}
.modal-head {
padding: 0.7rem 0.4rem 0.6rem 0.85rem;
}
.modal-body {
flex: 1;
padding: 0.9rem 0.85rem calc(1.5rem + env(safe-area-inset-bottom, 0px));
}
}
/* At the top of the pop-out of something added a moment ago. */
.added-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--success);
border-radius: var(--radius);
background: rgba(95, 138, 79, 0.14);
color: var(--success-strong);
}
+35
View File
@@ -0,0 +1,35 @@
import { ABILITY_ORDER } from './previewCalc';
import { formatMoney } from './format';
import type { Monster, Npc } from '../api/types';
// One thing in a campaign's lists that opens in a pop-out.
export type EntryKind = 'weapon' | 'item' | 'spell' | 'monster' | 'npc';
export interface EntryRef {
kind: EntryKind;
id: string;
// It was added by hand a moment ago: its pop-out offers to add another.
isNew?: boolean;
}
export type MonsterInput = Omit<Monster, 'id' | 'campaignId'>;
export type AbilityScores = Pick<Monster, 'strScore' | 'dexScore' | 'conScore' | 'intScore' | 'wisScore' | 'chaScore'>;
export const scoreField = (ability: string) => `${ability.toLowerCase()}Score` as keyof AbilityScores;
// "STR 8 · DEX 14 · …", in stat-block order.
export function abilityLine(scores: AbilityScores): string {
return ABILITY_ORDER.map((a) => `${a} ${scores[scoreField(a)]}`).join(' · ');
}
// "1 gp = $5": the rate an NPC changes gold and cash at, or null if they don't.
export function rateLabel(npc: Pick<Npc, 'exchangeGold' | 'exchangeCash'>): string | null {
if (!npc.exchangeGold || !npc.exchangeCash) return null;
return `${formatMoney(npc.exchangeGold, 'GOLD')} = ${formatMoney(npc.exchangeCash, 'CASH')}`;
}
// Whether every word of a search is somewhere in the text given: "fire bolt"
// finds "Fire Bolt", and "bolt fire" does too.
export function matchesSearch(query: string, ...fields: (string | number | null | undefined)[]): boolean {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const text = fields.filter((f) => f !== null && f !== undefined).join(' ').toLowerCase();
return words.every((word) => text.includes(word));
}
+11
View File
@@ -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}`;
File diff suppressed because it is too large Load Diff
+53 -8
View File
@@ -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>
)}
@@ -689,7 +732,7 @@ export function CharacterSheetPage() {
</div>
))}
{character.weapons.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No weapons. The DM hands these out.</p>}
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} />
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} onJump={weaponsPage.setPage} />
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedWeaponId} onChange={(e) => setPickedWeaponId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
@@ -744,7 +787,7 @@ export function CharacterSheetPage() {
</div>
))}
{character.spells.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No spells known. The DM grants these.</p>}
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} />
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} onJump={spellsPage.setPage} />
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedSpellId} onChange={(e) => setPickedSpellId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
@@ -836,7 +879,7 @@ export function CharacterSheetPage() {
</div>
))}
{character.inventoryItems.length === 0 && <p style={{ color: 'var(--text-muted)' }}>Nothing carried. The DM hands out items.</p>}
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} />
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} onJump={itemsPage.setPage} />
{isDM && (
<div className="flex-row mt-sm flex-wrap">
<select value={pickedItemId} onChange={(e) => setPickedItemId(e.target.value)} style={{ flex: 1, minWidth: 160 }}>
@@ -955,7 +998,7 @@ export function CharacterSheetPage() {
</div>
))}
</div>
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} />
<Pagination page={ledgerPage.page} pageCount={ledgerPage.pageCount} onPrev={ledgerPage.prevPage} onNext={ledgerPage.nextPage} onJump={ledgerPage.setPage} />
</div>
</>
)}
@@ -1059,9 +1102,11 @@ export function CharacterSheetPage() {
</div>
))}
</div>
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} />
<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>
);
}
+25 -6
View File
@@ -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';
@@ -22,7 +24,8 @@ 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 } = usePagination(characters, 12);
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>
@@ -101,7 +119,8 @@ export function PartyOverviewPage() {
</table>
</div>
)}
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} />
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
</div>
);
}
+26 -2
View File
@@ -1,9 +1,33 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import compression from 'compression'
import { defineConfig, type Connect, type Plugin } from 'vite'
// `vite preview` is what serves the built site, so the two things a web
// server would be set up to do are done here.
const servePreview: Plugin = {
name: 'serve-preview',
configurePreviewServer(server) {
const assets = join(server.config.root, server.config.build.outDir)
// Typed for Express, and at home in any Node server.
server.middlewares.use(compression() as Connect.NextHandleFunction)
// A built file carries a hash of its contents in its name, so a browser
// can keep it for good and never ask again. Only for files that are
// there: a name from an older build must not be remembered as missing.
server.middlewares.use((req, res, next) => {
const path = (req.url ?? '').split('?')[0]
if (path.startsWith('/assets/') && !path.includes('..') && existsSync(join(assets, path))) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
}
next()
})
},
}
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
plugins: [react(), servePreview],
preview: {
host: true,
+184 -4
View File
@@ -1,23 +1,24 @@
{
"name": "dnd-campaign-manager",
"version": "1.12.1",
"version": "1.14.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.12.1",
"version": "1.14.1",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.12.1",
"version": "1.14.1",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"bcrypt": "^6.0.0",
"compression": "^1.8.2",
"cors": "^2.8.5",
"dotenv": "^18.0.6",
"express": "^5.2.1",
@@ -32,7 +33,7 @@
}
},
"frontend": {
"version": "1.12.1",
"version": "1.14.1",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
@@ -42,10 +43,12 @@
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@types/compression": "^1.8.1",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"compression": "^1.8.2",
"oxlint": "^1.81.0",
"typescript": "~7.0.2",
"vite": "^8.3.0"
@@ -2740,6 +2743,38 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/express": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -2838,6 +2873,31 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz",
"integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@@ -2845,6 +2905,13 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -2899,6 +2966,20 @@
"pg-types": "^2.2.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
@@ -2919,6 +3000,27 @@
"@types/react": "^19.3.0"
}
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -4449,6 +4551,65 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz",
"integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"destroy": "1.2.0",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/compression/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/compression/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/confbox": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz",
@@ -4791,6 +4952,16 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -7535,6 +7706,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.12.1",
"version": "1.14.1",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [