Compare commits

...
19 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 e7b6e0065a V1.22.0: campaign settings for dice, scores, multiclassing, auto-rolls, XP and crits
- Settings grouped into Characters, Dice & rolls, Combat and Progression
- Dice: digital, physical or both; a campaign's own choice applies to every character
- Ability scores by 4d6, the standard array or point buy (27 points, 8 to 15)
- Track XP can be turned off for milestone levelling: XP is hidden and not awarded
- New settings saved ahead of their features: multiclassing, auto-rolled monster
  and NPC attacks, roll visibility, hidden monster rolls, HP on level up, crit rule
- docs/CAMPAIGN_SETTINGS.md lists every setting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:12:05 +02:00
joshuaandClaude Opus 5.5 57fba68a48 V1.21.0: a DM's toolkit: the battle at a glance, subclasses, character state, rolls, XP
Seven steps in one commit, 1.15.0 to 1.21.0. They change the same files
and were not saved apart, so they go in together.

- The battle at a glance (1.15). A strip across the top of an encounter
  lists everyone in turn order and stays in view; a click goes to that
  card. On a wide screen the stat block of whoever is on turn stays
  beside the cards, and Pin keeps anyone else's there. Saving throws,
  every skill and passive Perception, Investigation and Insight are on
  encounter cards and in the character panel, which "Sheet" on a card
  and a button on the party overview open. For the DM, N is Next Turn
  and S opens the sheet of whoever is on turn. The party overview shows
  passive scores and spell save DC.
- Subclasses (1.16). Three per class, picked in the level-up checklist
  at level 2 (cleric, druid, wizard) or 3, shown beside the class
  everywhere, and changed by the DM from the sheet: the old one's lines
  come out of Special Abilities and the new one's go in. Characters
  already at that level keep the path they were on.
- Character state (1.17). Inspiration, exhaustion and conditions on the
  sheet and in the panel, and counters for what a class does a set
  number of times (Rage, Ki, Channel Divinity...), which the player
  spends and a rest gives back. A long rest takes a level of exhaustion
  off. A character's conditions are their own now: they stay from one
  encounter to the next.
- Faster battles (1.18). Up to 20 of a monster added at once, numbered.
  One button rolls initiative for the monsters and NPCs. A condition can
  be given a number of rounds and then comes off by itself. Hovering
  over a condition shows its rule, and damaging someone who is
  Concentrating shows the save to call for.
- Rolls from the sheet (1.19). A number with a dotted underline rolls
  when clicked, with its bonus: skills, saves, ability checks, attacks,
  damage, spell dice, and a monster's attack, damage and abilities. The
  dice roller has advantage and disadvantage. "Call for a roll" rolls
  one check or save for the whole party. Rolls stay in the browser.
- XP and difficulty (1.20). An encounter shows the DM how hard it is,
  Trivial to Deadly, from the monsters' challenge ratings against the
  characters in it, and what it is worth in XP; one button shares that
  out, once. The sheet and the party overview show XP and when a level
  is due. Levelling up stays the DM's decision.
- Subclasses are optional per campaign (1.21). With "Characters choose a
  subclass" unticked in the campaign's Settings nobody is asked, each
  class follows its first subclass, and none is named.
- Conditions, exhaustion, counters and subclass features are reminders
  for the table: none of them changes a number on the sheet.
- The docs cover all of it, with generated tables of what each subclass
  adds.
- New migrations: a character's subclass; inspiration, exhaustion,
  conditions and spent counters on a character; the rounds left on a
  combatant's conditions; the XP awarded for an encounter; the
  campaign's subclass setting.
- All packages at version 1.21.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:36:27 +02:00
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
joshuaandClaude Opus 5.5 0ddf9846a2 V1.12.1: each library entry goes into a campaign once
- A weapon, item, spell or monster from the library can be added to a
  campaign once. Adding it again is refused: "Longsword is already in
  this campaign."
- The library list leaves out what the campaign already has, so what is
  left is what can still be added. When nothing is left it says so.
- An entry counts as added while the campaign has one by that name,
  whatever its capitals. Rename or delete the campaign's copy and the
  library offers it again. This also covers what a world's seed put in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:09:23 +02:00
joshuaandClaude Opus 5.5 9c97d61ce5 V1.12.0: worlds from a code, shops and cash in the seeds, up-to-date packages
- A DM can start a campaign from a ready-made world by typing its
  six-character code: "+ New Campaign" on the Campaigns page, then "Or
  start from a ready-made world". The whole world is built in one go
  with them as its DM: locations, weapons, items, spells, monsters,
  NPCs, encounters and notes. ALDENF is Aldenfell, HOLLOW is Hollowcrown.
- A world is one file in /backend/seeds. Drop another in and its code
  works straight away, with no restart. "npm run seeds:check" lists
  every world, or what is wrong with one: a misspelt field, a name used
  twice, an encounter pointing at something the file does not have. A
  file with a mistake in it is never offered. docs/SEEDS.md has the
  format.
- A seed can now hold everything a campaign can: gear effects, an NPC's
  wares and exchange rate, things for sale at an encounter, and rewards
  in silver, copper and cash.
- The database seed creates the admin account and nothing else. The
  Aldenfell and Hollowcrown seed commands are gone: worlds come from
  codes.
- Hollowcrown follows the V3 book: 4 starting items and 3 things
  equipped, the book's effects on every weapon and item, armor as items
  (library, modern and magic), worn gear at the book's levels, damage
  types on monsters, ten shop scenes with prices and stock, five money
  changers, Meridian loot as cash and mixed hoards as gold, silver and
  copper.
- Aldenfell: the Shield and Leather Armor change AC, Mireille and the
  Founding Fair stalls sell things, and the book's missing scenes are in
  (E5, E7, E15, E16, E18, E23-24, E26, the Drowned Shrine, Sevrisk's
  Hollow, a wyvern pass). The locket is found with Investigation, as the
  book has it.
- Packages are up to date: Prisma 7 (with the pg driver adapter and a
  prisma.config.js that holds the database URL), Express 5, zod 4,
  dotenv 18, jest 30, bcrypt 6 and TypeScript 7. "npm audit" finds
  nothing. Install scripts are approved for Prisma and bcrypt. On a
  server: "npm install", then "npm run deploy".
- The frontend's five lint warnings are fixed; useAuth and usePagination
  have files of their own.
- All packages at version 1.12.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:03:16 +02:00
joshuaandClaude Opus 5.5 b82084276a V1.11.1: HP and money are the DM's to change
- A player can no longer heal or hurt their own character: the Damage,
  Heal and Temp HP controls on the sheet are the DM's, and the server
  refuses the change from anyone else. Players still roll their own
  death saves.
- A player can no longer add money to their own purse or write it off:
  recording a gain or a spend is the DM's, in any coin or in cash, on
  the sheet and on the server.
- Players still exchange gold, silver and copper on the sheet, give
  money to another character, and buy from or change money with an NPC.
- All packages at version 1.11.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:05:24 +02:00
joshuaandClaude Opus 5.5 0124e3bc53 V1.11.0: gear that applies itself, equipping that matters, a character panel
- Weapons and items can carry effects: bonuses and penalties to AC, speed,
  initiative, max HP, passive Perception, ability scores, saving throws
  and skills, plus a note. They apply on their own while equipped and stop
  when the thing comes off. Armor is an item with an AC effect. The DM
  sets them with "Effects" on a row in the weapon or item pool.
- The sheet's "armor bonus" is now "Extra AC", the DM's own adjustment on
  top of gear, and only the DM can set it.
- Only what is equipped counts: a weapon has to be equipped to be used in
  an encounter. A character can have two things equipped at once, one of
  them a weapon (campaign settings). Equipping at the limit is refused
  with a message, and can be changed at any time, mid-encounter included.
- A built-in item library: armor, shields, worn gear and supplies, with
  their effects set. The weapon library gains paired weapons (a pair of
  daggers is one weapon).
- A floating character button on every page opens a panel with a
  character's numbers and everything they hold, with equip ticks. Players
  get their own characters, the DM everyone's, and each character card in
  an encounter has an Inventory button. It replaces the quick stats drawer.
- A player's own characters stand out: marked and highlighted in
  encounters, with a button that jumps to their card; highlighted on the
  party screen; and listed once, not twice, on the campaign page.
- All packages at version 1.11.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:03:53 +02:00
joshuaandClaude Opus 5.5 2dcb41ae4a V1.10.1: a phone-friendly layout, easier navigation, paged encounters
- The main destinations are always on screen: Campaigns, Campaign and
  Party (for the campaign last visited, from any page) and Account. They
  are links along the top bar on a wide screen, and a bar along the
  bottom on a phone or small tablet (900px or less).
- Nothing floats over the page on a phone: the dice roller is the last
  button of the bottom bar, and quick stats sits in the top bar.
- The campaign page and the character sheet start with a row of links to
  their sections.
- On a small screen every table is a stack of labelled cards, rows of
  controls wrap, fields shrink to fit, and things to tap are bigger.
- The encounter list is paged, ten to a page, as is the archived list.
  Moving an encounter still works across pages.
- Text boxes with no type given, and native checkboxes and dropdowns, now
  match the dark theme. Long monster notes and item descriptions wrap
  inside their table cell.
- All packages at version 1.10.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:37:31 +02:00
joshuaandClaude Opus 5.5 c852ecb2e4 V1.10.0: NPCs change money and sell things
- An NPC can change gold and cash at a rate of their own ("2 gold = $15"),
  the same both ways. Only gold and cash are taken: no silver, no copper
  and no change, and the amount has to be a whole multiple of the rate.
- An NPC can sell weapons and items from the campaign pools, each with a
  price in any coin or in cash, a level and an optional stock. The wares
  follow the NPC into every quest. An encounter can sell things itself
  too, for a stall with nobody behind it.
- Trading is open only in an NPC quest in progress, never in a battle,
  with an NPC who is in the scene and standing, for a character who is in
  it. Players see every rate and ware and trade for their own character
  in a new Trade card; the DM can trade for anyone.
- Only the DM sets trade up: "Trade" on an NPC's row on the campaign page,
  and the DM's part of the Trade card for an encounter's own wares. A
  ware's price and stock can be edited afterwards.
- Every exchange and purchase is written to the character's money log.
- All packages at version 1.10.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:45:40 +02:00
joshuaandClaude Opus 5.5 bdce3399ee V1.9.0: full monster stat blocks, and cash ($)
- "+ Add Monster" takes the whole stat block: challenge rating, speed, the
  six ability scores, special trait and notes, as well as name, AC, HP,
  attack and damage. Damage can name its type ("1d6+2 necrotic").
- Monsters can be edited: "Edit" on a row opens the same form filled in.
  Copies already in encounters show the new stat block.
- The monster list shows each full stat block, the library picker shows
  speed and ability scores too, and removing a monster asks first.
- Characters have a fourth balance, cash ($), for crossover worlds. It is
  gained, spent, given and awarded like a coin, but it has no worth in
  coins: it is never exchanged or used to make change.
- `npm test` in the backend runs again (the script pointed at a jest path
  that does not exist in the workspace).
- All packages at version 1.9.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:15:55 +02:00
joshuaandClaude Opus 5.5 f764878960 V1.8.1: combatants who have had their turn move to "Next Round"
- In a battle in progress, whoever is on turn is at the top of the list
  with those still to act beneath them. Each combatant whose turn has
  passed drops to a "Next Round" section at the bottom, and a new round
  puts everyone back.
- All packages at version 1.8.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:49:44 +02:00
joshuaandClaude Opus 5.5 6b466d497e V1.8.0: the fallen are hidden, and allies can revive the dead
- A dead character, or a monster or NPC at 0 HP, is out of the fight: the
  server no longer sends it to players, target marks included, and it has
  no turn. Healing or reviving brings it straight back.
- The DM's screen leaves the fallen out too until "Show dead" is ticked,
  which lists them in a group of their own, marked hidden.
- Players see dead characters by name in a Fallen box. The player whose
  character is on turn in a battle gets "Try revive" there if that
  character is on their feet and knows a spell that revives, at a level
  they can cast. The dead come back at 1 HP.
- Spells have a "Revives" tick in the campaign's spell list. Revivify and
  Raise Dead have it, in the library and in existing campaigns.
- A dead character cannot be added to an encounter until revived.
- All packages at version 1.8.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:25:46 +02:00
joshuaandClaude Opus 5.5 f8462a6e31 V1.7.2: the combatant on turn cannot be their own target
- In a battle under way, whoever is on turn has no Target button on their
  card, and the server refuses a target list that includes them.
- All packages at version 1.7.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:28:28 +02:00
joshuaandClaude Opus 5.5 754eb0ee68 V1.7.1: area attack AC penalty is a campaign setting
- The amount an area attack adds to each target's AC per extra target is
  now set per campaign, from 0 (no penalty) to 10. It starts at 2, so
  existing campaigns behave as before.
- The DM changes it in the campaign settings; the attack bar and the
  targets' cards use it.
- All packages at version 1.7.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:11:46 +02:00
121 changed files with 22245 additions and 4592 deletions
+133 -34
View File
@@ -2,7 +2,7 @@
A web app for a D&D 5e Dungeon Master and their players to run a campaign
together: campaign management, a guided dice-roll character builder, live
character sheets, a mid-combat "quick stats" panel, weapons/inventory/spells,
character sheets, a character panel on every page, weapons/inventory/spells,
monsters, and a real-time encounter/combat tracker.
## How it works
@@ -11,13 +11,15 @@ 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/WEAPONS.md` | The weapon pool, weapon levels and how damage grows |
| `docs/SPELLS.md` | The spell list, who can use which spells, the spell library |
| `docs/ITEMS.md` | The item pool and character inventory |
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, what players see |
| `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, 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 |
| `docs/NPCS.md` | Non-player characters |
| `docs/NPCS.md` | Non-player characters, what they sell and the rates they change money at |
| `docs/CAMPAIGN_SETTINGS.md` | Every campaign setting: dice, ability scores, subclasses and multiclassing, auto-rolled attacks, XP, crits |
| `docs/SEEDS.md` | World seeds: ready-made campaigns a DM starts from a code, and how to add one |
`CLAUDE_BUILD_PROMPT.md`, `docs/GAME_RULES_REFERENCE.md` and
`docs/ROLL_YOUR_CHARACTER.md` are the original specifications the first
@@ -25,14 +27,99 @@ version was built from. The app has since moved on in places (gear levels,
the builder's story steps, picking a motivation); the documents above are the
current behaviour.
## Getting around
The same four destinations are always on screen: **Campaigns** (the list),
**Campaign** and **Party** (for the campaign you were last in, from any page,
including a character sheet or an encounter) and **Account**.
- On a wide screen they are links along the top bar, and the dice roller
floats at the bottom left. **A number with a dotted underline rolls when
clicked** (a skill, a save, an attack, damage), with the bonus already in
and advantage or disadvantage if the roller is set to it. The DM's "🎲 Call
for a roll…" rolls one check for the whole party.
- On a phone or small tablet (900px wide or less) they are a bar along the
bottom of the screen, with the **dice roller** as its last button. Nothing
floats over the page: the 🧙 character button sits in the top bar.
- The **🧙 character button** is on every page (floating at the bottom right
on a wide screen). It opens a panel with your character's numbers, saving
throws, skills and everything they hold; the DM can open it for any
character. "📋 Sheet" on an encounter card and 📋 on the party overview open
the same panel for that character, without leaving the page.
- **In an encounter** a strip across the top lists everyone in turn order and
stays in view; a click goes to that card. On a wide screen the stat block
of whoever is on turn stays in view beside the cards, and **📌 Pin** keeps
anyone else's there instead. For the DM, **N** is Next Turn and **S** opens
the sheet of whoever is on turn.
- The DM adds **several of a monster at once** (numbered for them), rolls
initiative for all the monsters and NPCs with one button, and can give a
**condition a number of rounds**, after which it comes off by itself.
Hovering over a condition shows its rule, and damaging someone who is
Concentrating shows the save to call for.
- The **campaign page shows one part at a time**, picked from a row of
buttons under its name: Overview (characters and encounters), NPCs,
Locations, Weapons, Items, Spells, Monsters, Notes and Settings for the DM;
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.
- Each encounter shows the DM **how hard it is** (Easy to Deadly, from the
monsters' challenge ratings against the party) and what it is worth in
**XP**, which one button shares out. A sheet shows XP and when a level is
due; levelling up stays the DM's call.
- A character's **condition and counters** are on the sheet and in the panel:
inspiration, exhaustion, conditions (which stay with the character from one
encounter to the next) and class counters such as Rage and Ki, which the
player spends and a rest gives back.
- Every class has **three subclasses**, picked in the level-up checklist at
level 2 or 3 and shown beside the class everywhere; the DM can change one
from the sheet. A campaign can do without them: untick "Characters choose a
subclass" in its Settings and every class follows one path, unasked.
- **Campaign settings** come in four groups. **Characters**: starting gold
and gear, how ability scores are made (4d6, standard array or point buy),
subclasses, multiclassing. **Dice & rolls**: digital, physical or both, and
who sees a roll. **Combat**: auto-rolled monster and NPC attacks, the
critical hit rule, monster HP, the area penalty. **Progression**: XP
tracking (off for milestone levelling) and HP on a level up.
- **⬆ Level Up…** on a sheet (DM) takes the character up a level with the
whole checklist: HP, the class's new special abilities, a Boon, spells and
the free weapon upgrade.
- 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.
## Stack
- **Backend:** Node.js + Express, JavaScript, Prisma ORM, PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation.
- **Backend:** Node.js + Express, JavaScript, Prisma ORM (with the `pg` driver adapter), PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation.
- **Frontend:** React + Vite + TypeScript, React Router, TanStack Query, socket.io-client.
## Prerequisites
- Node.js 18+ (tested on Node 22)
- Node.js 20.19+ (tested on Node 24)
- A running PostgreSQL instance (local install, Docker, or a hosted service like Neon/Supabase)
## First-time setup
@@ -44,7 +131,8 @@ npm install # installs both workspaces (backend + frontend)
cd backend
cp .env.example .env # edit DATABASE_URL and JWT_SECRET
npx prisma migrate dev --name init
npx prisma db seed # adds the admin account + the Aldenfell campaign if missing (see output for login/invite code)
npx prisma generate # migrate no longer does this itself
npx prisma db seed # adds the admin account if it is missing (see output for the login and the world codes)
# Frontend
cd ../frontend
@@ -61,31 +149,38 @@ npm run dev:frontend # Vite dev server, prints its own port (5173 by default)
```
Open the frontend URL, register an account (or log in with the seeded admin
account — see the seed script's console output for credentials and invite
code), and start playing.
account — see the seed script's console output for the credentials), and
start playing.
The seed only adds: it never touches an existing account or campaign, so it is
safe to run on a server with real data. It skips the campaign if the admin
already has an active "Aldenfell"; archive or delete that one in the app
(Campaign Settings) and run it again for a fresh copy. `SEED_ADMIN_EMAIL`,
`SEED_ADMIN_PASSWORD` and `SEED_ADMIN_NAME` set the account it creates.
`npx prisma db seed -- --reset` wipes every user and campaign first.
The database seed creates the admin account and nothing else. It only adds: an
existing account, and its password, is left alone, so it is safe to run on a
server with real data. `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD` and
`SEED_ADMIN_NAME` set the account it creates. `npx prisma db seed -- --reset`
wipes every user and campaign first.
A second campaign, Hollowcrown: The Rift Between Worlds, has its own seed and
is not part of the default one. It follows the same rules (it only adds, and
skips if the admin already has an active Hollowcrown):
## Worlds
```bash
cd backend
npm run seed:hollowcrown
```
Campaigns are created in the app: blank, or from a **world seed** by its
code. On the Campaigns page click "+ New Campaign" and enter a code under "Or
start from a ready-made world"; the whole world is built for you as its DM.
On a server, after pulling a new version, run `npm run deploy` in `backend`
| Code | World |
|---|---|
| `ALDENF` | Aldenfell |
| `HOLLOW` | Hollowcrown: The Rift Between Worlds |
Each world is one file in `/backend/seeds`. Drop another file in and its code
works straight away, with no restart; `npm run seeds:check` (from the repo
root or `backend`) checks every file and says what is wrong with one.
`docs/SEEDS.md` has the format.
On a server, after pulling a new version, run `npm install` in the repo root
(when dependencies changed) and then `npm run deploy` in `backend`
and then restart the backend. It applies new migrations **and** regenerates
the Prisma client; without the second half the server keeps running with the
old data model and pages that use anything new fail to load (for example
"Could not load this encounter"). Change your password from the account page
(click your name in the top bar).
("Account" in the navigation).
## Tests
@@ -94,8 +189,9 @@ cd backend
npm test
```
The calculation engine (`src/game/calculations.js`) is fully unit tested and
runs with no external dependencies. The route-level integration tests in
The calculation engine (`src/game/calculations.js`) and the world seeds
(`src/seeds`, which also checks every file in `/backend/seeds`) are unit
tested and run with no external dependencies. The route-level integration tests in
`src/routes/api.integration.test.js` need a real, migrated database reachable
via `DATABASE_URL` — they skip themselves gracefully (with a console warning)
if the DB isn't reachable, so `npm test` still passes in an environment with
@@ -105,14 +201,17 @@ no database configured.
```
/backend — Express + Prisma API, Socket.io server
/prisma — schema.prisma, seed.js
/seed-data — reference JSON (weapons seeded per-campaign; species/classes/skills
are ported into src/game/data/*.js and used directly by the
calculation engine, not stored as DB rows)
prisma.config.js — where the Prisma CLI finds the schema, the database and the seed
/prisma — schema.prisma, migrations, seed.js (the admin account only)
/seeds — world seeds: one file per ready-made campaign, created by code
/seed-data — reference JSON (species/classes/skills/weapons, ported into
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
/sockets — Socket.io room wiring
/frontend — React + Vite + TypeScript app
/docs — game rules & character-builder specs (reference material)
+12 -13
View File
@@ -1,37 +1,36 @@
{
"name": "backend",
"version": "1.7.0",
"version": "1.22.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
"prisma": {
"seed": "node prisma/seed.js"
},
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js",
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"test": "jest",
"docs:lists": "node scripts/generate-doc-lists.js",
"prisma:migrate": "prisma migrate dev",
"prisma:generate": "prisma generate",
"prisma:seed": "prisma db seed",
"seeds:check": "node scripts/check-seeds.js",
"deploy": "prisma migrate deploy && prisma generate",
"seed:hollowcrown": "node prisma/seed-hollowcrown.js",
"prisma:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"bcrypt": "^6.0.0",
"compression": "^1.8.2",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
"dotenv": "^18.0.6",
"express": "^5.2.1",
"jsonwebtoken": "^9.0.2",
"socket.io": "^4.8.0",
"zod": "^3.23.8"
"zod": "^4.6.5"
},
"devDependencies": {
"jest": "^29.7.0",
"prisma": "^5.20.0",
"jest": "^30.5.2",
"prisma": "^7.10.0",
"supertest": "^7.0.0"
}
}
+15
View File
@@ -0,0 +1,15 @@
// Prisma CLI configuration: where the schema and migrations live, which
// database to run them against, and the command behind `prisma db seed`.
require('dotenv').config({ quiet: true });
const { defineConfig, env } = require('prisma/config');
module.exports = defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
seed: 'node prisma/seed.js',
},
datasource: {
url: env('DATABASE_URL'),
},
});
@@ -0,0 +1,2 @@
-- AlterTable: how much an area attack adds to each AC per extra target
ALTER TABLE "campaigns" ADD COLUMN "areaAttackAcPenalty" INTEGER NOT NULL DEFAULT 2;
@@ -0,0 +1,5 @@
-- AlterTable: spells that bring back the dead
ALTER TABLE "spells" ADD COLUMN "revives" BOOLEAN NOT NULL DEFAULT false;
-- The library's two revival spells, wherever a campaign already has them.
UPDATE "spells" SET "revives" = true WHERE "name" IN ('Revivify', 'Raise Dead');
@@ -0,0 +1,6 @@
-- AlterEnum: cash ($), a modern currency for crossover worlds. It has no fixed
-- worth in gold, so it is never exchanged or used to make change.
ALTER TYPE "Currency" ADD VALUE 'CASH';
-- AlterTable: cash is counted on its own, like each coin
ALTER TABLE "characters" ADD COLUMN "cash" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,28 @@
-- AlterTable: an NPC who changes money does it at their own rate, this many
-- gold for that much cash. Both are null for an NPC who does not.
ALTER TABLE "npcs" ADD COLUMN "exchangeGold" INTEGER;
ALTER TABLE "npcs" ADD COLUMN "exchangeCash" INTEGER;
-- CreateTable: something for sale, by an NPC or at an encounter itself
CREATE TABLE "sale_offers" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"npcId" TEXT,
"encounterId" TEXT,
"weaponId" TEXT,
"itemId" TEXT,
"price" INTEGER NOT NULL,
"currency" "Currency" NOT NULL DEFAULT 'GOLD',
"level" INTEGER NOT NULL DEFAULT 1,
"stock" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sale_offers_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_npcId_fkey" FOREIGN KEY ("npcId") REFERENCES "npcs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_weaponId_fkey" FOREIGN KEY ("weaponId") REFERENCES "weapons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,7 @@
-- AlterTable: a weapon or item can change the numbers on the sheet of whoever
-- has it equipped. `effects` is a list of { stat, amount }; `effectNote` is
-- for what it does that is not a number ("Disadvantage on Stealth").
ALTER TABLE "weapons" ADD COLUMN "effects" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "weapons" ADD COLUMN "effectNote" TEXT;
ALTER TABLE "items" ADD COLUMN "effects" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "items" ADD COLUMN "effectNote" TEXT;
@@ -0,0 +1,4 @@
-- AlterTable: how much a character can have equipped at once. Only equipped
-- gear can be used in an encounter or gives its effects.
ALTER TABLE "campaigns" ADD COLUMN "maxEquipped" INTEGER NOT NULL DEFAULT 2;
ALTER TABLE "campaigns" ADD COLUMN "maxEquippedWeapons" INTEGER NOT NULL DEFAULT 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;
@@ -0,0 +1,24 @@
-- AlterTable: the path a character takes within their class, as a key from
-- game/data/subclasses.js.
ALTER TABLE "characters" ADD COLUMN "subclass" TEXT;
-- Until now every class followed one path, written onto the sheet as the
-- character levelled. Finished characters already at the level their class
-- chooses at are given that path as their subclass.
UPDATE "characters" SET "subclass" = CASE "characterClass"
WHEN 'BARBARIAN' THEN 'BERSERKER'
WHEN 'BARD' THEN 'LORE'
WHEN 'CLERIC' THEN 'LIFE'
WHEN 'DRUID' THEN 'LAND'
WHEN 'FIGHTER' THEN 'CHAMPION'
WHEN 'MONK' THEN 'OPEN_HAND'
WHEN 'PALADIN' THEN 'DEVOTION'
WHEN 'RANGER' THEN 'HUNTER'
WHEN 'ROGUE' THEN 'THIEF'
WHEN 'SORCERER' THEN 'DRACONIC'
WHEN 'WARLOCK' THEN 'FIEND'
WHEN 'WIZARD' THEN 'EVOCATION'
END
WHERE "buildStage" = 'COMPLETE'
AND "characterClass" IS NOT NULL
AND "level" >= CASE WHEN "characterClass" IN ('CLERIC', 'DRUID', 'WIZARD') THEN 2 ELSE 3 END;
@@ -0,0 +1,20 @@
-- AlterTable: how a character is doing beyond HP, and the class counters
-- (Rage, Ki...) spent since the rest that gives them back, as { key: used }.
ALTER TABLE "characters" ADD COLUMN "resourcesUsed" JSONB NOT NULL DEFAULT '{}',
ADD COLUMN "inspiration" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "exhaustion" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "conditions" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- A character's conditions used to be kept per encounter. They are the
-- character's own now, so those given in an encounter still in progress move
-- onto the character.
UPDATE "characters" c SET "conditions" = held.conditions
FROM (
SELECT p."characterId", array_agg(DISTINCT condition) AS conditions
FROM "encounter_participants" p
JOIN "encounters" e ON e."id" = p."encounterId"
CROSS JOIN LATERAL unnest(p."conditions") AS condition
WHERE p."characterId" IS NOT NULL AND e."status" = 'ACTIVE'
GROUP BY p."characterId"
) held
WHERE c."id" = held."characterId";
@@ -0,0 +1,3 @@
-- AlterTable: conditions that wear off by themselves, as
-- { condition: rounds left }, counted down each time the round goes up.
ALTER TABLE "encounter_participants" ADD COLUMN "conditionRounds" JSONB NOT NULL DEFAULT '{}';
@@ -0,0 +1,3 @@
-- AlterTable: the experience points handed out for an encounter, in all;
-- null until the DM awards them.
ALTER TABLE "encounters" ADD COLUMN "xpAwarded" INTEGER;
@@ -0,0 +1,3 @@
-- AlterTable: a campaign can do without subclasses. Off, nobody chooses one
-- and each class follows its first.
ALTER TABLE "campaigns" ADD COLUMN "subclassesEnabled" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1,30 @@
-- Campaign settings: which dice, multiclassing, auto-rolled monster and NPC
-- attacks, XP tracking, HP on a level up, the ability-score method, who sees
-- a roll, and what a critical hit does.
-- CreateEnum
CREATE TYPE "CampaignDiceMode" AS ENUM ('DIGITAL', 'PHYSICAL', 'BOTH');
-- CreateEnum
CREATE TYPE "HpMethod" AS ENUM ('AVERAGE', 'ROLL');
-- CreateEnum
CREATE TYPE "ScoreMethod" AS ENUM ('ROLL_4D6', 'STANDARD_ARRAY', 'POINT_BUY');
-- CreateEnum
CREATE TYPE "RollVisibility" AS ENUM ('ALL', 'OWN');
-- CreateEnum
CREATE TYPE "CritRule" AS ENUM ('DOUBLE_DICE', 'MAX_PLUS_ROLL');
-- AlterTable
ALTER TABLE "campaigns" ADD COLUMN "abilityScoreMethod" "ScoreMethod" NOT NULL DEFAULT 'ROLL_4D6',
ADD COLUMN "autoRollMonsters" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "autoRollNpcs" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "critRule" "CritRule" NOT NULL DEFAULT 'DOUBLE_DICE',
ADD COLUMN "diceMode" "CampaignDiceMode" NOT NULL DEFAULT 'BOTH',
ADD COLUMN "hideMonsterRolls" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "hpOnLevelUp" "HpMethod" NOT NULL DEFAULT 'AVERAGE',
ADD COLUMN "multiclassEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "rollVisibility" "RollVisibility" NOT NULL DEFAULT 'ALL',
ADD COLUMN "trackXp" BOOLEAN NOT NULL DEFAULT true;
+169 -9
View File
@@ -1,7 +1,8 @@
// D&D Campaign Manager — Prisma schema
// Postgres. Run `npx prisma migrate dev --name init` after `DATABASE_URL` is set,
// then `npx prisma db seed` using the seed script built from /seed-data/*.json
// (see CLAUDE_BUILD_PROMPT.md, "Database" section).
// Postgres. The connection (DATABASE_URL) is set in prisma.config.js, not here.
// Run `npx prisma migrate dev` then `npx prisma generate`; `npx prisma db seed`
// adds the admin account (prisma/seed.js). Worlds are created in the app from
// the seeds in /backend/seeds.
generator client {
provider = "prisma-client-js"
@@ -9,7 +10,6 @@ generator client {
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ---------------------------------------------------------------------------
@@ -118,12 +118,14 @@ enum RewardType {
ITEM
}
// The coins a character carries. Their worth is fixed in utils/grants.js:
// 1 gold = 10 silver = 100 copper.
// The money a character carries. The coins' worth is fixed in utils/grants.js:
// 1 gold = 10 silver = 100 copper. Cash ($) is modern money for crossover
// worlds; it has no worth in coins and is never exchanged for them.
enum Currency {
GOLD
SILVER
COPPER
CASH
}
enum NoteVisibility {
@@ -136,6 +138,42 @@ enum DiceMode {
PHYSICAL
}
// Which dice a campaign plays with. BOTH leaves it to each character (and
// every roll can still be typed in from a real die).
enum CampaignDiceMode {
DIGITAL
PHYSICAL
BOTH
}
// How a character's maximum HP grows on a level up: the fixed average of the
// hit die (rounded up), or a roll of it.
enum HpMethod {
AVERAGE
ROLL
}
// How the builder arrives at the six ability scores.
enum ScoreMethod {
ROLL_4D6
STANDARD_ARRAY
POINT_BUY
}
// Who sees a roll. ALL: everyone at the table. OWN: a player sees their own;
// the DM always sees every roll.
enum RollVisibility {
ALL
OWN
}
// What a critical hit does to the damage: roll the dice twice (the rule), or
// take the dice at their maximum and roll them once more on top.
enum CritRule {
DOUBLE_DICE
MAX_PLUS_ROLL
}
// ---------------------------------------------------------------------------
// Identity & campaigns
// ---------------------------------------------------------------------------
@@ -168,6 +206,37 @@ model Campaign {
skipMotivationRoll Boolean @default(false)
// Off: players see only a health bar for monsters and NPCs, not the numbers.
showMonsterHp Boolean @default(true)
// An attack on several targets is harder to land: each target's AC counts
// as this much higher for every target beyond the first. 0 turns it off.
areaAttackAcPenalty Int @default(2)
// How much a character can have equipped at once: weapons and items
// together, and weapons alone. Only equipped gear can be used in an
// encounter or gives its effects, so this is what a character can bring to
// bear at one time. A pair of daggers is one weapon, made as such.
maxEquipped Int @default(2)
maxEquippedWeapons Int @default(1)
// Off: nobody chooses a subclass. Each class follows its first one without
// being asked, and subclasses are not named anywhere.
subclassesEnabled Boolean @default(true)
// On: a character can take levels in more than one class (game/multiclass.js).
multiclassEnabled Boolean @default(false)
// Which dice are rolled at this table; see CampaignDiceMode.
diceMode CampaignDiceMode @default(BOTH)
// On: when a monster's (or an NPC's) turn comes up in a battle, the server
// picks a target, rolls the attack and the damage, and puts it to the DM to
// accept, change or throw out.
autoRollMonsters Boolean @default(false)
autoRollNpcs Boolean @default(false)
// Off: experience is not kept. No XP on the sheets, nothing to award after
// an encounter, and nobody is shown as due a level.
trackXp Boolean @default(true)
hpOnLevelUp HpMethod @default(AVERAGE)
abilityScoreMethod ScoreMethod @default(ROLL_4D6)
rollVisibility RollVisibility @default(ALL)
// On: the DM's rolls for monsters and NPCs are seen by the DM alone until
// they are put into play.
hideMonsterRolls Boolean @default(true)
critRule CritRule @default(DOUBLE_DICE)
ownerId String
// Set while the campaign is archived: kept in full, but moved off the main
// list and closed to new members until the DM restores it.
@@ -183,6 +252,7 @@ model Campaign {
monsters Monster[]
npcs Npc[]
encounters Encounter[]
saleOffers SaleOffer[]
notes Note[]
locations Location[]
@@ -201,6 +271,7 @@ model Location {
encounters Encounter[]
visits CharacterLocationVisit[]
@@index([campaignId])
@@map("locations")
}
@@ -231,6 +302,7 @@ model CampaignMember {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([campaignId, userId])
@@index([userId])
@@map("campaign_members")
}
@@ -246,6 +318,9 @@ model Character {
name String
species Species?
characterClass CharacterClass?
// The path taken within the class: a key from game/data/subclasses.js. Null
// until the class reaches the level it chooses at.
subclass String?
level Int @default(1)
xp Int @default(0)
motivation String?
@@ -269,17 +344,33 @@ model Character {
deathSaveSuccesses Int @default(0)
deathSaveFailures Int @default(0)
stable Boolean @default(false) // at 0 HP but no longer rolling
armorBonus Int @default(0) // flat AC bonus from equipped armor/shield
// What has been spent since the last rest. Spell slots by slot level (the
// first entry is 1st-level slots); how many there are comes from class and
// level (game/calculations.js, spellSlotsFor). A character has one Hit Die
// per level, spent on short rests.
spellSlotsUsed Int[] @default([])
hitDiceUsed Int @default(0)
// Class counters spent since the rest that gives them back, as
// { key: used } (game/classResources.js says which a character has).
resourcesUsed Json @default("{}")
// How the character is doing beyond HP. Reminders for the table: none of
// them changes a number on the sheet. Conditions are the character's own,
// so they outlast the encounter they were given in.
inspiration Boolean @default(false)
exhaustion Int @default(0) // 0 to 6
conditions String[] @default([])
armorBonus Int @default(0) // a flat AC adjustment the DM sets by hand, on top of what equipped gear gives
speedOverride Int?
// The next three are composed by the builder from the player's picks
// (game/data/characterOptions.js). After that only the DM can edit them.
specialAbilities String? // shown in the quick-stats panel
// The purse: each coin is counted on its own and only changes through the
// The purse: each coin (and cash) is counted on its own and only changes through the
// money ledger (GoldTransaction).
gold Int @default(0)
silver Int @default(0)
copper Int @default(0)
cash Int @default(0) // whole dollars
background String?
appearance String?
@@ -310,6 +401,8 @@ model Character {
encounterEntries EncounterParticipant[]
locationVisits CharacterLocationVisit[]
@@index([campaignId])
@@index([userId])
@@map("characters")
}
@@ -361,12 +454,19 @@ model Weapon {
// Offered in the character builder's starting-equipment step. Off by default:
// the DM opts entries in, so the pool can hold gear that must be earned.
isStartingOption Boolean @default(false)
// What it does to the sheet of whoever has it equipped: a list of
// { stat, amount } (game/calculations.js, GEAR_STATS), and a note for
// anything that is not a number.
effects Json @default("[]")
effectNote String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
assigned CharacterWeapon[]
rewards EncounterReward[]
offers SaleOffer[]
@@index([campaignId])
@@map("weapons")
}
@@ -395,12 +495,18 @@ model Item {
damageScaling Int @default(1)
minLevel Int @default(1)
isStartingOption Boolean @default(false)
// As on a weapon: what it does to whoever has it equipped. Armor is an
// item with an AC effect.
effects Json @default("[]")
effectNote String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
held InventoryItem[]
rewards EncounterReward[]
offers SaleOffer[]
@@index([campaignId])
@@map("items")
}
@@ -455,10 +561,14 @@ model Spell {
duration String?
description String?
isStartingOption Boolean @default(false)
// Brings back the dead: a character who can cast it may revive a dead ally
// on their turn in a battle (encounters.routes.js).
revives Boolean @default(false)
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
known CharacterSpell[]
@@index([campaignId])
@@map("spells")
}
@@ -501,6 +611,7 @@ model Monster {
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
encounterAppearances EncounterParticipant[]
@@index([campaignId])
@@map("monsters")
}
@@ -518,11 +629,17 @@ model Npc {
maxHp Int?
attackBonus Int?
damageDice String?
// A money changer swaps gold and cash at their own rate: exchangeGold gold
// for exchangeCash cash, either way. Both null: this NPC does not exchange.
exchangeGold Int?
exchangeCash Int?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
encounterAppearances EncounterParticipant[]
wares SaleOffer[]
@@index([campaignId])
@@map("npcs")
}
@@ -549,6 +666,9 @@ model Encounter {
archivedAt DateTime?
// Place in the campaign's own order, lowest first. The DM rearranges it.
sortOrder Int @default(0)
// The experience points handed out for this encounter, in all; null until
// the DM awards them. They are awarded once.
xpAwarded Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -556,7 +676,9 @@ model Encounter {
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
participants EncounterParticipant[]
rewards EncounterReward[]
wares SaleOffer[]
@@index([campaignId])
@@map("encounters")
}
@@ -582,9 +704,40 @@ 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")
}
// Something for sale: a weapon or item from the campaign pools, at a price.
// It belongs to an NPC (and is on offer wherever that NPC is met) or to an
// encounter itself (a stall, a vending machine). Only the DM sets these up;
// players see them, and buy, in a quest encounter in progress
// (encounters.routes.js).
model SaleOffer {
id String @id @default(cuid())
campaignId String
npcId String? // exactly one of npcId / encounterId is set
encounterId String?
weaponId String? // exactly one of weaponId / itemId is set
itemId String?
price Int // for one, in `currency`
currency Currency @default(GOLD)
level Int @default(1) // gear level it is handed over at
stock Int? // how many are left; null = no limit
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
npc Npc? @relation(fields: [npcId], references: [id], onDelete: Cascade)
encounter Encounter? @relation(fields: [encounterId], references: [id], onDelete: Cascade)
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")
}
// A row per combatant in an encounter — a character, a monster or an NPC.
// A monster's currentHp/maxHp live here, per instance. A character's HP has
// one source of truth, the Character row: encounters read and write that, and
@@ -604,7 +757,10 @@ model EncounterParticipant {
initiative Int @default(0)
currentHp Int
maxHp Int
conditions String[] @default([]) // e.g. ["poisoned", "prone"]
conditions String[] @default([]) // e.g. ["poisoned", "prone"]; a character's own are on the Character row
// Conditions that wear off by themselves, as { condition: rounds left }.
// Counted down each time the round goes up; at 0 the condition is removed.
conditionRounds Json @default("{}")
isMonster Boolean
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
@@ -612,6 +768,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")
}
@@ -632,5 +790,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")
}
-32
View File
@@ -1,32 +0,0 @@
// Adds the Hollowcrown: The Rift Between Worlds campaign
// (seed-data/hollowcrown.js) for the admin account. Not part of the default
// seed — run it on its own:
//
// npm run seed:hollowcrown
//
// Like every seed it only adds (see seedCampaign.js): it never touches other
// campaigns, and does nothing if the admin already has an active Hollowcrown.
const { run, fromLibrary } = require('./seedCampaign');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../src/game/data/library');
const hollowcrown = require('../seed-data/hollowcrown.js');
const startingSpells = new Set(hollowcrown.startingSpells);
for (const key of startingSpells) fromLibrary(SPELL_LIBRARY, key); // fail early on a misspelt key
run({
...hollowcrown,
// The book's starting picks come from the site library; its modern relics
// and magic weapons are never starting options.
weapons: [
...hollowcrown.libraryWeapons.map((key) => fromLibrary(WEAPON_LIBRARY, key, { isStartingOption: true })),
...hollowcrown.weapons,
],
// The whole spell library goes in, so a spell is there to hand out the
// moment a character unlocks it; only the book's Session 0 list is offered
// in the builder.
spells: [
...SPELL_LIBRARY.map(({ key }) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: startingSpells.has(key) })),
...hollowcrown.spells,
],
monsters: [...hollowcrown.libraryMonsters.map((key) => fromLibrary(MONSTER_LIBRARY, key)), ...hollowcrown.monsters],
});
+48 -26
View File
@@ -1,32 +1,54 @@
// The default seed: the Aldenfell campaign (seed-data/aldenfell.js) for the
// admin account. See seedCampaign.js for what a seed does and why it is safe
// to run against a live database.
// The database seed: makes sure the admin account exists, and nothing else.
//
// npx prisma db seed add what is missing
// npx prisma db seed create the admin account if it is missing
// npx prisma db seed -- --reset DESTRUCTIVE: delete every user, campaign,
// character, note, etc. first
//
// Other campaigns have their own command (npm run seed:hollowcrown).
// It only ever adds, so it is safe against a live database: an existing
// account, and its password, is left alone. SEED_ADMIN_EMAIL /
// SEED_ADMIN_PASSWORD / SEED_ADMIN_NAME override the account that is created.
//
// Species/classes/skills are NOT seeded as rows — they're fixed game-rule
// tables ported into src/game/data/*.js, as is the default weapon/spell/monster
// library DMs pull from. The default weapons (seed-data/weapons.json) ARE
// seeded, into the campaign, since weapons are per-campaign in the schema.
const { run, fromLibrary } = require('./seedCampaign');
const { SPELL_LIBRARY } = require('../src/game/data/library');
const defaultWeapons = require('../seed-data/weapons.json');
const aldenfell = require('../seed-data/aldenfell.js');
// Worlds are not seeded here. A campaign is created in the app, blank or from
// one of the world seeds in /backend/seeds by its code (docs/SEEDS.md).
//
// Species/classes/skills are not rows either: they are fixed game-rule tables
// in src/game/data/*.js, as is the library DMs pull weapons, items, spells
// and monsters from.
const bcrypt = require('bcrypt');
const prisma = require('../src/db');
const { loadSeeds } = require('../src/seeds');
run(
{
...aldenfell,
// Everyday weapons are what a new character picks from; the rest are earned.
weapons: [...defaultWeapons.map((w) => ({ ...w, isStartingOption: true })), ...aldenfell.weapons],
items: aldenfell.items.map(({ starting, ...item }) => ({ ...item, isStartingOption: Boolean(starting) })),
spells: [
...aldenfell.spells.starting.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: true })),
...aldenfell.spells.earned.map((key) => fromLibrary(SPELL_LIBRARY, key, { isStartingOption: false })),
],
},
{ reset: process.argv.includes('--reset') },
);
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL || 'joshua@crosscode.co.za';
const ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD || '12345678';
const ADMIN_NAME = process.env.SEED_ADMIN_NAME || 'Joshua';
async function seed({ reset = false } = {}) {
if (reset) {
// Truncating the two roots cascades through every table that references them.
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
console.log('Database reset: every user and campaign was deleted.');
}
if (await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } })) {
console.log(`Admin account ${ADMIN_EMAIL} already exists — left as it is.`);
} else {
await prisma.user.create({
data: { email: ADMIN_EMAIL, passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12), displayName: ADMIN_NAME },
});
console.log(`Admin/DM login created: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
console.log(' Change that password from the account page ("Account" in the navigation) once you are logged in.');
}
const worlds = loadSeeds().filter((world) => !world.error);
if (worlds.length) {
console.log('Worlds to start a campaign from (Campaigns → New Campaign → world code):');
for (const { code, seed: world } of worlds) console.log(` ${code} ${world.campaign.name}`);
}
}
seed({ reset: process.argv.includes('--reset') })
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
-210
View File
@@ -1,210 +0,0 @@
// Shared by the campaign seeds (seed.js, seed-hollowcrown.js): makes sure the
// admin account exists, then adds one fully-built campaign for it.
//
// A seed only ever ADDS, so it is safe against a live database. The admin
// account is created if it is missing (an existing one, and its password, is
// left alone), and the campaign is added unless that account already owns an
// active campaign of the same name. To get a fresh copy, archive or delete the
// old one in the app and run the seed again.
//
// SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD / SEED_ADMIN_NAME override the
// account that is created.
//
// `content` is one campaign, with every pool as rows shaped like the Prisma
// models minus ids:
// campaign, locations, weapons, items, spells, monsters, npcs,
// sharedNotes, dmNotes (note bodies, in reading order), and
// encounters, in the order they should list:
// { name, mode, description, dmNotes,
// location: a location's name (optional) — where the scene is,
// monsters: [[monster name, count, display name?, max HP?], ...],
// npcs: [NPC name | { name, hp }, ...],
// rewards: [{ gold } | { weapon } | { item }, each with optional
// quantity, level (defaults to the entry's starting level)
// and note] }
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
const { generateInviteCode } = require('../src/utils/inviteCode');
const prisma = new PrismaClient();
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL || 'joshua@crosscode.co.za';
const ADMIN_PASSWORD = process.env.SEED_ADMIN_PASSWORD || '12345678';
const ADMIN_NAME = process.env.SEED_ADMIN_NAME || 'Joshua';
// Notes list newest-first on screen, so they are stamped a second apart,
// backwards: the first entry in the seed data is the one at the top.
function bookOrder(index) {
return new Date(Date.now() - index * 1000);
}
function byName(rows) {
return new Map(rows.map((row) => [row.name, row]));
}
function lookup(map, name, what, encounterName) {
const row = map.get(name);
if (!row) throw new Error(`Encounter "${encounterName}" references unknown ${what} "${name}"`);
return row;
}
async function createCampaign(db, admin, content) {
const campaign = await db.campaign.create({
data: {
...content.campaign,
inviteCode: generateInviteCode(),
ownerId: admin.id,
members: { create: { userId: admin.id, role: 'DM' } },
},
});
const campaignId = campaign.id;
const inCampaign = (rows) => rows.map((row) => ({ ...row, campaignId }));
await db.location.createMany({ data: inCampaign(content.locations) });
await db.weapon.createMany({ data: inCampaign(content.weapons) });
await db.item.createMany({ data: inCampaign(content.items) });
await db.spell.createMany({ data: inCampaign(content.spells) });
await db.monster.createMany({ data: inCampaign(content.monsters) });
await db.npc.createMany({ data: inCampaign(content.npcs) });
const monsters = byName(await db.monster.findMany({ where: { campaignId } }));
const npcs = byName(await db.npc.findMany({ where: { campaignId } }));
const weapons = byName(await db.weapon.findMany({ where: { campaignId } }));
const items = byName(await db.item.findMany({ where: { campaignId } }));
const locations = byName(await db.location.findMany({ where: { campaignId } }));
let rewardCount = 0;
for (const [index, enc] of content.encounters.entries()) {
const participants = [];
for (const [name, count, displayName, hp] of enc.monsters || []) {
const monster = lookup(monsters, name, 'monster', enc.name);
const label = displayName || monster.name;
const maxHp = hp ?? monster.maxHp;
for (let i = 1; i <= count; i++) {
participants.push({
monsterId: monster.id,
displayName: count > 1 ? `${label} #${i}` : label,
currentHp: maxHp,
maxHp,
isMonster: true,
});
}
}
for (const entry of enc.npcs || []) {
const { name, hp } = typeof entry === 'string' ? { name: entry } : entry;
const npc = lookup(npcs, name, 'NPC', enc.name);
// An NPC with no combat stats takes part with no hit points to track.
const maxHp = hp ?? npc.maxHp ?? 0;
participants.push({ npcId: npc.id, displayName: npc.name, currentHp: maxHp, maxHp, isMonster: false });
}
// Rewards list oldest-first, so these are stamped forwards.
const rewards = (enc.rewards || []).map(({ gold, weapon, item, quantity, level, note }, i) => {
const createdAt = new Date(Date.now() + i);
if (gold) return { type: 'GOLD', gold, note, createdAt };
const entry = weapon ? lookup(weapons, weapon, 'weapon', enc.name) : lookup(items, item, 'item', enc.name);
return {
type: weapon ? 'WEAPON' : 'ITEM',
[weapon ? 'weaponId' : 'itemId']: entry.id,
quantity: quantity ?? 1,
level: Math.max(level ?? entry.minLevel, entry.minLevel),
note,
createdAt,
};
});
rewardCount += rewards.length;
await db.encounter.create({
data: {
campaignId,
name: enc.name,
mode: enc.mode,
description: enc.description,
dmNotes: enc.dmNotes,
locationId: enc.location ? lookup(locations, enc.location, 'location', enc.name).id : null,
sortOrder: index,
participants: { create: participants },
rewards: { create: rewards },
},
});
}
const notes = [
...content.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
...content.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
];
await db.note.createMany({
data: notes.map((n, i) => ({ ...n, campaignId, authorId: admin.id, createdAt: bookOrder(i) })),
});
const starting = (rows) => rows.filter((row) => row.isStartingOption).length;
console.log(` Campaign: ${campaign.name} — invite code ${campaign.inviteCode}`);
console.log(
` ${content.weapons.length} weapons (${starting(content.weapons)} starting), ${content.items.length} items ` +
`(${starting(content.items)} starting), ${content.spells.length} spells (${starting(content.spells)} starting), ` +
`${content.locations.length} locations`,
);
console.log(
` ${content.monsters.length} monsters, ${content.npcs.length} NPCs, ${content.encounters.length} encounters ` +
`(${rewardCount} rewards lined up), ${notes.length} notes`,
);
}
async function resetDatabase() {
// Truncating the two roots cascades through every table that references them.
await prisma.$executeRawUnsafe('TRUNCATE TABLE "users", "campaigns" RESTART IDENTITY CASCADE');
}
// `reset` is DESTRUCTIVE: every user, campaign, character, note, etc. is
// deleted before anything is added.
async function seed(content, { reset = false } = {}) {
if (reset) {
await resetDatabase();
console.log('Database reset: every user and campaign was deleted.');
}
let admin = await prisma.user.findUnique({ where: { email: ADMIN_EMAIL } });
if (admin) {
console.log(`Admin account ${ADMIN_EMAIL} already exists — left as it is.`);
} else {
admin = await prisma.user.create({
data: { email: ADMIN_EMAIL, passwordHash: await bcrypt.hash(ADMIN_PASSWORD, 12), displayName: ADMIN_NAME },
});
console.log(`Admin/DM login created: ${ADMIN_EMAIL} / ${ADMIN_PASSWORD}`);
console.log(' Change that password from the account page (click your name, top right) once you are logged in.');
}
const existing = await prisma.campaign.findFirst({
where: { ownerId: admin.id, name: content.campaign.name, archivedAt: null },
});
if (existing) {
console.log(`${ADMIN_EMAIL} already has an active "${existing.name}" campaign — nothing added.`);
console.log(' For a fresh copy, archive or delete it under Campaign Settings and run the seed again.');
return;
}
// One transaction, so a failed seed doesn't leave half a campaign behind.
console.log('Seeding campaign...');
await prisma.$transaction((tx) => createCampaign(tx, admin, content), { timeout: 120000, maxWait: 20000 });
console.log('Seed complete.');
}
// Runs a seed script to completion: exit code 1 on failure, connection closed either way.
function run(content, options) {
return seed(content, options)
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
}
// A copy of a library entry as a pool row: its `key` and picker-only tags dropped.
function fromLibrary(library, key, extra = {}) {
const template = library.find((entry) => entry.key === key);
if (!template) throw new Error(`Unknown library entry "${key}"`);
const { key: _key, category, classes, ...row } = template;
return { ...row, ...extra };
}
module.exports = { run, fromLibrary };
+27
View File
@@ -0,0 +1,27 @@
// Checks every world seed in /backend/seeds and says what each one holds, or
// what is wrong with it. Run it after dropping in or editing a seed file:
//
// npm run seeds:check
const { loadSeeds, SEEDS_DIR } = require('../src/seeds');
const results = loadSeeds();
if (results.length === 0) console.log(`No seed files in ${SEEDS_DIR}.`);
for (const { file, code, seed, error } of results) {
if (error) {
console.error(`✗ ${file}\n${error.replace(/^/gm, ' ')}`);
process.exitCode = 1;
continue;
}
const starting = (rows) => rows.filter((row) => row.isStartingOption).length;
const count = (rows, of) => rows.reduce((sum, row) => sum + (row[of] || []).length, 0);
console.log(`✓ ${file} — code ${code} — ${seed.campaign.name}`);
console.log(
` ${seed.weapons.length} weapons (${starting(seed.weapons)} starting), ${seed.items.length} items (${starting(seed.items)} starting), ` +
`${seed.spells.length} spells (${starting(seed.spells)} starting), ${seed.locations.length} locations`,
);
console.log(
` ${seed.monsters.length} monsters, ${seed.npcs.length} NPCs (${count(seed.npcs, 'wares')} wares), ${seed.encounters.length} encounters ` +
`(${count(seed.encounters, 'rewards')} rewards, ${count(seed.encounters, 'wares')} wares), ${seed.sharedNotes.length + seed.dmNotes.length} notes`,
);
}
+63 -4
View File
@@ -1,4 +1,4 @@
// Writes the full lists (every library spell, weapon and monster, and every
// Writes the full lists (every library spell, weapon, item and monster, and every
// option the character builder offers) into the docs, straight from the data
// files the app itself uses, so the docs cannot drift from the app.
//
@@ -10,10 +10,13 @@
// is written by hand.
const fs = require('fs');
const path = require('path');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../src/game/data/library');
const { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../src/game/data/library');
const options = require('../src/game/data/characterOptions');
const { SPECIES } = require('../src/game/data/species');
const { CLASSES } = require('../src/game/data/classes');
const { BOONS, MAX_FEATURE_LEVEL } = require('../src/game/data/levelFeatures');
const { featuresAt } = require('../src/game/levelUp');
const { SUBCLASSES, SUBCLASS_LEVEL } = require('../src/game/data/subclasses');
const DOCS = path.join(__dirname, '..', '..', 'docs');
@@ -49,8 +52,31 @@ function weaponLibrary() {
ATTACK_WITH[w.attackAbility],
w.properties.join(', '),
w.rangeNormal ? `${w.rangeNormal}/${w.rangeLong} ft` : null,
[effectList(w.effects), w.effectNote].filter(Boolean).join('. ') || null,
]);
return `### ${category} (${weapons.length})\n\n${table(['Weapon', 'Base damage', 'Type', 'Attacks with', 'Properties', 'Range'], rows)}`;
return `### ${category} (${weapons.length})\n\n${table(['Weapon', 'Base damage', 'Type', 'Attacks with', 'Properties', 'Range', 'While equipped'], rows)}`;
})
.join('\n\n');
}
// "+6 AC, −10 Speed": what a library entry does while equipped.
const STAT_NAMES = { AC: 'AC', SPEED: 'Speed', INITIATIVE: 'Initiative', MAX_HP: 'Max HP', PASSIVE_PERCEPTION: 'Passive Perception', SAVES: 'all saves', SKILLS: 'all skill checks' };
const title = (key) => key.toLowerCase().split('_').map((w, i) => (i > 0 && w === 'of' ? w : w[0].toUpperCase() + w.slice(1))).join(' ');
function statName(stat) {
if (STAT_NAMES[stat]) return STAT_NAMES[stat];
if (stat.startsWith('SAVE_')) return `${stat.slice(5)} saves`;
if (stat.startsWith('SKILL_')) return title(stat.slice(6));
return stat;
}
const effectList = (effects = []) => effects.map((e) => `${signed(e.amount).replace('-', '−')} ${statName(e.stat)}`).join(', ');
function itemLibrary() {
const categories = [...new Set(ITEM_LIBRARY.map((i) => i.category))];
return categories
.map((category) => {
const items = ITEM_LIBRARY.filter((i) => i.category === category);
const rows = items.map((i) => [i.name, effectList(i.effects), i.effectNote, i.damageDice, i.description]);
return `### ${category} (${items.length})\n\n${table(['Item', 'While equipped', 'Note', 'Dice', 'Description'], rows)}`;
})
.join('\n\n');
}
@@ -91,11 +117,44 @@ function builderLists() {
].join('\n\n');
}
// What "Level Up" writes for each class at each level, what each of its
// subclasses adds, and the Boons.
function levelFeatures() {
const line = (f) => (f.boon ? '**Boon** (choose one)' : `**${f.name}**${f.upgrade ? ' (replaces)' : ''}: ${f.text}`);
const classes = Object.keys(CLASSES)
.sort()
.map((key) => {
const rows = [];
for (let level = 2; level <= MAX_FEATURE_LEVEL; level++) {
const features = featuresAt(key, level);
rows.push([level, features.length > 0 ? features.map(line).join('<br>') : 'No new feature']);
}
const subclassRows = SUBCLASSES[key].flatMap((subclass) =>
Object.keys(subclass.features)
.map(Number)
.sort((a, b) => a - b)
.map((level, i) => [i === 0 ? `**${subclass.name}**<br>${subclass.summary}` : '', level, subclass.features[level].map(line).join('<br>')])
);
return [
`### ${CLASSES[key].name}`,
table(['Level', 'Written into Special Abilities'], rows),
`**${CLASSES[key].name} subclasses** (chosen at level ${SUBCLASS_LEVEL[key]}). What each adds to the table above:`,
table(['Subclass', 'Level', 'Written into Special Abilities'], subclassRows),
].join('\n\n');
});
const boons = table(
['Boon', 'First time', 'Can be taken'],
BOONS.map((b) => [b.name, b.text(b.per), b.max === 1 ? 'Once' : `${b.max} times`])
);
return [`### Boons\n\n${boons}`, ...classes].join('\n\n');
}
const SECTIONS = {
'SPELLS.md': { 'spell-library': spellLibrary },
'WEAPONS.md': { 'weapon-library': weaponLibrary },
'ITEMS.md': { 'item-library': itemLibrary },
'MONSTERS.md': { 'monster-library': monsterLibrary },
'CHARACTERS.md': { 'builder-lists': builderLists },
'CHARACTERS.md': { 'builder-lists': builderLists, 'level-features': levelFeatures },
};
for (const [file, sections] of Object.entries(SECTIONS)) {
@@ -1,8 +1,9 @@
// Aldenfell campaign content: the gazetteer, bestiary and notes are condensed
// from the Aldenfell Campaign Book; the gear, item pool, NPC list and scenes
// are built out from it to use everything a campaign can hold.
// Consumed by prisma/seed.js. Shapes match the Prisma models minus ids, except
// where a section's comment says otherwise.
// Aldenfell: a world seed (docs/SEEDS.md). A DM starts a campaign from it by
// entering its code.
//
// The gazetteer, bestiary and notes are condensed from the Aldenfell Campaign
// Book; the gear, item pool, NPC list and scenes are built out from it to use
// everything a campaign can hold.
const campaign = {
name: 'Aldenfell',
@@ -137,17 +138,30 @@ const monsters = [
specialTrait: 'Keen hearing and smell — advantage on Perception checks that rely on them.',
notes: 'Medium beast, 25 XP. Bite +3 (1d6+1 piercing, DC 11 Str or prone). A chapel mastiff gone half-wild in the Sunken Vaults’ Old Crypt Hall (Room 2). Starving, not vicious — DC 12 Animal Handling or a thrown ration ends the fight.',
},
{
name: 'The Hollow King', challengeRating: '10', ac: 16, maxHp: 190, speed: 30,
strScore: 18, dexScore: 14, conScore: 18, intScore: 18, wisScore: 18, chaScore: 20, attackBonus: 9, damageDice: '4d8',
specialTrait: 'Many, not one — it is the will of everyone the founders sacrificed, so it cannot be charmed or frightened, and it speaks in several voices. Echoing dread — starting a turn within 30 ft: DC 16 Wis or frightened. What the Court remembers (recharge 5–6): each creature within 30 ft, DC 16 Wis or 6d6 psychic and lose its reaction (half on success).',
notes: 'A STARTING POINT, NOT THE BOOK’S: the Campaign Book gives no stat block for E26 and tells you to build one to your party’s level and the resources they brought. This one suits four level 7–8 characters; change the numbers freely. Fly 30 ft (hover). Multiattack (two Chilling Touch +9, 4d8 necrotic). Three legendary actions a round: move without provoking, one Chilling Touch, or (2 actions) pull a creature 20 ft toward the seal (DC 16 Str). The fight should never be only a sword fight — what the party does with the truth (E19, E22) matters more than its hit points.',
},
];
// ---------------------------------------------------------------------------
// Weapons the party earns. The everyday weapons (seed-data/weapons.json) are
// seeded as starting options; these are not, so they only reach a character as
// a reward or a gift. damageDice is the damage at minLevel, and a character
// must be at least a held copy's level to use it. damageScaling: 1 standard,
// 2 powerful, 3 legendary.
// Weapons. The everyday ones are copies from the site library and are what a
// new character picks from. The rest are not starting options, so they only
// reach a character as a reward, a gift or a purchase. damageDice is the
// damage at minLevel, and a character must be at least a held copy's level to
// use it. damageScaling: 1 standard, 2 powerful, 3 legendary.
// ---------------------------------------------------------------------------
const everydayWeapons = [
'dagger', 'shortsword', 'rapier', 'longsword', 'greatsword', 'battleaxe', 'handaxe', 'warhammer', 'mace',
'quarterstaff', 'spear', 'shortbow', 'longbow', 'light-crossbow', 'dart',
];
const weapons = [
...everydayWeapons.map((library) => ({ library, isStartingOption: true })),
{ name: 'Unarmed Strike', damageDice: '1', damageType: 'bludgeoning', attackAbility: 'STR', properties: [], isStartingOption: true },
{ name: 'Masterwork Dagger', damageDice: '1d6', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'thrown'], rangeNormal: 20, rangeLong: 60, minLevel: 2, damageScaling: 1 },
{ name: 'Smuggler’s Cutlass', damageDice: '1d8', damageType: 'slashing', attackAbility: 'FINESSE', properties: ['finesse'], rangeNormal: null, rangeLong: null, minLevel: 2, damageScaling: 1 },
{ name: 'Warden’s Longbow', damageDice: '1d10', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'heavy', 'two-handed'], rangeNormal: 150, rangeLong: 600, minLevel: 2, damageScaling: 2 },
@@ -159,38 +173,47 @@ const weapons = [
];
// ---------------------------------------------------------------------------
// Item pool. Items are descriptive: holding one never changes a number on the
// sheet. `starting: true` offers the item in the character builder.
// Item pool. Most items are descriptive. The ones with `effects` change the
// sheet of whoever has them equipped (armor is an item with an AC effect), and
// `effectNote` says what a number cannot. `isStartingOption: true` offers the
// item in the character builder.
// ---------------------------------------------------------------------------
const items = [
// Starting kit
{ name: 'Potion of Healing', description: 'A red liquid that glimmers when shaken. Drink it (an action) to regain the hit points shown.', damageDice: '2d4+2', starting: true },
{ name: 'Traveller’s Pack', description: 'Bedroll, mess kit, tinderbox, waterskin and five days of rations.', starting: true },
{ name: 'Rope (50 ft)', description: 'Hempen rope. Holds a climber; DC 17 Strength to burst.', starting: true },
{ name: 'Torches (bundle of 5)', description: 'Each burns for an hour: bright light for 20 feet, dim for 20 more.', starting: true },
{ name: 'Healer’s Kit', description: 'Ten uses. Spend one to stabilise a creature at 0 hit points without a Medicine check.', starting: true },
{ name: 'Thieves’ Tools', description: 'Picks, a small file and a mirror on a handle. Needed to pick a lock or disarm a trap.', starting: true },
{ name: 'Shield', description: 'A wooden or steel shield, +2 Armor Class while carried. Ask the DM to add it to your armor bonus.', starting: true },
{ name: 'Leather Armor', description: 'Boiled leather, Armor Class 11 + Dexterity. Ask the DM to set your armor bonus.', starting: true },
{ name: 'Spellcasting Focus', description: 'A wand, holy symbol, crystal or carved staff that your spells are cast through.', starting: true },
{ name: 'Founding Fair Token', description: 'A stamped brass token from the Lantern Market. Good for one drink at any stall, or a small favour from whoever ran the game you won it at.', starting: true },
{ name: 'Potion of Healing', description: 'A red liquid that glimmers when shaken. Drink it (an action) to regain the hit points shown.', damageDice: '2d4+2', isStartingOption: true },
{ name: 'Traveller’s Pack', description: 'Bedroll, mess kit, tinderbox, waterskin and five days of rations.', isStartingOption: true },
{ name: 'Rope (50 ft)', description: 'Hempen rope. Holds a climber; DC 17 Strength to burst.', isStartingOption: true },
{ name: 'Torches (bundle of 5)', description: 'Each burns for an hour: bright light for 20 feet, dim for 20 more.', isStartingOption: true },
{ name: 'Healer’s Kit', description: 'Ten uses. Spend one to stabilise a creature at 0 hit points without a Medicine check.', isStartingOption: true },
{ name: 'Thieves’ Tools', description: 'Picks, a small file and a mirror on a handle. Needed to pick a lock or disarm a trap.', isStartingOption: true },
{ name: 'Shield', description: 'A wooden or steel shield carried on one arm.', isStartingOption: true, effects: [{ stat: 'AC', amount: 2 }], effectNote: 'Takes a hand: no two-handed weapon while it is up.' },
{ name: 'Leather Armor', description: 'Boiled leather, light enough to sleep and sneak in.', isStartingOption: true, effects: [{ stat: 'AC', amount: 1 }] },
{ name: 'Spellcasting Focus', description: 'A wand, holy symbol, crystal or carved staff that your spells are cast through.', isStartingOption: true },
{ name: 'Founding Fair Token', description: 'A stamped brass token from the Lantern Market. Good for one drink at any stall, or a small favour from whoever ran the game you won it at.', isStartingOption: true },
// Found and earned
{ name: 'Potion of Greater Healing', description: 'A deeper red than the common potion. Drink it (an action) to regain the hit points shown.', damageDice: '4d4+4', minLevel: 3 },
{ library: 'studded-leather' },
{ library: 'chain-shirt' },
{ library: 'chain-mail' },
{ name: 'Alchemist’s Fire', description: 'A sticky flask that ignites in air. Throw it up to 20 feet: the target burns for the damage shown at the start of each of its turns until someone spends an action putting it out.', damageDice: '1d4' },
{ name: 'Silver Locket', description: 'A tarnished locket from the Old Crypt Hall, worth about 15 gp. The portrait inside has been scratched out.' },
{ name: 'Smugglers’ Ledger Page', description: 'A torn page from a crate in the Sunken Vaults, stamped with a merchant sigil nobody at the Concord Exchange recognises. Half a name survives in the margin.' },
{ name: 'Bell-Ringer’s Keepsake', description: 'A worn brass bell-clapper charm, pressed into your hand with more gratitude than its owner could put into words.' },
{ name: 'Grey Dawn Signet', description: 'An iron ring bearing the Order’s rising sun. Shown at Grey Dawn Hall or to any patrol, it marks you as someone the Order vouches for.' },
{ name: 'Concord Writ of Passage', description: 'A sealed Merchant Concord contract. Waives tolls and gate fees across Aldenfell while the Concord still considers you good for business.' },
{ name: 'Warden’s Charm', description: 'A knot of greywood bark and red thread. The Wardens will know you by it, and the forest’s paths seem a little easier to find while you carry it.' },
{ name: 'Warden’s Charm', description: 'A knot of greywood bark and red thread. The Wardens will know you by it, and the forest’s paths seem a little easier to find while you carry it.', effects: [{ stat: 'SKILL_SURVIVAL', amount: 1 }] },
{ name: 'Stonejaw Oath-Token', description: 'A river stone the size of a fist, carved with the Stonejaw clan mark. Proof that your word was tested and held.' },
{ name: 'Half-Burned Letter', description: 'Found among a missing person’s belongings in the Root. Most of it is ash. What is left mentions payments, a shrine, and a family crest.' },
{ name: 'Silver Shrine Relic', description: 'A small silver figure lifted from black water beneath the Greywood, worth about 75 gp. Its style is older than the kingdom.' },
{ name: 'Sealed Letter', description: 'Heavy paper, an unbroken wax seal, and a servant’s whispered warning about who must never know you have it.' },
{ name: 'Root-Fiber Scrap', description: 'A length of pale root that is still, very slightly, moving. It is cold, and it does not rot.' },
{ name: 'Seal-Stone Fragment', description: 'A shard of worked stone from the edge of the breach. Script runs along one face. It is warm, and holding it makes the bell script easier to look at.' },
{ name: 'Seized House Documents', description: 'Ledgers and letters taken from the noble house after it was confronted. No value in coin; a great deal of value to anyone building a case.' },
{ name: 'House Records', description: 'The fallen house’s private records: names, payments and meeting places for its contacts across Aldenfell.' },
{ name: 'Masterwork Chain Mail', description: 'Close-linked mail from the Grey Dawn’s own armoury, fitted rather than issued.', minLevel: 5, effects: [{ stat: 'AC', amount: 7 }, { stat: 'SPEED', amount: -10 }], effectNote: 'Disadvantage on Stealth checks.' },
{ name: 'The Seal-Stone', description: 'The stone at the heart of the seal, whole again and quiet. It is heavier than it looks, and it is never quite cold.', minLevel: 7, effects: [{ stat: 'SAVE_WIS', amount: 2 }], effectNote: 'Grows warm near anything of the Hollow Below. Its other properties are the DM’s to decide: something tied to binding, warding or detecting.' },
];
// ---------------------------------------------------------------------------
@@ -200,7 +223,7 @@ const items = [
// DM to teach later.
// ---------------------------------------------------------------------------
const spells = {
const spellKeys = {
starting: [
'chill-touch', 'eldritch-blast', 'fire-bolt', 'guidance', 'light', 'mage-hand', 'minor-illusion',
'prestidigitation', 'produce-flame', 'ray-of-frost', 'sacred-flame', 'shocking-grasp', 'spare-the-dying',
@@ -216,6 +239,11 @@ const spells = {
],
};
const spells = [
...spellKeys.starting.map((library) => ({ library, isStartingOption: true })),
...spellKeys.earned,
];
// ---------------------------------------------------------------------------
// NPCs. `description` is what the party sees once the NPC is in a scene;
// `dmNotes` never leaves the DM's screen. Combat stats only where a fight is
@@ -242,7 +270,17 @@ const npcs = [
{
name: 'Mireille Costa', role: 'Merchant Concord representative', disposition: 'NEUTRAL',
description: 'Charming and transactional, usually found at the Concord Exchange with a contract already drafted. She always has a job, and she always pays what she promised.',
dmNotes: 'Source of paying side work (E4B → The Smugglers’ Tithe). The Concord is neutral and pragmatic, and drops anyone who becomes bad for business — including the party.',
dmNotes: 'Source of paying side work (E4B → The Smugglers’ Tithe). The Concord is neutral and pragmatic, and drops anyone who becomes bad for business — including the party. She also sells: Concord goods at Concord prices, in any scene she is in (DC 12 Persuasion to haggle, per the DM Tables).',
wares: [
{ item: 'Potion of Healing', price: 50 },
{ item: 'Potion of Greater Healing', price: 150 },
{ item: 'Alchemist’s Fire', price: 50 },
{ item: 'Healer’s Kit', price: 5 },
{ item: 'Thieves’ Tools', price: 25 },
{ item: 'Studded Leather', price: 45 },
{ item: 'Chain Shirt', price: 50 },
{ item: 'Chain Mail', price: 75 },
],
},
{
name: 'Ashwen', role: 'Warden of the Greywood', disposition: 'NEUTRAL',
@@ -311,6 +349,8 @@ const npcs = [
// handed out. { gold } | { weapon } | { item }, with optional
// quantity, level (defaults to the entry's starting level) and
// note.
// wares — for sale at the scene itself (a stall), as on an NPC:
// { weapon | item, price, currency?, level?, stock? }.
// ---------------------------------------------------------------------------
const encounters = [
@@ -319,6 +359,18 @@ const encounters = [
description: 'Highcrown’s Lantern Market at the height of the Founding Fair: lantern-strung stalls, contests of strength and skill, minstrels, and strangers from every corner of the kingdom. This is where you meet.',
dmNotes: 'Character creation happens here (Roll-Your-Character System) — it can be the whole first half of session one. No combat. Let each PC win or lose something small at a stall, and introduce Mireille and Ser Kavan in passing so they are familiar faces by E4. Ends when the chapel bell fails to ring → E2.',
npcs: ['Mireille Costa', 'Ser Kavan Thorne'],
// The market stalls: everyday kit at everyday prices.
wares: [
{ item: 'Traveller’s Pack', price: 2 },
{ item: 'Rope (50 ft)', price: 1 },
{ item: 'Torches (bundle of 5)', price: 5, currency: 'COPPER' },
{ item: 'Spellcasting Focus', price: 10 },
{ item: 'Shield', price: 10 },
{ item: 'Leather Armor', price: 10 },
{ weapon: 'Dagger', price: 2 },
{ weapon: 'Shortbow', price: 25 },
{ weapon: 'Longsword', price: 15 },
],
},
{
name: 'E2 — The Bell That Wasn’t Ringing', mode: 'QUEST', location: 'Highcrown — The Founding Chapel',
@@ -329,9 +381,9 @@ const encounters = [
{
name: 'E3 — Sunken Vaults: The Old Crypt Hall', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
description: 'A long hall of burial niches beneath the chapel, some sealed, some broken open. Something is growling in the dark between the pillars.',
dmNotes: 'Vault Room 2, the hub (→ Rooms 3, 4, stair to 6). The dog is starving, not vicious: DC 12 Animal Handling or a thrown ration ends it. DC 13 Investigation: the open niches are just old theft. DC 12 Perception finds the locket.',
dmNotes: 'Vault Room 2, the hub (→ Rooms 3, 4, stair to 6). The dog is starving, not vicious: DC 12 Animal Handling or a thrown ration ends it. DC 13 Investigation: the open niches are just old theft. DC 12 Investigation spots the locket.',
monsters: [['Feral Guard Dog', 1]],
rewards: [{ item: 'Silver Locket', note: 'Room 2 — DC 12 Perception' }],
rewards: [{ item: 'Silver Locket', note: 'Room 2 — DC 12 Investigation' }],
},
{
name: 'E3 — Sunken Vaults: The Restless Ossuary', mode: 'BATTLE', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
@@ -360,6 +412,17 @@ const encounters = [
{ item: 'Concord Writ of Passage', note: 'E4B — on taking Mireille’s contract' },
],
},
{
name: 'E5 — Omens Montage', mode: 'QUEST', location: 'Greywood — The Old Shrine Sites',
description: 'Small things, weeks apart. A farmer’s cattle gone dry overnight out past the shrine road. A stranger at the next table describing, detail for detail, the dream you had last night. None of it is an attack. All of it is wrong.',
dmNotes: 'Pure atmosphere, no treasure. Run two or three as short scenes spread across sessions, never back to back:\n• Livestock and crops failing near an old shrine.\n• Shared nightmares around Highcrown — DC 12 Insight for a PC to realise their own dream matched a stranger’s account.\n• A minor noble behaving with odd, unearned loyalty to no one in particular (the corrupted house — a background detail, not a spotlight).\nThe Drowned Shrine (Root Room 5) is a good place to point an omen at. → E6 whenever it feels earned.',
},
{
name: 'E7 — A Closer Look', mode: 'QUEST',
description: 'One noble house keeps turning up at the edge of things: generous, quiet, a little too pleased with itself. Asking after it gets polite answers and closed doors.',
dmNotes: 'If the party actively investigates the odd house from E5 they find only dead ends at this stage — by design. Stage 1 omens accumulate; they do not resolve.\nException: a party that took E2C makes a DC 13 Insight check (not 15) to catch one real thread — a servant who flinched at the mention of the house’s name. Note what they learn for E10.\n→ E8: once they have engaged with two or more E4 threads and one full E5 omen, advance the clock to Stage 2 and move to Act 2.',
npcs: ['King Aldric Veyrmont', 'Princess Isolde Veyrmont'],
},
{
name: 'E6 — The Bell-Ringer’s Debt: Collectors', mode: 'BATTLE',
description: 'Three hard-faced men are waiting outside the bell-ringer’s door when you arrive. They are not here to hear him ring.',
@@ -440,6 +503,23 @@ const encounters = [
npcs: ['Threndir Ashclaw'],
rewards: [{ weapon: 'Threndir’s Fang', note: 'A shed tooth, set in a hilt — given only if he comes to respect them' }],
},
{
name: 'The Spine — Wyvern Pass', mode: 'BATTLE', location: 'The Spine — Ironvein',
description: 'The pass above Ironvein narrows to a ledge, and the ledge runs under a shelf of rock streaked white with droppings. Something large shifts up there and looks down at you.',
dmNotes: 'Optional travel encounter (Random Encounters, the Spine). Territorial, not evil: it attacks anything that lingers near the nest and ignores a party that keeps moving — DC 13 Stealth or Survival to pass without a fight. Stinger: DC 15 Con or poisoned for a minute. Ironvein below is the resupply stop, with its own rumours (miners going quiet, deeper tunnels avoided).',
monsters: [['Spine Wyvern', 1]],
},
{
name: 'The Spine — Sevrisk’s Hollow', mode: 'BATTLE', location: 'The Spine — Sevrisk’s Hollow',
description: 'A low, wet cave mouth breathing green-tinged mist. The bones outside it have been arranged, not dropped. From inside comes a voice like a blade being drawn: slow, amused, and very sure of itself.',
dmNotes: 'Optional, and dangerous: Sevrisk is the straightforward dragon fight, in contrast to Threndir. Territorial and opportunistic — she will bargain only to gain an edge, and breaks a bargain the moment it stops paying. Poison breath (recharge 5–6): 30-ft cone, DC 14 Con, 8d6. CR 5: too much for a party below level 4 without help or a plan. Her hoard is the treasure.',
monsters: [['Sevrisk (young green dragon)', 1, 'Sevrisk']],
rewards: [
{ gold: 400, note: 'Sevrisk’s hoard — coin and plate' },
{ item: 'Potion of Greater Healing', quantity: 2, note: 'In the hoard' },
{ weapon: 'Grey Dawn Longsword', note: 'In the hoard — a knight came here once and did not leave' },
],
},
{
name: 'E9 — The Root: Weeping Passage', mode: 'BATTLE', location: 'Hollow Below — The Weeping Passage',
description: 'A long tunnel beneath the Greywood where the walls weep, and the sound they make is almost a voice. Somewhere ahead, very faintly, a bell tolls.',
@@ -467,6 +547,12 @@ const encounters = [
{ weapon: 'Masterwork Dagger', note: 'Still in a dead hand' },
],
},
{
name: 'E9 — The Root: The Drowned Shrine', mode: 'QUEST', location: 'Hollow Below — The Root (Layer 2)',
description: 'A shrine older than anything in the vaults above, half sunk in still black water. The altar is whole. The symbol carved into it belongs to no faith anyone above has practised in a very long time.',
dmNotes: 'Root Room 5. A pre-kingdom holy site, connected to the Sunken Library in the Greywood. DC 16 History or Religion: the symbol is older than Aldenfell itself — a thread toward the Hollow King’s origin, without the full truth. A good place for a vision or omen (E5 can point here).\nThe relic: DC 13 Athletics or Investigation to lift it without disturbing the water. Disturbing it summons nothing, only ripples that spread wrong. Exits: back to Room 2, or a narrow crawl to Room 6.',
rewards: [{ item: 'Silver Shrine Relic', note: 'In the water — DC 13 Athletics or Investigation' }],
},
{
name: 'E9 — The Root: Bellcrawler Gallery', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
description: 'Narrow ledges over a drop your torch cannot find the bottom of. From three different directions at once, a bell begins to toll.',
@@ -474,7 +560,7 @@ const encounters = [
monsters: [['Bellcrawler', 3]],
rewards: [
{ gold: 90, note: 'Scattered along the ledges' },
{ item: 'Potion of Healing', note: 'In a fallen pack' },
{ item: 'Potion of Healing', note: 'In a fallen pack — the book has the coin or the potion, DM’s choice' },
],
},
{
@@ -482,7 +568,10 @@ const encounters = [
description: 'Back in Highcrown with a name, a ledger, and a question nobody at court wants asked out loud: what do you do with it?',
dmNotes: 'The ledger: every shrine “donation” falls three days after a livestock death.\n• E10A confront the house directly: DC 16 Persuasion/Intimidation with proof, DC 20 without.\n• E10B report to the crown, Isolde or Kavan: sets up Act 3 politics; if E9C, Kavan already suspected (trust bump).\n• E10C (E2C + the servant thread): private DC 13 check at advantage → confession and the sealed letter; the house never learns it is suspected. Best route into Act 3.',
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne'],
rewards: [{ item: 'Sealed Letter', note: 'E10C only' }],
rewards: [
{ item: 'Sealed Letter', note: 'E10C only — the servant’s evidence' },
{ item: 'Seized House Documents', note: 'E10A only — taken at the confrontation' },
],
},
{
name: 'E11 — Silence on the Border', mode: 'QUEST', location: 'Bramble Marches — Greyford Keep',
@@ -508,6 +597,27 @@ const encounters = [
monsters: [['Hollowed', 4]],
rewards: [{ item: 'Seal-Stone Fragment', note: 'Key item — from the breach’s edge' }],
},
{
name: 'E15 — The House Falls (or Doesn’t)', mode: 'BATTLE',
description: 'Word travels faster than truth in Highcrown. By the time you reach the house, half the city already has an opinion about what happened there — and most of them are wrong.',
dmNotes: 'Plays very differently by Act 2 route.\n• E15A (took E10A): the house has spent Act 2 preparing — hired guards, an alibi, perhaps a scapegoat servant. DC 18 to break the cover story, or this fight: house guards and one Hollowed-touched retainer.\n• E15B (took E10B): a tense public tribunal, no fight. DC 16 Persuasion or Insight steers it toward exile rather than execution (matters for a returning villain).\n• E15C (took E10C): the house suspects nothing. Move on its masters — the cult in Highcrown. Only this route reaches E15C-1: a cult lieutenant taken alive, who gives up the Hollow Court’s surface entrance (→ E18B).\nRemove the monsters for E15B and E15C. → E16.',
monsters: [['Bandit Captain', 2, 'House Guard Sergeant'], ['Highcrown Cutthroat', 4, 'House Guard'], ['Hollowed', 1, 'The Retainer']],
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne'],
rewards: [{ item: 'House Records', note: 'Names other cult contacts across Aldenfell — seeds for a side quest, or safely ignored' }],
},
{
name: 'E16 — What the Marches Cost', mode: 'QUEST', location: 'Bramble Marches — Greyford Keep',
description: 'Smoke on the horizon means something different out here than it does in Highcrown. Out here, it usually means someone made a choice that can’t be unmade.',
dmNotes: 'The consequences of E11. No loot: this is about consequence.\n• E16A — Greyford was reinforced (E11C, Spine Clans allied): the keep holds. A rare unambiguous good outcome; let them feel it. Spine Clan standing rises.\n• E16B — Greyford was under-supplied (Smugglers’ Tithe unresolved): the keep falls or is abandoned for good. The named refugee from Refugees of the Marches is lost unless the party steps in — DC 16 Athletics or Survival chase and rescue.\n→ E17 (pay off any debt the party has run up: the E14A weakening, an unresolved side quest, unchased omens — or a quiet breather with the bell-ringer) → E18.',
npcs: ['Greyford’s Commander'],
},
{
name: 'E18 — Into the Hollow Court', mode: 'BATTLE', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'The stair doesn’t so much end as stop pretending to be a stair. What’s beneath Aldenfell was never built — it grew, the way a wound grows scar tissue, except the scar tissue is watching you.',
dmNotes: 'Hollow Court Rooms 1–3. E18A enters by the Bent Stair; E18B (cult lieutenant from E15C-1) skips straight to the Memory Pool.\n• Room 1, The Bent Stair: DC 15 Wis save or lose track of how long the descent took (atmosphere, or 10 minutes lost). No monster.\n• Room 2, The Memory Pool: each PC who looks sees a vision tied to a choice they made — the payoff for the branch points. No damage; DC 17 Insight to understand it.\n• Room 3, The Court of Whispers: Vessa may speak plainly here. DC 16 Insight: she believes what she says. If it comes to a fight it is with these Hollowed guardians, never with her — remove them if the party talks.\n→ E19.',
monsters: [['Hollowed', 4, 'Hollowed Guardian']],
npcs: ['Vessa Hollow'],
},
{
name: 'Mid-campaign scare — The Hollow King’s Shadow', mode: 'BATTLE',
description: 'The light goes thin and the air goes cold. Something tall is standing where the wall should be, and the wall does not seem to mind.',
@@ -525,14 +635,20 @@ const encounters = [
description: 'One last gathering before the descent. Whoever is going to stand with you is here now — and they do not all agree about what happens next.',
dmNotes: 'Who shows up depends on the branch list — remove anyone the party lost or alienated.\n• E21A Grey Dawn track: Kavan and his wardens, by the book.\n• E21B Spine Clans track: Korgath’s war-band; advantage on the first Survival check at E22.\n• E21C Independent: no reinforcements; every choice stays theirs.\n• Several tracks: the groups arrive and disagree — the party mediates.',
npcs: ['Princess Isolde Veyrmont', 'Ser Kavan Thorne', 'Korgath Stonejaw', 'Ashwen'],
rewards: [{ item: 'Potion of Greater Healing', quantity: 2, note: 'Going-away gift from whoever stands with them' }],
rewards: [
{ item: 'Potion of Greater Healing', quantity: 2, note: 'Going-away gift from whoever stands with them' },
{ item: 'Masterwork Chain Mail', note: 'E21A — from the Grey Dawn armoury, if the Order stands with them' },
],
},
{
name: 'E22 — The Seal Chamber', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'A wound in the world, stitched shut long ago, and coming undone one thread at a time. Whatever you came here to decide, this is where you decide it.',
dmNotes: 'Court Room 5 — the key branch. DC 18 Arcana/Religion reveals the seal is a suppression of many. Optional DC 14 Wis saves vs disorientation.\n• E22A reinforce (DC 18 Arcana/Religion): the Sealed Ending — time bought.\n• E22B break it deliberately: E23 on the party’s terms — the only path to true victory.\n• E22C do nothing: the seal fails anyway; E24–E25 are harder.\nHollowbane is the campaign’s single most significant magic item: legendary growth, starts at level 7.',
npcs: ['Vessa Hollow'],
rewards: [{ weapon: 'Hollowbane', note: 'Drawn from the seal itself' }],
rewards: [
{ weapon: 'Hollowbane', note: 'Drawn from the seal itself — the campaign’s one significant magic item' },
{ item: 'The Seal-Stone', note: 'E22A only — the seal, reinforced rather than broken' },
],
},
{
name: 'E25 — The Last Approach', mode: 'BATTLE', location: 'Hollow Below — The Hollow Court (Layer 3)',
@@ -540,6 +656,19 @@ const encounters = [
dmNotes: 'A gauntlet of horror-register creatures; whichever faction the party rallied fights alongside them (add those NPCs). Grey Dawn = a disciplined line; Spine Clans = terrain and ritual (collapse passages); Independent = hardest, and entirely theirs. → E26, the Hollow King: build that stat block from DM Secrets, scaled to the party.',
monsters: [['Root Stalker', 1], ['Bellcrawler', 2]],
},
{
name: 'E23–E24 — The Waking', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'The Waking isn’t loud. That’s the wrong word for it. It’s the sound of something enormous remembering it has a name.',
dmNotes: 'Hollow Court Room 6. The seal gives way, one way or another.\n• Broke it deliberately (E22B): the party arrives prepared, on their own timing — full resources, no surprise round against them.\n• It failed on its own (E22C): the Hollow King is already partly awake — apply one stage of escalation before the party acts, and E25–E26 are noticeably harder.\nE24, What the Court Remembers: not combat. Its memories bleed into the room, fragments of who it was before. DC 14 Insight recognises a fragment as manipulation rather than truth — it is recruiting sympathy, not confessing. → E25.',
npcs: ['Vessa Hollow'],
},
{
name: 'E26 — The Hollow King', mode: 'BATTLE', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'There is no throne and no crown. There is a shape made of everyone who was put down here, and it has finally turned to look at you.',
dmNotes: 'The final confrontation. The book gives no stat block — it asks you to build one to the party’s level and what they brought. The “The Hollow King” monster here is a starting point for four level 7–8 characters: change it.\nThree shapes:\n• Grey Dawn track — disciplined tactics: Kavan’s wardens hold a line while the party lands the killing blow. Add Ser Kavan Thorne.\n• Spine Clans track — terrain and ritual: collapsing passages, the Memory Pool as a weapon. Add Korgath Stonejaw.\n• Independent — hardest: no reinforcements, and every choice in the last round is theirs.\nNegotiating through Vessa is a legitimate ending. → E27, Endings: Sealed, Grey Dawn, Spine Clans or Independent — and close where it opened, at the Lantern Market a year later.',
monsters: [['The Hollow King', 1]],
npcs: ['Vessa Hollow'],
},
];
// ---------------------------------------------------------------------------
@@ -803,4 +932,4 @@ The Salt Coast: 1 smugglers wary of witnesses · 2 short-handed fishing crew ·
The Bramble Marches: 1 Bandit Captain’s crew · 2 refugees on the road · 3 tense border patrol · 4 signs of the neighboring power · 5 a skirmish in progress · 6 watchful quiet.`,
];
module.exports = { campaign, locations, monsters, weapons, items, spells, npcs, encounters, sharedNotes, dmNotes };
module.exports = { code: 'ALDENF', campaign, locations, monsters, weapons, items, spells, npcs, encounters, sharedNotes, dmNotes };
File diff suppressed because one or more lines are too long
+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 }));
+6 -1
View File
@@ -1,5 +1,10 @@
// Prisma no longer reads .env itself, and this is the first thing to need it.
require('dotenv').config({ quiet: true });
const { PrismaClient } = require('@prisma/client');
const { PrismaPg } = require('@prisma/adapter-pg');
const prisma = new PrismaClient();
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
module.exports = prisma;
+30
View File
@@ -48,6 +48,31 @@ function rollAbilityScore() {
return abilityScoreFromDice(dice);
}
// The other two ways to make ability scores (Campaign.abilityScoreMethod).
// The standard array is six fixed scores, assigned like rolled ones. Point
// buy starts every score at 8 and spends a budget to raise them, to 15 at most.
const STANDARD_ARRAY = [15, 14, 13, 12, 10, 8];
const POINT_BUY_BUDGET = 27;
const POINT_BUY_COSTS = { 8: 0, 9: 1, 10: 2, 11: 3, 12: 4, 13: 5, 14: 7, 15: 9 };
// What a set of point-buy scores costs, or null if a score is out of range.
function pointBuyCost(scores) {
let total = 0;
for (const score of Object.values(scores)) {
const cost = POINT_BUY_COSTS[score];
if (cost === undefined) return null;
total += cost;
}
return total;
}
// Which dice a character rolls: the campaign's, unless it leaves the choice
// to each character (BOTH).
function effectiveDiceMode(campaign, character) {
if (campaign && campaign.diceMode && campaign.diceMode !== 'BOTH') return campaign.diceMode;
return character.diceMode;
}
// Fisher-Yates-ish random pick of `count` distinct items from `options`,
// using the same crypto RNG as dice rolls (never Math.random for anything
// that ends up on a character sheet).
@@ -154,6 +179,11 @@ module.exports = {
rollDie,
rollAbilityScore,
abilityScoreFromDice,
STANDARD_ARRAY,
POINT_BUY_BUDGET,
POINT_BUY_COSTS,
pointBuyCost,
effectiveDiceMode,
speciesChoicesForRoll,
classChoicesForRoll,
randomSample,
+37
View File
@@ -0,0 +1,37 @@
const { pointBuyCost, POINT_BUY_BUDGET, STANDARD_ARRAY, effectiveDiceMode } = require('./buildFlow');
describe('point buy', () => {
test('all eights cost nothing', () => {
expect(pointBuyCost({ STR: 8, DEX: 8, CON: 8, INT: 8, WIS: 8, CHA: 8 })).toBe(0);
});
test('the classic 15/15/15/8/8/8 spends exactly the budget', () => {
expect(pointBuyCost({ STR: 15, DEX: 15, CON: 15, INT: 8, WIS: 8, CHA: 8 })).toBe(POINT_BUY_BUDGET);
});
test('14 and 15 cost two points a step', () => {
expect(pointBuyCost({ STR: 13 })).toBe(5);
expect(pointBuyCost({ STR: 14 })).toBe(7);
expect(pointBuyCost({ STR: 15 })).toBe(9);
});
test('a score out of range is refused', () => {
expect(pointBuyCost({ STR: 16 })).toBeNull();
expect(pointBuyCost({ STR: 7 })).toBeNull();
});
});
test('the standard array is the six usual scores', () => {
expect(STANDARD_ARRAY).toEqual([15, 14, 13, 12, 10, 8]);
});
describe('effectiveDiceMode', () => {
test('a campaign with one kind of dice decides for everyone', () => {
expect(effectiveDiceMode({ diceMode: 'PHYSICAL' }, { diceMode: 'DIGITAL' })).toBe('PHYSICAL');
expect(effectiveDiceMode({ diceMode: 'DIGITAL' }, { diceMode: 'PHYSICAL' })).toBe('DIGITAL');
});
test('BOTH leaves it to the character', () => {
expect(effectiveDiceMode({ diceMode: 'BOTH' }, { diceMode: 'PHYSICAL' })).toBe('PHYSICAL');
});
});
+194 -9
View File
@@ -5,6 +5,9 @@
const { SPECIES } = require('./data/species');
const { CLASSES } = require('./data/classes');
const { SKILLS } = require('./data/skills');
const { resourceState } = require('./classResources');
const { CR_XP, XP_THRESHOLDS, COUNT_MULTIPLIERS, LEVEL_XP } = require('./data/xp');
const { FULL_CASTER_SLOTS, HALF_CASTER_SLOTS, PACT_SLOTS, CASTER_KIND, MAX_SLOT_LEVEL } = require('./data/spellSlots');
const ABILITIES = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
@@ -54,6 +57,11 @@ function passivePerception(perceptionSkillBonus) {
return 10 + perceptionSkillBonus;
}
// Any skill has a passive score: what it comes to without a roll.
function passiveScore(skillBonus) {
return 10 + skillBonus;
}
function armorClass(dexMod, armorBonus = 0) {
return 10 + dexMod + armorBonus;
}
@@ -121,12 +129,162 @@ function getClassSpellcastingAbility(characterClass) {
return data ? data.spellcastingAbility : null;
}
// ---------------------------------------------------------------------------
// Spell slots and Hit Dice: what a character has to spend between rests.
// ---------------------------------------------------------------------------
const MAX_LEVEL = 20;
function clampLevel(level) {
return Math.min(Math.max(Math.round(Number(level) || 1), 1), MAX_LEVEL);
}
// How many spell slots a class has at a level, by slot level (1st first), and
// which rest gives them back. Null for a class that never has any. A warlock's
// slots are all of one slot level, so the lower ones are 0.
function spellSlotsFor(characterClass, level) {
const kind = CASTER_KIND[characterClass];
if (!kind) return null;
const row = clampLevel(level) - 1;
if (kind === 'PACT') {
const [count, slotLevel] = PACT_SLOTS[row];
return { kind, restoresOn: 'SHORT', max: Array.from({ length: slotLevel }, (_, i) => (i === slotLevel - 1 ? count : 0)) };
}
return { kind, restoresOn: 'LONG', max: [...(kind === 'FULL' ? FULL_CASTER_SLOTS : HALF_CASTER_SLOTS)[row]] };
}
// The slot tracker: for each slot level the character has, how many slots
// there are and how many are spent. `used` is the stored tally (1st-level
// first), which may be shorter than the class's row or hold more than it now
// allows. Null when there are no slots to track (not a caster, or a level 1
// paladin).
function spellSlotState(characterClass, level, used = []) {
const slots = spellSlotsFor(characterClass, level);
if (!slots) return null;
const levels = slots.max
.map((max, i) => {
const spent = Math.min(Math.max(Number((used || [])[i]) || 0, 0), max);
return { level: i + 1, max, used: spent, remaining: max - spent };
})
.filter((slot) => slot.max > 0);
return levels.length > 0 ? { kind: slots.kind, restoresOn: slots.restoresOn, levels } : null;
}
// The highest level of spell a class can cast at a level; 0 for none.
function maxSpellLevel(characterClass, level) {
const slots = spellSlotsFor(characterClass, level);
return slots ? slots.max.length : 0;
}
// One Hit Die per level, of the class's size. Spent ones come back on a long rest.
function hitDiceState(characterClass, level, used = 0) {
const count = clampLevel(level);
const spent = Math.min(Math.max(Number(used) || 0, 0), count);
return { size: getClassHitDieSize(characterClass), count, used: spent, remaining: count - spent };
}
// ---------------------------------------------------------------------------
// Experience points and encounter difficulty (data/xp.js).
// ---------------------------------------------------------------------------
// The XP one monster of a challenge rating is worth. The rating is free text
// on a monster ("1/4", "5", "0.5"); null for anything that is not a rating.
function challengeXp(challengeRating) {
if (challengeRating == null) return null;
const text = String(challengeRating).trim().replace(/^cr\s*/i, '');
const fractions = { 0.125: '1/8', 0.25: '1/4', 0.5: '1/2' };
const key = fractions[text] || text;
return Object.prototype.hasOwnProperty.call(CR_XP, key) ? CR_XP[key] : null;
}
// How hard a fight is for a party. `partyLevels` is the level of each
// character in it; `challengeRatings` the rating of each monster. `xp` is
// what the monsters are worth; `adjustedXp` is that multiplied for their
// number, which is what is held against the party's thresholds. `rating` is
// TRIVIAL, EASY, MEDIUM, HARD or DEADLY, or null with no party or no rated
// monster to judge by. Monsters with no readable rating are counted in
// `unrated` and left out of everything else.
function encounterDifficulty(partyLevels, challengeRatings) {
const worth = challengeRatings.map(challengeXp);
const rated = worth.filter((xp) => xp !== null);
const xp = rated.reduce((sum, value) => sum + value, 0);
const multiplier = rated.length === 0 ? 1 : COUNT_MULTIPLIERS.find(([atLeast]) => rated.length >= atLeast)[1];
const adjustedXp = Math.floor(xp * multiplier);
let thresholds = null;
let rating = null;
if (partyLevels.length > 0) {
const [easy, medium, hard, deadly] = [0, 1, 2, 3].map((i) => partyLevels.reduce((sum, level) => sum + XP_THRESHOLDS[clampLevel(level) - 1][i], 0));
thresholds = { easy, medium, hard, deadly };
if (rated.length > 0) {
rating = adjustedXp >= deadly ? 'DEADLY' : adjustedXp >= hard ? 'HARD' : adjustedXp >= medium ? 'MEDIUM' : adjustedXp >= easy ? 'EASY' : 'TRIVIAL';
}
}
return { xp, adjustedXp, multiplier, rating, thresholds, monsters: rated.length, unrated: worth.length - rated.length };
}
// Where a character's XP puts them: what their next level takes, and whether
// they have it. Levelling stays the DM's decision; this only says it is due.
function xpProgress(xp, level) {
const at = clampLevel(level);
const nextLevelAt = at >= MAX_LEVEL ? null : LEVEL_XP[at];
return { nextLevelAt, levelUpDue: nextLevelAt !== null && xp >= nextLevelAt };
}
// ---------------------------------------------------------------------------
// Gear effects (house rule). A weapon or item in the campaign pools can carry
// effects: signed amounts added to one of the numbers on the sheet. They count
// while a character has the thing equipped and is high enough level to use it;
// holding two of something does not double it.
// ---------------------------------------------------------------------------
// What an effect can change. An ability effect changes the score itself, so
// everything worked out from that ability moves with it.
const GEAR_STATS = [
'AC',
'SPEED',
'INITIATIVE',
'MAX_HP',
'PASSIVE_PERCEPTION',
...ABILITIES,
'SAVES', // every saving throw
...ABILITIES.map((ability) => `SAVE_${ability}`),
'SKILLS', // every skill check
...Object.keys(SKILLS).map((skill) => `SKILL_${skill}`),
];
// The effects in force for a character, each with the name of what gives it:
// [{ stat, amount, source }]. Takes a character with `inventoryItems` (each
// with its item) and `weapons` (each with its weapon) loaded; without them
// there are none. `notes` are the things gear does that are not a number.
function activeGear(character) {
const level = character.level || 1;
const held = [
...(character.inventoryItems || []).map((ii) => ({ ...ii, entry: ii.item })),
...(character.weapons || []).map((cw) => ({ ...cw, entry: cw.weapon })),
].filter((h) => h.entry && h.equipped && canUseGear(level, h.level));
const effects = [];
const notes = [];
for (const { entry } of held) {
for (const effect of Array.isArray(entry.effects) ? entry.effects : []) {
if (GEAR_STATS.includes(effect.stat) && Number.isInteger(effect.amount) && effect.amount !== 0) {
effects.push({ stat: effect.stat, amount: effect.amount, source: entry.name });
}
}
if (entry.effectNote) notes.push({ source: entry.name, note: entry.effectNote });
}
return { effects, notes };
}
/**
* Assembles the full derived character sheet from raw stored data. This is
* the single function the API and quick-stats view should call rather than
* re-deriving any of the above by hand.
*
* @param {object} character - rolled scores, species/class, level, armorBonus, etc.
* With `inventoryItems` and `weapons` loaded (each with its pool entry),
* the effects of equipped gear are applied; see activeGear.
* @param {Array<{skill: string, proficient: boolean, expertise: boolean}>} skillProficiencies
* @param {Array<{ability: string, proficient: boolean}>} saveProficiencies
*/
@@ -145,14 +303,21 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
halfElfBonusAbility2 = null,
armorBonus = 0,
speedOverride = null,
spellSlotsUsed = [],
hitDiceUsed = 0,
} = character;
const halfElfBonusAbilities = [halfElfBonusAbility1, halfElfBonusAbility2].filter(Boolean);
const rolled = { STR: strScore, DEX: dexScore, CON: conScore, INT: intScore, WIS: wisScore, CHA: chaScore };
const gear = activeGear(character);
// The total equipped gear adds to (or takes from) one number.
const fromGear = (...stats) => gear.effects.filter((e) => stats.includes(e.stat)).reduce((sum, e) => sum + e.amount, 0);
const abilities = {};
for (const ability of ABILITIES) {
const final = finalAbilityScore(rolled[ability], ability, species, halfElfBonusAbilities);
// A score never drops below 1, whatever is weighing the character down.
const final = Math.max(1, finalAbilityScore(rolled[ability], ability, species, halfElfBonusAbilities) + fromGear(ability));
abilities[ability] = {
rolled: rolled[ability],
final,
@@ -166,7 +331,7 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
for (const ability of ABILITIES) {
const entry = saveProficiencies.find((s) => s.ability === ability);
const proficient = entry ? entry.proficient : false;
saves[ability] = { proficient, bonus: saveBonus(abilities[ability].modifier, proficient, profBonus) };
saves[ability] = { proficient, bonus: saveBonus(abilities[ability].modifier, proficient, profBonus) + fromGear('SAVES', `SAVE_${ability}`) };
}
const skills = {};
@@ -179,7 +344,7 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
ability,
proficient,
expertise,
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise),
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise) + fromGear('SKILLS', `SKILL_${skillKey}`),
};
}
@@ -189,14 +354,18 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
return {
abilities,
proficiencyBonus: profBonus,
armorClass: armorClass(abilities.DEX.modifier, armorBonus),
initiative: initiative(abilities.DEX.modifier),
speed: effectiveSpeed(species, speedOverride),
hp: { max: hpMax(level, hitDieSize, abilities.CON.modifier) },
hitDice: { size: hitDieSize, count: level },
armorClass: armorClass(abilities.DEX.modifier, armorBonus) + fromGear('AC'),
initiative: initiative(abilities.DEX.modifier) + fromGear('INITIATIVE'),
speed: Math.max(0, effectiveSpeed(species, speedOverride) + fromGear('SPEED')),
hp: { max: Math.max(1, hpMax(level, hitDieSize, abilities.CON.modifier) + fromGear('MAX_HP')) },
hitDice: hitDiceState(characterClass, level, hitDiceUsed),
saves,
skills,
passivePerception: passivePerception(skills.PERCEPTION.bonus),
passivePerception: passivePerception(skills.PERCEPTION.bonus) + fromGear('PASSIVE_PERCEPTION'),
passiveInvestigation: passiveScore(skills.INVESTIGATION.bonus),
passiveInsight: passiveScore(skills.INSIGHT.bonus),
// What equipped gear is doing to the numbers above, and what else it does.
gear,
spellcasting: spellcastingAbility
? {
ability: spellcastingAbility,
@@ -204,6 +373,10 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
attackBonus: spellAttackBonus(profBonus, abilities[spellcastingAbility].modifier),
}
: null,
// The slot tracker; null for a character with no spell slots.
spellSlots: spellSlotState(characterClass, level, spellSlotsUsed),
// The class counters (Rage, Ki...), with how many of each are left.
resources: resourceState(character, abilities, profBonus),
};
}
@@ -338,6 +511,7 @@ module.exports = {
saveBonus,
skillBonus,
passivePerception,
passiveScore,
armorClass,
initiative,
hpMax,
@@ -350,6 +524,17 @@ module.exports = {
getClassSaveProficiencies,
getClassHitDieSize,
getClassSpellcastingAbility,
MAX_LEVEL,
MAX_SLOT_LEVEL,
spellSlotsFor,
spellSlotState,
maxSpellLevel,
hitDiceState,
challengeXp,
encounterDifficulty,
xpProgress,
GEAR_STATS,
activeGear,
computeCharacterSheet,
computeWeaponStats,
MAX_GEAR_LEVEL,
+101
View File
@@ -0,0 +1,101 @@
// Class counters: the things a class can do a set number of times before a
// rest (Rage, Ki, Channel Divinity...). How many there are comes from class,
// subclass, level and ability modifiers; the character row keeps only how
// many are spent (`resourcesUsed`, { key: used }), as it does for spell slots.
// Pure: no DB calls.
//
// What a counter does is on the sheet in the special abilities; nothing here
// feeds another number.
const atLeastOne = (n) => Math.max(1, n);
// Each entry is offered to the classes (and, where set, the subclass) it
// names, from `from` level on. `max` gets { level, mods, profBonus }, where
// `mods` is the ability modifiers by ability. `rest` is the rest that gives
// them back: a SHORT one comes back on a long rest too.
const RESOURCES = [
{ key: 'RAGE', name: 'Rage', classes: ['BARBARIAN'], max: ({ level }) => (level >= 6 ? 4 : level >= 3 ? 3 : 2), rest: 'LONG' },
{ key: 'ZEALOUS_PRESENCE', name: 'Zealous Presence', classes: ['BARBARIAN'], subclass: 'ZEALOT', from: 10, max: () => 1, rest: 'LONG' },
// Font of Inspiration: from level 5 it comes back on a short rest.
{ key: 'BARDIC_INSPIRATION', name: 'Bardic Inspiration', classes: ['BARD'], max: ({ mods }) => atLeastOne(mods.CHA), rest: ({ level }) => (level >= 5 ? 'SHORT' : 'LONG') },
{ key: 'HEALING_HANDS', name: 'Healing Hands', classes: ['CLERIC'], max: () => 1, rest: 'SHORT' },
{ key: 'CHANNEL_DIVINITY', name: 'Channel Divinity', classes: ['CLERIC'], from: 2, max: ({ level }) => (level >= 6 ? 2 : 1), rest: 'SHORT' },
{ key: 'CHANNEL_DIVINITY', name: 'Channel Divinity', classes: ['PALADIN'], from: 3, max: () => 1, rest: 'SHORT' },
{ key: 'WARDING_FLARE', name: 'Warding Flare', classes: ['CLERIC'], subclass: 'LIGHT', from: 2, max: ({ mods }) => atLeastOne(mods.WIS), rest: 'LONG' },
{ key: 'WAR_PRIEST', name: 'War Priest', classes: ['CLERIC'], subclass: 'WAR', from: 2, max: ({ mods }) => atLeastOne(mods.WIS), rest: 'LONG' },
{ key: 'WILD_SHAPE', name: 'Wild Shape', classes: ['DRUID'], from: 2, max: () => 2, rest: 'SHORT' },
{ key: 'NATURAL_RECOVERY', name: 'Natural Recovery', classes: ['DRUID'], subclass: 'LAND', from: 2, max: () => 1, rest: 'LONG' },
{ key: 'STAR_MAP', name: 'Star Map (Guiding Bolt)', classes: ['DRUID'], subclass: 'STARS', from: 2, max: ({ profBonus }) => profBonus, rest: 'LONG' },
{ key: 'COSMIC_OMEN', name: 'Cosmic Omen', classes: ['DRUID'], subclass: 'STARS', from: 6, max: ({ profBonus }) => profBonus, rest: 'LONG' },
{ key: 'SECOND_WIND', name: 'Second Wind', classes: ['FIGHTER'], max: () => 1, rest: 'SHORT' },
{ key: 'ACTION_SURGE', name: 'Action Surge', classes: ['FIGHTER'], from: 2, max: () => 1, rest: 'SHORT' },
{ key: 'INDOMITABLE', name: 'Indomitable', classes: ['FIGHTER'], from: 9, max: () => 1, rest: 'LONG' },
{ key: 'SUPERIORITY_DICE', name: 'Superiority Dice', classes: ['FIGHTER'], subclass: 'BATTLE_MASTER', from: 3, max: ({ level }) => (level >= 7 ? 5 : 4), rest: 'SHORT' },
{ key: 'FIGHTING_SPIRIT', name: 'Fighting Spirit', classes: ['FIGHTER'], subclass: 'SAMURAI', from: 3, max: () => 3, rest: 'LONG' },
{ key: 'KI', name: 'Ki', classes: ['MONK'], from: 2, max: ({ level }) => level, rest: 'SHORT' },
{ key: 'WHOLENESS_OF_BODY', name: 'Wholeness of Body', classes: ['MONK'], subclass: 'OPEN_HAND', from: 6, max: () => 1, rest: 'LONG' },
{ key: 'LAY_ON_HANDS', name: 'Lay on Hands (HP)', classes: ['PALADIN'], max: ({ level }) => 5 * level, rest: 'LONG' },
{ key: 'DIVINE_SENSE', name: 'Divine Sense', classes: ['PALADIN'], max: ({ mods }) => atLeastOne(1 + mods.CHA), rest: 'LONG' },
{ key: 'WILD_TALENT', name: 'Wild Talent', classes: ['SORCERER'], max: () => 1, rest: 'LONG' },
{ key: 'SORCERY_POINTS', name: 'Sorcery Points', classes: ['SORCERER'], from: 2, max: ({ level }) => level, rest: 'LONG' },
{ key: 'TIDES_OF_CHAOS', name: 'Tides of Chaos', classes: ['SORCERER'], subclass: 'WILD_MAGIC', from: 3, max: () => 1, rest: 'LONG' },
{ key: 'FEY_PRESENCE', name: 'Fey Presence', classes: ['WARLOCK'], subclass: 'ARCHFEY', from: 3, max: () => 1, rest: 'SHORT' },
{ key: 'MISTY_ESCAPE', name: 'Misty Escape', classes: ['WARLOCK'], subclass: 'ARCHFEY', from: 6, max: () => 1, rest: 'SHORT' },
{ key: 'PATRONS_LUCK', name: 'Patron’s Luck', classes: ['WARLOCK'], subclass: 'FIEND', from: 6, max: () => 1, rest: 'SHORT' },
{ key: 'ENTROPIC_WARD', name: 'Entropic Ward', classes: ['WARLOCK'], subclass: 'GREAT_OLD_ONE', from: 6, max: () => 1, rest: 'SHORT' },
{ key: 'ARCANE_RECOVERY', name: 'Arcane Recovery', classes: ['WIZARD'], max: () => 1, rest: 'LONG' },
{ key: 'ILLUSORY_SELF', name: 'Illusory Self', classes: ['WIZARD'], subclass: 'ILLUSION', from: 10, max: () => 1, rest: 'SHORT' },
];
// The counters a character has, with how many of each are spent and left:
// [{ key, name, max, used, remaining, restoresOn }]. `used` is the stored
// tally, which may name counters the character no longer has or hold more
// than they now allow.
function resourceState({ characterClass, subclass = null, level = 1, resourcesUsed = {} }, abilities, profBonus) {
const mods = Object.fromEntries(Object.entries(abilities).map(([ability, detail]) => [ability, detail.modifier]));
const context = { level, mods, profBonus };
const tally = resourcesUsed && typeof resourcesUsed === 'object' ? resourcesUsed : {};
return RESOURCES.filter(
(resource) => resource.classes.includes(characterClass) && level >= (resource.from || 1) && (!resource.subclass || resource.subclass === subclass)
).map((resource) => {
const max = resource.max(context);
const used = Math.min(Math.max(Math.round(Number(tally[resource.key]) || 0), 0), max);
return {
key: resource.key,
name: resource.name,
max,
used,
remaining: max - used,
restoresOn: typeof resource.rest === 'function' ? resource.rest(context) : resource.rest,
};
});
}
// The tally to store, from a character's counters: only what is spent.
function resourceTally(resources) {
return Object.fromEntries(resources.filter((resource) => resource.used > 0).map((resource) => [resource.key, resource.used]));
}
// What a rest gives back: the tally afterwards, and the names of the counters
// that were refilled. A long rest refills every counter; a short rest those
// that restore on one.
function restResources(resources, restType) {
const refills = (resource) => resource.used > 0 && (restType === 'LONG' || resource.restoresOn === 'SHORT');
return {
resourcesUsed: resourceTally(resources.filter((resource) => !refills(resource))),
restored: resources.filter(refills).map((resource) => resource.name),
};
}
const MAX_EXHAUSTION = 6;
module.exports = { RESOURCES, resourceState, resourceTally, restResources, MAX_EXHAUSTION };
+60
View File
@@ -0,0 +1,60 @@
const { RESOURCES, resourceState, resourceTally, restResources } = require('./classResources');
const { computeCharacterSheet } = require('./calculations');
const { CLASSES } = require('./data/classes');
const { findSubclass } = require('./data/subclasses');
const of = (character) => computeCharacterSheet({ level: 1, ...character }).resources;
const named = (resources) => Object.fromEntries(resources.map((r) => [r.key, r.max]));
describe('class counters', () => {
test('every counter belongs to a real class and, where it names one, a real subclass', () => {
for (const resource of RESOURCES) {
for (const cls of resource.classes) {
expect(CLASSES[cls]).toBeDefined();
if (resource.subclass) expect(findSubclass(cls, resource.subclass)).not.toBeNull();
}
}
});
test('come from class and level', () => {
expect(named(of({ characterClass: 'BARBARIAN' }))).toEqual({ RAGE: 2 });
expect(named(of({ characterClass: 'BARBARIAN', level: 6 }))).toEqual({ RAGE: 4 });
expect(named(of({ characterClass: 'FIGHTER' }))).toEqual({ SECOND_WIND: 1 });
expect(named(of({ characterClass: 'FIGHTER', level: 9 }))).toEqual({ SECOND_WIND: 1, ACTION_SURGE: 1, INDOMITABLE: 1 });
expect(named(of({ characterClass: 'MONK' }))).toEqual({});
expect(named(of({ characterClass: 'MONK', level: 4 }))).toEqual({ KI: 4 });
expect(named(of({ characterClass: 'PALADIN', level: 3, chaScore: 10 }))).toEqual({ CHANNEL_DIVINITY: 1, LAY_ON_HANDS: 15, DIVINE_SENSE: 1 });
expect(of({ characterClass: 'ROGUE', level: 10 })).toEqual([]);
expect(of({ characterClass: null })).toEqual([]);
});
test('some follow an ability modifier, never below one', () => {
expect(named(of({ characterClass: 'BARD', chaScore: 16 })).BARDIC_INSPIRATION).toBe(3);
expect(named(of({ characterClass: 'BARD', chaScore: 6 })).BARDIC_INSPIRATION).toBe(1);
});
test('a subclass adds its own', () => {
expect(named(of({ characterClass: 'FIGHTER', level: 3 })).SUPERIORITY_DICE).toBeUndefined();
expect(named(of({ characterClass: 'FIGHTER', level: 3, subclass: 'BATTLE_MASTER' })).SUPERIORITY_DICE).toBe(4);
expect(named(of({ characterClass: 'FIGHTER', level: 7, subclass: 'BATTLE_MASTER' })).SUPERIORITY_DICE).toBe(5);
expect(named(of({ characterClass: 'DRUID', level: 2, subclass: 'LAND' }))).toEqual({ WILD_SHAPE: 2, NATURAL_RECOVERY: 1 });
});
test('a bard\'s inspiration comes back on a short rest from level 5', () => {
expect(of({ characterClass: 'BARD', level: 4 })[0].restoresOn).toBe('LONG');
expect(of({ characterClass: 'BARD', level: 5 })[0].restoresOn).toBe('SHORT');
});
test('the stored tally is squared up with what the character has', () => {
const resources = of({ characterClass: 'MONK', level: 4, resourcesUsed: { KI: 9, RAGE: 1 } });
expect(resources).toEqual([{ key: 'KI', name: 'Ki', max: 4, used: 4, remaining: 0, restoresOn: 'SHORT' }]);
expect(resourceTally(resources)).toEqual({ KI: 4 });
expect(resourceState({ characterClass: 'MONK', level: 4, resourcesUsed: null }, { WIS: { modifier: 0 } }, 2)[0].used).toBe(0);
});
test('a short rest refills what restores on one; a long rest everything', () => {
const resources = of({ characterClass: 'FIGHTER', level: 9, resourcesUsed: { SECOND_WIND: 1, INDOMITABLE: 1 } });
expect(restResources(resources, 'SHORT')).toEqual({ resourcesUsed: { INDOMITABLE: 1 }, restored: ['Second Wind'] });
expect(restResources(resources, 'LONG')).toEqual({ resourcesUsed: {}, restored: ['Second Wind', 'Indomitable'] });
});
});
+78
View File
@@ -0,0 +1,78 @@
// Default item library — armor, shields, a few worn pieces and common
// supplies, offered to any campaign so a DM isn't hand-typing them. Shape
// matches the `Item` Prisma model minus `id`/`campaignId`, plus a stable `key`
// for "add to campaign" lookups and a `category` for the library picker.
//
// Armor here is a flat bonus to AC on top of 10 + Dexterity, because that is
// how gear effects work (game/calculations.js, GEAR_STATS): the standard
// rules' "replaces 10 + DEX" and "DEX bonus capped at 2" are not modelled.
// Each suit's bonus is its standard AC less 10, and what the bonus cannot say
// is in the note. A DM who wants it stricter edits the copy in their campaign.
const STEALTH = 'Disadvantage on Stealth checks.';
const armor = (key, name, category, ac, description, note = null, more = []) => ({
key,
name,
category,
description,
damageDice: null,
effects: [{ stat: 'AC', amount: ac }, ...more],
effectNote: note,
});
const gear = (key, name, category, description, effects = [], effectNote = null, damageDice = null) => ({
key,
name,
category,
description,
damageDice,
effects,
effectNote,
});
const ITEM_LIBRARY = [
// Light armor
armor('padded-armor', 'Padded Armor', 'Light Armor', 1, 'Quilted layers of cloth and batting.', STEALTH),
armor('leather-armor', 'Leather Armor', 'Light Armor', 1, 'Boiled leather breastplate and shoulder guards over soft leather.'),
armor('studded-leather', 'Studded Leather', 'Light Armor', 2, 'Tough leather set with close rivets and spikes.'),
// Medium armor
armor('hide-armor', 'Hide Armor', 'Medium Armor', 2, 'Thick furs and pelts.', 'Standard rules: counts at most +2 from Dexterity.'),
armor('chain-shirt', 'Chain Shirt', 'Medium Armor', 3, 'Interlocking rings worn between layers of clothing.', 'Standard rules: counts at most +2 from Dexterity.'),
armor('scale-mail', 'Scale Mail', 'Medium Armor', 4, 'A coat and leggings of leather covered in overlapping metal scales.', `${STEALTH} Standard rules: counts at most +2 from Dexterity.`),
armor('breastplate', 'Breastplate', 'Medium Armor', 4, 'A fitted metal chest piece worn with supple leather.', 'Standard rules: counts at most +2 from Dexterity.'),
armor('half-plate', 'Half Plate', 'Medium Armor', 5, 'Shaped metal plates covering most of the body.', `${STEALTH} Standard rules: counts at most +2 from Dexterity.`),
// Heavy armor: slow, loud, and in the standard rules it ignores Dexterity.
armor('ring-mail', 'Ring Mail', 'Heavy Armor', 4, 'Leather armor with heavy rings sewn into it.', `${STEALTH} Standard rules: adds no Dexterity.`),
armor('chain-mail', 'Chain Mail', 'Heavy Armor', 6, 'Interlocking metal rings over a layer of quilted fabric, with gauntlets.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 13.`, [{ stat: 'SPEED', amount: -10 }]),
armor('splint-armor', 'Splint Armor', 'Heavy Armor', 7, 'Narrow vertical strips of metal riveted to a leather backing.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
armor('plate-armor', 'Plate Armor', 'Heavy Armor', 8, 'Shaped, interlocking metal plates covering the whole body.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
// Shields
armor('shield', 'Shield', 'Shields', 2, 'Wood or metal, carried in one hand.'),
armor('tower-shield', 'Tower Shield', 'Shields', 3, 'A wall of oak and iron, as tall as its bearer.', null, [{ stat: 'SPEED', amount: -5 }]),
// Worn gear
gear('ring-of-protection', 'Ring of Protection', 'Worn Gear', 'A plain band that turns blows aside.', [{ stat: 'AC', amount: 1 }, { stat: 'SAVES', amount: 1 }]),
gear('cloak-of-protection', 'Cloak of Protection', 'Worn Gear', 'A heavy cloak that seems to catch what was meant for you.', [{ stat: 'AC', amount: 1 }, { stat: 'SAVES', amount: 1 }]),
gear('cloak-of-elvenkind', 'Cloak of Elvenkind', 'Worn Gear', 'With the hood up, its colour shifts to match what is behind it.', [{ stat: 'SKILL_STEALTH', amount: 5 }], 'With the hood up, others have disadvantage on Perception checks to see you.'),
gear('boots-of-striding', 'Boots of Striding', 'Worn Gear', 'Light, tireless boots.', [{ stat: 'SPEED', amount: 10 }]),
gear('gauntlets-of-might', 'Gauntlets of Might', 'Worn Gear', 'Iron-banded gauntlets that lend a giant’s grip.', [{ stat: 'STR', amount: 2 }]),
gear('gloves-of-thievery', 'Gloves of Thievery', 'Worn Gear', 'Thin grey gloves that all but vanish when worn.', [{ stat: 'SKILL_SLEIGHT_OF_HAND', amount: 5 }]),
gear('amulet-of-health', 'Amulet of Health', 'Worn Gear', 'A red stone on a golden chain, warm against the skin.', [{ stat: 'CON', amount: 2 }]),
gear('headband-of-intellect', 'Headband of Intellect', 'Worn Gear', 'A silver circlet set with a pale blue stone.', [{ stat: 'INT', amount: 2 }]),
gear('sentinel-charm', 'Sentinel Charm', 'Worn Gear', 'A brass eye on a cord that never quite closes.', [{ stat: 'INITIATIVE', amount: 2 }, { stat: 'PASSIVE_PERCEPTION', amount: 5 }]),
gear('stone-of-good-luck', 'Stone of Good Luck', 'Worn Gear', 'A polished agate that sits well in a pocket.', [{ stat: 'SAVES', amount: 1 }, { stat: 'SKILLS', amount: 1 }]),
// Supplies: nothing to equip, but every pack has them.
gear('healing-potion', 'Healing Potion', 'Supplies', 'A red liquid that glimmers when shaken. Drink it to regain hit points.', [], null, '2d4+2'),
gear('rope', 'Rope (50 ft)', 'Supplies', 'Hempen rope. Holds a climber.'),
gear('torch', 'Torch', 'Supplies', 'Burns for an hour, lighting 20 feet brightly.', [], 'Held in a hand to give light.'),
gear('lantern', 'Hooded Lantern', 'Supplies', 'Bright light for 30 feet; the hood dims it to a glow.', [], 'Held in a hand to give light.'),
gear('rations', 'Rations (1 day)', 'Supplies', 'Dried meat, hard biscuit, nuts.'),
gear('bedroll', 'Bedroll', 'Supplies', 'A blanket roll for sleeping rough.'),
gear('thieves-tools', 'Thieves’ Tools', 'Supplies', 'A file, picks, a small mirror, narrow scissors and pliers.'),
gear('healers-kit', 'Healer’s Kit', 'Supplies', 'Bandages, salves and splints. Steadies a dying creature without a check.'),
];
module.exports = { ITEM_LIBRARY };
+251
View File
@@ -0,0 +1,251 @@
// What each class gains at each level after the first: the "Levelling-Up
// Checklist" tables of the Hollowcrown book (Rules Cheat Sheet), levels 2 to
// 10. These are the features every member of the class gets; what a subclass
// adds is in subclasses.js. Level 1 features are in characterOptions.js and
// are written by the builder.
//
// Each entry becomes a line of the character's special abilities, "Name —
// text". An `upgrade` rewrites the line that already has that name (Sneak
// Attack growing a die); anything else is added as a new line. `{ boon: true }`
// is a Boon: the DM picks one from BOONS below.
//
// None of this feeds the calculation engine: like the level 1 features, these
// are table rules in plain words.
const BOON = { boon: true };
const rage = (uses, damage) => ({
name: 'Rage',
text: `${uses} per day, for one minute: +${damage} melee damage, advantage on Strength checks and saves, and physical damage against you is halved.`,
upgrade: true,
});
const sneakAttack = (dice) => ({
name: 'Sneak Attack',
text: `Once per turn, deal an extra ${dice} damage to a target you have advantage against, or that has one of your allies next to it.`,
upgrade: true,
});
const bardicInspiration = (die) => ({
name: 'Bardic Inspiration',
text: `A few times per day, as a bonus action, give an ally a ${die} to add to one attack, check or save.`,
upgrade: true,
});
const wildShape = (limit) => ({
name: 'Wild Shape',
text: `Twice per short rest, become a beast you have seen of ${limit}, for up to half your level in hours. Use the beast's HP; when it hits 0 you return to your own form.`,
upgrade: true,
});
const EXTRA_ATTACK = { name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' };
const EVASION = { name: 'Evasion', text: 'On a Dexterity save for half damage, take none on a success and half on a failure.' };
const LANDS_STRIDE = { name: 'Land’s Stride', text: 'Ordinary difficult terrain and plants do not slow you.' };
const INVOCATIONS =
'Agonizing Blast (add your Charisma modifier to each Eldritch Blast hit), Devil’s Sight (see normally in darkness, even magical darkness, to 120 ft), Repelling Blast (Eldritch Blast hits push the target 10 ft away), Mask of Many Faces (cast Disguise Self at will), Beguiling Influence (proficiency in Deception and Persuasion, added by hand), Eyes of the Rune Keeper (read any writing)';
const invocations = (total) => ({
name: 'Eldritch Invocations',
text: `Choose one more with your DM (${total} in all). From level 5 the list adds Thirsting Blade (Pact of the Blade: attack twice with your pact weapon) and One with Shadows (become invisible while standing still in dim light or darkness).`,
});
const LEVEL_FEATURES = {
BARBARIAN: {
2: [
{ name: 'Reckless Attack', text: 'On your first attack each turn you may attack with advantage; attacks against you have advantage until your next turn.' },
{ name: 'Danger Sense', text: 'Advantage on Dexterity saves against effects you can see (traps, breath, spells).' },
],
3: [rage('Three times', 2)],
4: [BOON],
5: [EXTRA_ATTACK, { name: 'Fast Movement', text: '+10 ft speed when not in heavy armor (ask your DM to raise your speed).' }],
6: [rage('Four times', 2)],
7: [{ name: 'Feral Instinct', text: 'Advantage on initiative; you can act normally in a surprise round if you rage first.' }],
8: [BOON],
9: [{ name: 'Brutal Critical', text: 'Roll one extra weapon damage die on a critical hit.' }, rage('Four times', 3)],
},
BARD: {
2: [
{ name: 'Jack of All Trades', text: 'Add half your proficiency bonus (round down) to any check you are not proficient in.' },
{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d6.' },
],
3: [
{ name: 'Expertise', text: 'Double proficiency on two proficient skills (add by hand).' },
],
4: [BOON],
5: [bardicInspiration('d8'), { name: 'Font of Inspiration', text: 'Regain Bardic Inspiration on a short rest.' }],
6: [
{ name: 'Countercharm', text: 'Action: allies within 30 ft get advantage on saves against fear and charm until your next turn.' },
],
8: [BOON],
9: [{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d8.', upgrade: true }],
10: [
bardicInspiration('d10'),
{ name: 'Expertise', text: 'Double proficiency on four proficient skills (add by hand).', upgrade: true },
{ name: 'Magical Secrets', text: 'Learn two more spells from any class (the DM grants them within the unlock schedule).' },
],
},
CLERIC: {
2: [
{ name: 'Channel Divinity', text: 'Once per short rest, use Turn Undead or your domain’s Channel Divinity.' },
{ name: 'Turn Undead', text: 'Channel Divinity. Action: undead within 30 ft make a Wisdom save or flee from you for 1 minute.' },
],
4: [BOON],
5: [{ name: 'Destroy Undead', text: 'Undead of CR 1/2 or lower that fail against your Turn Undead are destroyed.' }],
6: [
{ name: 'Channel Divinity', text: 'Twice per short rest, use Turn Undead or your domain’s Channel Divinity.', upgrade: true },
],
8: [
BOON,
{ name: 'Destroy Undead', text: 'Undead of CR 1 or lower that fail against your Turn Undead are destroyed.', upgrade: true },
],
10: [{ name: 'Divine Intervention', text: 'Once per week, call on your god: roll d100; if you roll your level or lower, the god helps (the DM decides how).' }],
},
DRUID: {
2: [
wildShape('CR 1/4 or less (no flying or swimming)'),
],
4: [BOON, wildShape('CR 1/2 or less (it may swim, not fly)')],
8: [BOON, wildShape('CR 1 or less (it may swim or fly)')],
},
FIGHTER: {
2: [{ name: 'Action Surge', text: 'Once per short rest, take one extra action on your turn.' }],
4: [BOON],
5: [EXTRA_ATTACK],
6: [BOON],
8: [BOON],
9: [{ name: 'Indomitable', text: 'Once per long rest, reroll a failed saving throw.' }],
},
MONK: {
2: [
{ name: 'Ki', text: 'Points equal to your level; they come back on a short rest. Ki save DC is 8 + proficiency bonus + Wisdom modifier.' },
{ name: 'Flurry of Blows', text: '1 ki: two unarmed strikes as a bonus action.' },
{ name: 'Patient Defense', text: '1 ki: Dodge as a bonus action.' },
{ name: 'Step of the Wind', text: '1 ki: Dash or Disengage as a bonus action, jump distance doubled.' },
{ name: 'Unarmored Movement', text: '+10 ft speed without armor (ask your DM to raise your speed).' },
],
3: [
{ name: 'Deflect Missiles', text: 'Reaction: reduce ranged weapon damage by 1d10 + Dexterity modifier + your level.' },
],
4: [BOON, { name: 'Slow Fall', text: 'Reaction: reduce falling damage by 5 × your level.' }],
5: [
EXTRA_ATTACK,
{ name: 'Stunning Strike', text: '1 ki after a melee hit: the target makes a Constitution save or is stunned until your next turn.' },
{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d6 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.', upgrade: true },
],
6: [
{ name: 'Ki-Empowered Strikes', text: 'Your unarmed strikes count as magical.' },
{ name: 'Unarmored Movement', text: '+15 ft speed without armor (ask your DM to raise your speed).', upgrade: true },
],
7: [EVASION, { name: 'Stillness of Mind', text: 'Action: end charm or fear on yourself.' }],
8: [BOON],
9: [{ name: 'Unarmored Movement', text: '+15 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true }],
10: [
{ name: 'Purity of Body', text: 'Immune to poison and disease.' },
{ name: 'Unarmored Movement', text: '+20 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
],
},
PALADIN: {
2: [
{ name: 'Divine Smite', text: 'After a melee hit, spend a spell slot to deal +2d8 radiant (+1d8 per slot level above 1st; +1d8 more against undead and fiends).' },
{ name: 'Fighting Style', text: 'Choose one with your DM: +1 AC in armor (the DM raises Extra AC by 1), +2 damage with a one-handed weapon, or protect an adjacent ally (impose disadvantage on an attack as a reaction).' },
{ name: 'Spellcasting', text: 'Paladin spells unlock: you now have spell slots.' },
],
3: [
{ name: 'Divine Health', text: 'Immune to disease.' },
],
4: [BOON],
5: [EXTRA_ATTACK],
6: [{ name: 'Aura of Protection', text: 'You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious.' }],
8: [BOON],
10: [{ name: 'Aura of Courage', text: 'You and allies within 10 ft can’t be frightened.' }],
},
RANGER: {
2: [
{ name: 'Fighting Style', text: 'Choose one with your DM: +2 to ranged attacks, +1 AC in armor (the DM raises Extra AC by 1), or add your ability modifier again to a paired weapon’s damage (added by hand).' },
{ name: 'Spellcasting', text: 'Ranger spells unlock: you now have spell slots.' },
],
3: [
{ name: 'Primeval Awareness', text: 'Spend a spell slot: sense aberrations, dragons, fey, fiends, undead and elementals within 1 mile for 1 minute per slot level.' },
],
4: [BOON],
5: [EXTRA_ATTACK],
6: [
{ name: 'Favored Enemy', text: 'Choose a second favored enemy with your DM.' },
{ name: 'Natural Explorer', text: 'Choose a second favored terrain with your DM.' },
],
8: [BOON, LANDS_STRIDE],
10: [
{ name: 'Hide in Plain Sight', text: 'Spend 1 minute camouflaging: +10 to Stealth while you stay still.' },
{ name: 'Natural Explorer', text: 'Choose a third favored terrain with your DM.' },
],
},
ROGUE: {
2: [{ name: 'Cunning Action', text: 'Bonus action: Dash, Disengage or Hide.' }],
3: [sneakAttack('2d6')],
4: [BOON],
5: [sneakAttack('3d6'), { name: 'Uncanny Dodge', text: 'Reaction: halve the damage of an attack that hits you.' }],
6: [{ name: 'Expertise', text: 'Double proficiency on two more proficient skills (add by hand).' }],
7: [sneakAttack('4d6'), EVASION],
8: [BOON],
9: [sneakAttack('5d6')],
10: [BOON],
},
SORCERER: {
2: [{ name: 'Font of Magic', text: 'Sorcery points equal to your level; they come back on a long rest. Bonus action: turn points into a spell slot (2 points = 1st, 3 = 2nd, 5 = 3rd, 6 = 4th, 7 = 5th), or a spell slot into points equal to its level.' }],
3: [{ name: 'Metamagic', text: 'Choose two with your DM: Quickened Spell (2 points: cast an action spell as a bonus action), Twinned Spell (points = spell level: hit a second target with a single-target spell), Careful Spell (1 point: allies auto-succeed on your spell’s save), Subtle Spell (1 point: cast without words or gestures), Extended Spell (1 point: double duration), Heightened Spell (3 points: one target has disadvantage on its first save).' }],
4: [BOON],
8: [BOON],
10: [{ name: 'Metamagic', text: 'Choose a third option with your DM.' }],
},
WARLOCK: {
2: [{ name: 'Eldritch Invocations', text: `Choose two with your DM: ${INVOCATIONS}.` }],
3: [{ name: 'Pact Boon', text: 'Choose one with your DM: Pact of the Blade (summon a weapon of your choice as an action; it counts as magical), Pact of the Chain (gain a small familiar spirit: an imp, a sprite or a cat-shaped shadow), or Pact of the Tome (a book of three extra cantrips from any class, which the DM grants).' }],
4: [BOON],
5: [invocations('three')],
7: [invocations('four')],
8: [BOON],
9: [invocations('five')],
},
WIZARD: {
4: [BOON],
8: [BOON],
},
};
// The class tables above stop here; a higher level still raises the numbers.
const MAX_FEATURE_LEVEL = 10;
// From level 5 a damaging cantrip rolls twice the dice the site shows. Written
// onto the sheet of every class that casts, so nobody forgets.
const CANTRIP_LEVEL = 5;
const CANTRIP_FEATURE = {
name: 'Cantrips',
text: 'Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll).',
};
// Boons stand in for ability score increases, which the site does not have
// (every number on the sheet comes from the scores rolled at the build). Each
// is worth what +2 to a best ability would be. `max` is how often one can be
// taken; `per` is what each taking adds, and `text` the line for that many.
const BOONS = [
{
key: 'WEAPON_MASTERY',
name: 'Weapon Mastery',
max: 2,
per: 1,
text: (amount) => `+${amount} to attack and damage rolls with weapons (add it to the numbers the site shows).`,
},
{
key: 'ARCANE_FOCUS',
name: 'Arcane Focus',
max: 2,
per: 1,
text: (amount) => `+${amount} to spell save DC and spell attack bonus (add it to the numbers the site shows).`,
},
// AC is the most powerful number in the game, so this one is taken once.
{ key: 'TOUGHENED', name: 'Toughened', max: 1, per: 1, text: (amount) => `+${amount} AC (counted in your Extra AC).` },
// The site works max HP out itself, so the extra lives in Temp HP.
{ key: 'HARDY', name: 'Hardy', max: 2, per: 5, text: (amount) => `+${amount} maximum HP: gain ${amount} Temp HP after each long rest.` },
];
module.exports = { LEVEL_FEATURES, MAX_FEATURE_LEVEL, CANTRIP_LEVEL, CANTRIP_FEATURE, BOONS };
+5 -3
View File
@@ -1,7 +1,9 @@
// Combined default-content library — see weaponsLibrary.js / spellsLibrary.js
// / monstersLibrary.js for the individual sets and their shape.
// Combined default-content library — see weaponsLibrary.js / itemsLibrary.js
// / spellsLibrary.js / monstersLibrary.js for the individual sets and their
// shape.
const { WEAPON_LIBRARY } = require('./weaponsLibrary');
const { ITEM_LIBRARY } = require('./itemsLibrary');
const { SPELL_LIBRARY } = require('./spellsLibrary');
const { MONSTER_LIBRARY } = require('./monstersLibrary');
module.exports = { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY };
module.exports = { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY };
+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 };
+2 -2
View File
@@ -115,7 +115,7 @@ const SPELL_LIBRARY = [
{ key: 'nondetection', name: 'Nondetection', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: '8 hours', description: 'Hide a target from divination magic.', classes: ['BARD', 'RANGER', 'WIZARD'] },
{ key: 'protection-from-energy', name: 'Protection from Energy', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'Resistance to one damage type of your choice.', classes: ['CLERIC', 'DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
{ key: 'remove-curse', name: 'Remove Curse', level: 3, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Lift a curse from a creature or object.', classes: ['CLERIC', 'PALADIN', 'WARLOCK', 'WIZARD'] },
{ key: 'revivify', name: 'Revivify', level: 3, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature that died within the last minute returns to life with 1 hit point.', classes: ['CLERIC', 'PALADIN'] },
{ key: 'revivify', name: 'Revivify', level: 3, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature that died within the last minute returns to life with 1 hit point.', revives: true, classes: ['CLERIC', 'PALADIN'] },
{ key: 'sleet-storm', name: 'Sleet Storm', level: 3, school: 'Conjuration', castingTime: '1 action', range: '150 feet', duration: 'Concentration, up to 1 minute', description: 'Freezing rain falls in a 40-foot-radius cylinder, icing the ground.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'slow', name: 'Slow', level: 3, school: 'Transmutation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Up to six creatures are hindered, halving their speed and worse.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'spirit-guardians', name: 'Spirit Guardians', level: 3, school: 'Conjuration', castingTime: '1 action', range: 'Self (15-foot radius)', duration: 'Concentration, up to 10 minutes', description: 'Protective spirits swirl around you, damaging enemies that enter.', damageDice: '3d8', classes: ['CLERIC'] },
@@ -150,7 +150,7 @@ const SPELL_LIBRARY = [
{ key: 'insect-plague', name: 'Insect Plague', level: 5, school: 'Conjuration', castingTime: '1 action', range: '300 feet', duration: 'Concentration, up to 10 minutes', description: 'A swarm of biting, stinging insects fills a 20-foot-radius sphere.', damageDice: '4d10', classes: ['CLERIC', 'DRUID', 'SORCERER'] },
{ key: 'legend-lore', name: 'Legend Lore', level: 5, school: 'Divination', castingTime: '10 minutes', range: 'Self', duration: 'Instantaneous', description: 'Learn what you know, or have heard, about a name, place, or object.', classes: ['BARD', 'CLERIC', 'WIZARD'] },
{ key: 'mass-cure-wounds', name: 'Mass Cure Wounds', level: 5, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'Up to six creatures in a 30-foot-radius sphere regain hit points.', classes: ['BARD', 'CLERIC', 'DRUID'] },
{ key: 'raise-dead', name: 'Raise Dead', level: 5, school: 'Necromancy', castingTime: '1 hour', range: 'Touch', duration: 'Instantaneous', description: 'Return a dead creature to life, as long as its body is mostly intact.', classes: ['BARD', 'CLERIC', 'PALADIN'] },
{ key: 'raise-dead', name: 'Raise Dead', level: 5, school: 'Necromancy', castingTime: '1 hour', range: 'Touch', duration: 'Instantaneous', description: 'Return a dead creature to life, as long as its body is mostly intact.', revives: true, classes: ['BARD', 'CLERIC', 'PALADIN'] },
{ key: 'wall-of-force', name: 'Wall of Force', level: 5, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'An invisible wall of force springs into existence, impenetrable to physical force.', classes: ['WIZARD'] },
{ key: 'wall-of-stone', name: 'Wall of Stone', level: 5, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A nonmagical wall of solid stone springs into existence.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
];
+501
View File
@@ -0,0 +1,501 @@
// Subclasses: the path a character takes within their class. Three per class.
// The first of each is the path every character of that class followed before
// subclasses could be chosen (the Champion fighter, the Life cleric...); its
// lines are word for word what the class tables used to write, so sheets
// written back then still match.
//
// `features` are by character level, in the same shape as LEVEL_FEATURES
// (levelFeatures.js): each becomes a line of the special abilities, and an
// `upgrade` rewrites the line that already has its name. Like the class
// tables they stop at level 10, and none of it feeds the calculation engine.
// The level at which a class chooses.
const SUBCLASS_LEVEL = {
BARBARIAN: 3,
BARD: 3,
CLERIC: 2,
DRUID: 2,
FIGHTER: 3,
MONK: 3,
PALADIN: 3,
RANGER: 3,
ROGUE: 3,
SORCERER: 3,
WARLOCK: 3,
WIZARD: 2,
};
const SUBCLASSES = {
BARBARIAN: [
{
key: 'BERSERKER',
name: 'Path of the Berserker',
summary: 'Rage as pure fury: extra attacks now, exhaustion after.',
features: {
3: [{ name: 'Frenzy', text: 'Path of the Berserker. While raging you may make one extra melee attack as a bonus action each turn; when the rage ends, gain one level of exhaustion.' }],
6: [{ name: 'Mindless Rage', text: 'You can’t be charmed or frightened while raging.' }],
10: [{ name: 'Intimidating Presence', text: 'Action: one creature within 30 ft makes a Wisdom save (DC 8 + proficiency bonus + Charisma modifier) or is frightened of you until the end of your next turn.' }],
},
},
{
key: 'TOTEM_WARRIOR',
name: 'Path of the Totem Warrior',
summary: 'A spirit animal walks with you and lends its strength.',
features: {
3: [
{ name: 'Spirit Seeker', text: 'Path of the Totem Warrior. You can speak with beasts and sense what they sense, as a ritual with your DM.' },
{ name: 'Totem Spirit', text: 'Choose one with your DM. Bear: while raging, all damage but psychic is halved. Eagle: while raging, opportunity attacks against you have disadvantage and you can Dash as a bonus action. Wolf: while raging, allies have advantage on melee attacks against enemies next to you.' },
],
6: [{ name: 'Aspect of the Beast', text: 'Choose one with your DM. Bear: carry twice as much, advantage on Strength checks to push, pull or lift. Eagle: see clearly up to a mile, no penalty in dim light. Wolf: track at a fast pace and move stealthily at a normal one.' }],
10: [{ name: 'Spirit Walker', text: 'As a ritual, ask the spirits of a place for what they know (the DM answers as a nature spirit would).' }],
},
},
{
key: 'ZEALOT',
name: 'Path of the Zealot',
summary: 'A god’s fury in your blows, and death has trouble keeping you.',
features: {
3: [
{ name: 'Divine Fury', text: 'Path of the Zealot. While raging, the first creature you hit each turn takes an extra 1d6 + half your level radiant or necrotic damage.' },
{ name: 'Warrior of the Gods', text: 'Spells that bring you back from the dead cost nothing to cast on you.' },
],
6: [{ name: 'Fanatical Focus', text: 'Once per rage, reroll a saving throw you just failed.' }],
10: [{ name: 'Zealous Presence', text: 'Once per long rest, bonus action: up to ten allies within 60 ft have advantage on attacks and saves until your next turn.' }],
},
},
],
BARD: [
{
key: 'LORE',
name: 'College of Lore',
summary: 'Knows a bit of everything, and a word to trip anyone up.',
features: {
3: [
{ name: 'Cutting Words', text: 'College of Lore. Reaction: spend a Bardic Inspiration to subtract the die from an enemy’s attack, check or damage roll.' },
{ name: 'Bonus Proficiencies', text: 'Treat three extra skills of your choice as proficient (add your proficiency bonus by hand).' },
],
6: [{ name: 'Additional Magical Secrets', text: 'Learn two spells from any class list (the DM grants them within the unlock schedule).' }],
},
},
{
key: 'VALOR',
name: 'College of Valor',
summary: 'A skald in the front line, inspiring by example.',
features: {
3: [
{ name: 'Combat Inspiration', text: 'College of Valor. A creature with your Bardic Inspiration may add the die to a weapon damage roll, or to its AC against one attack as a reaction.' },
{ name: 'Battle Training', text: 'You fight in medium armor and with a shield (the DM raises Extra AC to match what you wear).' },
],
6: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
},
},
{
key: 'SWORDS',
name: 'College of Swords',
summary: 'A blade dancer whose performance is the fight itself.',
features: {
3: [
{ name: 'Fighting Style', text: 'College of Swords. Choose one with your DM: +2 damage with a one-handed weapon and nothing in the other hand, or add your ability modifier to the damage of a second weapon (added by hand).' },
{ name: 'Blade Flourish', text: 'When you attack on your turn, +10 ft speed that turn. On a hit, spend a Bardic Inspiration and add the die to the damage, and choose: add it to your AC until your next turn, hit a second creature next to you for the die’s roll, or push the target 5 ft + the roll and follow it.' },
],
6: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
},
},
],
CLERIC: [
{
key: 'LIFE',
name: 'Life Domain',
summary: 'The healer: more from every cure, and healing in a burst.',
features: {
2: [{ name: 'Preserve Life', text: 'Channel Divinity. Action: share out healing of 5 × your level among creatures within 30 ft (up to half their max HP each).' }],
6: [{ name: 'Blessed Healer', text: 'When you heal someone else with a spell, you heal yourself 2 + the spell’s level.' }],
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 radiant damage.' }],
},
},
{
key: 'LIGHT',
name: 'Light Domain',
summary: 'Fire and radiance: blinds attackers and burns the dark away.',
features: {
2: [
{ name: 'Warding Flare', text: 'Light Domain. Reaction, a few times per day (your Wisdom modifier, at least once): a creature within 30 ft that attacks you has disadvantage on that attack.' },
{ name: 'Radiance of the Dawn', text: 'Channel Divinity. Action: magical darkness within 30 ft is dispelled, and each enemy there takes 2d10 + your level radiant damage (Constitution save for half).' },
],
6: [{ name: 'Improved Flare', text: 'Use Warding Flare when a creature attacks someone else within 30 ft of you.' }],
8: [{ name: 'Potent Spellcasting', text: 'Add your Wisdom modifier to the damage of your cleric cantrips.' }],
},
},
{
key: 'WAR',
name: 'War Domain',
summary: 'A soldier of the faith: extra strikes and blows that land.',
features: {
2: [
{ name: 'War Priest', text: 'War Domain. A few times per day (your Wisdom modifier, at least once), after you take the Attack action, make one weapon attack as a bonus action.' },
{ name: 'Guided Strike', text: 'Channel Divinity. When you make an attack roll, add +10 to it after seeing the roll.' },
],
6: [{ name: 'War God’s Blessing', text: 'Channel Divinity. Reaction: give +10 to an attack roll made by a creature within 30 ft.' }],
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 damage of the weapon’s type.' }],
},
},
],
DRUID: [
{
key: 'LAND',
name: 'Circle of the Land',
summary: 'A keeper of old magic, at home in the wild places.',
features: {
2: [
{ name: 'Natural Recovery', text: 'Once per day on a short rest, recover spent spell slots totalling up to half your level (rounded up).' },
{ name: 'Bonus Cantrip', text: 'Learn one extra cantrip (the DM grants it).' },
],
6: [{ name: 'Land’s Stride', text: 'Ordinary difficult terrain and plants do not slow you; advantage on saves against magical plants.' }],
10: [{ name: 'Nature’s Ward', text: 'Immune to poison and disease; you can’t be charmed or frightened by fey or elementals.' }],
},
},
{
key: 'MOON',
name: 'Circle of the Moon',
summary: 'A shapeshifter who fights as the beast.',
features: {
2: [
{ name: 'Combat Wild Shape', text: 'Circle of the Moon. Use Wild Shape as a bonus action. While in beast form, bonus action: spend a spell slot to heal 1d8 per slot level.' },
{ name: 'Circle Forms', text: 'Your Wild Shape can be a beast of up to CR 1; from level 6, up to CR equal to a third of your level (rounded down).' },
],
6: [{ name: 'Primal Strike', text: 'Your attacks in beast form count as magical.' }],
10: [{ name: 'Elemental Wild Shape', text: 'Spend both uses of Wild Shape at once to become an air, earth, fire or water elemental.' }],
},
},
{
key: 'STARS',
name: 'Circle of Stars',
summary: 'Reads the night sky and wears its constellations.',
features: {
2: [
{ name: 'Star Map', text: 'Circle of Stars. You know the Guidance cantrip, and cast Guiding Bolt without a slot a number of times per day equal to your proficiency bonus (the DM grants both).' },
{ name: 'Starry Form', text: 'Bonus action: spend a Wild Shape use to take a starry form for 10 minutes. Choose Archer (bonus action: ranged spell attack, 60 ft, 1d8 + Wisdom modifier radiant), Chalice (when you cast a healing spell, another creature within 30 ft heals 1d8 + Wisdom modifier) or Dragon (treat a roll of 9 or lower as 10 on Intelligence and Wisdom checks and on concentration saves).' },
],
6: [{ name: 'Cosmic Omen', text: 'After a long rest roll a die. Even (Weal): reaction, add 1d6 to a creature’s attack, check or save. Odd (Woe): reaction, subtract 1d6. Usable a number of times per day equal to your proficiency bonus, on creatures within 30 ft.' }],
10: [{ name: 'Twinkling Constellations', text: 'Archer and Chalice use 2d8. In Dragon form you can fly 20 ft. At the start of each turn in Starry Form you may change constellation.' }],
},
},
],
FIGHTER: [
{
key: 'CHAMPION',
name: 'Champion',
summary: 'Simple and deadly: more critical hits, more athletic.',
features: {
3: [{ name: 'Improved Critical', text: 'Your weapon attacks score a critical hit on a roll of 19 or 20.' }],
7: [{ name: 'Remarkable Athlete', text: 'Add half your proficiency bonus to Strength, Dexterity and Constitution checks you are not proficient in; long jump + Strength modifier feet.' }],
10: [{ name: 'Additional Fighting Style', text: 'Choose a second option from your Fighting Style list. (If it is +1 AC in armor, the DM raises Extra AC by 1.)' }],
},
},
{
key: 'BATTLE_MASTER',
name: 'Battle Master',
summary: 'A tactician who turns each swing into a manoeuvre.',
features: {
3: [
{ name: 'Combat Superiority', text: 'Battle Master. Four superiority dice (d8), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier.' },
{ name: 'Manoeuvres', text: 'Choose three with your DM: Trip Attack (add the die to damage; Strength save or prone), Disarming Attack (add the die; Strength save or it drops what it holds), Pushing Attack (add the die; Strength save or pushed 15 ft), Precision Attack (add the die to an attack roll), Riposte (reaction when a creature misses you: attack it and add the die), Parry (reaction: reduce melee damage by the die + Dexterity modifier), Rally (bonus action: an ally gains the die + Charisma modifier Temp HP), Menacing Attack (add the die; Wisdom save or frightened until your next turn).' },
],
7: [
{ name: 'Know Your Enemy', text: 'After a minute watching a creature, the DM tells you whether it is your equal, better or worse in two of: Strength, Dexterity, Constitution, AC, current HP, level.' },
{ name: 'Combat Superiority', text: 'Battle Master. Five superiority dice (d8), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier.', upgrade: true },
{ name: 'Manoeuvres', text: 'Choose two more with your DM (five in all) from the list on your level 3 line.' },
],
10: [{ name: 'Combat Superiority', text: 'Battle Master. Five superiority dice (d10), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier. Choose two more manoeuvres with your DM (seven in all).', upgrade: true }],
},
},
{
key: 'SAMURAI',
name: 'Samurai',
summary: 'Unbreakable resolve: strikes with advantage and shrugs off blows.',
features: {
3: [
{ name: 'Fighting Spirit', text: 'Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 5 Temp HP.' },
{ name: 'Bonus Proficiency', text: 'Treat one of History, Insight, Performance or Persuasion as proficient (add your proficiency bonus by hand).' },
],
7: [{ name: 'Elegant Courtier', text: 'Add your Wisdom modifier to Persuasion checks, and treat Wisdom saves as proficient (both added by hand).' }],
10: [
{ name: 'Tireless Spirit', text: 'If you roll initiative with no uses of Fighting Spirit left, you regain one.' },
{ name: 'Fighting Spirit', text: 'Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 10 Temp HP.', upgrade: true },
],
},
},
],
MONK: [
{
key: 'OPEN_HAND',
name: 'Way of the Open Hand',
summary: 'Pure martial arts: knocks down, pushes away, heals itself.',
features: {
3: [{ name: 'Open Hand Technique', text: 'Flurry hits can knock a target prone (Dexterity save), push it 15 ft (Strength save) or stop its reactions.' }],
6: [{ name: 'Wholeness of Body', text: 'Once per long rest, heal 3 × your level as an action.' }],
},
},
{
key: 'SHADOW',
name: 'Way of Shadow',
summary: 'A ninja: steps between shadows and strikes unseen.',
features: {
3: [{ name: 'Shadow Arts', text: 'Way of Shadow. 2 ki: cast Darkness, Darkvision, Pass without Trace or Silence without a slot. You can also make a small illusion at will.' }],
6: [{ name: 'Shadow Step', text: 'Bonus action, while in dim light or darkness: teleport up to 60 ft to another dim or dark spot you can see, and have advantage on your first melee attack this turn.' }],
},
},
{
key: 'FOUR_ELEMENTS',
name: 'Way of the Four Elements',
summary: 'Bends fire, water, earth and air with ki.',
features: {
3: [
{ name: 'Elemental Attunement', text: 'Way of the Four Elements. Action: a harmless trick of fire, water, earth or air within 30 ft (light or snuff a flame, chill or warm, shape a little earth or mist).' },
{ name: 'Elemental Disciplines', text: 'Choose one with your DM: Fangs of the Fire Snake (1 ki: your unarmed reach is 10 ft this turn and hits deal fire damage; +1 ki for +1d10 fire), Fist of Unbroken Air (2 ki: one creature within 30 ft takes 3d10 bludgeoning, is pushed 20 ft and knocked prone; Strength save for half and no push), Water Whip (2 ki: one creature within 30 ft takes 3d10 bludgeoning and is pulled 25 ft or knocked prone; Dexterity save for half), Shape the Flowing River (1 ki: reshape or freeze water or ice in a 30 ft cube).' },
],
6: [{ name: 'Elemental Disciplines', text: 'Choose one more with your DM (two in all). New at this level: Gong of the Summit (3 ki: cast Shatter), Clench of the North Wind (3 ki: cast Hold Person).' }],
},
},
],
PALADIN: [
{
key: 'DEVOTION',
name: 'Oath of Devotion',
summary: 'The shining knight: honesty, courage, a blessed blade.',
features: {
3: [
{ name: 'Sacred Weapon', text: 'Oath of Devotion, Channel Divinity. Add your Charisma modifier to attack rolls with your weapon for 1 minute; it sheds light.' },
{ name: 'Turn the Unholy', text: 'Channel Divinity. Fiends and undead within 30 ft make a Wisdom save or flee for 1 minute.' },
],
7: [{ name: 'Aura of Devotion', text: 'You and allies within 10 ft can’t be charmed.' }],
},
},
{
key: 'ANCIENTS',
name: 'Oath of the Ancients',
summary: 'A green knight who guards life, light and laughter.',
features: {
3: [
{ name: 'Nature’s Wrath', text: 'Oath of the Ancients, Channel Divinity. Spectral vines restrain a creature within 10 ft (Strength or Dexterity save; it repeats the save at the end of each of its turns).' },
{ name: 'Turn the Faithless', text: 'Channel Divinity. Fey and fiends within 30 ft make a Wisdom save or flee for 1 minute.' },
],
7: [{ name: 'Aura of Warding', text: 'You and allies within 10 ft take half damage from spells.' }],
},
},
{
key: 'VENGEANCE',
name: 'Oath of Vengeance',
summary: 'A relentless hunter of one sworn enemy at a time.',
features: {
3: [
{ name: 'Vow of Enmity', text: 'Oath of Vengeance, Channel Divinity. Bonus action: for 1 minute you have advantage on attack rolls against one creature within 10 ft.' },
{ name: 'Abjure Enemy', text: 'Channel Divinity. One creature within 60 ft makes a Wisdom save or is frightened and cannot move for 1 minute; on a success its speed is halved.' },
],
7: [{ name: 'Relentless Avenger', text: 'When you hit with an opportunity attack, move up to half your speed straight afterwards without provoking attacks.' }],
},
},
],
RANGER: [
{
key: 'HUNTER',
name: 'Hunter',
summary: 'Brings down the biggest thing in the room.',
features: {
3: [{ name: 'Colossus Slayer', text: 'Hunter’s Prey. Once per turn, +1d8 damage to a creature that is already below its max HP.' }],
7: [{ name: 'Escape the Horde', text: 'Defensive Tactics. Opportunity attacks against you have disadvantage.' }],
},
},
{
key: 'BEAST_MASTER',
name: 'Beast Master',
summary: 'Fights beside an animal companion.',
features: {
3: [{ name: 'Ranger’s Companion', text: 'Beast Master. A beast of CR 1/4 or lower fights beside you (the DM adds it to encounters as a monster). It acts on your turn: it can move freely, and attacks, Dashes, Disengages or Helps when you use your action to tell it to. Add your proficiency bonus to its AC, attacks and damage. If it dies, bond with another over 8 hours.' }],
7: [{ name: 'Exceptional Training', text: 'On a turn your companion does not attack, it can Dash, Disengage or Help as your bonus action. Its attacks count as magical.' }],
},
},
{
key: 'GLOOM_STALKER',
name: 'Gloom Stalker',
summary: 'An ambusher who owns the first round and the dark.',
features: {
3: [
{ name: 'Dread Ambusher', text: 'Gloom Stalker. Add your Wisdom modifier to initiative (ask your DM to count it). On your first turn of a fight: +10 ft speed and one extra weapon attack, which deals +1d8 damage on a hit.' },
{ name: 'Umbral Sight', text: 'You see in darkness to 60 ft, and creatures that rely on darkvision cannot see you in the dark.' },
],
7: [{ name: 'Iron Mind', text: 'Treat Wisdom saves as proficient (add your proficiency bonus by hand).' }],
},
},
],
ROGUE: [
{
key: 'THIEF',
name: 'Thief',
summary: 'Quick fingers, rooftops and anything that can be used.',
features: {
3: [
{ name: 'Fast Hands', text: 'Bonus action: Sleight of Hand, pick a lock, disarm a trap or Use an Object.' },
{ name: 'Second-Story Work', text: 'Climbing costs no extra movement; running jumps + Dexterity modifier feet.' },
],
9: [{ name: 'Supreme Sneak', text: 'Advantage on Stealth if you move no more than half your speed.' }],
},
},
{
key: 'ASSASSIN',
name: 'Assassin',
summary: 'Strikes first, and the first strike is the worst.',
features: {
3: [
{ name: 'Assassinate', text: 'Assassin. Advantage on attacks against any creature that has not yet taken a turn in this fight, and any hit on a surprised creature is a critical hit.' },
{ name: 'Tools of the Trade', text: 'You are practised with disguises and poisons: advantage on checks to make or use them.' },
],
9: [{ name: 'Infiltration Expertise', text: 'With a week and 25 gold you build a false identity with papers and a history; nobody doubts it without an obvious reason.' }],
},
},
{
key: 'SWASHBUCKLER',
name: 'Swashbuckler',
summary: 'A duellist: in, strike, out, and a smile.',
features: {
3: [
{ name: 'Fancy Footwork', text: 'Swashbuckler. A creature you made a melee attack against this turn cannot make opportunity attacks against you until the turn ends.' },
{ name: 'Rakish Audacity', text: 'Add your Charisma modifier to initiative (ask your DM to count it). You can Sneak Attack a creature with nobody else next to you or it, without needing advantage.' },
],
9: [{ name: 'Panache', text: 'Action: Persuasion against a creature’s Insight. A hostile one has disadvantage on attacks against anyone but you for 1 minute; any other is charmed by you for 1 minute.' }],
},
},
],
SORCERER: [
{
key: 'DRACONIC',
name: 'Draconic Bloodline',
summary: 'Dragon blood: tougher skin and one element at your call.',
features: {
3: [{ name: 'Dragon Ancestor', text: 'Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier (ask your DM about Extra AC).' }],
6: [{ name: 'Elemental Affinity', text: 'Draconic Bloodline. Add your Charisma modifier to one damage roll of your dragon’s element; spend 1 sorcery point to resist that element for 1 hour.' }],
},
},
{
key: 'WILD_MAGIC',
name: 'Wild Magic',
summary: 'Raw chaos: luck you can bend, and surprises you cannot.',
features: {
3: [
{ name: 'Wild Magic Surge', text: 'Wild Magic. After you cast a sorcerer spell of 1st level or higher, the DM may have you roll a d20: on a 1, something strange happens (the DM decides, or rolls on a table of their own).' },
{ name: 'Tides of Chaos', text: 'Once per long rest, gain advantage on one attack, check or save. The DM may give it back by calling for a Wild Magic Surge.' },
],
6: [{ name: 'Bend Luck', text: 'Reaction, 2 sorcery points: add or subtract 1d4 from another creature’s attack, check or save.' }],
},
},
{
key: 'STORM',
name: 'Storm Sorcery',
summary: 'Wind and lightning: never stands still for long.',
features: {
3: [
{ name: 'Tempestuous Magic', text: 'Storm Sorcery. Bonus action, just before or after casting a spell of 1st level or higher: fly 10 ft without provoking opportunity attacks.' },
{ name: 'Wind Speaker', text: 'You speak the tongue of air, water, earth and fire elementals.' },
],
6: [
{ name: 'Heart of the Storm', text: 'You resist lightning and thunder damage. When you start casting a lightning or thunder spell of 1st level or higher, creatures of your choice within 10 ft take half your level in lightning or thunder damage.' },
{ name: 'Storm Guide', text: 'Action: stop rain falling within 20 ft of you. Bonus action: choose which way the wind blows within 100 ft until your next turn.' },
],
},
},
],
WARLOCK: [
{
key: 'FIEND',
name: 'The Fiend',
summary: 'A pact with something from the hells: power fed by the fallen.',
features: {
3: [{ name: 'Dark One’s Blessing', text: 'The Fiend. When you bring a hostile creature to 0 HP, gain Temp HP equal to your Charisma modifier + your level (the DM adds it).' }],
6: [{ name: 'Patron’s Luck', text: 'Once per short rest, add a d10 to an ability check or saving throw after seeing the roll.' }],
10: [{ name: 'Patron’s Resilience', text: 'After each rest, choose one damage type: you resist it until you choose another.' }],
},
},
{
key: 'ARCHFEY',
name: 'The Archfey',
summary: 'A fey lord’s favourite: glamour, vanishing and beguilement.',
features: {
3: [{ name: 'Fey Presence', text: 'The Archfey. Once per short rest, action: each creature in a 10 ft cube from you makes a Wisdom save or is charmed or frightened (your choice) until the end of your next turn.' }],
6: [{ name: 'Misty Escape', text: 'Once per short rest, reaction when you take damage: turn invisible and teleport up to 60 ft. You stay invisible until your next turn, or until you attack or cast.' }],
10: [{ name: 'Beguiling Defenses', text: 'You can’t be charmed. When a creature tries, turn it back: it makes a Wisdom save or is charmed by you for 1 minute.' }],
},
},
{
key: 'GREAT_OLD_ONE',
name: 'The Great Old One',
summary: 'Something vast and unknowable whispers through you.',
features: {
3: [{ name: 'Awakened Mind', text: 'The Great Old One. Speak mind to mind with any creature you can see within 30 ft; it needs no shared language, only to know one.' }],
6: [{ name: 'Entropic Ward', text: 'Once per short rest, reaction: an attack against you has disadvantage. If it misses, your next attack against that creature has advantage.' }],
10: [{ name: 'Thought Shield', text: 'Nobody reads your thoughts unless you allow it. You resist psychic damage, and whoever deals it to you takes the same amount.' }],
},
},
],
WIZARD: [
{
key: 'EVOCATION',
name: 'School of Evocation',
summary: 'Blasting, with friends left standing in the middle of it.',
features: {
2: [
{ name: 'Evocation Savant', text: 'School of Evocation. Copying evocation spells costs half the time and gold.' },
{ name: 'Sculpt Spells', text: 'When you cast an evocation spell that hits an area, choose 1 + the spell’s level creatures to automatically save and take no damage.' },
],
6: [{ name: 'Potent Cantrip', text: 'Creatures that save against your damaging cantrips still take half damage.' }],
10: [{ name: 'Empowered Evocation', text: 'Add your Intelligence modifier to the damage of your evocation spells.' }],
},
},
{
key: 'ABJURATION',
name: 'School of Abjuration',
summary: 'Wards and counters: a shield of magic that takes the hits.',
features: {
2: [
{ name: 'Abjuration Savant', text: 'School of Abjuration. Copying abjuration spells costs half the time and gold.' },
{ name: 'Arcane Ward', text: 'When you cast an abjuration spell of 1st level or higher, a ward appears with HP of twice your level + your Intelligence modifier; it takes damage before you do (the DM tracks it as Temp HP). It lasts until a long rest; later abjuration spells restore twice their level to it.' },
],
6: [{ name: 'Projected Ward', text: 'Reaction: when a creature within 30 ft takes damage, your Arcane Ward takes it instead.' }],
10: [{ name: 'Improved Abjuration', text: 'Add your proficiency bonus to ability checks made as part of an abjuration spell (Counterspell, Dispel Magic).' }],
},
},
{
key: 'ILLUSION',
name: 'School of Illusion',
summary: 'Tricks of light and sound that hold up to a second look.',
features: {
2: [
{ name: 'Illusion Savant', text: 'School of Illusion. Copying illusion spells costs half the time and gold.' },
{ name: 'Improved Minor Illusion', text: 'You know Minor Illusion (the DM grants it), and it makes a sound and an image with one casting.' },
],
6: [{ name: 'Malleable Illusions', text: 'Action: change what an illusion of yours looks like while it lasts, if it lasts a minute or more.' }],
10: [{ name: 'Illusory Self', text: 'Once per short rest, reaction when a creature attacks you: a copy of you takes the blow and the attack misses.' }],
},
},
],
};
function subclassesOf(characterClass) {
return SUBCLASSES[characterClass] || [];
}
// One subclass of a class by its key; null when the class has no such subclass.
function findSubclass(characterClass, key) {
return subclassesOf(characterClass).find((subclass) => subclass.key === key) || null;
}
function subclassName(characterClass, key) {
const subclass = findSubclass(characterClass, key);
return subclass ? subclass.name : null;
}
// The path a class followed before subclasses could be chosen.
function defaultSubclass(characterClass) {
return subclassesOf(characterClass)[0] || null;
}
module.exports = { SUBCLASS_LEVEL, SUBCLASSES, subclassesOf, findSubclass, subclassName, defaultSubclass };
+9
View File
@@ -3,6 +3,9 @@
// matches the `Weapon` Prisma model minus `id`/`campaignId`, plus a stable
// `key` for "add to campaign" lookups and a `category` for the library
// picker's filter. attackAbility follows the schema's STR|DEX|FINESSE.
//
// A character has one weapon equipped at a time (a campaign setting), so a
// weapon fought with in each hand is a single entry of its own: see "Paired".
const WEAPON_LIBRARY = [
// Simple Melee
@@ -49,6 +52,12 @@ const WEAPON_LIBRARY = [
{ key: 'heavy-crossbow', name: 'Heavy Crossbow', category: 'Martial Ranged', damageDice: '1d10', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'heavy', 'loading', 'two-handed'], rangeNormal: 100, rangeLong: 400 },
{ key: 'longbow', name: 'Longbow', category: 'Martial Ranged', damageDice: '1d8', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'heavy', 'two-handed'], rangeNormal: 150, rangeLong: 600 },
{ key: 'net', name: 'Net', category: 'Martial Ranged', damageDice: '-', damageType: 'special', attackAbility: 'DEX', properties: ['special', 'thrown'], rangeNormal: 5, rangeLong: 15 },
// Paired: one in each hand, as a single weapon. The damage is both blades.
{ key: 'paired-daggers', name: 'Paired Daggers', category: 'Paired', damageDice: '2d4', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'paired'], rangeNormal: null, rangeLong: null, effectNote: 'A dagger in each hand: takes both hands.' },
{ key: 'paired-shortswords', name: 'Paired Shortswords', category: 'Paired', damageDice: '2d6', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'paired'], rangeNormal: null, rangeLong: null, effectNote: 'A shortsword in each hand: takes both hands.' },
{ key: 'paired-scimitars', name: 'Paired Scimitars', category: 'Paired', damageDice: '2d6', damageType: 'slashing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'paired'], rangeNormal: null, rangeLong: null, effectNote: 'A scimitar in each hand: takes both hands.' },
{ key: 'paired-handaxes', name: 'Paired Handaxes', category: 'Paired', damageDice: '2d6', damageType: 'slashing', attackAbility: 'STR', properties: ['light', 'paired'], rangeNormal: null, rangeLong: null, effectNote: 'A handaxe in each hand: takes both hands.' },
];
module.exports = { WEAPON_LIBRARY };
+51
View File
@@ -0,0 +1,51 @@
// Experience points: what a monster is worth by challenge rating, how hard a
// fight is for a party, and the XP each level takes. The 5e (2014) tables.
// XP for defeating one monster of a challenge rating.
const CR_XP = {
0: 10, '1/8': 25, '1/4': 50, '1/2': 100,
1: 200, 2: 450, 3: 700, 4: 1100, 5: 1800, 6: 2300, 7: 2900, 8: 3900, 9: 5000, 10: 5900,
11: 7200, 12: 8400, 13: 10000, 14: 11500, 15: 13000, 16: 15000, 17: 18000, 18: 20000, 19: 22000, 20: 25000,
21: 33000, 22: 41000, 23: 50000, 24: 62000, 25: 75000, 26: 90000, 27: 105000, 28: 120000, 29: 135000, 30: 155000,
};
// What one character of a level can take on: [easy, medium, hard, deadly],
// level 1 first. A party's thresholds are its members' added up.
const XP_THRESHOLDS = [
[25, 50, 75, 100],
[50, 100, 150, 200],
[75, 150, 225, 400],
[125, 250, 375, 500],
[250, 500, 750, 1100],
[300, 600, 900, 1400],
[350, 750, 1100, 1700],
[450, 900, 1400, 2100],
[550, 1100, 1600, 2400],
[600, 1200, 1900, 2800],
[800, 1600, 2400, 3600],
[1000, 2000, 3000, 4500],
[1100, 2200, 3400, 5100],
[1250, 2500, 3800, 5700],
[1400, 2800, 4300, 6400],
[1600, 3200, 4800, 7200],
[2000, 3900, 5900, 8800],
[2100, 4200, 6300, 9500],
[2400, 4900, 7300, 10900],
[2800, 5700, 8500, 12700],
];
// Many monsters are harder than their XP says: the total is multiplied by
// this to judge the fight (never to award it). [at least this many, multiplier].
const COUNT_MULTIPLIERS = [
[15, 4],
[11, 3],
[7, 2.5],
[3, 2],
[2, 1.5],
[1, 1],
];
// Total XP at which each level is reached, level 1 first.
const LEVEL_XP = [0, 300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, 85000, 100000, 120000, 140000, 165000, 195000, 225000, 265000, 305000, 355000];
module.exports = { CR_XP, XP_THRESHOLDS, COUNT_MULTIPLIERS, LEVEL_XP };
+250
View File
@@ -0,0 +1,250 @@
// Levelling up: the Hollowcrown book's "Levelling-Up Checklist" (Rules Cheat
// Sheet). Works out what a character gains at a level, and rewrites their
// special abilities to say so. Pure: no DB calls.
const { LEVEL_FEATURES, MAX_FEATURE_LEVEL, CANTRIP_LEVEL, CANTRIP_FEATURE, BOONS } = require('./data/levelFeatures');
const { SUBCLASS_LEVEL, subclassesOf, findSubclass, defaultSubclass } = require('./data/subclasses');
const { CLASS_FEATURES } = require('./data/characterOptions');
const { CASTER_KIND } = require('./data/spellSlots');
const { getClassSpellcastingAbility, maxSpellLevel, scaledDamageDice, MAX_GEAR_LEVEL } = require('./calculations');
// A choice that cannot be made: the message is for whoever made it.
class LevelUpError extends Error {}
// What a class gains on reaching `level`, in the order it is written down:
// the class's own features, then those of the subclass (its key) when one is
// given. An entry is { name, text, upgrade? } or { boon: true }.
function featuresAt(characterClass, level, subclassKey = null) {
const features = [...((LEVEL_FEATURES[characterClass] || {})[level] || [])];
const subclass = findSubclass(characterClass, subclassKey);
if (subclass) features.push(...(subclass.features[level] || []));
if (level === CANTRIP_LEVEL && getClassSpellcastingAbility(characterClass)) features.push(CANTRIP_FEATURE);
return features;
}
// Special abilities are one per line, "Name — what it does". The builder
// writes a long dash; a DM typing by hand may use "--".
const featureLine = (feature) => `${feature.name} — ${feature.text}`;
function lineName(line) {
const match = /^\s*(.+?)\s*(?:—|–|--)\s/.exec(line);
return match ? match[1].toLowerCase() : null;
}
const toLines = (specialAbilities) => (specialAbilities ? specialAbilities.split(/\r?\n/) : []);
const fromLines = (lines) => lines.join('\n') || null;
const findLine = (lines, name) => lines.findIndex((line) => lineName(line) === name.toLowerCase());
// Writes features into the text. An upgrade takes the place of the line with
// its name (Sneak Attack growing a die); anything else goes on the end, unless
// that very line is there already.
function addFeatures(specialAbilities, features) {
const lines = toLines(specialAbilities);
for (const feature of features) {
const line = featureLine(feature);
const at = feature.upgrade ? findLine(lines, feature.name) : -1;
if (at >= 0) lines[at] = line;
else if (!lines.includes(line)) lines.push(line);
}
return fromLines(lines);
}
// ---------------------------------------------------------------------------
// Subclasses (data/subclasses.js). A class chooses one at SUBCLASS_LEVEL. Until
// it has, a line on the sheet says the choice is owed.
// ---------------------------------------------------------------------------
const SUBCLASS_OWED = 'Subclass';
// Whether a class at this level should have a subclass.
function owesSubclass(characterClass, level) {
return subclassesOf(characterClass).length > 0 && level >= SUBCLASS_LEVEL[characterClass];
}
const subclassOwedFeature = (characterClass) => ({
name: SUBCLASS_OWED,
text: `Choose one with your DM: ${subclassesOf(characterClass).map((subclass) => subclass.name).join(', ')}.`,
});
// Everything a subclass has given by `level`, in the order it came.
function subclassFeaturesThrough(subclass, level) {
return Object.keys(subclass.features)
.map(Number)
.filter((at) => at <= level)
.sort((a, b) => a - b)
.flatMap((at) => subclass.features[at]);
}
// Writes a newly chosen subclass onto the sheet: everything it has given up
// to the character's level, in place of the line saying a choice is owed.
function withSubclass(specialAbilities, characterClass, level, subclassKey) {
const subclass = findSubclass(characterClass, subclassKey);
if (!subclass) throw new LevelUpError('That is not a subclass of this class.');
const lines = toLines(specialAbilities).filter((line) => lineName(line) !== SUBCLASS_OWED.toLowerCase());
return addFeatures(fromLines(lines), subclassFeaturesThrough(subclass, level));
}
// Takes a subclass's lines back off the sheet, found by their names. A line
// the class itself also writes under that name is left alone.
function withoutSubclass(specialAbilities, characterClass, subclassKey) {
const subclass = findSubclass(characterClass, subclassKey);
if (!subclass) return specialAbilities;
const classNames = new Set(
[...(CLASS_FEATURES[characterClass] || []), ...Object.values(LEVEL_FEATURES[characterClass] || {}).flat()]
.filter((feature) => feature.name)
.map((feature) => feature.name.toLowerCase())
);
const names = new Set(
Object.values(subclass.features)
.flat()
.map((feature) => feature.name.toLowerCase())
.filter((name) => !classNames.has(name))
);
return fromLines(toLines(specialAbilities).filter((line) => !names.has(lineName(line))));
}
// The DM changing (or setting, or clearing) a character's subclass after the
// fact: the old one's lines go, the new one's are written.
function switchSubclass(specialAbilities, characterClass, level, fromKey, toKey) {
const cleared = withoutSubclass(specialAbilities, characterClass, fromKey);
if (toKey) return withSubclass(cleared, characterClass, level, toKey);
return owesSubclass(characterClass, level) ? addFeatures(cleared, [subclassOwedFeature(characterClass)]) : cleared;
}
// The subclasses a character may pick from on reaching `level`, each with
// what it would write; null when there is no choice to make (too low a level,
// or one is chosen already).
function subclassChoice(characterClass, level, currentKey) {
if (!owesSubclass(characterClass, level) || findSubclass(characterClass, currentKey)) return null;
return {
level: SUBCLASS_LEVEL[characterClass],
options: subclassesOf(characterClass).map((subclass) => ({
key: subclass.key,
name: subclass.name,
summary: subclass.summary,
features: subclassFeaturesThrough(subclass, level).map(({ name, text }) => ({ name, text })),
})),
};
}
// How many times a Boon has been taken, read off its line ("Hardy — +10
// maximum HP…" is twice).
function boonTaken(specialAbilities, boon) {
const lines = toLines(specialAbilities);
const at = findLine(lines, boon.name);
if (at < 0) return 0;
const amount = /\+\s*(\d+)/.exec(lines[at]);
return Math.max(1, Math.round((amount ? Number(amount[1]) : boon.per) / boon.per));
}
// Every Boon, with what taking it now would write and whether it still can be.
function boonOptions(specialAbilities) {
return BOONS.map((boon) => {
const taken = boonTaken(specialAbilities, boon);
return { key: boon.key, name: boon.name, taken, max: boon.max, available: taken < boon.max, text: boon.text((Math.min(taken, boon.max - 1) + 1) * boon.per) };
});
}
// Temp HP the Hardy Boon gives after each long rest; 0 without it.
function hardyTempHp(specialAbilities) {
const hardy = BOONS.find((boon) => boon.key === 'HARDY');
return boonTaken(specialAbilities, hardy) * hardy.per;
}
// What reaching `level` does to a character's special abilities. `boonKey` is
// the Boon picked, at a level that grants one; without it the line says a
// choice is still owed. `subclass` is { key, chosen }: the character's
// subclass, and whether it is being picked at this very level (then everything
// it has given so far is written). Without one, from the level the class
// chooses at, a line says that choice is owed too. Also returns what the Boon
// does beyond the text: how much Extra AC it adds, and the Temp HP it gives
// straight away.
function advance(specialAbilities, characterClass, level, boonKey, { key: subclassKey = null, chosen = false } = {}) {
const entries = featuresAt(characterClass, level, chosen ? null : subclassKey);
let text = addFeatures(specialAbilities, entries.filter((entry) => !entry.boon));
if (chosen) text = withSubclass(text, characterClass, level, subclassKey);
else if (!findSubclass(characterClass, subclassKey) && owesSubclass(characterClass, level)) text = addFeatures(text, [subclassOwedFeature(characterClass)]);
const result = { armorBonus: 0, tempHp: 0 };
if (entries.some((entry) => entry.boon)) {
if (!boonKey) {
text = addFeatures(text, [{ name: `Boon (level ${level})`, text: `Choose one with your DM: ${BOONS.map((boon) => boon.name).join(', ')}.` }]);
} else {
const boon = BOONS.find((b) => b.key === boonKey);
if (!boon) throw new LevelUpError('That is not one of the Boons.');
const taken = boonTaken(text, boon);
if (taken >= boon.max) {
throw new LevelUpError(`${boon.name} can be taken ${boon.max === 1 ? 'only once' : `at most ${boon.max} times`}. Pick a different Boon.`);
}
const amount = (taken + 1) * boon.per;
text = addFeatures(text, [{ name: boon.name, text: boon.text(amount), upgrade: true }]);
if (boon.key === 'TOUGHENED') result.armorBonus = 1;
if (boon.key === 'HARDY') result.tempHp = amount;
}
}
return { specialAbilities: text, ...result };
}
// The book's unlock schedule: what a caster may be granted on reaching
// `level`. Null for a class with no spells.
function spellsGainedAt(characterClass, level) {
const kind = CASTER_KIND[characterClass];
if (!kind) return null;
const highest = maxSpellLevel(characterClass, level);
// Half casters: two spells at level 2, then one at each odd level.
const halfCasterSpells = level === 2 ? 2 : level > 2 && level % 2 === 1 ? 1 : 0;
return {
maxSpellLevel: highest,
// Set when this level opens up a new level of spells.
unlocksSpellLevel: highest > maxSpellLevel(characterClass, level - 1) ? highest : null,
newSpells: kind === 'HALF' ? halfCasterSpells : characterClass === 'WIZARD' ? 2 : 1,
newCantrips: kind !== 'HALF' && (level === 4 || level === 10) ? 1 : 0,
};
}
// In a campaign with subclasses switched off nobody chooses: on reaching the
// level their class would choose at, a character is put on the class's first
// subclass. Returns that subclass's key when this is the moment, else null.
function subclassByDefault(characterClass, level, currentKey) {
if (!owesSubclass(characterClass, level) || findSubclass(characterClass, currentKey)) return null;
return defaultSubclass(characterClass).key;
}
// The checklist for a character's next level. Takes the character with its
// weapons loaded (each with its pool entry). `subclasses` is the campaign's
// setting: off, there is no subclass to pick, and the class's first one is
// taken without asking (`subclassTaken`), its features listed with the rest.
function levelUpPlan(character, { subclasses = true } = {}) {
const toLevel = character.level + 1;
const taken = subclasses ? null : subclassByDefault(character.characterClass, toLevel, character.subclass);
const entries = [
...featuresAt(character.characterClass, toLevel, taken ? null : character.subclass),
...(taken ? subclassFeaturesThrough(findSubclass(character.characterClass, taken), toLevel) : []),
];
return {
fromLevel: character.level,
toLevel,
// Set when a subclass has to be picked on the way to this level.
subclass: subclasses ? subclassChoice(character.characterClass, toLevel, character.subclass) : null,
subclassTaken: taken,
features: entries.filter((entry) => !entry.boon).map(({ name, text, upgrade }) => ({ name, text, upgrade: Boolean(upgrade) })),
boon: entries.some((entry) => entry.boon) ? boonOptions(character.specialAbilities) : null,
spells: spellsGainedAt(character.characterClass, toLevel),
// The free upgrade: one weapon of standard growth goes up a level, never
// past its holder.
weapons: (character.weapons || [])
.filter((cw) => cw.weapon.damageScaling === 1 && cw.level < Math.min(toLevel, MAX_GEAR_LEVEL))
.map((cw) => ({
weaponId: cw.weaponId,
name: cw.weapon.name,
equipped: cw.equipped,
level: cw.level,
damageDice: scaledDamageDice(cw.weapon.damageDice, cw.level, cw.weapon.damageScaling, cw.weapon.minLevel),
nextDamageDice: scaledDamageDice(cw.weapon.damageDice, cw.level + 1, cw.weapon.damageScaling, cw.weapon.minLevel),
})),
// The class tables stop at level 10; past it only the numbers move.
beyondTables: toLevel > MAX_FEATURE_LEVEL,
};
}
module.exports = { LevelUpError, featuresAt, owesSubclass, subclassChoice, subclassByDefault, switchSubclass, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
+234
View File
@@ -0,0 +1,234 @@
const { featuresAt, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan, switchSubclass, LevelUpError } = require('./levelUp');
const { SUBCLASSES, SUBCLASS_LEVEL } = require('./data/subclasses');
const { LEVEL_FEATURES, BOONS } = require('./data/levelFeatures');
const { CLASSES } = require('./data/classes');
const { composeSpecialAbilities } = require('./buildFlow');
const boon = (key) => BOONS.find((b) => b.key === key);
const CHAMPION = { key: 'CHAMPION' };
describe('the class tables', () => {
test('cover every class from level 2 to 10, with a Boon at 4 and 8', () => {
for (const cls of Object.keys(CLASSES)) {
expect(LEVEL_FEATURES[cls]).toBeDefined();
for (const level of [4, 8]) expect(featuresAt(cls, level).some((f) => f.boon)).toBe(true);
for (const level of Object.keys(LEVEL_FEATURES[cls])) expect(Number(level)).toBeGreaterThanOrEqual(2);
}
// Fighters get a third Boon at 6, rogues at 10.
expect(featuresAt('FIGHTER', 6).some((f) => f.boon)).toBe(true);
expect(featuresAt('ROGUE', 10).some((f) => f.boon)).toBe(true);
expect(featuresAt('WIZARD', 6).some((f) => f.boon)).toBe(false);
});
test('every feature has a name and a text', () => {
for (const levels of Object.values(LEVEL_FEATURES)) {
for (const features of Object.values(levels)) {
for (const f of features) expect(f.boon || (f.name && f.text)).toBeTruthy();
}
}
});
test('casters are told at level 5 that cantrips double; others are not', () => {
expect(featuresAt('WIZARD', 5).map((f) => f.name)).toContain('Cantrips');
expect(featuresAt('PALADIN', 5).map((f) => f.name)).toEqual(['Extra Attack', 'Cantrips']);
expect(featuresAt('FIGHTER', 5).map((f) => f.name)).toEqual(['Extra Attack']);
});
test('nothing is written past level 10', () => {
expect(featuresAt('FIGHTER', 11)).toEqual([]);
});
});
describe('addFeatures', () => {
test('adds a new feature as a line of its own, once', () => {
const once = addFeatures('Second Wind — heal.', [{ name: 'Action Surge', text: 'One extra action.' }]);
expect(once).toBe('Second Wind — heal.\nAction Surge — One extra action.');
expect(addFeatures(once, [{ name: 'Action Surge', text: 'One extra action.' }])).toBe(once);
});
test('an upgrade takes the place of the line with its name, however the dash was typed', () => {
const upgrade = { name: 'Sneak Attack', text: '2d6.', upgrade: true };
expect(addFeatures('Sneak Attack — 1d6.\nThieves’ Cant — slang.', [upgrade])).toBe('Sneak Attack — 2d6.\nThieves’ Cant — slang.');
expect(addFeatures('sneak attack -- 1d6.', [upgrade])).toBe('Sneak Attack — 2d6.');
// With no line to rewrite it is simply added.
expect(addFeatures(null, [upgrade])).toBe('Sneak Attack — 2d6.');
});
test('a feature that is not an upgrade leaves a line of the same name alone', () => {
const text = addFeatures('Favored Enemy — Undead.', [{ name: 'Favored Enemy', text: 'Choose a second.' }]);
expect(text).toBe('Favored Enemy — Undead.\nFavored Enemy — Choose a second.');
});
});
describe('advance', () => {
test('a rogue reaching level 3 has a bigger Sneak Attack and two new features', () => {
const level1 = composeSpecialAbilities('HUMAN', 'ROGUE', []);
const { specialAbilities } = advance(advance(level1, 'ROGUE', 2).specialAbilities, 'ROGUE', 3, undefined, { key: 'THIEF', chosen: true });
const lines = specialAbilities.split('\n');
expect(lines.filter((l) => l.startsWith('Sneak Attack'))).toEqual([expect.stringContaining('2d6')]);
expect(lines.some((l) => l.startsWith('Cunning Action — '))).toBe(true);
expect(lines.some((l) => l.startsWith('Fast Hands — '))).toBe(true);
expect(lines.some((l) => l.startsWith('Versatile — '))).toBe(true); // what was there stays
});
test('a Boon is written down, and says what it does beyond the text', () => {
expect(advance(null, 'FIGHTER', 4, 'TOUGHENED', CHAMPION)).toEqual({ specialAbilities: 'Toughened — +1 AC (counted in your Extra AC).', armorBonus: 1, tempHp: 0 });
const hardy = advance(null, 'FIGHTER', 4, 'HARDY', CHAMPION);
expect(hardy).toMatchObject({ armorBonus: 0, tempHp: 5 });
// Taken again, its one line grows.
const twice = advance(hardy.specialAbilities, 'FIGHTER', 6, 'HARDY', CHAMPION);
expect(twice.specialAbilities).toBe('Hardy — +10 maximum HP: gain 10 Temp HP after each long rest.');
expect(twice.tempHp).toBe(10);
expect(hardyTempHp(twice.specialAbilities)).toBe(10);
});
test('a Boon cannot be taken more often than it allows', () => {
const toughened = advance(null, 'FIGHTER', 4, 'TOUGHENED', CHAMPION).specialAbilities;
expect(() => advance(toughened, 'FIGHTER', 6, 'TOUGHENED')).toThrow(LevelUpError);
const mastery = advance(advance(null, 'FIGHTER', 4, 'WEAPON_MASTERY').specialAbilities, 'FIGHTER', 6, 'WEAPON_MASTERY').specialAbilities;
expect(boonTaken(mastery, boon('WEAPON_MASTERY'))).toBe(2);
expect(() => advance(mastery, 'FIGHTER', 8, 'WEAPON_MASTERY')).toThrow(LevelUpError);
expect(() => advance(null, 'FIGHTER', 4, 'FLIGHT')).toThrow(LevelUpError);
expect(boonOptions(mastery).find((o) => o.key === 'WEAPON_MASTERY').available).toBe(false);
expect(boonOptions(mastery).find((o) => o.key === 'HARDY')).toMatchObject({ available: true, taken: 0 });
});
test('with no Boon picked the line says one is owed', () => {
const { specialAbilities, armorBonus } = advance(null, 'WIZARD', 4, undefined, { key: 'EVOCATION' });
expect(specialAbilities).toBe('Boon (level 4) — Choose one with your DM: Weapon Mastery, Arcane Focus, Toughened, Hardy.');
expect(armorBonus).toBe(0);
});
});
describe('spellsGainedAt', () => {
test('follows the unlock schedule', () => {
expect(spellsGainedAt('FIGHTER', 3)).toBeNull();
expect(spellsGainedAt('WIZARD', 3)).toEqual({ maxSpellLevel: 2, unlocksSpellLevel: 2, newSpells: 2, newCantrips: 0 });
expect(spellsGainedAt('CLERIC', 4)).toEqual({ maxSpellLevel: 2, unlocksSpellLevel: null, newSpells: 1, newCantrips: 1 });
expect(spellsGainedAt('WARLOCK', 5)).toMatchObject({ maxSpellLevel: 3, unlocksSpellLevel: 3, newSpells: 1 });
// Half casters: two spells at level 2, then one at each odd level.
expect(spellsGainedAt('PALADIN', 2)).toEqual({ maxSpellLevel: 1, unlocksSpellLevel: 1, newSpells: 2, newCantrips: 0 });
expect(spellsGainedAt('RANGER', 4)).toMatchObject({ newSpells: 0, newCantrips: 0 });
expect(spellsGainedAt('RANGER', 5)).toMatchObject({ maxSpellLevel: 2, unlocksSpellLevel: 2, newSpells: 1 });
});
});
describe('levelUpPlan', () => {
const held = (over) => ({ weaponId: 'w', equipped: true, level: 1, weapon: { name: 'Longsword', damageDice: '1d8', damageScaling: 1, minLevel: 1 }, ...over });
test('lists the features, the Boon and the weapons that can take the free upgrade', () => {
const plan = levelUpPlan({ level: 3, characterClass: 'FIGHTER', specialAbilities: null, weapons: [held(), held({ weaponId: 'p', weapon: { name: 'Dawnblade', damageDice: '1d8', damageScaling: 2, minLevel: 1 } }), held({ weaponId: 'top', level: 4 })] });
expect(plan).toMatchObject({ fromLevel: 3, toLevel: 4, features: [], spells: null, beyondTables: false });
expect(plan.boon).toHaveLength(4);
// Only standard growth, and never past the character's new level.
expect(plan.weapons).toEqual([{ weaponId: 'w', name: 'Longsword', equipped: true, level: 1, damageDice: '1d8', nextDamageDice: '1d10' }]);
});
test('has no Boon to pick at a level that grants none', () => {
const plan = levelUpPlan({ level: 1, characterClass: 'FIGHTER', specialAbilities: null, weapons: [] });
expect(plan.boon).toBeNull();
expect(plan.subclass).toBeNull();
expect(plan.features.map((f) => f.name)).toEqual(['Action Surge']);
});
test('asks for a subclass at the level the class chooses, and whenever one is still owed', () => {
const fighter = { characterClass: 'FIGHTER', specialAbilities: null, weapons: [] };
const plan = levelUpPlan({ ...fighter, level: 2 });
expect(plan.subclass.level).toBe(3);
expect(plan.subclass.options.map((o) => o.key)).toEqual(['CHAMPION', 'BATTLE_MASTER', 'SAMURAI']);
expect(plan.subclass.options[0].features.map((f) => f.name)).toEqual(['Improved Critical']);
// A wizard chooses a level earlier.
expect(levelUpPlan({ ...fighter, characterClass: 'WIZARD', level: 1 }).subclass.level).toBe(2);
// Still owed later on: the options then carry everything missed so far.
const late = levelUpPlan({ ...fighter, level: 6 });
expect(late.subclass.options[0].features.map((f) => f.name)).toEqual(['Improved Critical', 'Remarkable Athlete']);
// With one chosen there is nothing to ask, and its features are in the list.
const chosen = levelUpPlan({ ...fighter, level: 6, subclass: 'CHAMPION' });
expect(chosen.subclass).toBeNull();
expect(chosen.features.map((f) => f.name)).toEqual(['Remarkable Athlete']);
});
});
describe('a campaign without subclasses', () => {
const fighter = { characterClass: 'FIGHTER', specialAbilities: null, weapons: [] };
test('asks for nothing: the class takes its first subclass at the level it would have chosen', () => {
const plan = levelUpPlan({ ...fighter, level: 2 }, { subclasses: false });
expect(plan.subclass).toBeNull();
expect(plan.subclassTaken).toBe('CHAMPION');
expect(plan.features.map((f) => f.name)).toEqual(['Improved Critical']);
// Before that level, and once it is taken, there is nothing to take.
expect(levelUpPlan({ ...fighter, level: 1 }, { subclasses: false }).subclassTaken).toBeNull();
const later = levelUpPlan({ ...fighter, level: 6, subclass: 'CHAMPION' }, { subclasses: false });
expect(later.subclassTaken).toBeNull();
expect(later.features.map((f) => f.name)).toEqual(['Remarkable Athlete']);
});
test('a character who joined above that level gets everything missed so far', () => {
const plan = levelUpPlan({ ...fighter, level: 6 }, { subclasses: false });
expect(plan.subclassTaken).toBe('CHAMPION');
expect(plan.features.map((f) => f.name)).toEqual(['Improved Critical', 'Remarkable Athlete']);
});
test('with subclasses on, the plan takes none by default', () => {
expect(levelUpPlan({ ...fighter, level: 2 }).subclassTaken).toBeNull();
});
});
describe('subclasses', () => {
test('every class has three, each with a summary and features from its choice level to 10', () => {
for (const cls of Object.keys(CLASSES)) {
expect(SUBCLASSES[cls]).toHaveLength(3);
for (const subclass of SUBCLASSES[cls]) {
expect(subclass.key && subclass.name && subclass.summary).toBeTruthy();
const levels = Object.keys(subclass.features).map(Number);
expect(Math.min(...levels)).toBe(SUBCLASS_LEVEL[cls]);
expect(Math.max(...levels)).toBeLessThanOrEqual(10);
for (const f of Object.values(subclass.features).flat()) {
expect(f.name && f.text).toBeTruthy();
expect(f.name).not.toMatch(/—|–|--/); // the dash is what ends a name on the sheet
}
}
expect(new Set(SUBCLASSES[cls].map((s) => s.key)).size).toBe(3);
}
});
test('a subclass adds its features to the class\'s at each level', () => {
expect(featuresAt('FIGHTER', 3).map((f) => f.name)).toEqual([]);
expect(featuresAt('FIGHTER', 3, 'CHAMPION').map((f) => f.name)).toEqual(['Improved Critical']);
expect(featuresAt('CLERIC', 8, 'WAR').map((f) => f.boon || f.name)).toEqual([true, 'Destroy Undead', 'Divine Strike']);
expect(featuresAt('FIGHTER', 3, 'LIFE')).toEqual([]); // another class's subclass counts for nothing
});
test('without one, from the level the class chooses at, a line says a subclass is owed', () => {
expect(advance(null, 'FIGHTER', 2).specialAbilities).toBe('Action Surge — Once per short rest, take one extra action on your turn.');
const owed = advance(null, 'FIGHTER', 3).specialAbilities;
expect(owed).toBe('Subclass — Choose one with your DM: Champion, Battle Master, Samurai.');
// Said once, however many levels go by.
expect(advance(owed, 'FIGHTER', 5).specialAbilities.split('\n').filter((l) => l.startsWith('Subclass — '))).toHaveLength(1);
});
test('picking one late writes everything it has given so far, and clears the line', () => {
const owed = advance(advance(null, 'FIGHTER', 3).specialAbilities, 'FIGHTER', 5).specialAbilities;
const { specialAbilities } = advance(owed, 'FIGHTER', 7, undefined, { key: 'BATTLE_MASTER', chosen: true });
const lines = specialAbilities.split('\n');
expect(lines.some((l) => l.startsWith('Subclass — '))).toBe(false);
// The level 7 upgrade has taken the place of the level 3 line.
expect(lines.filter((l) => l.startsWith('Combat Superiority — '))).toEqual([expect.stringContaining('Five superiority dice (d8)')]);
expect(lines.filter((l) => l.startsWith('Manoeuvres — '))).toHaveLength(2);
expect(lines.some((l) => l.startsWith('Know Your Enemy — '))).toBe(true);
expect(() => advance(owed, 'FIGHTER', 7, undefined, { key: 'LIFE', chosen: true })).toThrow(LevelUpError);
});
test('switching takes the old subclass\'s lines off and writes the new one\'s', () => {
const champion = advance(advance('Second Wind — heal.', 'FIGHTER', 3, undefined, { key: 'CHAMPION', chosen: true }).specialAbilities, 'FIGHTER', 5, undefined, CHAMPION).specialAbilities;
expect(champion.split('\n').map((l) => l.split(' — ')[0])).toEqual(['Second Wind', 'Improved Critical', 'Extra Attack']);
const samurai = switchSubclass(champion, 'FIGHTER', 5, 'CHAMPION', 'SAMURAI');
expect(samurai.split('\n').map((l) => l.split(' — ')[0])).toEqual(['Second Wind', 'Extra Attack', 'Fighting Spirit', 'Bonus Proficiency']);
// Cleared altogether, the choice is owed again.
expect(switchSubclass(samurai, 'FIGHTER', 5, 'SAMURAI', null).split('\n').map((l) => l.split(' — ')[0])).toEqual(['Second Wind', 'Extra Attack', 'Subclass']);
// A line the class also writes under the same name is not taken away.
const valor = advance('Extra Attack — mine.', 'BARD', 6, undefined, { key: 'VALOR', chosen: true }).specialAbilities;
expect(switchSubclass(valor, 'BARD', 6, 'VALOR', 'LORE')).not.toContain('Combat Inspiration');
});
});
+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();
});
});
+76
View File
@@ -0,0 +1,76 @@
const { challengeXp, encounterDifficulty, xpProgress } = require('./calculations');
const { CR_XP, XP_THRESHOLDS, LEVEL_XP } = require('./data/xp');
const { MONSTER_LIBRARY } = require('./data/library');
describe('challengeXp', () => {
test('reads a rating however it was typed', () => {
expect(challengeXp('0')).toBe(10);
expect(challengeXp('1/8')).toBe(25);
expect(challengeXp(' 1/4 ')).toBe(50);
expect(challengeXp('0.5')).toBe(100);
expect(challengeXp('CR 3')).toBe(700);
expect(challengeXp(5)).toBe(1800);
expect(challengeXp('30')).toBe(155000);
});
test('is null for what is not a rating', () => {
for (const text of [null, undefined, '', 'boss', '31', '1/3', '-1']) expect(challengeXp(text)).toBeNull();
});
test('every monster in the built-in bestiary has a rating it can read', () => {
for (const monster of MONSTER_LIBRARY) expect(challengeXp(monster.challengeRating)).not.toBeNull();
});
});
describe('the tables', () => {
test('cover every rating from 0 to 30 and every level from 1 to 20, always rising', () => {
expect(Object.keys(CR_XP)).toHaveLength(34);
expect(XP_THRESHOLDS).toHaveLength(20);
expect(LEVEL_XP).toHaveLength(20);
for (let level = 1; level < 20; level++) {
expect(LEVEL_XP[level]).toBeGreaterThan(LEVEL_XP[level - 1]);
for (let i = 0; i < 4; i++) expect(XP_THRESHOLDS[level][i]).toBeGreaterThanOrEqual(XP_THRESHOLDS[level - 1][i]);
}
for (const [easy, medium, hard, deadly] of XP_THRESHOLDS) expect(easy < medium && medium < hard && hard < deadly).toBe(true);
});
});
describe('encounterDifficulty', () => {
const party = [1, 1, 1, 1]; // easy 100, medium 200, hard 300, deadly 400
test('adds up the party\'s thresholds and the monsters\' XP', () => {
const one = encounterDifficulty(party, ['1/2']);
expect(one).toEqual({ xp: 100, adjustedXp: 100, multiplier: 1, rating: 'EASY', thresholds: { easy: 100, medium: 200, hard: 300, deadly: 400 }, monsters: 1, unrated: 0 });
expect(encounterDifficulty([3, 5], []).thresholds).toEqual({ easy: 325, medium: 650, hard: 975, deadly: 1500 });
});
test('more monsters make a harder fight than their XP alone', () => {
expect(encounterDifficulty(party, ['1/8'])).toMatchObject({ xp: 25, adjustedXp: 25, rating: 'TRIVIAL' });
expect(encounterDifficulty(party, ['1/4', '1/4'])).toMatchObject({ xp: 100, multiplier: 1.5, adjustedXp: 150, rating: 'EASY' });
expect(encounterDifficulty(party, ['1/4', '1/4', '1/4'])).toMatchObject({ xp: 150, multiplier: 2, adjustedXp: 300, rating: 'HARD' });
expect(encounterDifficulty(party, ['1/4', '1/4', '1/4', '1/4'])).toMatchObject({ xp: 200, adjustedXp: 400, rating: 'DEADLY' });
expect(encounterDifficulty(party, Array(7).fill('0')).multiplier).toBe(2.5);
expect(encounterDifficulty(party, Array(11).fill('0')).multiplier).toBe(3);
expect(encounterDifficulty(party, Array(15).fill('0')).multiplier).toBe(4);
});
test('leaves monsters with no rating out, and says how many', () => {
expect(encounterDifficulty(party, ['1', 'boss', null])).toMatchObject({ xp: 200, adjustedXp: 200, monsters: 1, unrated: 2, rating: 'MEDIUM' });
});
test('has no rating without a party, or with nothing rated to fight', () => {
expect(encounterDifficulty([], ['1'])).toMatchObject({ xp: 200, rating: null, thresholds: null });
expect(encounterDifficulty(party, [])).toMatchObject({ xp: 0, rating: null });
expect(encounterDifficulty(party, ['boss'])).toMatchObject({ rating: null, unrated: 1 });
});
});
describe('xpProgress', () => {
test('says what the next level takes and whether it is due', () => {
expect(xpProgress(0, 1)).toEqual({ nextLevelAt: 300, levelUpDue: false });
expect(xpProgress(299, 1)).toEqual({ nextLevelAt: 300, levelUpDue: false });
expect(xpProgress(300, 1)).toEqual({ nextLevelAt: 300, levelUpDue: true });
expect(xpProgress(300, 2)).toEqual({ nextLevelAt: 900, levelUpDue: false });
expect(xpProgress(999999, 20)).toEqual({ nextLevelAt: null, levelUpDue: false });
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
require('dotenv').config();
require('dotenv').config({ quiet: true });
const http = require('http');
const { Server } = require('socket.io');
const { createApp } = require('./app');
+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;
File diff suppressed because it is too large Load Diff
+363 -17
View File
@@ -4,10 +4,17 @@ const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { requireCampaignMember, requireDM } = require('../middleware/campaignAccess');
const { generateInviteCode } = require('../utils/inviteCode');
const { computeCharacterSheet, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
const { computeCharacterSheet, xpProgress, GEAR_STATS, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL, MAX_LEVEL } = require('../game/calculations');
const { rollDie } = require('../game/buildFlow');
const { SHEET_INCLUDE, sheetOf, settleHp } = require('../utils/sheet');
const { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
const { visitedLocationIds } = require('../utils/locations');
const { deathSaveInfo } = require('../utils/hp');
const { deathSaveInfo, lifeState } = require('../utils/hp');
const { hitDiceProblem, shortRest, longRest } = require('../utils/rest');
const { CURRENCIES } = require('../utils/grants');
const { subclassName } = require('../game/data/subclasses');
const { findSeed, normalizeCode, CODE_LENGTH } = require('../seeds');
const { createCampaignFromSeed } = require('../seeds/createCampaign');
const router = express.Router();
@@ -25,6 +32,20 @@ function campaignSummary(campaign, role) {
startingSpellCount: campaign.startingSpellCount,
skipMotivationRoll: campaign.skipMotivationRoll,
showMonsterHp: campaign.showMonsterHp,
areaAttackAcPenalty: campaign.areaAttackAcPenalty,
maxEquipped: campaign.maxEquipped,
maxEquippedWeapons: campaign.maxEquippedWeapons,
subclassesEnabled: campaign.subclassesEnabled,
multiclassEnabled: campaign.multiclassEnabled,
diceMode: campaign.diceMode,
autoRollMonsters: campaign.autoRollMonsters,
autoRollNpcs: campaign.autoRollNpcs,
trackXp: campaign.trackXp,
hpOnLevelUp: campaign.hpOnLevelUp,
abilityScoreMethod: campaign.abilityScoreMethod,
rollVisibility: campaign.rollVisibility,
hideMonsterRolls: campaign.hideMonsterRolls,
critRule: campaign.critRule,
ownerId: campaign.ownerId,
archivedAt: campaign.archivedAt,
role,
@@ -54,6 +75,24 @@ const createCampaignSchema = z.object({
startingSpellCount: z.number().int().min(0).max(50).optional(),
skipMotivationRoll: z.boolean().optional(),
showMonsterHp: z.boolean().optional(),
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
// How many weapons and items a character can have equipped together, and
// how many of those can be weapons. Lowering them takes nothing off anyone:
// they are checked when something is put on.
maxEquipped: z.number().int().min(1).max(20).optional(),
maxEquippedWeapons: z.number().int().min(0).max(20).optional(),
// Off: nobody chooses a subclass, and each class follows its first one.
subclassesEnabled: z.boolean().optional(),
multiclassEnabled: z.boolean().optional(),
diceMode: z.enum(['DIGITAL', 'PHYSICAL', 'BOTH']).optional(),
autoRollMonsters: z.boolean().optional(),
autoRollNpcs: z.boolean().optional(),
trackXp: z.boolean().optional(),
hpOnLevelUp: z.enum(['AVERAGE', 'ROLL']).optional(),
abilityScoreMethod: z.enum(['ROLL_4D6', 'STANDARD_ARRAY', 'POINT_BUY']).optional(),
rollVisibility: z.enum(['ALL', 'OWN']).optional(),
hideMonsterRolls: z.boolean().optional(),
critRule: z.enum(['DOUBLE_DICE', 'MAX_PLUS_ROLL']).optional(),
});
// POST /campaigns — create, creator becomes DM/owner
@@ -85,6 +124,28 @@ router.post('/', async (req, res, next) => {
}
});
// POST /campaigns/from-seed — a ready-made world (a file in /backend/seeds)
// as a new campaign, by its code. As with a blank campaign, whoever creates
// it is its DM and owner; the same code can be used again for another copy.
router.post('/from-seed', async (req, res, next) => {
try {
const parsed = z.object({ code: z.string().max(40) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (normalizeCode(parsed.data.code).length !== CODE_LENGTH) {
return res.status(400).json({ error: `A world code is ${CODE_LENGTH} letters or numbers` });
}
const seed = findSeed(parsed.data.code);
if (!seed) return res.status(404).json({ error: 'There is no world with that code' });
// One transaction, so a failure doesn't leave half a world behind. A big
// world is thousands of rows, which is more than the default 5 seconds.
const campaign = await prisma.$transaction((tx) => createCampaignFromSeed(tx, req.user, seed), { timeout: 120000, maxWait: 20000 });
res.status(201).json({ campaign: campaignSummary(campaign, 'DM') });
} catch (err) {
next(err);
}
});
// POST /campaigns/join/:inviteCode — must be before requireCampaignMember-gated routes below
router.post('/join/:inviteCode', async (req, res, next) => {
try {
@@ -176,7 +237,8 @@ router.post('/:id/invite', requireCampaignMember(), requireDM(), async (req, res
// Characters within a campaign
// ---------------------------------------------------------------------------
function characterSummary(character) {
// `subclasses` is the campaign's setting: off, no subclass is named.
function characterSummary(character, subclasses = true) {
const sheet = computeCharacterSheet(
character,
character.skillProficiencies || [],
@@ -190,17 +252,38 @@ function characterSummary(character) {
name: character.name,
species: character.species,
characterClass: character.characterClass,
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
level: character.level,
xp: character.xp,
xpProgress: xpProgress(character.xp, character.level),
buildStage: character.buildStage,
currentHp: character.currentHp,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
inspiration: character.inspiration,
exhaustion: character.exhaustion,
conditions: character.conditions,
gold: character.gold,
silver: character.silver,
copper: character.copper,
cash: character.cash,
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
// What the DM reads off without asking for a roll.
passives:
character.buildStage === 'COMPLETE'
? { perception: sheet.passivePerception, investigation: sheet.passiveInvestigation, insight: sheet.passiveInsight }
: null,
spellSaveDC: character.buildStage === 'COMPLETE' && sheet.spellcasting ? sheet.spellcasting.saveDC : null,
// What each rolls with, for a check the DM calls for from the whole party.
checks: character.buildStage === 'COMPLETE' ? { abilities: sheet.abilities, saves: sheet.saves, skills: sheet.skills } : null,
// What is left to spend before the next rest: the slot tracker (null for
// a character with no spell slots) and Hit Dice, with the Constitution
// modifier each die healed is rolled with.
spellSlots: character.buildStage === 'COMPLETE' ? sheet.spellSlots : null,
hitDice: character.buildStage === 'COMPLETE' ? { ...sheet.hitDice, conModifier: sheet.abilities.CON.modifier } : null,
// The class counters (Rage, Ki...), with how many of each are left.
resources: character.buildStage === 'COMPLETE' ? sheet.resources : [],
currentLocationId: character.currentLocationId,
currentLocationName: character.currentLocation ? character.currentLocation.name : null,
};
@@ -210,10 +293,10 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
try {
const characters = await prisma.character.findMany({
where: { campaignId: req.campaign.id },
include: { skillProficiencies: true, saveProficiencies: true, currentLocation: true },
include: { ...SHEET_INCLUDE, currentLocation: true },
orderBy: { createdAt: 'asc' },
});
const summaries = characters.map(characterSummary);
const summaries = characters.map((character) => characterSummary(character, req.campaign.subclassesEnabled));
if (req.membership.role === 'DM') return res.json({ characters: summaries });
// A player sees the party's headline numbers, but another character's
@@ -223,7 +306,7 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
characters: summaries.map((c) => {
if (c.userId === req.user.id) return c;
const knowsPlace = c.currentLocationId && visited.has(c.currentLocationId);
return { ...c, gold: null, silver: null, copper: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
return { ...c, gold: null, silver: null, copper: null, cash: null, ...(knowsPlace ? {} : { currentLocationId: null, currentLocationName: null }) };
}),
});
} catch (err) {
@@ -279,6 +362,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)
// ---------------------------------------------------------------------------
@@ -308,6 +460,25 @@ router.get('/:id/weapons', requireCampaignMember(), async (req, res, next) => {
}
});
// What a weapon or item does to the sheet of whoever has it equipped: signed
// amounts on the numbers in GEAR_STATS (armor is an item with an AC effect),
// and a note for what is not a number.
const gearEffects = {
effects: z
.array(z.object({ stat: z.enum(GEAR_STATS), amount: z.number().int().min(-100).max(100).refine((n) => n !== 0, 'An effect of 0 does nothing') }))
.max(12)
.optional(),
effectNote: z.string().trim().max(300).optional().nullable(),
};
// A change to what a pool entry does reaches everyone who has it equipped:
// their maximum HP may have dropped, and every open sheet should refetch.
async function gearChanged(req, heldModel, poolKey, poolId, holderIds) {
const holders = holderIds || (await prisma[heldModel].findMany({ where: { [poolKey]: poolId }, select: { characterId: true } })).map((h) => h.characterId);
await settleHp(prisma, holders);
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:updated', {});
}
const weaponSchema = z.object({
name: z.string().min(1).max(80),
damageDice: z.string().min(1).max(20),
@@ -319,6 +490,7 @@ const weaponSchema = z.object({
damageScaling: z.number().int().min(1).max(MAX_DAMAGE_SCALING).optional(),
minLevel: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
isStartingOption: z.boolean().optional(),
...gearEffects,
});
// Raising a pool entry's starting level lifts every held copy that is now
@@ -363,6 +535,7 @@ router.patch('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), asy
const weapon = await updatePoolEntry('weapon', req.params.weaponId, req.campaign.id, parsed.data);
if (!weapon) return res.status(404).json({ error: 'Weapon not found in this campaign' });
await liftHeldCopies('characterWeapon', 'weaponId', weapon.id, parsed.data.minLevel);
await gearChanged(req, 'characterWeapon', 'weaponId', weapon.id);
res.json({ weapon });
} catch (err) {
next(err);
@@ -371,9 +544,11 @@ router.patch('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), asy
router.delete('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const holders = (await prisma.characterWeapon.findMany({ where: { weaponId: req.params.weaponId }, select: { characterId: true } })).map((h) => h.characterId);
if (!(await deletePoolEntry('weapon', req.params.weaponId, req.campaign.id))) {
return res.status(404).json({ error: 'Weapon not found in this campaign' });
}
await gearChanged(req, 'characterWeapon', 'weaponId', req.params.weaponId, holders);
res.status(204).end();
} catch (err) {
next(err);
@@ -403,6 +578,7 @@ const spellSchema = z.object({
description: z.string().max(4000).optional().nullable(),
damageDice: z.string().max(20).optional().nullable(),
isStartingOption: z.boolean().optional(),
revives: z.boolean().optional(), // brings back the dead
});
router.post('/:id/spells', requireCampaignMember(), requireDM(), async (req, res, next) => {
@@ -459,6 +635,7 @@ const itemSchema = z.object({
damageScaling: z.number().int().min(1).max(MAX_DAMAGE_SCALING).optional(),
minLevel: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
isStartingOption: z.boolean().optional(),
...gearEffects,
});
router.post('/:id/items', requireCampaignMember(), requireDM(), async (req, res, next) => {
@@ -479,6 +656,7 @@ router.patch('/:id/items/:itemId', requireCampaignMember(), requireDM(), async (
const item = await updatePoolEntry('item', req.params.itemId, req.campaign.id, parsed.data);
if (!item) return res.status(404).json({ error: 'Item not found in this campaign' });
await liftHeldCopies('inventoryItem', 'itemId', item.id, parsed.data.minLevel);
await gearChanged(req, 'inventoryItem', 'itemId', item.id);
res.json({ item });
} catch (err) {
next(err);
@@ -487,9 +665,11 @@ router.patch('/:id/items/:itemId', requireCampaignMember(), requireDM(), async (
router.delete('/:id/items/:itemId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const holders = (await prisma.inventoryItem.findMany({ where: { itemId: req.params.itemId }, select: { characterId: true } })).map((h) => h.characterId);
if (!(await deletePoolEntry('item', req.params.itemId, req.campaign.id))) {
return res.status(404).json({ error: 'Item not found in this campaign' });
}
await gearChanged(req, 'inventoryItem', 'itemId', req.params.itemId, holders);
res.status(204).end();
} catch (err) {
next(err);
@@ -522,7 +702,7 @@ const monsterSchema = z.object({
wisScore: z.number().int().min(1).max(30).optional(),
chaScore: z.number().int().min(1).max(30).optional(),
attackBonus: z.number().int().min(-20).max(50).optional(),
damageDice: z.string().max(20).optional().nullable(),
damageDice: z.string().max(60).optional().nullable(), // may name its type: "1d6+2 necrotic"
challengeRating: z.string().max(10).optional().nullable(),
specialTrait: z.string().max(500).optional().nullable(),
notes: z.string().max(2000).optional().nullable(),
@@ -566,6 +746,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';
@@ -574,7 +757,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) });
@@ -588,21 +771,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) {
@@ -641,7 +851,11 @@ router.put('/:id/encounters/order', requireCampaignMember(), requireDM(), async
router.get('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const npcs = await prisma.npc.findMany({ where: { campaignId: req.campaign.id }, orderBy: { name: 'asc' } });
const npcs = await prisma.npc.findMany({
where: { campaignId: req.campaign.id },
include: { wares: { include: WARE_INCLUDE, orderBy: { createdAt: 'asc' } } },
orderBy: { name: 'asc' },
});
res.json({ npcs });
} catch (err) {
next(err);
@@ -658,12 +872,31 @@ const npcSchema = z.object({
maxHp: z.number().int().min(1).max(100000).optional().nullable(),
attackBonus: z.number().int().min(-20).max(50).optional().nullable(),
damageDice: z.string().max(20).optional().nullable(),
// The money changer's rate: exchangeGold gold for exchangeCash cash. Both
// null for an NPC who does not change money.
exchangeGold: z.number().int().min(1).max(1000000).optional().nullable(),
exchangeCash: z.number().int().min(1).max(1000000).optional().nullable(),
});
// A rate has two sides: both are given, or both are cleared.
function halfARate(data) {
const sides = [data.exchangeGold, data.exchangeCash];
return sides.some((side) => side === undefined) !== sides.every((side) => side === undefined) || (sides[0] === null) !== (sides[1] === null);
}
const HALF_A_RATE = 'An exchange rate needs both sides: so much gold for so much cash.';
const WARE_INCLUDE = { weapon: true, item: true };
// Open encounter pages refetch: what is for sale in a scene just changed.
function emitTradeChanged(req) {
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('encounter:participant-updated', {});
}
router.post('/:id/npcs', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = npcSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
const npc = await prisma.npc.create({ data: { ...parsed.data, campaignId: req.campaign.id } });
res.status(201).json({ npc });
} catch (err) {
@@ -675,8 +908,10 @@ router.patch('/:id/npcs/:npcId', requireCampaignMember(), requireDM(), async (re
try {
const parsed = npcSchema.partial().safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (halfARate(parsed.data)) return res.status(400).json({ error: HALF_A_RATE });
const npc = await updatePoolEntry('npc', req.params.npcId, req.campaign.id, parsed.data);
if (!npc) return res.status(404).json({ error: 'NPC not found in this campaign' });
emitTradeChanged(req);
res.json({ npc });
} catch (err) {
next(err);
@@ -776,15 +1011,100 @@ router.delete('/:id/locations/:locationId', requireCampaignMember(), requireDM()
}
});
// ---------------------------------------------------------------------------
// Wares — weapons and items for sale, by an NPC or at an encounter itself.
// Only the DM sets them up. Players see them, and buy, on the encounter page
// (encounters.routes.js); an NPC's wares go wherever the NPC does.
// ---------------------------------------------------------------------------
const wareSchema = z
.object({
npcId: z.string().optional(),
encounterId: z.string().optional(),
weaponId: z.string().optional(),
itemId: z.string().optional(),
price: z.number().int().min(1).max(1000000),
currency: z.enum(CURRENCIES).default('GOLD'),
level: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
stock: z.number().int().min(0).max(9999).optional().nullable(), // null: no limit
})
.refine((data) => Boolean(data.npcId) !== Boolean(data.encounterId), { message: 'Say who sells it: an NPC or an encounter, not both' })
.refine((data) => Boolean(data.weaponId) !== Boolean(data.itemId), { message: 'Pick one weapon or one item to sell' });
router.post('/:id/wares', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = wareSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const data = parsed.data;
const inCampaign = async (model, id) => (id ? prisma[model].findFirst({ where: { id, campaignId: req.campaign.id } }) : null);
const seller = (await inCampaign('npc', data.npcId)) || (await inCampaign('encounter', data.encounterId));
if (!seller) return res.status(400).json({ error: 'That seller is not part of this campaign.' });
const entry = (await inCampaign('weapon', data.weaponId)) || (await inCampaign('item', data.itemId));
if (!entry) return res.status(400).json({ error: `That ${data.weaponId ? 'weapon' : 'item'} is not part of this campaign.` });
const level = data.level ?? entry.minLevel;
if (level < entry.minLevel) {
return res.status(400).json({ error: `${entry.name} starts at level ${entry.minLevel} and can't go below it.` });
}
const ware = await prisma.saleOffer.create({ data: { ...data, level, campaignId: req.campaign.id }, include: WARE_INCLUDE });
emitTradeChanged(req);
res.status(201).json({ ware });
} catch (err) {
next(err);
}
});
// The price and what is left can change; what is sold, and by whom, cannot.
const patchWareSchema = z.object({
price: z.number().int().min(1).max(1000000).optional(),
currency: z.enum(CURRENCIES).optional(),
stock: z.number().int().min(0).max(9999).nullable().optional(),
});
router.patch('/:id/wares/:wareId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = patchWareSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (!(await updatePoolEntry('saleOffer', req.params.wareId, req.campaign.id, parsed.data))) {
return res.status(404).json({ error: 'Nothing like that is for sale in this campaign' });
}
emitTradeChanged(req);
res.json({ ware: await prisma.saleOffer.findUnique({ where: { id: req.params.wareId }, include: WARE_INCLUDE }) });
} catch (err) {
next(err);
}
});
router.delete('/:id/wares/:wareId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
if (!(await deletePoolEntry('saleOffer', req.params.wareId, req.campaign.id))) {
return res.status(404).json({ error: 'Nothing like that is for sale in this campaign' });
}
emitTradeChanged(req);
res.status(204).end();
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Default content library — static SRD-style weapons/spells/monsters any
// campaign can pull from, instead of the DM hand-typing everything. See
// backend/src/game/data/{weapons,spells,monsters}Library.js for the sets.
// ---------------------------------------------------------------------------
// A library entry goes into a campaign once: it is taken for as long as the
// pool holds a row by its name, and free again when that row is renamed or
// deleted.
async function inPool(model, campaignId, name) {
const row = await prisma[model].findFirst({ where: { campaignId, name: { equals: name, mode: 'insensitive' } }, select: { id: true } });
return Boolean(row);
}
router.get('/:id/library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
res.json({ weapons: WEAPON_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY });
res.json({ weapons: WEAPON_LIBRARY, items: ITEM_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY });
} catch (err) {
next(err);
}
@@ -796,6 +1116,9 @@ router.post('/:id/weapons/from-library', requireCampaignMember(), requireDM(), a
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const template = WEAPON_LIBRARY.find((w) => w.key === parsed.data.key);
if (!template) return res.status(404).json({ error: 'Unknown library weapon.' });
if (await inPool('weapon', req.campaign.id, template.name)) {
return res.status(409).json({ error: `${template.name} is already in this campaign.` });
}
const { key, category, ...data } = template;
const weapon = await prisma.weapon.create({ data: { ...data, campaignId: req.campaign.id } });
res.status(201).json({ weapon });
@@ -804,12 +1127,32 @@ router.post('/:id/weapons/from-library', requireCampaignMember(), requireDM(), a
}
});
router.post('/:id/items/from-library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = z.object({ key: z.string() }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const template = ITEM_LIBRARY.find((i) => i.key === parsed.data.key);
if (!template) return res.status(404).json({ error: 'Unknown library item.' });
if (await inPool('item', req.campaign.id, template.name)) {
return res.status(409).json({ error: `${template.name} is already in this campaign.` });
}
const { key, category, ...data } = template;
const item = await prisma.item.create({ data: { ...data, campaignId: req.campaign.id } });
res.status(201).json({ item });
} catch (err) {
next(err);
}
});
router.post('/:id/spells/from-library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = z.object({ key: z.string() }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const template = SPELL_LIBRARY.find((s) => s.key === parsed.data.key);
if (!template) return res.status(404).json({ error: 'Unknown library spell.' });
if (await inPool('spell', req.campaign.id, template.name)) {
return res.status(409).json({ error: `${template.name} is already in this campaign.` });
}
const { key, classes, ...data } = template;
const spell = await prisma.spell.create({ data: { ...data, campaignId: req.campaign.id } });
res.status(201).json({ spell });
@@ -824,6 +1167,9 @@ router.post('/:id/monsters/from-library', requireCampaignMember(), requireDM(),
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const template = MONSTER_LIBRARY.find((m) => m.key === parsed.data.key);
if (!template) return res.status(404).json({ error: 'Unknown library monster.' });
if (await inPool('monster', req.campaign.id, template.name)) {
return res.status(409).json({ error: `${template.name} is already in this campaign.` });
}
const { key, ...data } = template;
const monster = await prisma.monster.create({ data: { ...data, campaignId: req.campaign.id } });
res.status(201).json({ monster });
+502 -49
View File
@@ -11,9 +11,16 @@ const {
getClassSpellcastingAbility,
scaledDamageDice,
canUseGear,
spellSlotState,
xpProgress,
MAX_GEAR_LEVEL,
MAX_LEVEL,
MAX_SLOT_LEVEL,
ABILITIES,
} = require('../game/calculations');
const { LevelUpError, advance, levelUpPlan, switchSubclass, subclassByDefault } = require('../game/levelUp');
const { resourceTally, MAX_EXHAUSTION } = require('../game/classResources');
const { SUBCLASS_LEVEL, subclassesOf, findSubclass, subclassName } = require('../game/data/subclasses');
const {
rollDie,
rollAbilityScore,
@@ -26,11 +33,16 @@ const {
composeFromPicks,
validateTalents,
composeSpecialAbilities,
STANDARD_ARRAY,
POINT_BUY_BUDGET,
pointBuyCost,
effectiveDiceMode,
} = require('../game/buildFlow');
const characterOptions = require('../game/data/characterOptions');
const { SKILLS } = require('../game/data/skills');
const { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
const { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
const { recordVisits } = require('../utils/locations');
const { SHEET_INCLUDE, sheetOf, settleHp } = require('../utils/sheet');
const { absorbDamage, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
const router = express.Router();
@@ -47,10 +59,15 @@ async function loadFullCharacter(id) {
weapons: { include: { weapon: true } },
spells: { include: { spell: true } },
currentLocation: true,
campaign: { select: { subclassesEnabled: true, diceMode: true } },
},
});
}
// Whether a character's campaign uses subclasses. Takes a character loaded
// with its campaign (loadFullCharacter).
const usesSubclasses = (character) => !character.campaign || character.campaign.subclassesEnabled;
// privateNotes is a real privacy boundary, not a UI hide — it's only ever
// included in the payload for the character's own owning player.
//
@@ -62,6 +79,8 @@ async function loadFullCharacter(id) {
function serializeCharacter(character, isOwner, isDM = true) {
const restricted = !isOwner && !isDM;
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
// With subclasses switched off for the campaign, none is named or offered.
const subclasses = usesSubclasses(character);
return {
id: character.id,
campaignId: character.campaignId,
@@ -70,8 +89,14 @@ function serializeCharacter(character, isOwner, isDM = true) {
name: character.name,
species: character.species,
characterClass: character.characterClass,
subclass: subclasses ? character.subclass : null,
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
// What the class can choose from, and the level it chooses at.
subclassLevel: subclasses ? (SUBCLASS_LEVEL[character.characterClass] ?? null) : null,
subclassOptions: subclasses ? subclassesOf(character.characterClass).map(({ key, name, summary }) => ({ key, name, summary })) : [],
level: character.level,
xp: character.xp,
xpProgress: xpProgress(character.xp, character.level),
motivation: character.motivation,
halfElfBonusAbility1: character.halfElfBonusAbility1,
halfElfBonusAbility2: character.halfElfBonusAbility2,
@@ -86,17 +111,24 @@ function serializeCharacter(character, isOwner, isDM = true) {
currentHp: character.currentHp,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
inspiration: character.inspiration,
exhaustion: character.exhaustion,
conditions: character.conditions,
armorBonus: character.armorBonus,
specialAbilities: character.specialAbilities,
background: character.background,
appearance: character.appearance,
privateNotes: isOwner ? character.privateNotes : undefined,
diceMode: character.diceMode,
// The dice this character rolls with, and whether that is theirs to choose
// (the campaign leaves it to each character) or the campaign's.
diceMode: effectiveDiceMode(character.campaign, character),
diceModeChoosable: !character.campaign || character.campaign.diceMode === 'BOTH',
currentLocationId: restricted ? null : character.currentLocationId,
currentLocation: restricted ? null : character.currentLocation || null,
gold: restricted ? null : character.gold,
silver: restricted ? null : character.silver,
copper: restricted ? null : character.copper,
cash: restricted ? null : character.cash,
restricted,
buildStage: character.buildStage,
buildState: character.buildStage === 'COMPLETE' ? null : character.buildState,
@@ -111,11 +143,17 @@ function serializeCharacter(character, isOwner, isDM = true) {
: null,
usable: canUseGear(character.level, ii.level),
})),
weapons: character.weapons.map((cw) => ({
...cw,
scaledDamageDice: scaledDamageDice(cw.weapon.damageDice, cw.level, cw.weapon.damageScaling, cw.weapon.minLevel),
usable: canUseGear(character.level, cw.level),
})),
weapons: character.weapons.map((cw) => {
// What this character rolls with it: to hit, and on top of its dice.
const { attackBonus, damageBonus } = computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true, cw.level);
return {
...cw,
scaledDamageDice: scaledDamageDice(cw.weapon.damageDice, cw.level, cw.weapon.damageScaling, cw.weapon.minLevel),
attackBonus,
damageBonus,
usable: canUseGear(character.level, cw.level),
};
}),
spells: character.spells.map((cs) => ({ ...cs, usable: canUseGear(character.level, cs.spell.level) })),
sheet: character.buildStage === 'COMPLETE' ? sheet : null,
};
@@ -149,23 +187,33 @@ const patchCharacterSchema = z.object({
name: z.string().min(1).max(80).optional(),
currentHp: z.number().int().optional(),
tempHp: z.number().int().min(0).max(9999).optional(),
armorBonus: z.number().int().min(0).max(30).optional(),
armorBonus: z.number().int().min(-30).max(30).optional(),
speedOverride: z.number().int().min(0).nullable().optional(),
specialAbilities: z.string().max(4000).nullable().optional(),
background: z.string().max(4000).nullable().optional(),
appearance: z.string().max(4000).nullable().optional(),
privateNotes: z.string().max(4000).nullable().optional(),
currentLocationId: z.string().nullable().optional(),
level: z.number().int().min(1).max(20).optional(),
xp: z.number().int().min(0).optional(),
level: z.number().int().min(1).max(MAX_LEVEL).optional(),
subclass: z.string().max(40).nullable().optional(), // a key from game/data/subclasses.js; null clears it
xp: z.number().int().min(0).max(10000000).optional(),
hitDiceUsed: z.number().int().min(0).max(MAX_LEVEL).optional(),
inspiration: z.boolean().optional(),
exhaustion: z.number().int().min(0).max(MAX_EXHAUSTION).optional(),
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
});
// Progression and placement are the DM's call, and so is rewriting what the
// builder composed (special abilities, background, appearance): a player
// picks those from lists and cannot type their own. The owning player can
// picks those from lists and cannot type their own. So are HP and temp HP:
// 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).
const DM_ONLY_PATCH_FIELDS = ['level', 'xp', 'currentLocationId', 'speedOverride', 'specialAbilities', 'background', 'appearance'];
// 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. Hit
// Dice are spent on a rest, which the DM runs. Exhaustion and conditions are
// the DM's to give and take away. Inspiration is the DM's to give; the player
// may spend it (checked below).
const DM_ONLY_PATCH_FIELDS = ['currentHp', 'tempHp', 'level', 'subclass', 'xp', 'currentLocationId', 'speedOverride', 'armorBonus', 'specialAbilities', 'background', 'appearance', 'hitDiceUsed', 'exhaustion', 'conditions'];
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
@@ -173,14 +221,63 @@ function clamp(value, min, max) {
router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
if (req.body && ['gold', 'silver', 'copper'].some((coin) => req.body[coin] !== undefined)) {
return res.status(400).json({ error: 'Gold, silver and copper changes go through the money tracker.' });
if (req.body && Object.values(COIN_COLUMN).some((coin) => req.body[coin] !== undefined)) {
return res.status(400).json({ error: 'Gold, silver, copper and cash changes go through the money tracker.' });
}
const parsed = patchCharacterSchema.safeParse(req.body);
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 level, XP, speed, location, special abilities, background or appearance.' });
return res.status(403).json({ error: 'Only the DM can change HP, temp HP, Hit Dice, level, subclass, XP, speed, armor bonus, location, exhaustion, conditions, special abilities, background or appearance.' });
}
if (!req.isDM && parsed.data.inspiration === true) {
return res.status(403).json({ error: 'Only the DM can give inspiration. You can spend it.' });
}
if (parsed.data.conditions) parsed.data.conditions = [...new Set(parsed.data.conditions)];
const newLevel = parsed.data.level ?? req.character.level;
if (parsed.data.level !== undefined || parsed.data.hitDiceUsed !== undefined) {
// A character has one Hit Die per level, so none can be spent beyond that.
parsed.data.hitDiceUsed = Math.min(parsed.data.hitDiceUsed ?? req.character.hitDiceUsed, newLevel);
}
if (newLevel !== req.character.level) {
// Spent slots of a level the character no longer has are forgotten.
const slots = spellSlotState(req.character.characterClass, newLevel, req.character.spellSlotsUsed);
const tally = [];
for (const slot of slots ? slots.levels : []) tally[slot.level - 1] = slot.used;
parsed.data.spellSlotsUsed = Array.from(tally, (used) => used || 0);
}
const characterClass = req.character.characterClass;
const askedSubclass = parsed.data.subclass; // before a level change may take one by default
let abilities = parsed.data.specialAbilities === undefined ? req.character.specialAbilities : null;
const writesAbilities = req.character.buildStage === 'COMPLETE' && parsed.data.specialAbilities === undefined;
if (writesAbilities && newLevel > req.character.level) {
// Setting a higher level by hand still writes down what the class and
// subclass give on the way there. A Boon, and a subclass not yet picked,
// are left as a line saying one is owed: picking them is part of
// levelling up properly (POST /:id/level-up).
let key = req.character.subclass;
for (let level = req.character.level + 1; level <= newLevel; level++) {
// With subclasses switched off, the class's first one is taken at the
// level it would have been chosen.
const taken = req.campaign.subclassesEnabled ? null : subclassByDefault(characterClass, level, key);
abilities = advance(abilities, characterClass, level, undefined, taken ? { key: taken, chosen: true } : { key }).specialAbilities;
if (taken) key = taken;
}
parsed.data.specialAbilities = abilities;
if (key !== req.character.subclass && askedSubclass === undefined) parsed.data.subclass = key;
}
if (askedSubclass !== undefined && askedSubclass !== req.character.subclass) {
if (!req.campaign.subclassesEnabled) return res.status(400).json({ error: 'Subclasses are switched off for this campaign.' });
const picked = askedSubclass;
if (picked !== null) {
if (!findSubclass(characterClass, picked)) return res.status(400).json({ error: 'That is not a subclass of this character\'s class.' });
if (newLevel < SUBCLASS_LEVEL[characterClass]) {
return res.status(400).json({ error: `${req.character.name} chooses a subclass at level ${SUBCLASS_LEVEL[characterClass]}.` });
}
}
// The old subclass's lines come off the sheet and the new one's go on.
if (writesAbilities) parsed.data.specialAbilities = switchSubclass(abilities, characterClass, newLevel, req.character.subclass, picked);
}
// Damage is soaked by temp HP before it reaches real HP — unless this
@@ -189,7 +286,8 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
const settingTempHp = parsed.data.tempHp !== undefined;
if (req.character.buildStage === 'COMPLETE' && (settingHp || parsed.data.level !== undefined)) {
// Current HP stays within 0..max, including when a level change moves max.
const maxHp = computeCharacterSheet({ ...req.character, ...parsed.data }).hp.max;
const geared = await prisma.character.findUnique({ where: { id: req.character.id }, include: SHEET_INCLUDE });
const maxHp = computeCharacterSheet({ ...geared, ...parsed.data }, geared.skillProficiencies, geared.saveProficiencies).hp.max;
if (!settingHp) {
parsed.data.currentHp = clamp(req.character.currentHp, 0, maxHp);
} else {
@@ -232,6 +330,8 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
where: { characterId: updated.id, spell: { level: { gt: updated.level } } },
data: { prepared: false },
});
// Gear that came off may have been holding the maximum HP up.
await settleHp(prisma, [updated.id]);
}
const full = await loadFullCharacter(updated.id);
@@ -300,6 +400,234 @@ router.post('/:id/death-saves', requireCharacterAccess({ write: true }), async (
}
});
// ---------------------------------------------------------------------------
// Spell slots — the slot tracker. How many slots there are comes from class
// and level (game/calculations.js); the character row keeps how many are spent.
// ---------------------------------------------------------------------------
const ORDINALS = ['1st', '2nd', '3rd', '4th', '5th', '6th', '7th', '8th', '9th'];
const slotLevelSchema = z.number().int().min(1).max(MAX_SLOT_LEVEL);
const spellSlotSchema = z.discriminatedUnion('action', [
// Casting a spell: one slot of that level is spent.
z.object({ action: z.literal('use'), level: slotLevelSchema }),
// The DM setting how many of a level are spent: giving one back, or fixing a mistake.
z.object({ action: z.literal('set'), level: slotLevelSchema, used: z.number().int().min(0).max(9) }),
// The DM giving every slot back.
z.object({ action: z.literal('restore') }),
]);
// The owning player spends their own slots, one at a time. Getting one back
// is the DM's: by hand here, or for everyone with a rest
// (POST /campaigns/:id/rest).
router.post('/:id/spell-slots', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = spellSlotSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { character } = req;
if (character.buildStage !== 'COMPLETE') return res.status(400).json({ error: 'Character build is not finished yet.' });
const input = parsed.data;
if (input.action !== 'use' && !req.isDM) {
return res.status(403).json({ error: 'Only the DM can give a spell slot back.' });
}
const slots = spellSlotState(character.characterClass, character.level, character.spellSlotsUsed);
if (!slots) return res.status(400).json({ error: `${character.name} has no spell slots.` });
// The stored tally, squared up with what the class has at this level.
const used = Array.from({ length: slots.levels[slots.levels.length - 1].level }, (_, i) => {
const slot = slots.levels.find((s) => s.level === i + 1);
return slot ? slot.used : 0;
});
if (input.action === 'restore') {
used.fill(0);
} else {
const slot = slots.levels.find((s) => s.level === input.level);
if (!slot) return res.status(400).json({ error: `${character.name} has no ${ORDINALS[input.level - 1]}-level spell slots.` });
if (input.action === 'use') {
if (slot.remaining === 0) {
return res.status(400).json({
error: `${character.name} has no ${ORDINALS[input.level - 1]}-level spell slots left. They come back on a ${slots.restoresOn === 'SHORT' ? 'short or long' : 'long'} rest.`,
});
}
used[input.level - 1] = slot.used + 1;
} else {
used[input.level - 1] = Math.min(input.used, slot.max);
}
}
await prisma.character.update({ where: { id: character.id }, data: { spellSlotsUsed: used } });
const full = await loadFullCharacter(character.id);
emitCharacterUpdated(req);
res.json({ character: serializeCharacter(full, req.isOwner) });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Class counters — Rage, Ki, Channel Divinity and the like. Which a character
// has, and how many, comes from class, subclass and level
// (game/classResources.js); the character row keeps how many are spent.
// ---------------------------------------------------------------------------
const resourceKey = z.string().min(1).max(40);
const resourceSchema = z.discriminatedUnion('action', [
// Using it: one is spent, or `amount` from a pool (Lay on Hands, Ki).
z.object({ action: z.literal('use'), key: resourceKey, amount: z.number().int().min(1).max(999).default(1) }),
// The DM setting how many are spent: giving some back, or fixing a mistake.
z.object({ action: z.literal('set'), key: resourceKey, used: z.number().int().min(0).max(999) }),
// The DM giving every counter back.
z.object({ action: z.literal('restore') }),
]);
// The owning player spends their own. Getting any back is the DM's: by hand
// here, or for everyone with a rest (POST /campaigns/:id/rest).
router.post('/:id/resources', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = resourceSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (req.character.buildStage !== 'COMPLETE') return res.status(400).json({ error: 'Character build is not finished yet.' });
const input = parsed.data;
if (input.action !== 'use' && !req.isDM) return res.status(403).json({ error: 'Only the DM can give a use back.' });
const character = await loadFullCharacter(req.character.id);
const resources = sheetOf(character).resources;
if (input.action === 'restore') {
for (const resource of resources) resource.used = 0;
} else {
const resource = resources.find((r) => r.key === input.key);
if (!resource) return res.status(400).json({ error: `${character.name} has no such counter.` });
if (input.action === 'use') {
if (input.amount > resource.remaining) {
const back = resource.restoresOn === 'SHORT' ? 'short or long' : 'long';
return res.status(400).json({
error:
resource.remaining === 0
? `${character.name} has no ${resource.name} left. It comes back on a ${back} rest.`
: `${character.name} has only ${resource.remaining} ${resource.name} left.`,
});
}
resource.used += input.amount;
} else {
resource.used = Math.min(input.used, resource.max);
}
}
await prisma.character.update({ where: { id: character.id }, data: { resourcesUsed: resourceTally(resources) } });
const full = await loadFullCharacter(character.id);
emitCharacterUpdated(req);
res.json({ character: serializeCharacter(full, req.isOwner) });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Levelling up — one level at a time, with the checklist that goes with it
// (game/levelUp.js): the new level, full HP, the class's new special
// abilities, a Boon where the level grants one, new spells, fresh spell slots
// and the free weapon upgrade.
// ---------------------------------------------------------------------------
// Answers the request and returns false when this character cannot be levelled up.
function canLevelUp(req, res, character) {
let refusal = null;
if (!req.isDM) refusal = [403, 'Only the DM can level a character up.'];
else if (character.buildStage !== 'COMPLETE') refusal = [400, 'Character build is not finished yet.'];
else if (character.level >= MAX_LEVEL) refusal = [400, `${character.name} is already level ${MAX_LEVEL}, the highest there is.`];
if (refusal) res.status(refusal[0]).json({ error: refusal[1] });
return !refusal;
}
// What the next level brings, for the DM to look over before saying yes.
router.get('/:id/level-up', requireCharacterAccess(), async (req, res, next) => {
try {
const character = await loadFullCharacter(req.character.id);
if (!canLevelUp(req, res, character)) return;
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character) });
const after = sheetOf({ ...character, level: plan.toLevel, spellSlotsUsed: [] });
res.json({ plan: { ...plan, maxHp: { from: sheetOf(character).hp.max, to: after.hp.max }, spellSlots: after.spellSlots } });
} catch (err) {
next(err);
}
});
const levelUpSchema = z.object({
subclass: z.string().optional(), // which subclass, at the level the class chooses one
boon: z.string().optional(), // which Boon, at a level that grants one
weaponId: z.string().nullable().optional(), // the weapon that gets the free upgrade
spellIds: z.array(z.string()).max(50).default([]), // spells newly granted
});
router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = levelUpSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { subclass, boon, weaponId, spellIds } = parsed.data;
const character = await loadFullCharacter(req.character.id);
if (!canLevelUp(req, res, character)) return;
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character) });
if (plan.subclass && !subclass) return res.status(400).json({ error: `Level ${plan.toLevel} needs a subclass: pick one.` });
if (!plan.subclass && subclass) return res.status(400).json({ error: `There is no subclass to pick at level ${plan.toLevel}.` });
if (subclass && !plan.subclass.options.some((option) => option.key === subclass)) {
return res.status(400).json({ error: 'That is not a subclass of this character\'s class.' });
}
if (plan.boon && !boon) return res.status(400).json({ error: `Level ${plan.toLevel} grants a Boon: pick one.` });
if (!plan.boon && boon) return res.status(400).json({ error: `Level ${plan.toLevel} does not grant a Boon.` });
if (weaponId && !plan.weapons.some((w) => w.weaponId === weaponId)) {
return res.status(400).json({ error: 'That weapon cannot take the free upgrade: it has to be one this character holds, of standard growth, below their new level.' });
}
const spellsFound = await prisma.spell.count({ where: { id: { in: spellIds }, campaignId: character.campaignId } });
if (new Set(spellIds).size !== spellIds.length || spellsFound !== spellIds.length) {
return res.status(400).json({ error: "Pick the new spells from this campaign's spell list, each once." });
}
let gained;
try {
// The subclass picked, or the one taken for them where the campaign has no choosing.
const newSubclass = subclass || plan.subclassTaken;
gained = advance(character.specialAbilities, character.characterClass, plan.toLevel, boon, newSubclass ? { key: newSubclass, chosen: true } : { key: character.subclass });
} catch (err) {
if (!(err instanceof LevelUpError)) throw err;
return res.status(400).json({ error: err.message });
}
const armorBonus = clamp(character.armorBonus + gained.armorBonus, -30, 30);
const maxHp = sheetOf({ ...character, level: plan.toLevel, armorBonus }).hp.max;
await prisma.$transaction(async (tx) => {
if (weaponId) {
await tx.characterWeapon.update({
where: { characterId_weaponId: { characterId: character.id, weaponId } },
data: { level: { increment: 1 } },
});
}
await tx.characterSpell.createMany({ data: spellIds.map((spellId) => ({ characterId: character.id, spellId })), skipDuplicates: true });
await tx.character.update({
where: { id: character.id },
data: {
level: plan.toLevel,
...(subclass || plan.subclassTaken ? { subclass: subclass || plan.subclassTaken } : {}),
specialAbilities: gained.specialAbilities,
armorBonus,
tempHp: Math.max(character.tempHp, gained.tempHp),
spellSlotsUsed: [],
// The new maximum, and healed to it. Levelling does not raise the dead.
...(lifeState(character) === 'DEAD' ? {} : { currentHp: maxHp, ...NO_DEATH_SAVES }),
},
});
});
const full = await loadFullCharacter(character.id);
emitCharacterUpdated(req);
res.json({ character: serializeCharacter(full, req.isOwner) });
} catch (err) {
next(err);
}
});
router.delete('/:id', requireCharacterAccess(), async (req, res, next) => {
try {
if (!req.isDM) return res.status(403).json({ error: 'DM only' });
@@ -407,6 +735,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
name: source.name,
species: source.species,
characterClass: source.characterClass,
subclass: source.subclass,
level: source.level,
xp: source.xp,
motivation: source.motivation,
@@ -425,6 +754,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
gold: source.gold,
silver: source.silver,
copper: source.copper,
cash: source.cash,
background: source.background,
appearance: source.appearance,
// Same privacy boundary as serializeCharacter: a DM copying a
@@ -473,7 +803,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
// a user-facing error string on failure, otherwise returns the value(s) in
// the exact same shape the digital path would have produced.
function resolveSingleRoll(character, body, sides) {
if (character.diceMode !== 'PHYSICAL') return rollDie(sides);
if (effectiveDiceMode(character.campaign, character) !== 'PHYSICAL') return rollDie(sides);
const value = Number(body?.value);
if (!validateManualRoll(sides, value)) {
throw new Error(`Enter the d${sides} you rolled — a whole number from 1 to ${sides}.`);
@@ -482,7 +812,11 @@ function resolveSingleRoll(character, body, sides) {
}
function resolveAbilityRolls(character, body) {
if (character.diceMode !== 'PHYSICAL') {
// The standard array is not rolled at all: the same six scores for everyone.
if (character.campaign.abilityScoreMethod === 'STANDARD_ARRAY') {
return STANDARD_ARRAY.map((total) => ({ dice: [], dropped: null, total }));
}
if (effectiveDiceMode(character.campaign, character) !== 'PHYSICAL') {
return Array.from({ length: 6 }, () => rollAbilityScore());
}
const sets = body?.abilitySets;
@@ -503,6 +837,9 @@ router.patch('/:id/build/dice-mode', requireCharacterAccess({ write: true }), as
const parsed = z.object({ diceMode: z.enum(['DIGITAL', 'PHYSICAL']) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (req.campaign.diceMode !== 'BOTH') {
return res.status(400).json({ error: `This campaign plays with ${req.campaign.diceMode === 'PHYSICAL' ? 'physical' : 'digital'} dice only.` });
}
const alreadyRolling = character.buildStage !== 'ROLLING_SPECIES' || Boolean((character.buildState || {}).speciesRoll);
if (alreadyRolling) {
return res.status(400).json({ error: 'Dice mode can only be chosen before the first roll of the build.' });
@@ -544,6 +881,9 @@ router.post('/:id/build/roll', requireCharacterAccess({ write: true }), async (r
if (buildState.abilityRolls) {
return res.status(400).json({ error: 'Ability scores were already rolled for this character.' });
}
if (req.campaign.abilityScoreMethod === 'POINT_BUY') {
return res.status(400).json({ error: 'This campaign uses point buy: nothing is rolled, the scores are bought.' });
}
buildState.abilityRolls = resolveAbilityRolls(character, req.body);
} else {
return res.status(400).json({ error: `No die roll is required at the "${stage}" step.` });
@@ -570,15 +910,20 @@ const chooseSchemas = {
// Motivation is story only, so it is simply picked from the list (the stage
// keeps its old name from when it was a die roll).
ROLLING_MOTIVATION: z.object({ motivation: z.number().int() }),
// Rolled scores (and the standard array) are assigned by index; point buy
// sends the six scores themselves.
ASSIGNING_SCORES: z.object({
assignments: z.object({
STR: z.number().int().min(0).max(5),
DEX: z.number().int().min(0).max(5),
CON: z.number().int().min(0).max(5),
INT: z.number().int().min(0).max(5),
WIS: z.number().int().min(0).max(5),
CHA: z.number().int().min(0).max(5),
}),
pointBuy: z.record(z.enum(ABILITIES), z.number().int().min(8).max(15)).optional(),
assignments: z
.object({
STR: z.number().int().min(0).max(5),
DEX: z.number().int().min(0).max(5),
CON: z.number().int().min(0).max(5),
INT: z.number().int().min(0).max(5),
WIS: z.number().int().min(0).max(5),
CHA: z.number().int().min(0).max(5),
})
.optional(),
}),
// Skill proficiencies are now auto-generated (see autoSelectSkills, applied
// when this stage is entered from ASSIGNING_SCORES below) — this payload is
@@ -595,8 +940,8 @@ const chooseSchemas = {
.optional(),
}),
CHOOSING_ABILITIES: z.object({ talents: z.array(z.string()) }),
CHOOSING_BACKGROUND: z.object({ picks: z.record(z.array(z.string())) }),
CHOOSING_APPEARANCE: z.object({ picks: z.record(z.array(z.string())) }),
CHOOSING_BACKGROUND: z.object({ picks: z.record(z.string(), z.array(z.string())) }),
CHOOSING_APPEARANCE: z.object({ picks: z.record(z.string(), z.array(z.string())) }),
CHOOSING_EQUIPMENT: z.object({
weaponIds: z.array(z.string()).default([]),
itemIds: z.array(z.string()).default([]),
@@ -684,8 +1029,23 @@ router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async
buildState.motivationChoice = motivation.key;
buildState.motivationText = motivation.label;
} else if (stage === 'ASSIGNING_SCORES') {
let { assignments } = parsed.data;
if (req.campaign.abilityScoreMethod === 'POINT_BUY') {
const scores = parsed.data.pointBuy;
if (!scores || ABILITIES.some((ability) => scores[ability] === undefined)) {
return res.status(400).json({ error: 'Buy a score for each of the six abilities.' });
}
const cost = pointBuyCost(scores);
if (cost === null || cost > POINT_BUY_BUDGET) {
return res.status(400).json({ error: `Those scores cost ${cost} points; you have ${POINT_BUY_BUDGET}.` });
}
// Kept the way rolled scores are, each assigned to its own ability.
buildState.abilityRolls = ABILITIES.map((ability) => ({ dice: [], dropped: null, total: scores[ability] }));
buildState.pointBuy = scores;
assignments = Object.fromEntries(ABILITIES.map((ability, i) => [ability, i]));
}
if (!buildState.abilityRolls) return res.status(400).json({ error: 'Roll ability scores first.' });
const { assignments } = parsed.data;
if (!assignments) return res.status(400).json({ error: 'Assign each score to an ability.' });
const usedIndexes = Object.values(assignments);
const uniqueIndexes = new Set(usedIndexes);
if (uniqueIndexes.size !== 6 || Math.max(...usedIndexes) > 5) {
@@ -891,22 +1251,54 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
return res.status(400).json({ error: 'Character build is not finished yet.' });
}
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
// The pack, the spell list and the purse are the owner's and the DM's to
// see, as on the sheet; anyone else gets what is visible across the table.
const restricted = !req.isOwner && !req.isDM;
const effectsOf = (entry) => ({ effects: Array.isArray(entry.effects) ? entry.effects : [], effectNote: entry.effectNote || null });
const weapons = character.weapons
.filter((cw) => canUseGear(character.level, cw.level))
.map((cw) => ({
characterWeaponId: cw.id,
weaponId: cw.weaponId,
name: cw.weapon.name,
level: cw.level,
equipped: cw.equipped,
quantity: cw.quantity,
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true, cw.level),
}));
// Everything held is listed, usable or not: `usable` says whether the
// character is high enough level for it.
const weapons = character.weapons.map((cw) => ({
characterWeaponId: cw.id,
weaponId: cw.weaponId,
name: cw.weapon.name,
level: cw.level,
equipped: cw.equipped,
quantity: cw.quantity,
usable: canUseGear(character.level, cw.level),
...effectsOf(cw.weapon),
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true, cw.level),
}));
const inventory = character.inventoryItems.map((ii) => ({
inventoryItemId: ii.id,
itemId: ii.itemId,
name: ii.item.name,
description: ii.item.description,
level: ii.level,
equipped: ii.equipped,
quantity: ii.quantity,
usable: canUseGear(character.level, ii.level),
damageDice: ii.item.damageDice ? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel) : null,
...effectsOf(ii.item),
}));
const spells = character.spells.map((cs) => ({
characterSpellId: cs.id,
name: cs.spell.name,
level: cs.spell.level,
damageDice: cs.spell.damageDice,
description: cs.spell.description,
prepared: cs.prepared,
usable: canUseGear(character.level, cs.spell.level),
}));
res.json({
quickStats: {
id: character.id,
name: character.name,
level: character.level,
species: character.species,
characterClass: character.characterClass,
subclassName: usesSubclasses(character) ? subclassName(character.characterClass, character.subclass) : null,
currentHp: character.currentHp,
maxHp: sheet.hp.max,
tempHp: character.tempHp,
@@ -916,8 +1308,26 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
speed: sheet.speed,
proficiencyBonus: sheet.proficiencyBonus,
spellcasting: sheet.spellcasting,
spellSlots: sheet.spellSlots,
hitDice: sheet.hitDice,
resources: sheet.resources,
inspiration: character.inspiration,
exhaustion: character.exhaustion,
conditions: character.conditions,
passivePerception: sheet.passivePerception,
passiveInvestigation: sheet.passiveInvestigation,
passiveInsight: sheet.passiveInsight,
abilities: sheet.abilities,
saves: sheet.saves,
skills: sheet.skills,
specialAbilities: character.specialAbilities,
// What equipped gear is adding and taking away, and what else it does.
gear: sheet.gear,
weapons,
restricted,
inventory: restricted ? [] : inventory,
spells: restricted ? [] : spells,
purse: restricted ? null : { gold: character.gold, silver: character.silver, copper: character.copper, cash: character.cash },
},
});
} catch (err) {
@@ -938,8 +1348,11 @@ function coinName(currency) {
return currency.toLowerCase();
}
// For a payment the whole purse can't cover.
const PURSE_SELECT = { gold: true, silver: true, copper: true, cash: true };
// For a payment the whole purse can't cover. Cash stands apart from the coins.
function notEnoughMoney(character, amount, currency) {
if (currency === 'CASH') return `Not enough cash — ${character.name} has $${character.cash}, which doesn't cover $${amount}.`;
return `Not enough money — ${character.name} has ${character.gold} gold, ${character.silver} silver and ${character.copper} copper (worth ${worthInGold(character)} gold), which doesn't cover ${amount} ${coinName(currency)}.`;
}
@@ -951,16 +1364,18 @@ router.get('/:id/gold', requireCharacterAccess(), async (req, res, next) => {
include: GOLD_TRANSACTION_INCLUDE,
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
res.json({ gold: req.character.gold, silver: req.character.silver, copper: req.character.copper, transactions });
res.json({ gold: req.character.gold, silver: req.character.silver, copper: req.character.copper, cash: req.character.cash, transactions });
} catch (err) {
next(err);
}
});
// amount is signed: positive = gained, negative = spent. The owning player
// and the DM can both record entries; each one is stamped with who made it.
// A spend is paid from the whole purse: if there aren't enough of the coin
// amount is signed: positive = gained, negative = spent. Only the DM records
// entries here — a player cannot add money to their own purse or write it
// off; theirs moves by giving, buying and exchanging. Each entry is stamped
// with who made it. A spend is paid from the whole purse: if there aren't enough of the coin
// named, bigger coins are broken (or smaller ones counted out) to cover it.
// Cash is only ever paid in cash.
router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z
@@ -972,6 +1387,7 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { amount, currency, reason } = parsed.data;
if (!req.isDM) return res.status(403).json({ error: 'Only the DM can record a gain or a spend.' });
let transactions;
try {
@@ -986,7 +1402,7 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
}
emitCharacterUpdated(req);
const purse = await prisma.character.findUnique({ where: { id: req.character.id }, select: { gold: true, silver: true, copper: true } });
const purse = await prisma.character.findUnique({ where: { id: req.character.id }, select: PURSE_SELECT });
res.status(201).json({ ...purse, transaction: transactions[0], transactions });
} catch (err) {
next(err);
@@ -996,11 +1412,12 @@ router.post('/:id/gold', requireCharacterAccess({ write: true }), async (req, re
// Changes coins of one kind into another at their fixed worth (1 gold = 10
// silver = 100 copper). `amount` is how many `from` coins to hand over; it has
// to come to a whole number of `to` coins. Both sides go on the ledger. Unlike
// a spend, this moves exactly the coins named: no change is made.
// a spend, this moves exactly the coins named: no change is made. Cash has no
// worth in coins, so it can't be exchanged.
router.post('/:id/gold/exchange', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = z
.object({ from: z.enum(CURRENCIES), to: z.enum(CURRENCIES), amount: z.number().int().min(1).max(1000000) })
.object({ from: z.enum(COINS), to: z.enum(COINS), amount: z.number().int().min(1).max(1000000) })
.refine((data) => data.from !== data.to, { message: 'Pick two different coins' })
.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
@@ -1061,6 +1478,29 @@ const GEAR = [
const gearLevelSchema = z.number().int().min(1).max(MAX_GEAR_LEVEL);
// A character can only have so much equipped at once (campaign settings:
// weapons and items together, and weapons alone). Returns why `adding` — a
// weapon or an item, not yet counted — cannot go on as well, or null if it
// can. `exceptPoolId` is the pool entry being equipped, in case it already is.
async function equipBlocked(req, kind, exceptPoolId) {
const { maxEquipped, maxEquippedWeapons } = req.campaign;
const where = { characterId: req.character.id, equipped: true };
const [weapons, items] = await Promise.all([
prisma.characterWeapon.findMany({ where: { ...where, ...(kind === 'weapon' ? { weaponId: { not: exceptPoolId } } : {}) }, include: { weapon: true } }),
prisma.inventoryItem.findMany({ where: { ...where, ...(kind === 'item' ? { itemId: { not: exceptPoolId } } : {}) }, include: { item: true } }),
]);
const names = (rows, key) => rows.map((row) => row[key].name).join(', ');
if (kind === 'weapon' && weapons.length >= maxEquippedWeapons) {
if (maxEquippedWeapons === 0) return 'Nobody can equip a weapon in this campaign.';
return `${req.character.name} already has ${names(weapons, 'weapon')} equipped, and can hold ${maxEquippedWeapons === 1 ? 'one weapon' : `${maxEquippedWeapons} weapons`} at a time. Unequip it first.`;
}
if (weapons.length + items.length >= maxEquipped) {
const on = [names(weapons, 'weapon'), names(items, 'item')].filter(Boolean).join(', ');
return `${req.character.name} already has ${maxEquipped === 1 ? 'something' : `${maxEquipped} things`} equipped (${on}). Unequip one first.`;
}
return null;
}
for (const gear of GEAR) {
const uniqueKey = `characterId_${gear.key}`;
const whereHeld = (req) => ({ [uniqueKey]: { characterId: req.character.id, [gear.key]: req.params.poolId } });
@@ -1098,6 +1538,10 @@ for (const gear of GEAR) {
if (parsed.data[gear.flag] && !canUseGear(req.character.level, requiredLevel(parsed.data, entry))) {
return res.status(400).json({ error: tooHighMessage(requiredLevel(parsed.data, entry)) });
}
if (gear.ownLevel && parsed.data.equipped) {
const blocked = await equipBlocked(req, gear.pool, poolId);
if (blocked) return res.status(400).json({ error: blocked });
}
// Giving something the character already holds adds to the stack (the
// stack keeps the level it has).
@@ -1107,6 +1551,7 @@ for (const gear of GEAR) {
update: gear.ownLevel ? { quantity: { increment: parsed.data.quantity } } : { [gear.flag]: parsed.data[gear.flag] },
include,
});
if (gear.ownLevel) await settleHp(prisma, [req.character.id]);
emitCharacterUpdated(req);
res.status(201).json({ [gear.held]: row });
} catch (err) {
@@ -1138,8 +1583,13 @@ for (const gear of GEAR) {
if (data[gear.flag]) return res.status(400).json({ error: tooHighMessage(needed) });
data[gear.flag] = false; // levelled past its holder: it comes off
}
if (gear.ownLevel && data.equipped && !existing.equipped) {
const blocked = await equipBlocked(req, gear.pool, req.params.poolId);
if (blocked) return res.status(400).json({ error: blocked });
}
const row = await prisma[gear.held].update({ where: whereHeld(req), data, include });
if (gear.ownLevel) await settleHp(prisma, [req.character.id]);
emitCharacterUpdated(req);
res.json({ [gear.held]: row });
} catch (err) {
@@ -1154,6 +1604,7 @@ for (const gear of GEAR) {
where: { characterId: req.character.id, [gear.key]: req.params.poolId },
});
if (count === 0) return res.status(404).json({ error: `This character doesn't have that ${gear.label}.` });
if (gear.ownLevel) await settleHp(prisma, [req.character.id]);
emitCharacterUpdated(req);
res.status(204).end();
} catch (err) {
@@ -1240,6 +1691,8 @@ router.post('/:id/give', requireCharacterAccess({ write: true }), async (req, re
throw err;
}
// What was handed over may have been equipped, and holding the giver's maximum HP up.
await settleHp(prisma, [giver.id]);
const io = req.app.get('io');
for (const characterId of [giver.id, recipient.id]) {
io.to(`campaign:${giver.campaignId}`).emit('character:updated', { characterId });
+493 -69
View File
@@ -1,12 +1,15 @@
const crypto = require('crypto');
const express = require('express');
const { z } = require('zod');
const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { loadCampaignMembership } = require('../middleware/campaignAccess');
const { computeCharacterSheet, computeWeaponStats, canUseGear, abilityModifier, MAX_GEAR_LEVEL } = require('../game/calculations');
const { CURRENCIES, adjustGold, giveGear } = require('../utils/grants');
const { computeCharacterSheet, computeWeaponStats, scaledDamageDice, canUseGear, abilityModifier, encounterDifficulty, MAX_GEAR_LEVEL } = require('../game/calculations');
const { CURRENCIES, InsufficientGoldError, adjustGold, spendCoins, giveGear } = require('../utils/grants');
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
const { lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
const { SHEET_INCLUDE } = require('../utils/sheet');
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
const { subclassName } = require('../game/data/subclasses');
const router = express.Router();
@@ -14,8 +17,10 @@ router.use(requireAuth);
async function requireEncounterAccess(req, res, next) {
try {
const encounter = await prisma.encounter.findUnique({ where: { id: req.params.id } });
if (!encounter) return res.status(404).json({ error: 'Encounter not found' });
const found = await prisma.encounter.findUnique({ where: { id: req.params.id }, include: { campaign: true } });
if (!found) return res.status(404).json({ error: 'Encounter not found' });
// The campaign rides along for its settings (req.campaign), not on the encounter.
const { campaign, ...encounter } = found;
const membership = await loadCampaignMembership(req.user.id, encounter.campaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
@@ -32,6 +37,7 @@ async function requireEncounterAccess(req, res, next) {
}
req.encounter = encounter;
req.campaign = campaign;
req.membership = membership;
req.isDM = isDM;
return next();
@@ -66,18 +72,101 @@ function initiativeBonus({ character, monster }) {
return 0;
}
// Out of the fight: a dead character, or a monster or NPC brought to 0 HP.
// The fallen are hidden from the players and skipped in the turn order; the
// DM still sees them. It is worked out from HP each time, never stored, so
// healing a monster or reviving a character brings them straight back. Takes
// a participant with its character loaded.
function isFallen(p) {
if (p.character) return lifeState(p.character) === 'DEAD';
return p.maxHp > 0 && p.currentHp <= 0;
}
// The first combatant still standing at or after `from` in the order, going
// round to the top when the end is reached. If nobody is standing, whoever is
// at `from`.
function standingFrom(order, from) {
for (let step = 0; step < order.length; step += 1) {
const p = order[(from + step) % order.length];
if (!isFallen(p)) return p;
}
return order[from % order.length] ?? null;
}
// An encounter's combatants in turn order, with what isFallen needs.
async function loadTurnOrder(encounterId) {
const participants = await prisma.encounterParticipant.findMany({
where: { encounterId },
include: { character: { select: { currentHp: true, deathSaveFailures: true, stable: true } } },
});
return inTurnOrder(participants);
}
// The spell a character would raise the dead with: one flagged as reviving
// in the campaign's spell list, at a level they can cast. Takes a character
// with its spells loaded.
function revivalSpell(character) {
return character.spells.map((cs) => cs.spell).find((spell) => spell.revives && canUseGear(character.level, spell.level));
}
// The rounds left on a combatant's timed conditions, squared up with the
// conditions they are actually under: { condition: rounds }.
function roundsFor(conditionRounds, conditions) {
const rounds = conditionRounds && typeof conditionRounds === 'object' ? conditionRounds : {};
return Object.fromEntries(conditions.filter((condition) => Number.isInteger(rounds[condition]) && rounds[condition] > 0).map((condition) => [condition, rounds[condition]]));
}
// A new round: every timed condition has one round less to run, and those
// that run out are removed (from the character too, when it is a character's).
// Returns what wore off, as [{ name, condition }], for the DM to read out.
async function tickConditions(req) {
const participants = await prisma.encounterParticipant.findMany({
where: { encounterId: req.encounter.id },
include: { character: { select: { id: true, conditions: true } } },
});
const expired = [];
for (const p of participants) {
const conditions = p.character ? p.character.conditions : p.conditions;
const before = roundsFor(p.conditionRounds, conditions);
if (Object.keys(before).length === 0) continue;
const after = {};
for (const [condition, rounds] of Object.entries(before)) {
if (rounds > 1) after[condition] = rounds - 1;
else expired.push({ name: p.displayName, condition });
}
const left = conditions.filter((condition) => before[condition] === undefined || after[condition] !== undefined);
await prisma.encounterParticipant.update({ where: { id: p.id }, data: { conditionRounds: after, conditions: left } });
if (p.character && left.length !== conditions.length) {
await prisma.character.update({ where: { id: p.character.id }, data: { conditions: left } });
emitCharacterUpdated(req, p.character.id);
}
}
return expired;
}
const initiativeRollSchema = z.number().int().min(1, 'An initiative roll is a d20: 1 to 20').max(20, 'An initiative roll is a d20: 1 to 20');
// What every campaign member sees about a character in an encounter: the
// sheet's headline numbers plus the weapons and spells it can actually use —
// so the party can plan around each other mid-fight, not only the DM. Uses the
// sheet's headline numbers plus what it has in hand — the weapons and items
// it has equipped — and the spells it can cast, so the party can plan around
// each other mid-fight, not only the DM. A weapon has to be equipped to be
// used; what is still in the pack is in the character panel, for the
// character's own player and the DM. Uses the
// same calculation-engine functions as the sheet, never re-deriving formulas.
function characterCombatInfo(character) {
function characterCombatInfo(character, subclasses = true) {
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
// Gear and spells above the character's level are held but can't be used,
// so they stay out of the fight.
// so they stay out of the fight; so does anything not equipped.
const inHand = (held) => held.equipped && canUseGear(character.level, held.level);
const items = character.inventoryItems.filter(inHand).map((ii) => ({
inventoryItemId: ii.id,
name: ii.item.name,
level: ii.level,
quantity: ii.quantity,
damageDice: ii.item.damageDice ? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel) : null,
}));
const weapons = character.weapons
.filter((cw) => canUseGear(character.level, cw.level))
.filter(inHand)
.map((cw) => ({
characterWeaponId: cw.id,
name: cw.weapon.name,
@@ -100,26 +189,55 @@ function characterCombatInfo(character) {
level: character.level,
species: character.species,
characterClass: character.characterClass,
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
armorClass: sheet.armorClass,
maxHp: sheet.hp.max,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
canRevive: Boolean(revivalSpell(character)),
speed: sheet.speed,
initiativeBonus: sheet.initiative,
passivePerception: sheet.passivePerception,
passiveInvestigation: sheet.passiveInvestigation,
passiveInsight: sheet.passiveInsight,
proficiencyBonus: sheet.proficiencyBonus,
abilities: sheet.abilities,
saves: sheet.saves,
skills: sheet.skills,
spellSlots: sheet.spellSlots,
resources: sheet.resources,
inspiration: character.inspiration,
exhaustion: character.exhaustion,
specialAbilities: character.specialAbilities,
// What equipped gear is adding and taking away. The numbers above already include it.
gear: sheet.gear,
weapons,
items,
spells,
spellcasting: sheet.spellcasting,
};
}
// How hard the scene is for the characters in it, and what its monsters are
// worth: every monster counts, standing or not. Takes the encounter's
// participants with their characters and monsters loaded.
function difficultyOf(participants) {
return encounterDifficulty(
participants.filter((p) => p.character).map((p) => p.character.level),
participants.filter((p) => p.monster).map((p) => p.monster.challengeRating)
);
}
// What is for sale, oldest first, with the weapon or item itself.
const WARES = { include: { weapon: true, item: true }, orderBy: { createdAt: 'asc' } };
// The DM gets everything. Players get the scene as their characters see it:
// no DM notes, no monster stat blocks, no NPC secrets or stats, and only the
// rewards that have actually been handed out.
// rewards that have actually been handed out. What an NPC sells and the rate
// they change money at are for everyone: `npc.wares`, `npc.exchangeGold` and
// `npc.exchangeCash`, and the scene's own `wares`. The fallen (isFallen) are left
// out of a player's list altogether; of those, only the dead characters are
// named, in `fallen`, so an ally can try to revive them.
//
// `viewerId` is the user the payload is for. A player is told where a scene
// is once one of their characters has been there, and — when the campaign
@@ -129,43 +247,50 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
const encounter = await prisma.encounter.findUnique({
where: { id: encounterId },
include: {
campaign: { select: { showMonsterHp: true } },
campaign: { select: { showMonsterHp: true, subclassesEnabled: true } },
location: { select: { id: true, name: true, description: true } },
participants: {
include: {
character: {
include: {
skillProficiencies: true,
saveProficiencies: true,
weapons: { include: { weapon: true } },
...SHEET_INCLUDE,
spells: { include: { spell: true } },
},
},
monster: true,
npc: true,
npc: { include: { wares: WARES } },
},
},
rewards: { include: { weapon: true, item: true }, orderBy: { createdAt: 'asc' } },
wares: WARES,
},
});
if (!encounter) return encounter;
const hideHp = !isDM && !encounter.campaign.showMonsterHp;
const participants = inTurnOrder(encounter.participants).map((p) => {
const inOrder = inTurnOrder(encounter.participants);
const hiddenIds = new Set(inOrder.filter(isFallen).map((p) => p.id));
const fallen = inOrder
.filter((p) => p.character && hiddenIds.has(p.id))
.map((p) => ({ participantId: p.id, characterId: p.character.id, name: p.displayName }));
const participants = inOrder.filter((p) => isDM || !hiddenIds.has(p.id)).map((p) => {
const { character, monster, npc, ...row } = p;
// The bonus is part of a monster's stat block, so only the DM gets it for
// those; a character's is on their sheet for all to see.
const bonus = isDM || character ? { initiativeBonus: initiativeBonus(p) } : {};
const out = { ...row, ...bonus, kind: npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER' };
const out = { ...row, ...bonus, kind: npc || p.npcId ? 'NPC' : p.isMonster ? 'MONSTER' : 'CHARACTER', hidden: hiddenIds.has(p.id) };
if (character) {
// A character combatant's HP is the character's own HP, not a
// per-encounter copy, so the tracker and the sheet can never disagree.
const { maxHp, ...combatInfo } = characterCombatInfo(character);
const { maxHp, ...combatInfo } = characterCombatInfo(character, encounter.campaign.subclassesEnabled);
out.currentHp = character.currentHp;
out.maxHp = maxHp;
// Likewise their conditions: the character's own, which outlast the scene.
out.conditions = character.conditions;
out.character = { id: character.id, name: character.name, userId: character.userId };
out.characterCombatInfo = combatInfo;
}
out.conditionRounds = roundsFor(p.conditionRounds, out.conditions);
if (monster) out.monster = isDM ? monster : { id: monster.id, name: monster.name };
if (npc) {
const { dmNotes, ac, maxHp, attackBonus, damageDice, campaignId, createdAt, ...publicNpc } = npc;
@@ -186,8 +311,11 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
...scene,
locationId: knowsPlace ? scene.locationId : null,
location: knowsPlace ? location : null,
...(isDM ? { dmNotes } : {}),
...(isDM ? { dmNotes, difficulty: difficultyOf(encounter.participants) } : {}),
// A player is not told that someone they cannot see is being targeted.
targetParticipantIds: isDM ? scene.targetParticipantIds : scene.targetParticipantIds.filter((id) => !hiddenIds.has(id)),
participants,
fallen,
rewards: isDM ? rewards : rewards.filter((r) => r.awardedAt),
};
}
@@ -255,11 +383,10 @@ router.patch('/:id', requireEncounterAccess, requireDM, async (req, res, next) =
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
}
// Starting a battle puts the first combatant in the initiative order on turn.
// Starting a battle puts the first combatant still standing on turn.
const mode = data.mode ?? req.encounter.mode;
if (data.status === 'ACTIVE' && mode === 'BATTLE' && !req.encounter.activeParticipantId) {
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
data.activeParticipantId = inTurnOrder(participants)[0]?.id ?? null;
data.activeParticipantId = standingFrom(await loadTurnOrder(req.encounter.id), 0)?.id ?? null;
}
// Targets belong to the moment; beginning, ending or reopening clears them.
@@ -295,16 +422,19 @@ const participantSchema = z
npcId: z.string().optional(),
displayName: z.string().min(1).max(80).optional(),
initiativeRoll: initiativeRollSchema.optional(),
// Several of one monster at once: "Goblin 1" to "Goblin 4".
count: z.number().int().min(1).max(20).default(1),
})
.refine((data) => [data.characterId, data.monsterId, data.npcId].filter(Boolean).length === 1, {
message: 'Provide exactly one of characterId, monsterId or npcId',
});
})
.refine((data) => data.count === 1 || Boolean(data.monsterId), { message: 'Only monsters can be added several at a time' });
router.post('/:id/participants', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const parsed = participantSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { characterId, monsterId, npcId, displayName, initiativeRoll } = parsed.data;
const { characterId, monsterId, npcId, displayName, initiativeRoll, count } = parsed.data;
let currentHp, maxHp, resolvedName;
let bonus = 0;
@@ -312,11 +442,15 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
if (characterId) {
const character = await prisma.character.findUnique({
where: { id: characterId },
include: { skillProficiencies: true, saveProficiencies: true },
include: SHEET_INCLUDE,
});
if (!character || character.campaignId !== req.encounter.campaignId) {
return res.status(400).json({ error: 'That character is not part of this campaign.' });
}
// The dead stay where they fell: they join no new scene until revived.
if (lifeState(character) === 'DEAD') {
return res.status(400).json({ error: `${character.name} is dead and cannot join an encounter. Revive them first.` });
}
currentHp = character.currentHp;
maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
resolvedName = displayName || character.name;
@@ -341,22 +475,40 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
resolvedName = displayName || npc.name;
}
const participant = await prisma.encounterParticipant.create({
data: {
encounterId: req.encounter.id,
characterId: characterId || null,
monsterId: monsterId || null,
npcId: npcId || null,
displayName: resolvedName,
initiativeRoll: initiativeRoll ?? null,
initiative: initiativeRoll == null ? 0 : initiativeRoll + bonus,
currentHp,
maxHp,
isMonster: Boolean(monsterId),
},
});
// Several at once are numbered, carrying on from any of that name already
// in the scene ("Goblin 3" after "Goblin 1" and "Goblin 2").
let names = [resolvedName];
if (count > 1) {
const present = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id }, select: { displayName: true } });
const numbered = present
.filter((p) => p.displayName.startsWith(`${resolvedName} `))
.map((p) => Number(p.displayName.slice(resolvedName.length + 1)))
.filter((n) => Number.isInteger(n) && n > 0);
const from = Math.max(0, ...numbered);
names = Array.from({ length: count }, (_, i) => `${resolvedName} ${from + i + 1}`.slice(-80));
}
await respondWithEncounter(req, res, 201, { participant });
const participants = [];
for (const name of names) {
participants.push(
await prisma.encounterParticipant.create({
data: {
encounterId: req.encounter.id,
characterId: characterId || null,
monsterId: monsterId || null,
npcId: npcId || null,
displayName: name,
initiativeRoll: initiativeRoll ?? null,
initiative: initiativeRoll == null ? 0 : initiativeRoll + bonus,
currentHp,
maxHp,
isMonster: Boolean(monsterId),
},
})
);
}
await respondWithEncounter(req, res, 201, { participant: participants[0], participants });
} catch (err) {
next(err);
}
@@ -367,6 +519,9 @@ const updateParticipantSchema = z.object({
maxHp: z.number().int().min(1).max(100000).optional(),
initiativeRoll: initiativeRollSchema.nullable().optional(), // null clears the roll
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
// Rounds left on the conditions that wear off by themselves; one left out
// lasts until the DM removes it.
conditionRounds: z.record(z.string().max(40), z.number().int().min(1).max(100)).optional(),
displayName: z.string().min(1).max(80).optional(),
});
@@ -379,7 +534,7 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, async (
const existing = await prisma.encounterParticipant.findUnique({
where: { id: req.params.participantId },
include: { character: { include: { skillProficiencies: true, saveProficiencies: true } }, monster: true },
include: { character: { include: SHEET_INCLUDE }, monster: true },
});
if (!existing || existing.encounterId !== req.encounter.id) {
return res.status(404).json({ error: 'Combatant not found in this encounter' });
@@ -397,11 +552,22 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, async (
if (data.initiativeRoll !== undefined) {
data.initiative = data.initiativeRoll == null ? 0 : data.initiativeRoll + initiativeBonus(existing);
}
if (data.conditions) data.conditions = [...new Set(data.conditions)];
if (data.conditions !== undefined || data.conditionRounds !== undefined) {
// A condition that goes takes its countdown with it.
const conditions = data.conditions ?? (existing.character ? existing.character.conditions : existing.conditions);
data.conditionRounds = roundsFor(data.conditionRounds ?? existing.conditionRounds, conditions);
}
if (existing.character) {
// Max HP comes from the sheet, and damage/healing lands on the character.
const { character } = existing;
const maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
data.maxHp = maxHp;
if (data.conditions !== undefined) {
// So do conditions: they are the character's, and stay after the scene.
await prisma.character.update({ where: { id: character.id }, data: { conditions: data.conditions } });
emitCharacterUpdated(req, character.id);
}
if (data.currentHp !== undefined) {
if (lifeState(character) === 'DEAD' && data.currentHp > 0) {
return res.status(400).json({ error: `${character.name} is dead. Revive them before healing.` });
@@ -428,16 +594,16 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, async (
router.delete('/:id/participants/:participantId', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
const order = inTurnOrder(participants);
const order = await loadTurnOrder(req.encounter.id);
const index = order.findIndex((p) => p.id === req.params.participantId);
if (index === -1) return res.status(404).json({ error: 'Combatant not found in this encounter' });
// Removing whoever is on turn passes the turn to the next combatant, and
// their targets go with the turn. Removing a target just unmarks it.
// Removing whoever is on turn passes the turn to the next combatant still
// standing, and their targets go with the turn. Removing a target just
// unmarks it.
const { activeParticipantId, targetParticipantIds } = req.encounter;
if (activeParticipantId === req.params.participantId) {
const next = order.length > 1 ? order[(index + 1) % order.length] : null;
const next = standingFrom([...order.slice(index + 1), ...order.slice(0, index)], 0);
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { activeParticipantId: next ? next.id : null, targetParticipantIds: [] } });
} else if (targetParticipantIds.includes(req.params.participantId)) {
const left = targetParticipantIds.filter((id) => id !== req.params.participantId);
@@ -450,6 +616,68 @@ router.delete('/:id/participants/:participantId', requireEncounterAccess, requir
}
});
// A dead character can be brought back in the middle of a battle by an ally:
// the character on turn, if they are on their feet and can cast a spell that
// raises the dead (revivalSpell). The dead come back at 1 HP, and with that
// are back in everyone's list. A player tries it for their own character; the
// DM for whoever is on turn. (The DM can also revive anyone outright, through
// the character's death saves.)
router.post('/:id/participants/:participantId/revive', requireEncounterAccess, async (req, res, next) => {
try {
const { mode, status, activeParticipantId } = req.encounter;
const onTurnId = mode === 'BATTLE' && status === 'ACTIVE' ? activeParticipantId : null;
const onTurn = onTurnId
? await prisma.encounterParticipant.findFirst({
where: { id: onTurnId, encounterId: req.encounter.id },
include: { character: { include: { spells: { include: { spell: true } } } } },
})
: null;
const reviver = onTurn?.character;
if (!req.isDM && (!reviver || reviver.userId !== req.user.id)) {
return res.status(403).json({ error: "You can only try to revive someone on your own character's turn" });
}
if (!reviver) return res.status(400).json({ error: 'Only a character on turn in a battle can try to revive someone.' });
const target = await prisma.encounterParticipant.findUnique({ where: { id: req.params.participantId }, include: { character: true } });
if (!target || target.encounterId !== req.encounter.id) {
return res.status(404).json({ error: 'Combatant not found in this encounter' });
}
if (!target.character || lifeState(target.character) !== 'DEAD') {
return res.status(400).json({ error: `${target.displayName} is not dead.` });
}
if (lifeState(reviver) !== 'UP') return res.status(400).json({ error: `${reviver.name} is in no state to revive anyone.` });
const spell = revivalSpell(reviver);
if (!spell) return res.status(400).json({ error: `${reviver.name} has no spell that can bring back the dead.` });
await prisma.character.update({ where: { id: target.character.id }, data: { currentHp: 1, ...NO_DEATH_SAVES } });
await prisma.encounterParticipant.update({ where: { id: target.id }, data: { currentHp: 1 } });
emitCharacterUpdated(req, target.character.id);
await respondWithEncounter(req, res, 200, { revivedWith: spell.name });
} catch (err) {
next(err);
}
});
// Rolls initiative for every monster and NPC that has no roll yet: a d20 each,
// with their bonus added as when a roll is typed in. Characters are left to
// their players.
router.post('/:id/roll-initiative', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const waiting = await prisma.encounterParticipant.findMany({
where: { encounterId: req.encounter.id, characterId: null, initiativeRoll: null },
include: { monster: true },
});
for (const p of waiting) {
const roll = crypto.randomInt(1, 21);
await prisma.encounterParticipant.update({ where: { id: p.id }, data: { initiativeRoll: roll, initiative: roll + initiativeBonus(p) } });
}
await respondWithEncounter(req, res, 200, { rolled: waiting.length });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Targets — who the combatant on turn is going for
// ---------------------------------------------------------------------------
@@ -460,27 +688,36 @@ const targetsSchema = z.object({ participantIds: z.array(z.string()).max(50) });
// Combatants can be marked as targets, for everyone to see; the DM's screen
// then offers to apply damage to them. One is an ordinary attack, more is an
// area attack. The DM can mark anyone at any time. A player can while their
// own character is on turn in a battle.
// own character is on turn in a battle. Nobody can mark the combatant who is
// on turn: you don't target yourself.
router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
try {
const parsed = targetsSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const participantIds = [...new Set(parsed.data.participantIds)];
// Whoever is on turn, when a battle is under way.
const { mode, status, activeParticipantId } = req.encounter;
const onTurnId = mode === 'BATTLE' && status === 'ACTIVE' ? activeParticipantId : null;
if (!req.isDM) {
const { mode, status, activeParticipantId } = req.encounter;
const onTurn =
mode === 'BATTLE' && status === 'ACTIVE' && activeParticipantId
? await prisma.encounterParticipant.findFirst({
where: { id: activeParticipantId, encounterId: req.encounter.id, character: { userId: req.user.id } },
select: { id: true },
})
: null;
if (!onTurn) return res.status(403).json({ error: "You can only pick targets on your own character's turn" });
const ownTurn = onTurnId
? await prisma.encounterParticipant.findFirst({
where: { id: onTurnId, encounterId: req.encounter.id, character: { userId: req.user.id } },
select: { id: true },
})
: null;
if (!ownTurn) return res.status(403).json({ error: "You can only pick targets on your own character's turn" });
}
const found = await prisma.encounterParticipant.count({ where: { id: { in: participantIds }, encounterId: req.encounter.id } });
if (found !== participantIds.length) return res.status(400).json({ error: 'Combatant not found in this encounter' });
if (onTurnId && participantIds.includes(onTurnId)) {
return res.status(400).json({ error: 'The combatant on turn cannot be their own target' });
}
// A player can only mark who they can see: to them the fallen are not there.
const order = await loadTurnOrder(req.encounter.id);
const markable = new Set(order.filter((p) => req.isDM || !isFallen(p)).map((p) => p.id));
if (!participantIds.every((id) => markable.has(id))) return res.status(400).json({ error: 'Combatant not found in this encounter' });
await prisma.encounter.update({ where: { id: req.encounter.id }, data: { targetParticipantIds: participantIds } });
await respondWithEncounter(req, res);
@@ -493,26 +730,29 @@ router.put('/:id/targets', requireEncounterAccess, async (req, res, next) => {
// Turns and rounds
// ---------------------------------------------------------------------------
// Passes the turn down the initiative order; going past the last combatant
// starts the next round.
// Passes the turn down the initiative order to the next combatant still
// standing (the fallen have no turn); going past the last combatant starts
// the next round.
router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
const order = inTurnOrder(participants);
const order = await loadTurnOrder(req.encounter.id);
if (order.length === 0) return res.status(400).json({ error: 'Add combatants before taking turns.' });
const index = order.findIndex((p) => p.id === req.encounter.activeParticipantId);
const wrapped = index === order.length - 1;
const upNext = standingFrom(order, index + 1);
const wrapped = index !== -1 && order.indexOf(upNext) <= index;
await prisma.encounter.update({
where: { id: req.encounter.id },
data: {
status: 'ACTIVE',
activeParticipantId: order[wrapped || index === -1 ? 0 : index + 1].id,
activeParticipantId: upNext.id,
targetParticipantIds: [], // the next combatant picks their own
...(wrapped ? { currentRound: { increment: 1 } } : {}),
},
});
await respondWithEncounter(req, res);
// What wore off as the new round began, for the DM to read out.
const expired = wrapped ? await tickConditions(req) : [];
await respondWithEncounter(req, res, 200, { expired });
} catch (err) {
next(err);
}
@@ -520,7 +760,7 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const participants = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id } });
const order = await loadTurnOrder(req.encounter.id);
const updated = await prisma.encounter.update({
where: { id: req.encounter.id },
data: {
@@ -528,15 +768,57 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
status: 'ACTIVE',
targetParticipantIds: [],
// A new round starts back at the top of the initiative order.
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: inTurnOrder(participants)[0]?.id ?? null } : {}),
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: standingFrom(order, 0)?.id ?? null } : {}),
},
});
const expired = await tickConditions(req);
await syncCharacterLocations(req);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
encounterId: updated.id,
currentRound: updated.currentRound,
});
res.json({ encounter: await serializeEncounter(updated.id, req.isDM, req.user.id) });
res.json({ expired, encounter: await serializeEncounter(updated.id, req.isDM, req.user.id) });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Experience — awarded once, shared evenly between the characters in the scene
// ---------------------------------------------------------------------------
const awardXpSchema = z.object({ xp: z.number().int().min(1).max(10000000).optional() });
// The DM hands out the encounter's XP: what its monsters are worth unless
// another amount is given, in equal shares (rounded down) to every character
// in the scene. Nobody levels up by it; the sheet says when a level is due.
router.post('/:id/award-xp', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const parsed = awardXpSchema.safeParse(req.body || {});
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (!req.campaign.trackXp) {
return res.status(400).json({ error: 'This campaign does not keep experience points.' });
}
if (req.encounter.xpAwarded != null) {
return res.status(400).json({ error: `XP has already been awarded for this encounter (${req.encounter.xpAwarded}). Change a character's XP on their sheet.` });
}
const participants = await prisma.encounterParticipant.findMany({
where: { encounterId: req.encounter.id },
include: { character: { select: { id: true, name: true, level: true } }, monster: { select: { challengeRating: true } } },
});
const characters = participants.filter((p) => p.character).map((p) => p.character);
if (characters.length === 0) return res.status(400).json({ error: 'There are no characters in this encounter to award XP to.' });
const xp = parsed.data.xp ?? difficultyOf(participants).xp;
if (!(xp > 0)) return res.status(400).json({ error: 'There is no XP to award: no monster here has a challenge rating. Type an amount.' });
const share = Math.floor(xp / characters.length);
await prisma.$transaction([
prisma.character.updateMany({ where: { id: { in: characters.map((c) => c.id) } }, data: { xp: { increment: share } } }),
prisma.encounter.update({ where: { id: req.encounter.id }, data: { xpAwarded: xp } }),
]);
for (const character of characters) emitCharacterUpdated(req, character.id);
await respondWithEncounter(req, res, 200, { xp, share, awardedTo: characters.map((c) => c.name) });
} catch (err) {
next(err);
}
@@ -673,4 +955,146 @@ router.post('/:id/rewards/:rewardId/award', requireEncounterAccess, requireDM, a
}
});
// ---------------------------------------------------------------------------
// Trade — changing gold and cash with an NPC, and buying what is for sale.
// The DM sets the rates and the wares up (campaigns.routes.js); here the
// players, or the DM for them, use them.
// ---------------------------------------------------------------------------
class TradeError extends Error {
constructor(message, status = 400) {
super(message);
this.status = status;
}
}
function money(amount, currency) {
return currency === 'CASH' ? `$${amount}` : `${amount} ${currency.toLowerCase()}`;
}
// Trading takes someone to trade with and the time to do it, so it happens
// in a quest in progress and never in a battle.
function assertTradeOpen(encounter) {
if (encounter.mode !== 'QUEST') throw new TradeError('Trading happens in a quest, not in a battle.');
if (encounter.status !== 'ACTIVE') throw new TradeError('Trading opens once the quest is in progress.');
}
// The character doing the trading: in this scene, alive, and the player's
// own (the DM can trade for anyone).
async function loadTrader(req, characterId) {
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, characterId }, include: { character: true } });
if (!row) throw new TradeError('That character is not in this encounter.');
if (!req.isDM && row.character.userId !== req.user.id) throw new TradeError('You can only trade for your own character.', 403);
if (isFallen(row)) throw new TradeError(`${row.character.name} is dead.`);
return row.character;
}
// An NPC to trade with has to be in the scene and still standing.
async function loadTradingNpc(req, npcId) {
const row = await prisma.encounterParticipant.findFirst({ where: { encounterId: req.encounter.id, npcId }, include: { npc: true } });
if (!row || isFallen(row)) throw new TradeError('That NPC is not here to trade with.');
return row.npc;
}
function tradeRoute(handler) {
return async (req, res, next) => {
try {
assertTradeOpen(req.encounter);
await handler(req, res);
} catch (err) {
if (err instanceof TradeError) return res.status(err.status).json({ error: err.message });
next(err);
}
};
}
// Changes gold into cash, or cash into gold, at the NPC's own rate. Only
// gold counts: silver and copper are not taken, and none come back. `amount`
// is what is handed over, and has to come to a whole number the other way.
router.post(
'/:id/exchange',
requireEncounterAccess,
tradeRoute(async (req, res) => {
const parsed = z
.object({ characterId: z.string(), npcId: z.string(), from: z.enum(['GOLD', 'CASH']), amount: z.number().int().min(1).max(1000000) })
.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { characterId, npcId, from, amount } = parsed.data;
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
const character = await loadTrader(req, characterId);
const npc = await loadTradingNpc(req, npcId);
if (!npc.exchangeGold || !npc.exchangeCash) throw new TradeError(`${npc.name} does not change money.`);
const rate = { GOLD: npc.exchangeGold, CASH: npc.exchangeCash };
if (amount % rate[from] !== 0) {
throw new TradeError(
`${npc.name} gives ${money(rate.CASH, 'CASH')} for ${money(rate.GOLD, 'GOLD')}: hand over ${from.toLowerCase()} in multiples of ${rate[from]}.`
);
}
const received = (amount / rate[from]) * rate[to];
try {
await prisma.$transaction(async (tx) => {
const base = { characterId: character.id, userId: req.user.id };
await adjustGold(tx, { ...base, currency: from, amount: -amount, reason: `Changed with ${npc.name} for ${money(received, to)}` });
await adjustGold(tx, { ...base, currency: to, amount: received, reason: `Changed with ${npc.name} from ${money(amount, from)}` });
});
} catch (err) {
if (!(err instanceof InsufficientGoldError)) throw err;
throw new TradeError(`${character.name} only has ${money(from === 'GOLD' ? character.gold : character.cash, from)}.`);
}
emitCharacterUpdated(req, character.id);
await respondWithEncounter(req, res, 201, { received, currency: to });
})
);
// Buys something that is for sale here: from an NPC in the scene, or from the
// scene itself. The price is paid from the purse (coins make change; a cash
// price takes cash), and the weapon or item joins what the character holds.
router.post(
'/:id/wares/:wareId/buy',
requireEncounterAccess,
tradeRoute(async (req, res) => {
const parsed = z.object({ characterId: z.string(), quantity: z.number().int().min(1).max(999).default(1) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { characterId, quantity } = parsed.data;
const character = await loadTrader(req, characterId);
const ware = await prisma.saleOffer.findUnique({ where: { id: req.params.wareId }, include: { weapon: true, item: true } });
if (!ware || ware.campaignId !== req.encounter.campaignId || (ware.encounterId && ware.encounterId !== req.encounter.id)) {
throw new TradeError('That is not for sale here.', 404);
}
const seller = ware.npcId ? (await loadTradingNpc(req, ware.npcId)).name : null;
const kind = ware.weapon ? 'weapon' : 'item';
const entry = ware[kind];
const total = ware.price * quantity;
try {
await prisma.$transaction(async (tx) => {
if (ware.stock !== null) {
// The atomic decrement is the stock check: two buyers can't both take the last one.
const taken = await tx.saleOffer.updateMany({ where: { id: ware.id, stock: { gte: quantity } }, data: { stock: { decrement: quantity } } });
if (taken.count === 0) throw new TradeError(ware.stock === 0 ? `${entry.name} is sold out.` : `Only ${ware.stock} of ${entry.name} left.`);
}
const what = `${quantity > 1 ? `${quantity} × ` : ''}${entry.name}`;
await spendCoins(tx, {
characterId: character.id,
amount: total,
currency: ware.currency,
reason: seller ? `Bought ${what} from ${seller}` : `Bought ${what}`,
userId: req.user.id,
});
await giveGear(tx, kind, { characterId: character.id, entry, quantity, level: ware.level });
});
} catch (err) {
if (!(err instanceof InsufficientGoldError)) throw err;
throw new TradeError(`${character.name} can't afford that: it costs ${money(total, ware.currency)}.`);
}
emitCharacterUpdated(req, character.id);
await respondWithEncounter(req, res, 201);
})
);
module.exports = router;
+121
View File
@@ -0,0 +1,121 @@
// Creates one campaign, fully built, from a checked seed (seeds/index.js,
// buildSeed), with `owner` as its DM. `db` is a transaction client, so a
// failure leaves nothing behind.
const { generateInviteCode } = require('../utils/inviteCode');
// Notes list newest-first on screen, so they are stamped a second apart,
// backwards: the first entry in the seed is the one at the top.
function bookOrder(index) {
return new Date(Date.now() - index * 1000);
}
function byName(rows) {
return new Map(rows.map((row) => [row.name, row]));
}
const MONEY = { gold: 'GOLD', silver: 'SILVER', copper: 'COPPER', cash: 'CASH' };
async function createCampaignFromSeed(db, owner, seed) {
let inviteCode = generateInviteCode();
while (await db.campaign.findUnique({ where: { inviteCode } })) inviteCode = generateInviteCode();
const campaign = await db.campaign.create({
data: { ...seed.campaign, inviteCode, ownerId: owner.id, members: { create: { userId: owner.id, role: 'DM' } } },
});
const campaignId = campaign.id;
const inCampaign = (rows) => rows.map((row) => ({ ...row, campaignId }));
await db.location.createMany({ data: inCampaign(seed.locations) });
await db.weapon.createMany({ data: inCampaign(seed.weapons) });
await db.item.createMany({ data: inCampaign(seed.items) });
await db.spell.createMany({ data: inCampaign(seed.spells) });
await db.monster.createMany({ data: inCampaign(seed.monsters) });
await db.npc.createMany({ data: inCampaign(seed.npcs.map(({ wares, ...npc }) => npc)) });
const where = { where: { campaignId } };
const monsters = byName(await db.monster.findMany(where));
const npcs = byName(await db.npc.findMany(where));
const weapons = byName(await db.weapon.findMany(where));
const items = byName(await db.item.findMany(where));
const locations = byName(await db.location.findMany(where));
// A weapon or item named in a reward or a ware, with the level it is handed
// over at: its own starting level unless the seed asks for higher.
const gear = ({ weapon, item, level }) => {
const entry = weapon ? weapons.get(weapon) : items.get(item);
return { [weapon ? 'weaponId' : 'itemId']: entry.id, level: level ?? entry.minLevel };
};
const wares = (list, seller) =>
(list || []).map(({ price, currency, stock, ...ware }, i) => ({
...seller,
...gear(ware),
campaignId,
price,
currency: currency || 'GOLD',
stock: stock ?? null,
createdAt: new Date(Date.now() + i), // wares list in the order written
}));
for (const npc of seed.npcs) {
if (npc.wares) await db.saleOffer.createMany({ data: wares(npc.wares, { npcId: npcs.get(npc.name).id }) });
}
for (const [index, enc] of seed.encounters.entries()) {
const participants = [];
for (const [name, count, displayName, hp] of enc.monsters || []) {
const monster = monsters.get(name);
const label = displayName || monster.name;
const maxHp = hp ?? monster.maxHp;
for (let i = 1; i <= count; i++) {
participants.push({
monsterId: monster.id,
displayName: count > 1 ? `${label} #${i}` : label,
currentHp: maxHp,
maxHp,
isMonster: true,
});
}
}
for (const entry of enc.npcs || []) {
const { name, hp } = typeof entry === 'string' ? { name: entry } : entry;
const npc = npcs.get(name);
// An NPC with no combat stats takes part with no hit points to track.
const maxHp = hp ?? npc.maxHp ?? 0;
participants.push({ npcId: npc.id, displayName: npc.name, currentHp: maxHp, maxHp, isMonster: false });
}
// Rewards list oldest-first, so these are stamped forwards.
const rewards = (enc.rewards || []).map(({ quantity, note, ...reward }, i) => {
const createdAt = new Date(Date.now() + i);
const coin = Object.keys(MONEY).find((key) => reward[key]);
if (coin) return { type: 'GOLD', gold: reward[coin], currency: MONEY[coin], note, createdAt };
return { type: reward.weapon ? 'WEAPON' : 'ITEM', ...gear(reward), quantity: quantity ?? 1, note, createdAt };
});
const encounter = await db.encounter.create({
data: {
campaignId,
name: enc.name,
mode: enc.mode,
description: enc.description,
dmNotes: enc.dmNotes,
locationId: enc.location ? locations.get(enc.location).id : null,
sortOrder: index,
participants: { create: participants },
rewards: { create: rewards },
},
});
if (enc.wares) await db.saleOffer.createMany({ data: wares(enc.wares, { encounterId: encounter.id }) });
}
await db.note.createMany({
data: [
...seed.sharedNotes.map((body) => ({ body, visibility: 'SHARED' })),
...seed.dmNotes.map((body) => ({ body, visibility: 'DM_ONLY' })),
].map((note, i) => ({ ...note, campaignId, authorId: owner.id, createdAt: bookOrder(i) })),
});
return campaign;
}
module.exports = { createCampaignFromSeed };
+328
View File
@@ -0,0 +1,328 @@
// World seeds: ready-made campaigns a DM starts from by typing a code.
//
// Every .js file in /backend/seeds is one world (docs/SEEDS.md has the full
// format). It exports its `code` and the campaign's content; this module
// finds the files, checks them, and turns one into rows for
// createCampaign.js. The folder is read again on every lookup, so a file
// dropped in is live without a restart.
const fs = require('fs');
const path = require('path');
const { z } = require('zod');
const { GEAR_STATS, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
const { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
const { CURRENCIES } = require('../utils/grants');
const SEEDS_DIR = path.join(__dirname, '..', '..', 'seeds');
const CODE_LENGTH = 6;
const CODE_PATTERN = /^[A-Z0-9]{6}$/;
// What someone types, as a seed file would have it.
function normalizeCode(code) {
return String(code || '').replace(/[\s-]/g, '').toUpperCase();
}
// ---------------------------------------------------------------------------
// The shape of a seed file. Objects are strict, so a misspelt field is an
// error rather than something silently left out of the campaign. The limits
// are the ones the app itself enforces (routes/*.js), so whatever a seed
// creates can still be edited on screen.
// ---------------------------------------------------------------------------
const text = (max) => z.string().trim().min(1).max(max);
const optionalText = (max) => z.string().max(max).nullish();
const dice = z.string().min(1).max(20).nullish();
// A creature's damage may name its type: "1d6+2 necrotic".
const damage = z.string().min(1).max(60).nullish();
const score = z.number().int().min(1).max(30).optional();
const gearLevel = z.number().int().min(1).max(MAX_GEAR_LEVEL);
const gear = {
damageScaling: z.number().int().min(1).max(MAX_DAMAGE_SCALING).optional(),
minLevel: gearLevel.optional(),
isStartingOption: z.boolean().optional(),
effects: z.array(z.strictObject({ stat: z.enum(GEAR_STATS), amount: z.number().int().min(-100).max(100) })).max(12).optional(),
effectNote: optionalText(300),
};
const POOLS = {
weapons: {
library: WEAPON_LIBRARY,
row: z.strictObject({
name: text(80),
damageDice: z.string().min(1).max(20),
damageType: text(40),
attackAbility: z.enum(['STR', 'DEX', 'FINESSE']),
properties: z.array(z.string()).optional(),
rangeNormal: z.number().int().min(0).nullish(),
rangeLong: z.number().int().min(0).nullish(),
...gear,
}),
},
items: {
library: ITEM_LIBRARY,
row: z.strictObject({ name: text(120), description: optionalText(2000), damageDice: dice, ...gear }),
},
spells: {
library: SPELL_LIBRARY,
row: z.strictObject({
name: text(80),
level: z.number().int().min(0).max(9),
damageDice: dice,
school: optionalText(40),
castingTime: optionalText(80),
range: optionalText(80),
duration: optionalText(80),
description: optionalText(4000),
isStartingOption: z.boolean().optional(),
revives: z.boolean().optional(),
}),
},
monsters: {
library: MONSTER_LIBRARY,
row: z.strictObject({
name: text(80),
ac: z.number().int().min(0).max(40),
maxHp: z.number().int().min(1).max(10000),
speed: z.number().int().min(0).max(500).optional(),
strScore: score,
dexScore: score,
conScore: score,
intScore: score,
wisScore: score,
chaScore: score,
attackBonus: z.number().int().min(-10).max(30).optional(),
damageDice: damage,
challengeRating: optionalText(10),
specialTrait: optionalText(500),
notes: optionalText(2000),
}),
},
};
const moneyAmount = z.number().int().min(1).max(1000000);
// Something for sale: one weapon or one item from this seed's pools, by name.
const wareSchema = z
.strictObject({
weapon: z.string().optional(),
item: z.string().optional(),
price: moneyAmount,
currency: z.enum(CURRENCIES).optional(),
level: gearLevel.nullish(),
stock: z.number().int().min(0).max(9999).nullish(),
})
.refine((ware) => Boolean(ware.weapon) !== Boolean(ware.item), { message: 'A ware sells one weapon or one item' });
// Loot: money ({ gold: 25 }, or silver / copper / cash), a weapon or an item.
const rewardSchema = z
.strictObject({
gold: moneyAmount.nullish(),
silver: moneyAmount.nullish(),
copper: moneyAmount.nullish(),
cash: moneyAmount.nullish(),
weapon: z.string().nullish(),
item: z.string().nullish(),
quantity: z.number().int().min(1).max(999).nullish(),
level: gearLevel.nullish(),
note: optionalText(200),
})
.refine((reward) => ['gold', 'silver', 'copper', 'cash', 'weapon', 'item'].filter((key) => reward[key] != null).length === 1, {
message: 'A reward is one thing: gold, silver, copper, cash, a weapon or an item',
});
const npcSchema = z.strictObject({
name: text(80),
role: optionalText(80),
disposition: z.enum(['FRIENDLY', 'NEUTRAL', 'HOSTILE']).optional(),
description: optionalText(2000),
dmNotes: optionalText(4000),
ac: z.number().int().min(0).max(40).nullish(),
maxHp: z.number().int().min(1).max(10000).nullish(),
attackBonus: z.number().int().min(-10).max(30).nullish(),
damageDice: damage,
// A money changer: exchangeGold gold for exchangeCash cash, either way.
exchangeGold: moneyAmount.nullish(),
exchangeCash: moneyAmount.nullish(),
wares: z.array(wareSchema).optional(),
});
const encounterSchema = z.strictObject({
name: text(120),
mode: z.enum(['BATTLE', 'QUEST']).optional(),
description: optionalText(4000),
dmNotes: optionalText(4000),
location: z.string().nullish(),
// [monster name, how many, display name?, max HP?]
monsters: z
.array(z.tuple([z.string(), z.number().int().min(1).max(50)]).rest(z.union([z.string(), z.number().int().min(1), z.null(), z.undefined()])))
.optional(),
npcs: z.array(z.union([z.string(), z.strictObject({ name: z.string(), hp: z.number().int().min(0).optional() })])).optional(),
rewards: z.array(rewardSchema).optional(),
wares: z.array(wareSchema).optional(),
});
// A pool entry is a full row, or one copied from the site library: its key
// alone, or { library: key } with fields to change on the copy. The rows
// themselves are checked once the copies are made (resolvePool).
const poolSchema = z.array(z.union([z.string(), z.record(z.string(), z.any())])).default([]);
const seedSchema = z.strictObject({
code: z.string().regex(CODE_PATTERN, `The code is ${CODE_LENGTH} capital letters or digits`),
campaign: z.strictObject({
name: text(120),
description: optionalText(2000),
startingGold: z.number().int().min(0).max(1000000).optional(),
startingItemCount: z.number().int().min(0).max(50).optional(),
startingSpellCount: z.number().int().min(0).max(50).optional(),
skipMotivationRoll: z.boolean().optional(),
showMonsterHp: z.boolean().optional(),
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
maxEquipped: z.number().int().min(1).max(20).optional(),
maxEquippedWeapons: z.number().int().min(0).max(20).optional(),
subclassesEnabled: z.boolean().optional(),
multiclassEnabled: z.boolean().optional(),
diceMode: z.enum(['DIGITAL', 'PHYSICAL', 'BOTH']).optional(),
autoRollMonsters: z.boolean().optional(),
autoRollNpcs: z.boolean().optional(),
trackXp: z.boolean().optional(),
hpOnLevelUp: z.enum(['AVERAGE', 'ROLL']).optional(),
abilityScoreMethod: z.enum(['ROLL_4D6', 'STANDARD_ARRAY', 'POINT_BUY']).optional(),
rollVisibility: z.enum(['ALL', 'OWN']).optional(),
hideMonsterRolls: z.boolean().optional(),
critRule: z.enum(['DOUBLE_DICE', 'MAX_PLUS_ROLL']).optional(),
}),
locations: z.array(z.strictObject({ name: text(80), description: optionalText(2000) })).default([]),
weapons: poolSchema,
items: poolSchema,
spells: poolSchema,
monsters: poolSchema,
npcs: z.array(npcSchema).default([]),
encounters: z.array(encounterSchema).default([]),
// Note bodies, in reading order.
sharedNotes: z.array(text(10000)).default([]),
dmNotes: z.array(text(10000)).default([]),
});
class SeedError extends Error {}
function issueList(error) {
return error.issues.map((issue) => `${issue.path.join('.') || '(file)'}: ${issue.message}`);
}
// Swaps library references for copies of the library's rows (less the key and
// the picker-only tags) and checks every row. Problems are pushed to `problems`.
function resolvePool(pool, entries, problems) {
const { library, row } = POOLS[pool];
const rows = [];
entries.forEach((entry, index) => {
let candidate = entry;
if (typeof entry === 'string' || (entry && entry.library)) {
const { library: key, ...changes } = typeof entry === 'string' ? { library: entry } : entry;
const template = library.find((item) => item.key === key);
if (!template) return problems.push(`${pool}.${index}: there is no "${key}" in the ${pool} library`);
const { key: _key, category, classes, ...copy } = template;
candidate = { ...copy, ...changes };
}
const parsed = row.safeParse(candidate);
if (!parsed.success) return problems.push(...issueList(parsed.error).map((issue) => `${pool}.${index} (${candidate.name || '?'}): ${issue}`));
rows.push(parsed.data);
});
return rows;
}
// Checks a seed file's export and returns it ready to create: library
// references filled in, defaults applied. Throws a SeedError listing
// everything wrong with it.
function buildSeed(raw) {
const parsed = seedSchema.safeParse(raw);
if (!parsed.success) throw new SeedError(issueList(parsed.error).join('\n'));
const seed = parsed.data;
const problems = [];
for (const pool of Object.keys(POOLS)) seed[pool] = resolvePool(pool, seed[pool], problems);
// Encounters, rewards and wares point at things by name, so within a pool a
// name can only be used once.
const names = {};
for (const pool of ['locations', 'weapons', 'items', 'spells', 'monsters', 'npcs']) {
names[pool] = new Set();
for (const { name } of seed[pool]) {
if (names[pool].has(name)) problems.push(`${pool}: "${name}" is there twice`);
names[pool].add(name);
}
}
const gearByName = { weapon: new Map(seed.weapons.map((w) => [w.name, w])), item: new Map(seed.items.map((i) => [i.name, i])) };
const checkGear = (where, { weapon, item, level }) => {
const kind = weapon ? 'weapon' : 'item';
const entry = gearByName[kind].get(weapon || item);
if (!entry) return problems.push(`${where}: there is no ${kind} called "${weapon || item}"`);
if (level && level < (entry.minLevel || 1)) problems.push(`${where}: "${entry.name}" starts at level ${entry.minLevel}, so it can't be level ${level}`);
};
for (const npc of seed.npcs) {
if (Boolean(npc.exchangeGold) !== Boolean(npc.exchangeCash)) problems.push(`NPC "${npc.name}": an exchange rate needs both exchangeGold and exchangeCash`);
(npc.wares || []).forEach((ware) => checkGear(`NPC "${npc.name}" wares`, ware));
}
for (const encounter of seed.encounters) {
const where = `Encounter "${encounter.name}"`;
if (encounter.location && !names.locations.has(encounter.location)) problems.push(`${where}: there is no location called "${encounter.location}"`);
for (const [monster] of encounter.monsters || []) {
if (!names.monsters.has(monster)) problems.push(`${where}: there is no monster called "${monster}"`);
}
for (const entry of encounter.npcs || []) {
const name = typeof entry === 'string' ? entry : entry.name;
if (!names.npcs.has(name)) problems.push(`${where}: there is no NPC called "${name}"`);
}
for (const reward of encounter.rewards || []) if (reward.weapon || reward.item) checkGear(`${where} rewards`, reward);
(encounter.wares || []).forEach((ware) => checkGear(`${where} wares`, ware));
}
if (problems.length) throw new SeedError(problems.join('\n'));
return seed;
}
// ---------------------------------------------------------------------------
// The folder
// ---------------------------------------------------------------------------
// Every seed file, good or bad: [{ file, code, seed } | { file, error }].
// A file is only built again when it has changed on disk.
const built = new Map();
function loadSeeds(dir = SEEDS_DIR) {
if (!fs.existsSync(dir)) return [];
const results = [];
const codes = new Map();
for (const file of fs.readdirSync(dir).filter((name) => name.endsWith('.js')).sort()) {
const fullPath = path.join(dir, file);
const { mtimeMs } = fs.statSync(fullPath);
let entry = built.get(fullPath);
if (!entry || entry.mtimeMs !== mtimeMs) {
try {
delete require.cache[require.resolve(fullPath)];
const seed = buildSeed(require(fullPath));
entry = { mtimeMs, result: { file, code: seed.code, seed } };
} catch (err) {
entry = { mtimeMs, result: { file, error: err.message } };
}
built.set(fullPath, entry);
}
let { result } = entry;
if (result.code && codes.has(result.code)) {
result = { file, error: `Its code ${result.code} is already used by ${codes.get(result.code)}` };
} else if (result.code) {
codes.set(result.code, file);
}
results.push(result);
}
return results;
}
// The world behind a code, or null. A file with something wrong in it is
// never offered; `npm run seeds:check` says what is wrong.
function findSeed(code, dir = SEEDS_DIR) {
const wanted = normalizeCode(code);
const match = loadSeeds(dir).find((result) => result.code === wanted && !result.error);
return match ? match.seed : null;
}
module.exports = { SEEDS_DIR, CODE_LENGTH, SeedError, normalizeCode, buildSeed, loadSeeds, findSeed };
+138
View File
@@ -0,0 +1,138 @@
const fs = require('fs');
const os = require('os');
const path = require('path');
const { buildSeed, loadSeeds, findSeed, normalizeCode, SeedError } = require('./index');
// The smallest seed there is, with whatever a test adds or changes.
function seedWith(extra = {}) {
return { code: 'TEST01', campaign: { name: 'Test World' }, ...extra };
}
describe('the seeds in /backend/seeds', () => {
const results = loadSeeds();
test('every file is a valid world', () => {
expect(results.length).toBeGreaterThan(0);
for (const { file, error } of results) expect({ file, error }).toEqual({ file, error: undefined });
});
test('each has its own code', () => {
const codes = results.map((result) => result.code);
expect(new Set(codes).size).toBe(codes.length);
});
});
describe('buildSeed', () => {
test('fills in what is left out', () => {
const seed = buildSeed(seedWith());
expect(seed.weapons).toEqual([]);
expect(seed.encounters).toEqual([]);
expect(seed.sharedNotes).toEqual([]);
});
test('copies library entries, with changes, and drops the library-only fields', () => {
const seed = buildSeed(seedWith({ weapons: ['dagger', { library: 'longsword', isStartingOption: true, minLevel: 2 }], monsters: ['goblin'] }));
expect(seed.weapons[0]).toMatchObject({ name: 'Dagger', damageDice: '1d4' });
expect(seed.weapons[1]).toMatchObject({ name: 'Longsword', isStartingOption: true, minLevel: 2 });
expect(seed.weapons[0]).not.toHaveProperty('key');
expect(seed.weapons[0]).not.toHaveProperty('category');
expect(seed.monsters[0].name).toBe('Goblin');
});
test('refuses a code that is not six capital letters or digits', () => {
for (const code of ['abc123', 'ABC12', 'ABC1234', 'ABC-12', undefined]) {
expect(() => buildSeed(seedWith({ code }))).toThrow(SeedError);
}
});
test('refuses a misspelt field rather than leaving it out', () => {
expect(() => buildSeed(seedWith({ npcs: [{ name: 'Mira', desciption: 'A baker' }] }))).toThrow(/desciption/);
expect(() => buildSeed(seedWith({ items: [{ name: 'Rope', starting: true }] }))).toThrow(/starting/);
});
test('refuses an unknown library key', () => {
expect(() => buildSeed(seedWith({ spells: ['fire-bolt', 'not-a-spell'] }))).toThrow(/no "not-a-spell" in the spells library/);
});
test('refuses the same name twice in a pool', () => {
expect(() => buildSeed(seedWith({ weapons: ['dagger', 'dagger'] }))).toThrow(/"Dagger" is there twice/);
});
test('refuses an encounter that points at something the seed does not have', () => {
const encounter = (extra) => seedWith({ encounters: [{ name: 'The Bridge', ...extra }] });
expect(() => buildSeed(encounter({ location: 'Nowhere' }))).toThrow(/no location called "Nowhere"/);
expect(() => buildSeed(encounter({ monsters: [['Troll', 1]] }))).toThrow(/no monster called "Troll"/);
expect(() => buildSeed(encounter({ npcs: ['Mira'] }))).toThrow(/no NPC called "Mira"/);
expect(() => buildSeed(encounter({ rewards: [{ item: 'Rope' }] }))).toThrow(/no item called "Rope"/);
expect(() => buildSeed(encounter({ wares: [{ weapon: 'Dagger', price: 2 }] }))).toThrow(/no weapon called "Dagger"/);
});
test('a reward is exactly one thing', () => {
const rewards = (list) => seedWith({ items: [{ name: 'Rope' }], encounters: [{ name: 'The Bridge', rewards: list }] });
expect(buildSeed(rewards([{ gold: 5 }, { cash: 20 }, { item: 'Rope', quantity: 2 }])).encounters[0].rewards).toHaveLength(3);
expect(() => buildSeed(rewards([{ gold: 5, item: 'Rope' }]))).toThrow(SeedError);
expect(() => buildSeed(rewards([{ note: 'nothing' }]))).toThrow(SeedError);
});
test('gear cannot be sold or handed out below its starting level', () => {
const world = (ware) => seedWith({ items: [{ name: 'Old Crown', minLevel: 4 }], npcs: [{ name: 'Mira', wares: [ware] }] });
expect(buildSeed(world({ item: 'Old Crown', price: 100, level: 5 })).npcs[0].wares).toHaveLength(1);
expect(() => buildSeed(world({ item: 'Old Crown', price: 100, level: 2 }))).toThrow(/starts at level 4/);
});
test('an exchange rate needs both halves', () => {
expect(() => buildSeed(seedWith({ npcs: [{ name: 'Mira', exchangeGold: 1 }] }))).toThrow(/exchange rate/);
expect(buildSeed(seedWith({ npcs: [{ name: 'Mira', exchangeGold: 1, exchangeCash: 20 }] })).npcs[0].exchangeCash).toBe(20);
});
test('an effect has to be on a number the sheet has', () => {
const item = (effects) => seedWith({ items: [{ name: 'Shield', effects }] });
expect(buildSeed(item([{ stat: 'AC', amount: 2 }])).items[0].effects).toEqual([{ stat: 'AC', amount: 2 }]);
expect(() => buildSeed(item([{ stat: 'LUCK', amount: 2 }]))).toThrow(SeedError);
});
});
describe('the seeds folder', () => {
let dir;
const drop = (file, seed) => fs.writeFileSync(path.join(dir, file), `module.exports = ${JSON.stringify(seed)};`);
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'seeds-'));
});
afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});
test('a code finds its world however it is typed', () => {
drop('one.js', seedWith({ code: 'AB12CD' }));
expect(normalizeCode(' ab1-2cd ')).toBe('AB12CD');
expect(findSeed(' ab1-2cd ', dir).campaign.name).toBe('Test World');
expect(findSeed('ZZZZZZ', dir)).toBeNull();
expect(findSeed('', dir)).toBeNull();
});
test('a file dropped in later is picked up', () => {
expect(findSeed('LATER1', dir)).toBeNull();
drop('later.js', seedWith({ code: 'LATER1' }));
expect(findSeed('LATER1', dir)).not.toBeNull();
});
test('a broken file is reported and never offered, without hiding the good ones', () => {
drop('bad.js', seedWith({ code: 'BAD001', encounters: [{ name: 'X', monsters: [['Nobody', 1]] }] }));
drop('good.js', seedWith({ code: 'GOOD01' }));
fs.writeFileSync(path.join(dir, 'syntax.js'), 'module.exports = {');
const byFile = Object.fromEntries(loadSeeds(dir).map((result) => [result.file, result]));
expect(byFile['bad.js'].error).toMatch(/no monster called "Nobody"/);
expect(byFile['syntax.js'].error).toBeTruthy();
expect(byFile['good.js'].error).toBeUndefined();
expect(findSeed('BAD001', dir)).toBeNull();
expect(findSeed('GOOD01', dir)).not.toBeNull();
});
test('two files cannot share a code: the first keeps it', () => {
drop('a.js', seedWith({ code: 'SAME01', campaign: { name: 'First' } }));
drop('b.js', seedWith({ code: 'SAME01', campaign: { name: 'Second' } }));
expect(loadSeeds(dir)[1].error).toMatch(/already used by a\.js/);
expect(findSeed('SAME01', dir).campaign.name).toBe('First');
});
});
+18 -12
View File
@@ -6,10 +6,14 @@
// The coins, and what each is worth counted in copper: silver is 0.1 gold and
// copper is 0.01 gold, so 1 gold = 10 silver = 100 copper and 1 silver = 10
// copper. Whole numbers keep exchanges exact.
const CURRENCIES = ['GOLD', 'SILVER', 'COPPER'];
const COINS = ['GOLD', 'SILVER', 'COPPER'];
const COIN_VALUE = { GOLD: 100, SILVER: 10, COPPER: 1 };
// Each coin is its own column on the character.
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper' };
// Everything a purse can hold: the coins, plus cash ($) — modern money for
// crossover worlds. Cash has no worth in coins, so it is never exchanged for
// them and never used to make change.
const CURRENCIES = [...COINS, 'CASH'];
// Each currency is its own column on the character.
const COIN_COLUMN = { GOLD: 'gold', SILVER: 'silver', COPPER: 'copper', CASH: 'cash' };
class InsufficientGoldError extends Error {}
@@ -43,7 +47,7 @@ function planPayment(purse, currency, amount) {
const delta = { GOLD: 0, SILVER: 0, COPPER: 0 };
const left = (coin) => purse[coin] + delta[coin];
let owed = amount * worth(currency);
if (CURRENCIES.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
if (COINS.reduce((sum, coin) => sum + purse[coin] * worth(coin), 0) < owed) return null;
const pay = (coin, count) => {
delta[coin] -= count;
@@ -51,13 +55,13 @@ function planPayment(purse, currency, amount) {
};
pay(currency, Math.min(purse[currency], amount));
const bigger = CURRENCIES.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
const bigger = COINS.filter((coin) => worth(coin) > worth(currency)).reverse(); // nearest first
for (const coin of bigger) {
if (owed <= 0) break;
pay(coin, Math.min(left(coin), Math.ceil(owed / worth(coin))));
// Overpaid: hand the difference back, biggest coins first.
let change = -owed;
for (const small of CURRENCIES.filter((c) => worth(c) < worth(coin))) {
for (const small of COINS.filter((c) => worth(c) < worth(coin))) {
if (change <= 0) break;
const count = Math.floor(change / worth(small));
delta[small] += count;
@@ -66,7 +70,7 @@ function planPayment(purse, currency, amount) {
if (owed < 0) owed = 0;
}
for (const coin of CURRENCIES.filter((c) => worth(c) < worth(currency))) {
for (const coin of COINS.filter((c) => worth(c) < worth(currency))) {
if (owed <= 0) break;
pay(coin, Math.min(left(coin), Math.floor(owed / worth(coin))));
}
@@ -76,14 +80,16 @@ function planPayment(purse, currency, amount) {
// Pays `amount` coins of `currency` out of a character's purse, making change
// as needed (see planPayment). Every coin that moved gets its own ledger row;
// the rows are returned, the one in `currency` first when there is one. Throws
// InsufficientGoldError if the purse as a whole can't cover it.
// InsufficientGoldError if the purse as a whole can't cover it. Cash is paid in
// cash alone: coins don't cover it, and it doesn't cover coins.
async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason, userId, include }) {
if (!COINS.includes(currency)) return [await adjustGold(db, { characterId, amount: -amount, currency, reason, userId, include })];
const character = await db.character.findUnique({ where: { id: characterId }, select: { gold: true, silver: true, copper: true } });
const purse = { GOLD: character.gold, SILVER: character.silver, COPPER: character.copper };
const delta = planPayment(purse, currency, amount);
if (!delta) throw new InsufficientGoldError();
const moved = CURRENCIES.filter((coin) => delta[coin] !== 0).sort((a, b) => (b === currency) - (a === currency));
const moved = COINS.filter((coin) => delta[coin] !== 0).sort((a, b) => (b === currency) - (a === currency));
const madeChange = moved.some((coin) => coin !== currency);
const label = reason || 'Spent';
const rows = [];
@@ -103,9 +109,9 @@ async function spendCoins(db, { characterId, amount, currency = 'GOLD', reason,
return rows;
}
// A purse's whole worth as one figure in gold, for messages: 265 copper-worth -> 2.65.
// A purse's coins as one figure in gold, for messages: 265 copper-worth -> 2.65.
function worthInGold(character) {
const copper = CURRENCIES.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
const copper = COINS.reduce((sum, coin) => sum + character[COIN_COLUMN[coin]] * COIN_VALUE[coin], 0);
return copper / COIN_VALUE.GOLD;
}
@@ -127,4 +133,4 @@ async function giveGear(db, kind, { characterId, entry, quantity = 1, level, equ
});
}
module.exports = { CURRENCIES, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
module.exports = { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, planPayment, spendCoins, worthInGold, giveGear };
+110
View File
@@ -0,0 +1,110 @@
// Resting (the Hollowcrown book's Rules Cheat Sheet, "Resting"). Each function
// takes a finished character and its sheet and returns the columns to write
// plus an account of what happened, for the DM to read. Pure: no DB calls.
//
// Short rest (an hour): spend Hit Dice to heal, each one the class's die plus
// the Constitution modifier. A warlock's spell slots come back.
// Class counters that restore on a short rest come back.
// Long rest (a night): all HP, all spell slots, every class counter, Hit Dice
// up to half the level (at least one), one level of exhaustion less, and temp
// HP is gone, except what the Hardy Boon gives.
const { changeHp, lifeState, NO_DEATH_SAVES } = require('./hp');
const { hardyTempHp } = require('../game/levelUp');
const { restResources } = require('../game/classResources');
// Once a day on a short rest a wizard or a Land druid (from level 2) gets some
// spent slots back. Which ones is the player's choice, so the DM puts them
// back on the sheet; this is the reminder, or null when it does not apply.
function recoveryNote(character, sheet) {
const feature = character.characterClass === 'WIZARD' ? 'Arcane Recovery' : character.characterClass === 'DRUID' && character.level >= 2 && character.subclass === 'LAND' ? 'Natural Recovery' : null;
if (!feature || !sheet.spellSlots || !sheet.spellSlots.levels.some((slot) => slot.used > 0)) return null;
const budget = Math.ceil(character.level / 2);
return `${feature}: once per day, ${character.name} can regain spent slots adding up to ${budget} slot level${budget === 1 ? '' : 's'} (none of 6th level or higher). Put them back on the sheet.`;
}
// The class counters a rest refills: the tally to store and a line for the DM.
function refill(sheet, restType) {
const { resourcesUsed, restored } = restResources(sheet.resources || [], restType);
return { resourcesUsed, notes: restored.length > 0 ? [`Back: ${restored.join(', ')}.`] : [] };
}
// A problem with the Hit Dice asked for, or null. `rolled` is the total the
// dice showed, for dice rolled at the table.
function hitDiceProblem(character, sheet, dice, rolled) {
if (dice === 0) return null;
const { size, remaining } = sheet.hitDice;
if (lifeState(character) === 'DYING') return `${character.name} is dying: steady or heal them before they spend Hit Dice.`;
if (dice > remaining) return `${character.name} has ${remaining} Hit ${remaining === 1 ? 'Die' : 'Dice'} left, not ${dice}.`;
if (rolled != null && (rolled < dice || rolled > dice * size)) {
return `${character.name}: ${dice}d${size} adds up to between ${dice} and ${dice * size}, not ${rolled}.`;
}
return null;
}
// `rolls` is what each Hit Die showed, or `rolled` their total when they were
// rolled at the table. The Constitution modifier is added once per die, and
// the rest never takes HP away.
function shortRest(character, sheet, { dice = 0, rolls = null, rolled = null } = {}) {
const total = dice === 0 ? 0 : rolls ? rolls.reduce((sum, roll) => sum + roll, 0) : rolled;
const conBonus = dice * sheet.abilities.CON.modifier;
const healed = Math.max(0, total + conBonus);
const pact = Boolean(sheet.spellSlots) && sheet.spellSlots.restoresOn === 'SHORT';
const slotsRestored = pact && sheet.spellSlots.levels.some((slot) => slot.used > 0);
const counters = refill(sheet, 'SHORT');
const data = {
...(healed > 0 ? changeHp(character, character.currentHp + healed, sheet.hp.max) : {}),
hitDiceUsed: sheet.hitDice.used + dice,
...(pact ? { spellSlotsUsed: [] } : {}),
resourcesUsed: counters.resourcesUsed,
};
const hpAfter = data.currentHp ?? character.currentHp;
return {
data,
result: {
hpBefore: character.currentHp,
hpAfter,
maxHp: sheet.hp.max,
tempHp: character.tempHp,
hitDice: { size: sheet.hitDice.size, spent: dice, rolls, total, conBonus, healed: hpAfter - character.currentHp, left: sheet.hitDice.remaining - dice, of: sheet.hitDice.count, regained: 0 },
slotsRestored,
notes: [...counters.notes, recoveryNote(character, sheet)].filter(Boolean),
},
};
}
function longRest(character, sheet) {
const regained = Math.min(sheet.hitDice.used, Math.max(1, Math.floor(character.level / 2)));
const tempHp = hardyTempHp(character.specialAbilities);
const counters = refill(sheet, 'LONG');
const exhaustion = Math.max(0, (character.exhaustion || 0) - 1);
const data = {
currentHp: sheet.hp.max,
tempHp,
...NO_DEATH_SAVES,
hitDiceUsed: sheet.hitDice.used - regained,
spellSlotsUsed: [],
resourcesUsed: counters.resourcesUsed,
exhaustion,
};
return {
data,
result: {
hpBefore: character.currentHp,
hpAfter: sheet.hp.max,
maxHp: sheet.hp.max,
tempHp,
hitDice: { size: sheet.hitDice.size, spent: 0, rolls: null, total: 0, conBonus: 0, healed: 0, left: sheet.hitDice.remaining + regained, of: sheet.hitDice.count, regained },
slotsRestored: Boolean(sheet.spellSlots) && sheet.spellSlots.levels.some((slot) => slot.used > 0),
notes: [
...counters.notes,
...(character.exhaustion > 0 ? [exhaustion > 0 ? `Exhaustion down to level ${exhaustion}.` : 'No longer exhausted.'] : []),
...(tempHp > 0 ? [`Hardy: ${tempHp} Temp HP for the day.`] : []),
],
},
};
}
module.exports = { hitDiceProblem, shortRest, longRest };
+125
View File
@@ -0,0 +1,125 @@
const { hitDiceProblem, shortRest, longRest } = require('./rest');
const { computeCharacterSheet } = require('../game/calculations');
const CLEAR = { deathSaveSuccesses: 0, deathSaveFailures: 0, stable: false };
// A level 5 fighter (d10) with CON 14 (+2): 10 + 2 + 4 × (6 + 2) = 44 HP.
const hero = (over = {}) => ({
name: 'Aria',
characterClass: 'FIGHTER',
level: 5,
conScore: 14,
currentHp: 10,
tempHp: 0,
hitDiceUsed: 0,
spellSlotsUsed: [],
specialAbilities: null,
...CLEAR,
...over,
});
const sheet = (character) => computeCharacterSheet(character);
describe('shortRest', () => {
test('spent Hit Dice heal what they rolled plus Constitution for each', () => {
const c = hero();
const { data, result } = shortRest(c, sheet(c), { dice: 2, rolls: [7, 4] });
expect(data).toMatchObject({ currentHp: 25, hitDiceUsed: 2 });
expect(result).toMatchObject({ hpBefore: 10, hpAfter: 25, maxHp: 44 });
expect(result.hitDice).toMatchObject({ size: 10, spent: 2, total: 11, conBonus: 4, healed: 15, left: 3, of: 5 });
});
test('takes a total rolled at the table, stops at max HP, and never takes HP away', () => {
const c = hero({ currentHp: 40 });
expect(shortRest(c, sheet(c), { dice: 1, rolled: 9 }).data.currentHp).toBe(44);
const frail = hero({ conScore: 3, currentHp: 10 }); // CON -4
const rest = shortRest(frail, sheet(frail), { dice: 1, rolled: 2 });
expect(rest.data.currentHp).toBeUndefined();
expect(rest.data.hitDiceUsed).toBe(1);
expect(rest.result.hitDice.healed).toBe(0);
});
test('with no dice spent nothing changes for a fighter', () => {
const c = hero({ hitDiceUsed: 3 });
expect(shortRest(c, sheet(c)).data).toEqual({ hitDiceUsed: 3, resourcesUsed: {} });
});
test('gives back the class counters that restore on a short rest, and only those', () => {
const c = hero({ level: 9, resourcesUsed: { SECOND_WIND: 1, ACTION_SURGE: 1, INDOMITABLE: 1 } });
const { data, result } = shortRest(c, sheet(c));
expect(data.resourcesUsed).toEqual({ INDOMITABLE: 1 });
expect(result.notes).toEqual(['Back: Second Wind, Action Surge.']);
});
test('gives a warlock their slots back, and nobody else', () => {
const warlock = hero({ characterClass: 'WARLOCK', spellSlotsUsed: [0, 0, 2] });
const rest = shortRest(warlock, sheet(warlock));
expect(rest.data.spellSlotsUsed).toEqual([]);
expect(rest.result.slotsRestored).toBe(true);
const wizard = hero({ characterClass: 'WIZARD', spellSlotsUsed: [2, 1] });
const wizardRest = shortRest(wizard, sheet(wizard));
expect(wizardRest.data.spellSlotsUsed).toBeUndefined();
expect(wizardRest.result.slotsRestored).toBe(false);
// ...but a wizard is reminded of Arcane Recovery: half their level, rounded up.
expect(wizardRest.result.notes).toEqual([expect.stringContaining('Arcane Recovery')]);
expect(wizardRest.result.notes[0]).toContain('3 slot levels');
const fresh = hero({ characterClass: 'WIZARD' });
expect(shortRest(fresh, sheet(fresh)).result.notes).toEqual([]);
});
test('wakes a stable character who spends a Hit Die', () => {
const c = hero({ currentHp: 0, stable: true });
expect(shortRest(c, sheet(c), { dice: 1, rolled: 5 }).data).toMatchObject({ currentHp: 7, ...CLEAR });
});
});
describe('hitDiceProblem', () => {
test('refuses more dice than are left, an impossible total, and the dying', () => {
const c = hero({ hitDiceUsed: 4 });
expect(hitDiceProblem(c, sheet(c), 0)).toBeNull();
expect(hitDiceProblem(c, sheet(c), 1, 10)).toBeNull();
expect(hitDiceProblem(c, sheet(c), 1)).toBeNull();
expect(hitDiceProblem(c, sheet(c), 2)).toMatch(/1 Hit Die left/);
expect(hitDiceProblem(c, sheet(c), 1, 11)).toMatch(/between 1 and 10/);
expect(hitDiceProblem(c, sheet(c), 1, 0)).toMatch(/between 1 and 10/);
const dying = hero({ currentHp: 0 });
expect(hitDiceProblem(dying, sheet(dying), 1)).toMatch(/dying/);
});
});
describe('longRest', () => {
test('heals to full, gives back every slot and half the level in Hit Dice', () => {
const c = hero({ characterClass: 'WIZARD', currentHp: 3, tempHp: 4, hitDiceUsed: 5, spellSlotsUsed: [4, 3, 2] });
const { data, result } = longRest(c, sheet(c));
expect(data).toEqual({ currentHp: sheet(c).hp.max, tempHp: 0, ...CLEAR, hitDiceUsed: 3, spellSlotsUsed: [], resourcesUsed: {}, exhaustion: 0 });
expect(result.hitDice).toMatchObject({ regained: 2, left: 2, of: 5 });
expect(result.slotsRestored).toBe(true);
});
test('gives back at least one Hit Die, and no more than were spent', () => {
const novice = hero({ level: 1, hitDiceUsed: 1 });
expect(longRest(novice, sheet(novice)).data.hitDiceUsed).toBe(0);
const rested = hero({ hitDiceUsed: 1 });
expect(longRest(rested, sheet(rested)).result.hitDice).toMatchObject({ regained: 1, left: 5 });
});
test('gets a dying character back on their feet', () => {
const c = hero({ currentHp: 0, deathSaveFailures: 2, deathSaveSuccesses: 1 });
expect(longRest(c, sheet(c)).data).toMatchObject({ currentHp: 44, ...CLEAR });
});
test('gives every class counter back and takes one level of exhaustion off', () => {
const c = hero({ level: 9, exhaustion: 2, resourcesUsed: { SECOND_WIND: 1, INDOMITABLE: 1 } });
const { data, result } = longRest(c, sheet(c));
expect(data).toMatchObject({ resourcesUsed: {}, exhaustion: 1 });
expect(result.notes).toEqual(['Back: Second Wind, Indomitable.', 'Exhaustion down to level 1.']);
const tired = hero({ exhaustion: 1 });
expect(longRest(tired, sheet(tired)).result.notes).toEqual(['No longer exhausted.']);
});
test('leaves the Hardy Boon its Temp HP', () => {
const c = hero({ tempHp: 2, specialAbilities: 'Hardy — +10 maximum HP: gain 10 Temp HP after each long rest.' });
const { data, result } = longRest(c, sheet(c));
expect(data.tempHp).toBe(10);
expect(result.notes).toEqual(['Hardy: 10 Temp HP for the day.']);
});
});
+35
View File
@@ -0,0 +1,35 @@
// Loading a character so that its sheet comes out right. The sheet depends on
// what the character has equipped (game/calculations.js, activeGear), so every
// query that feeds computeCharacterSheet includes the same relations.
const { computeCharacterSheet } = require('../game/calculations');
const SHEET_INCLUDE = {
skillProficiencies: true,
saveProficiencies: true,
inventoryItems: { include: { item: true } },
weapons: { include: { weapon: true } },
};
function sheetOf(character) {
return computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
}
// Equipping, unequipping or changing gear can lower a character's maximum HP.
// Brings current HP back down to the new maximum where it is now above it,
// and returns the ids of the characters that changed. Takes a Prisma client or
// transaction client as `db`.
async function settleHp(db, characterIds) {
const changed = [];
const characters = await db.character.findMany({ where: { id: { in: [...new Set(characterIds)] }, buildStage: 'COMPLETE' }, include: SHEET_INCLUDE });
for (const character of characters) {
const max = sheetOf(character).hp.max;
if (character.currentHp > max) {
await db.character.update({ where: { id: character.id }, data: { currentHp: max } });
changed.push(character.id);
}
}
return changed;
}
module.exports = { SHEET_INCLUDE, sheetOf, settleHp };
+49
View File
@@ -0,0 +1,49 @@
# Campaign Settings
Each campaign has its own settings. The DM changes them on the campaign page
under **Campaign Settings → Edit**, and players read them but can't change
them. They are columns on the `Campaign` row (`backend/prisma/schema.prisma`),
returned with the campaign by `GET /campaigns/:id`, and changed with
`PATCH /campaigns/:id` (DM only). A world seed can set any of them
([SEEDS.md](SEEDS.md)).
Changing a setting never rewrites what already happened. For example,
turning XP tracking off keeps everyone's XP; it just stops showing it.
## Characters
| Setting | Default | What it does |
|---|---|---|
| `startingGold` | 0 | Gold every new character starts with. |
| `startingItemCount` | 0 | How many starting weapons and items (together) the builder lets a character take. |
| `startingSpellCount` | 0 | How many starting spells the builder lets a character take. |
| `abilityScoreMethod` | `ROLL_4D6` | How the builder makes the six scores: `ROLL_4D6` (roll 4d6 and drop the lowest, six times, then place them), `STANDARD_ARRAY` (place 15, 14, 13, 12, 10 and 8), or `POINT_BUY` (every score starts at 8 and is raised with 27 points, to 15 at most). |
| `skipMotivationRoll` | off | Leaves the motivation step out of the builder. |
| `subclassesEnabled` | on | Characters choose a subclass. Off: each class follows its first subclass, unasked and unnamed. |
| `multiclassEnabled` | off | On a level up, a character may take the level in another class whose ability requirements they meet. |
| `maxEquipped` / `maxEquippedWeapons` | 2 / 1 | How much a character can have equipped at once, and how much of it can be weapons. |
## Dice and rolls
| Setting | Default | What it does |
|---|---|---|
| `diceMode` | `BOTH` | `DIGITAL`: the app rolls every die. `PHYSICAL`: everyone rolls real dice and types in what they show (the server checks each value is on the die). `BOTH`: each character picks one or the other before their first build roll. |
| `rollVisibility` | `ALL` | `ALL`: everyone sees every roll. `OWN`: a player sees only their own rolls. The DM always sees every roll. |
| `hideMonsterRolls` | on | The DM's rolls for monsters and NPCs stay hidden from players until they are put into play. |
## Combat
| Setting | Default | What it does |
|---|---|---|
| `autoRollMonsters` | off | When a monster's turn comes up in a battle, the server picks its target, rolls the attack and the damage, and offers the result to the DM to accept, change or throw out. |
| `autoRollNpcs` | off | The same for NPCs. Friendly NPCs attack monsters; hostile ones attack the party. |
| `showMonsterHp` | on | Players see the exact HP of monsters and NPCs. Off: they see a health bar. |
| `critRule` | `DOUBLE_DICE` | What a critical hit does to damage. `DOUBLE_DICE`: the damage dice are rolled twice. `MAX_PLUS_ROLL`: the dice count at their maximum, plus one roll of them. |
| `areaAttackAcPenalty` | 2 | In an attack on several targets, each target's AC counts this much higher for every target after the first. 0 turns it off. |
## Progression
| Setting | Default | What it does |
|---|---|---|
| `trackXp` | on | Experience points are kept: on the sheets, awarded after encounters, with "level up due" shown. Off (milestone levelling): no XP is shown or awarded, and the DM decides when the party levels. |
| `hpOnLevelUp` | `AVERAGE` | How maximum HP grows on a level up. `AVERAGE`: the hit die's average, rounded up, plus Constitution. `ROLL`: a roll of the hit die plus Constitution. |
+777 -50
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)
---
@@ -64,7 +68,11 @@ first entry in its money log) and goes straight into the builder.
### Dice mode
Chosen once, before the first roll, and then locked:
The campaign's `diceMode` setting decides this
([CAMPAIGN_SETTINGS.md](CAMPAIGN_SETTINGS.md)). A campaign set to digital or
physical dice applies that to every character, and the builder doesn't ask.
A campaign set to **both** lets each character choose once, before their
first roll. After that the choice is locked:
| Mode | How rolls happen |
|---|---|
@@ -137,7 +145,7 @@ this order:
These are table rules written in plain words for the DM to apply. The app
does not calculate anything from them: Rage does not add damage by itself, and
Unarmored Defense does not change AC unless the DM sets the armor bonus.
Unarmored Defense does not change AC unless the DM sets the extra AC.
**Background** is five lines, one per question:
@@ -259,6 +267,13 @@ The six abilities are Strength, Dexterity, Constitution, Intelligence, Wisdom
and Charisma.
- The **rolled score** is what the dice gave (3–18). This is what is stored.
How it is made follows the campaign's `abilityScoreMethod`:
- **Roll 4d6** (the default): roll four d6, drop the lowest, and add the rest.
This is done six times, and the six totals are placed on the abilities.
- **Standard array**: the scores 15, 14, 13, 12, 10 and 8 are placed instead.
- **Point buy**: every score starts at 8. Raising a score costs 1 point per
step up to 13, and 2 points per step for 14 and 15. The budget is
27 points. The scores go straight to their abilities.
- The **final score** is the rolled score plus the species bonus. This is what
the sheet shows and what every calculation uses.
- The **modifier** is `(final score − 10) ÷ 2`, rounded down.
@@ -278,11 +293,153 @@ Ability scores cannot be changed after the build is finalized.
## 6. Level and XP
- Every character starts at **level 1**. The range is 1 to 20.
- Levelling is **manual**: the DM changes the Level field on the sheet. Only
the DM can change level or XP.
- **XP** is stored but no screen shows or edits it, and it does not drive level.
- Only the DM can change level or XP. There are two ways to change a level,
both on the sheet: the **⬆ Level Up…** button, and the Level field.
- **XP** (experience points) is shown under the character's name on the sheet
as "37 / 300 XP": what they have, and what their next level takes. The party
overview shows the same under each level.
- XP comes from encounters: when the DM awards an encounter's XP it is shared
between the characters in it ([ENCOUNTERS.md](ENCOUNTERS.md), section 9).
The DM can also type a character's XP into the **XP** field beside Level.
- **XP does not level a character up.** When a character has the XP for
their next level the sheet shows "⬆ Level up due" (the party overview shows
⬆), and the DM levels them with "⬆ Level Up…" when the table is ready. A DM
who levels by milestones can turn **Track experience points** off in the
campaign settings. That hides XP everywhere, closes the award panel, and
leaves levelling entirely to the DM.
Level feeds two things:
| Level | XP needed | Level | XP needed | Level | XP needed | Level | XP needed |
|---|---|---|---|---|---|---|---|
| 1 | 0 | 6 | 14,000 | 11 | 85,000 | 16 | 195,000 |
| 2 | 300 | 7 | 23,000 | 12 | 100,000 | 17 | 225,000 |
| 3 | 900 | 8 | 34,000 | 13 | 120,000 | 18 | 265,000 |
| 4 | 2,700 | 9 | 48,000 | 14 | 140,000 | 19 | 305,000 |
| 5 | 6,500 | 10 | 64,000 | 15 | 165,000 | 20 | 355,000 |
### Level Up: the checklist
"⬆ Level Up…" takes a character up **one level** and does everything that
goes with it. It opens a pop-out that lists the five steps and asks for the
choices; nothing changes until the DM presses "Level up to N".
| Step | What the site does | What the DM chooses |
|---|---|---|
| 1. Level | Raises the level by one. Proficiency bonus, saves, skills and spell DC follow | Nothing |
| 2. Hit points | Works out the new max HP and heals to it (a dead character stays dead). One more Hit Die | Nothing |
| 3. Special abilities | Writes the class's and the subclass's new features for that level into Special Abilities ([section 21](#21-what-each-class-gains-as-it-levels)) | The **subclass**, at the level the class chooses one; the **Boon**, at a level that grants one |
| 4. Spells | Gives a caster a fresh set of spell slots for the new level, and grants the spells ticked | Which spells to grant, from the campaign's spell list |
| 5. Gear | Raises one weapon by one gear level | Which weapon, or none |
**Special abilities.** Each feature is one line, "Name — what it does". A
feature that grows (Sneak Attack gaining a die, Rage gaining a use) rewrites
the line it already has; anything else is added at the end. Where a line says
"choose" (a fighting style, metamagic, an invocation), the DM edits it once
the player has chosen. From level 5 every class that casts also gets a
"Cantrips" line: damaging cantrips roll double the dice shown.
The class and subclass tables run from **level 2 to level 10**. Above level 10
the numbers still move but no feature is written; add them by hand.
**Subclasses.** Each class has three subclasses: the path it takes from early
on. A cleric, a druid and a wizard choose at **level 2**; every other class at
**level 3**.
| Class | Subclasses |
|---|---|
| Barbarian | Path of the Berserker, Path of the Totem Warrior, Path of the Zealot |
| Bard | College of Lore, College of Valor, College of Swords |
| Cleric | Life Domain, Light Domain, War Domain |
| Druid | Circle of the Land, Circle of the Moon, Circle of Stars |
| Fighter | Champion, Battle Master, Samurai |
| Monk | Way of the Open Hand, Way of Shadow, Way of the Four Elements |
| Paladin | Oath of Devotion, Oath of the Ancients, Oath of Vengeance |
| Ranger | Hunter, Beast Master, Gloom Stalker |
| Rogue | Thief, Assassin, Swashbuckler |
| Sorcerer | Draconic Bloodline, Wild Magic, Storm Sorcery |
| Warlock | The Fiend, The Archfey, The Great Old One |
| Wizard | School of Evocation, School of Abjuration, School of Illusion |
- At the level the class chooses, the checklist lists the three with a line
about each. Picking one shows the features it brings, and the button stays
"Choose a subclass first" until one is picked.
- From then on each level writes the subclass's features along with the
class's.
- The subclass is shown beside the class wherever the class is: the sheet, the
character panel, encounter cards, the party overview and the campaign's
character list ("Fighter (Champion)").
- The DM can set or change it later with the **Subclass** menu beside the
Level field (shown once the character is at the level their class chooses).
The old subclass's lines are taken out of Special Abilities, found by their
names, and the new one's are written, up to the character's level. A line
the DM had edited ("choose three…") is replaced by the new text. "Not
chosen" takes the subclass away and writes "Subclass — Choose one with your
DM…" in its place.
- Like every special ability, a subclass's features are written for the table
to apply: none of them changes a number on the sheet.
**A campaign without subclasses.** "Characters choose a subclass", in the
campaign's Settings, is ticked for a new campaign. Unticked:
- Nobody is asked. On reaching the level their class would choose at, a
character is put on the **first subclass in the table above** (Champion
fighter, Life cleric, Evocation wizard…), by Level Up and by the Level
field alike, and its features are written with the class's. A character
already past that level gets everything it has given so far the next time
they level.
- No subclass is named anywhere, the checklist has no subclass step, the sheet
has no Subclass menu, and no "Subclass — Choose one" line is written.
- A character who already had a subclass keeps following it, unnamed.
- Class counters still follow the subclass a character is on (a druid's
Natural Recovery, for one).
- Ticking the setting again shows each character's subclass and lets the DM
change it. Unticking takes nothing off any sheet.
The first subclass of each class in the table is the single path that class
followed before version 1.16. Characters who were already at their class's
choice level when subclasses arrived were given that one, since their special
abilities had been written from it.
**Boons** stand in for ability score increases, which the site does not have
(every number on the sheet comes from the scores rolled at the build). Most
classes get one at levels 4 and 8; a fighter also at 6, a rogue also at 10.
| Boon | What it is | What the site does |
|---|---|---|
| Weapon Mastery | +1 to attack and damage rolls with weapons | Writes the line. The player adds the +1 to the numbers shown |
| Arcane Focus | +1 to spell save DC and spell attack bonus | Writes the line. The player adds the +1 to the numbers shown |
| Toughened | +1 AC | Writes the line and raises **Extra AC** by 1 |
| Hardy | +5 maximum HP, as temp HP | Writes the line, gives 5 **temp HP** now and again after every long rest |
Toughened can be taken once; each of the others twice (its one line then
reads +2, or +10).
**Spells.** The pop-out says what the book's unlock schedule allows at the new
level, and lists the campaign's spells the character does not yet know, up to
the highest slot level they now have. What is ticked is granted; the count is
the DM's to keep.
| Class | New spells each level | Extra cantrip |
|---|---|---|
| Bard, Cleric, Druid, Sorcerer, Warlock | 1 | At levels 4 and 10 |
| Wizard | 2 | At levels 4 and 10 |
| Paladin, Ranger | 2 at level 2, then 1 at each odd level | None |
**Gear.** The free upgrade goes to one held weapon of **standard growth**
whose gear level is below the character's new level (the equipped one is
offered first). Powerful and legendary weapons never get it.
### The Level field
Typing a number into Level sets it outright: for a character who joins late,
or to put a mistake right. Going **up** this way still writes down every
feature the class gains on the way (and the subclass, when one is chosen), but
it asks nothing: a Boon is left as a line saying one is owed ("Boon (level 4)
— Choose one with your DM…"), and so is a subclass not yet picked ("Subclass —
Choose one with your DM…", cleared when one is picked), HP is not healed, no
spell is granted and no weapon is raised. Going **down** takes
no special ability away; the DM edits the text.
Level feeds these things:
**Proficiency bonus** — `2 + (level − 1) ÷ 4`, rounded down.
@@ -294,7 +451,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
@@ -315,15 +474,61 @@ Example: a Fighter (d10) with CON 14 (+2) has 12 at level 1 and 28 at level 3.
**Current HP** is a stored number, separate from max:
- Set to max when the build is finalized, and when a character is copied.
- Changed with the Damage and Heal buttons on the sheet, or by the DM in an
encounter (section 15). Both change the same number.
- Changed by the **DM only**: with the Damage and Heal buttons on the sheet,
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. Damage is not taken from it
automatically.
**Temp HP** is a tracked number shown beside HP, set by the DM. Damage comes
off it first. A long rest clears it.
### Hit Dice and rests
A character has **one Hit Die per level**, of their class's size (the same die
as in the table above). The sheet, the character panel and the party overview
show how many are left. Only the DM changes that number: with a rest, or by
typing into "Hit Dice left" on the sheet.
The DM's **🛏 Rest…** button is on the campaign page (beside "Characters") and
on the party overview. It opens a pop-out: tick who rests, pick short or long,
and press the button. Everyone ticked rests at once, and the pop-out then says
what it did for each of them. Dead characters start unticked, and a rest does
nothing for them.
| | Short rest (1 hour) | Long rest (8 hours) |
|---|---|---|
| HP | Each Hit Die spent heals what it rolled + the CON modifier | Back to max; death saves cleared |
| Hit Dice | The ones spent are used up | Spent ones come back, up to half the level (at least 1) |
| Spell slots | A warlock gets all of theirs back | Everyone gets all of theirs back |
| Temp HP | Unchanged | Cleared, then the Hardy Boon gives its 5 or 10 |
| Class counters | Those marked short rest come back | All come back |
| Exhaustion | Unchanged | One level less |
| Inspiration and conditions | Unchanged | Unchanged |
**Spending Hit Dice.** On a short rest each resting character has a line that
says what they can roll: "Spend [ ] of 3 d10". The DM types how many dice the
player spends, and the pop-out shows what to roll ("Roll 2d10") and the
Constitution that will be added. Then either:
- the player rolls real dice and the DM types **what they added up to**, or
- the total is left empty and **the site rolls** them, and shows each die.
The healing is the total, plus the CON modifier once for each die, and never
less than nothing. It stops at max HP. A dying character cannot spend Hit
Dice until they are steadied or healed; a stable one can, and wakes up.
A short rest is refused as a whole, with nothing changed for anyone, if a
character is asked to spend more dice than they have or the total typed is not
one those dice could show.
After a short rest a wizard (Arcane Recovery) or a Circle of the Land druid
(Natural Recovery) with spent slots gets a reminder: once per day they may
regain slots adding up to half their level, rounded up. Which slots is the
player's choice, so the DM puts them back on the sheet.
### At 0 HP: death saves
@@ -334,7 +539,7 @@ HP bar, on their encounter card, in the quick stats and in the party lists.
|---|---|---|
| Dying | Rolling death saves | Three successes, three failures, healing, or being stabilised |
| Stable | At 0 HP, no longer rolling | Healing wakes them; damage makes them Dying again |
| Dead | Three failures, or one blow too big to survive | Only the DM's **Revive at 1 HP** |
| Dead | Three failures, or one blow too big to survive | The DM's **Revive at 1 HP**, or an ally's reviving spell in a battle |
**Rolling.** While Dying, type what the d20 showed and click **Death save**.
The app does not roll it.
@@ -359,28 +564,112 @@ The app does not roll it.
- **Healing** from 0, by any amount, puts the character back up and clears
the tallies and Stable.
- **Dead** characters cannot be healed: Heal is refused with a message. The
DM clicks **Revive at 1 HP**, then heals as normal.
DM clicks **Revive at 1 HP**, then heals as normal. In a battle, an ally
with a reviving spell can do the same on their own turn, and until then the
dead are hidden from the players' encounter screen: see
[ENCOUNTERS.md](ENCOUNTERS.md), section 6.
- The **DM** can click a pip to set either tally by hand, to fix a mistake.
Dropping to 0 always starts from empty tallies. None of this changes any
other number on the sheet, and nothing is added to the conditions in an
encounter.
### Condition and counters
The sheet's "Condition and counters" card, also at the top of the character
panel, is how a character is doing beyond hit points. All of it is a reminder
for the table: **none of it changes a number on the sheet**.
- **Inspiration.** The DM gives it ("✨ Give inspiration") and can take it
away. The character's own player sees "✨ Inspired" and a **Spend** button.
A character has it or does not; it is not counted.
- **Exhaustion**, level 0 to 6, set by the DM. The card lists what the level
means (1: disadvantage on ability checks; 2: speed halved; 3: disadvantage
on attacks and saves; 4: HP maximum halved; 5: speed 0; 6: death), each
level adding to those before. A **long rest takes one level off**.
- **Conditions** (Poisoned, Prone, Concentrating…), given and removed by the
DM. Hovering over one shows its rule. They belong to the character, not to
an encounter: a condition given in a battle is still there after it, on the
sheet, in the panel and in the next encounter, until the DM removes it
([ENCOUNTERS.md](ENCOUNTERS.md), section 8).
- **Class counters**: the things a class does a set number of times before a
rest. Each has a row of pips, filled while a use is left, and the rest that
gives it back. The character's own player and the DM press **Use** to spend
one. Only the DM gives one back: a click on a pip sets how many are left,
and "All back" refills them all. A counter with more than ten uses (Lay on
Hands, a high-level monk's Ki) has a box for how much to spend in place of
pips, and "+ Back" for the DM.
| Class | Counter | How many | Back on |
|---|---|---|---|
| Barbarian | Rage | 2; 3 from level 3; 4 from level 6 | Long rest |
| Bard | Bardic Inspiration | Charisma modifier, at least 1 | Long rest; short rest from level 5 |
| Cleric | Healing Hands | 1 | Short rest |
| Cleric | Channel Divinity (from level 2) | 1; 2 from level 6 | Short rest |
| Druid | Wild Shape (from level 2) | 2 | Short rest |
| Fighter | Second Wind | 1 | Short rest |
| Fighter | Action Surge (from level 2) | 1 | Short rest |
| Fighter | Indomitable (from level 9) | 1 | Long rest |
| Monk | Ki (from level 2) | The character's level | Short rest |
| Paladin | Lay on Hands (HP) | 5 × level | Long rest |
| Paladin | Divine Sense | 1 + Charisma modifier, at least 1 | Long rest |
| Paladin | Channel Divinity (from level 3) | 1 | Short rest |
| Sorcerer | Wild Talent | 1 | Long rest |
| Sorcerer | Sorcery Points (from level 2) | The character's level | Long rest |
| Wizard | Arcane Recovery | 1 | Long rest |
A ranger, a rogue and a warlock have none of their own. Subclasses add theirs:
| Subclass | Counter | How many | Back on |
|---|---|---|---|
| Zealot (level 10) | Zealous Presence | 1 | Long rest |
| Light Domain | Warding Flare | Wisdom modifier, at least 1 | Long rest |
| War Domain | War Priest | Wisdom modifier, at least 1 | Long rest |
| Circle of the Land | Natural Recovery | 1 | Long rest |
| Circle of Stars | Star Map (Guiding Bolt) | Proficiency bonus | Long rest |
| Circle of Stars (level 6) | Cosmic Omen | Proficiency bonus | Long rest |
| Battle Master | Superiority Dice | 4; 5 from level 7 | Short rest |
| Samurai | Fighting Spirit | 3 | Long rest |
| Open Hand (level 6) | Wholeness of Body | 1 | Long rest |
| Wild Magic | Tides of Chaos | 1 | Long rest |
| The Archfey | Fey Presence; Misty Escape (level 6) | 1 each | Short rest |
| The Fiend (level 6) | Patron's Luck | 1 | Short rest |
| The Great Old One (level 6) | Entropic Ward | 1 | Short rest |
| School of Illusion (level 10) | Illusory Self | 1 | Short rest |
A **short rest** gives back the counters marked short rest; a **long rest**
gives back every counter. The rest pop-out names what came back. What a
counter does is in the character's special abilities; the site only keeps
count. Spending a counter does nothing else: a monk who spends Ki, or a
sorcerer turning points into a slot, still presses the other buttons
themselves.
## 8. Other derived stats
| Stat | Formula |
|---|---|
| Armor Class | 10 + DEX modifier + armor bonus |
| Initiative | DEX modifier |
| Speed | Species speed, unless a speed override is set |
| Passive Perception | 10 + Perception skill bonus |
| Armor Class | 10 + DEX modifier + equipped gear + the DM's extra AC |
| Initiative | DEX modifier + equipped gear |
| Speed | Species speed, unless a speed override is set, + equipped gear |
| Passive Perception | 10 + Perception skill bonus + equipped gear |
| Passive Investigation, Passive Insight | 10 + that skill's bonus (gear that raises the skill raises it too) |
| Hit dice | One per level, of the class's die size (shown only; not spent or tracked) |
| Spell save DC | 8 + proficiency bonus + spellcasting ability modifier |
| Spell attack bonus | Proficiency bonus + spellcasting ability modifier |
**Armor bonus** is a single number typed in on the sheet to stand for armor and
shield. There are no armor items, and equipping an inventory item does not
change AC.
**Equipped gear.** Armor, shields and other gear are items (and weapons) with
**effects**: bonuses and penalties to AC, speed, initiative, max HP, passive
Perception, ability scores, saving throws and skills. They apply on their own
while the thing is equipped, and stop when it comes off. Every number on the
sheet already includes them, and the "From Equipped Gear" card says which
item is doing what. An effect on an ability score changes the score itself,
so everything in sections 5 to 10 that is built on that ability moves too.
The full rules are in [ITEMS.md](ITEMS.md), section 5.
**Extra AC** is a number only the DM can set on the sheet, added to AC on top
of gear, for anything an item does not cover. It can be negative. (It used to
be called "armor bonus" and stood in for all armor; characters keep whatever
it was set to.)
**Speed override** exists in the data and API (DM only) but has no field on
the sheet.
@@ -405,9 +694,54 @@ Skill proficiencies are drawn at random during the build (step 5) and cannot
be changed from the sheet afterwards. Expertise is supported by the
calculations but is only ever set if the DM supplies it during step 5.
### Rolling from the sheet
Numbers with a dotted underline roll when clicked. The dice roller opens with
the result under a label saying what it was ("Aria: Stealth"), the die that
came up, the bonus and the total. A natural 20 or 1 is called out.
| Click | Rolls |
|---|---|
| An ability's modifier | d20 + the modifier (an ability check) |
| A saving throw, a skill | d20 + its bonus |
| A weapon's "to hit" | d20 + its attack bonus |
| A weapon's damage | Its dice at its gear level + the damage bonus |
| The spell attack bonus | d20 + the bonus |
| A spell's dice | Those dice |
| Initiative (on an encounter card) | d20 + the initiative bonus |
- The same numbers roll on the sheet, in the character panel, and on an
encounter card's details and the spotlight. On an encounter a monster's or
NPC's attack and damage, and a monster's ability scores, roll too (for the
DM, who is the only one sent them).
- **Advantage and disadvantage.** The roller has Disadv. / Normal / Adv.
buttons. Whichever is lit applies to every d20 rolled until it is changed:
two d20s are rolled and the higher or lower kept, and both are shown.
- A roll is made in the browser of whoever clicked. It is not sent to the
server, nobody else sees it, and it changes nothing: a damage roll is not
applied, a spell slot is not spent, and an initiative roll still has to be
typed into its field.
- Of dice written with more after them ("2d8+3 fire") the dice are rolled.
Text that does not start with dice (a DM may type anything as an item's or
a spell's dice) is shown as plain text.
- Boons that say "add it to the numbers the site shows" (Weapon Mastery,
Arcane Focus) are not in the roll either: add them to the total.
**Calling for a roll (DM).** "🎲 Call for a roll…" on the party overview, and
in an encounter's header, rolls one check for several characters at once.
Pick a skill, a saving throw or an ability check; optionally a DC, and
advantage or disadvantage; untick anyone who is not rolling. Each character
gets their own d20 and their own bonus, and with a DC each is marked "made it"
or "failed", with how many made it and whether a group check (at least half)
succeeds. On the party overview it offers every finished character who is not
dead; in an encounter, the characters in it who are still up. These rolls are
the DM's own, like any other: the players do not see them.
## 10. Gear pools, starting options and gear levels
Each campaign has three **pools**, managed by the DM on the campaign page:
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 |
|---|---|---|
@@ -424,7 +758,10 @@ character.
characters from the pool, changes how many a character has, levels them up,
and takes them away.
- **The owning player** can tick and untick Equipped (weapons, items) and
Prepared (spells) on gear their character can use, and can **give** what
Prepared (spells) on gear their character can use, at any time, including
mid-encounter. Only so much can be equipped at once: by default two things,
one of them a weapon (a campaign setting; [ITEMS.md](ITEMS.md), section 3).
A weapon has to be equipped to be used in an encounter, and can **give** what
their character holds to another character (section 12).
- Removing an entry from a pool removes it from every character holding it.
@@ -459,8 +796,9 @@ starting level, copies below it are lifted to it and unequipped.
| … | … |
Gear above the character's level can still be **held**, but it shows
"Requires level N", cannot be equipped, and is left out of quick stats and
encounters. If the DM levels a piece of gear past its holder, or lowers the
"Requires level N", cannot be equipped, gives none of its effects, and is
left out of the character's encounter card. The character panel still lists
it, marked "needs level N". If the DM levels a piece of gear past its holder, or lowers the
character's level, anything now out of reach is unequipped automatically.
**How damage grows.** The dice in the pool are the damage at the gear's
@@ -516,6 +854,10 @@ value, which is fixed.
## 11. Weapons and spells
A character holds weapons, each with a quantity, a level and an equipped flag.
Only an **equipped** weapon is in hand: it is the one shown on the character's
encounter card and offered in the DM's attack bar. One weapon can be equipped
at a time unless the campaign allows more, so a weapon for each hand is one
weapon made as a pair ([WEAPONS.md](WEAPONS.md), sections 3 and 4).
| Number | Formula |
|---|---|
@@ -540,13 +882,16 @@ 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
**Inventory** is the items a character holds from the campaign's item pool,
each with a quantity, a level and an equipped flag. Giving an item the
character already has adds to the stack. Items have no effect on any stat. An
character already has adds to the stack. An item can have **effects** that
change the sheet while it is equipped: this is how armor works
([ITEMS.md](ITEMS.md), section 5). An
item can carry optional dice (a potion's healing, a bomb's damage) that grow
with its level exactly like a weapon's (section 10).
@@ -562,8 +907,11 @@ behind it.
- The sheet's Money card shows the three balances, what the purse is worth in
gold altogether, and every change: the amount and coin, what it was for, who
recorded it, when, and that coin's balance afterwards.
- The **owning player and the DM** can both record a gain or a spend, choosing
the coin. Other players cannot see the purse or the log.
- Only the **DM** records a gain or a spend, choosing the coin: a player
cannot add money to their own purse or write it off. A player's money
moves by exchanging coins, giving to another character, and trading with
an NPC; it arrives from the DM, an encounter reward, a gift or an NPC.
Other players cannot see the purse or the log.
- **Spending makes change for you.** A spend (or a gift to another character)
is paid from the whole purse. Coins of the kind named go first; if there
aren't enough, the nearest bigger coin is broken and the change comes back
@@ -576,9 +924,24 @@ behind it.
coins: silver into gold goes in tens, copper into silver in tens, copper
into gold in hundreds.
- The log is the only way a balance changes. Entries cannot be edited or
deleted; to correct a mistake, record the opposite entry.
deleted; to correct a mistake, the DM records the opposite entry.
- Starting gold (a campaign setting) is gold only. Encounter rewards and gifts
between characters can be in any coin.
between characters can be in any coin, or in cash.
- **Cash ($)** is a fourth balance for crossover worlds, counted in whole
dollars and shown as `$40`. It is gained, spent, given and awarded like a
coin and has its own log entries, but it has no worth in coins: it cannot
be exchanged on the sheet, a cash spend is paid from cash alone, and cash
is never broken to cover a coin spend. It shows in the purse only when
held.
- **Gold and cash are changed for each other by an NPC**, not on the sheet.
An NPC the DM has given an exchange rate ("1 gold = $5") changes them
either way at that rate, in an NPC quest in progress that both the NPC and
the character are in. Only gold and cash: silver and copper are not taken.
Each NPC has their own rate. See [NPCS.md](NPCS.md), section 5.
- **Buying.** In the same kind of scene a character can buy the weapons and
items an NPC, or the encounter itself, has for sale. The price is paid from
the purse like any other spend and logged as "Bought <thing> from <NPC>".
See [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
### Giving to another character
@@ -624,11 +987,11 @@ character in the same campaign.
| Screen | What it shows |
|---|---|
| Campaign page | Character list: species, class, level, HP, AC, location. Players see their own first, then the rest of the party. |
| Party overview | One table of every character with an HP bar, AC, gold and location. |
| 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, badges for inspiration, exhaustion and conditions, AC, passive Perception / Investigation / Insight, spell save DC, Hit Dice left, spell slots left, gold and location. A 📋 button beside each name opens the character panel for that character without leaving the page. The DM's "🛏 Rest…" button is here too. A player's own characters are highlighted and marked "★ Yours". |
| Character sheet | Everything in this document. |
| Character builder | A character that is still being built (section 2). |
| Quick stats drawer | A combat summary: HP, AC, initiative, speed, proficiency bonus, spellcasting numbers, special abilities, and each weapon's attack and damage. |
| Character panel (the floating 🧙 button, on every page: bottom right, or in the top bar on a phone) | One character at a glance: HP, AC, initiative, speed, proficiency bonus, passive Perception, ability scores, Hit Dice left, inspiration, exhaustion, conditions and class counters (section 7), **saving throws and every skill** (● proficient, ◆ expertise) with passive Perception, Investigation and Insight, special abilities, what equipped gear is doing, and spellcasting numbers with the spell slot tracker; then **everything the character holds**: every weapon with its attack and damage, every item, every spell and the purse, with what is equipped and what each thing does. Gear can be equipped and unequipped from here. The saves and skills, special abilities, inventory and spells each fold away with a click on their title. A player gets their own characters in the campaign they are in; the DM gets every character, with a menu to switch. Another player's panel shows the numbers, saves, skills and weapons only. It replaced the quick stats drawer. |
| Encounter page | Characters taking part in a battle or quest (next section). |
Changes are pushed live to everyone who has the campaign open, so sheets and
@@ -666,7 +1029,7 @@ players never receive.
| | DM | Players |
|---|---|---|
| A character | Level, species, class, HP, AC, speed, initiative bonus, passive perception, proficiency bonus, ability scores, saves, usable weapons and spells, special abilities | The same |
| A character | Level, species, class, HP, AC, speed, initiative bonus, passive perception, investigation and insight, proficiency bonus, ability scores, saves, skills, usable weapons and spells, spell slots left, special abilities | The same |
| A monster | The full stat block: AC, speed, attack, damage, challenge, ability scores, trait, notes | Name, HP and conditions only |
| An NPC | Everything, including DM notes and stats | Name, role, attitude and description |
| Rewards | All of them | Only the ones that have been awarded |
@@ -731,17 +1094,34 @@ Another Campaign" card on its sheet.
| View the sheet and quick stats | Yes | Yes | Yes |
| View private notes | Yes | No | No |
| Run the builder | Yes | Yes | No |
| Change HP, temp HP, armor bonus | Yes | Yes | No |
| Change HP, temp HP | No | Yes | No |
| Set the extra AC | No | Yes | No |
| Open the character panel with the full inventory | Yes | Yes | No (numbers and weapons only) |
| Enter a death save, or stabilise | Yes | Yes | No |
| Set death-save tallies by hand, revive from dead | No | Yes | No |
| Spend a spell slot | Yes | Yes | No |
| Give a spell slot back, or all of them | No | Yes | No |
| Call a short or long rest | No | Yes | No |
| Change the Hit Dice left | No | Yes | No |
| Spend a class counter (Rage, Ki…) | Yes | Yes | No |
| Give a class counter back, or all of them | No | Yes | No |
| Give inspiration, or take it away | No | Yes | No |
| Spend inspiration | Yes | Yes | No |
| Set exhaustion; give or remove a condition | No | Yes | No |
| Level a character up (the checklist), pick a subclass or a Boon | No | Yes | No |
| Set or change the subclass on the sheet (in a campaign that uses subclasses) | No | Yes | No |
| Switch subclasses on or off for the campaign | No | Yes | No |
| Edit special abilities, background, appearance (after the build) | No | Yes | No |
| Tick Equipped / Prepared | Yes | Yes | No |
| Give, remove or re-count weapons, spells and items | No | Yes | No |
| Level a weapon or item up or down | No | Yes | No |
| Record a gold gain or spend | Yes | Yes | No |
| Record a money gain or spend | No | Yes | No |
| Exchange coins (gold, silver, copper) | Yes | Yes | No |
| Give gold, weapons, items or spells to another character | Yes | Yes | No |
| Change gold and cash with an NPC, or buy what is for sale, in a quest in progress | Yes | Yes | No |
| View the money log, gold and inventory | Yes | Yes | No |
| Change level, XP, location or speed override | No | Yes | No |
| See a character's XP | Yes | Yes | Yes |
| Post a character note | Yes | Yes | Yes |
| Copy to another campaign | Yes | Yes | No |
| Delete the character | No | Yes | No |
@@ -763,20 +1143,30 @@ 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
- Armor and shields as items (only the single armor bonus number)
- Class and subclass features beyond level 10 (the DM adds them to the sheet
by hand), subclasses beyond the three per class, the spellcasting
subclasses of non-casters (Eldritch Knight, Arcane Trickster) and
multiclassing
- Spell preparation limits and concentration
- Ability score increases and feats on level-up (a Boon is given in their
place, and Weapon Mastery and Arcane Focus are added by the player, not by
the site)
- What a class counter or a condition does: the site counts Rages, Ki and the
rest and lists conditions, but applies none of their effects
- Counters for species traits, talents and Boons, and uses the special
abilities give "a few times per day" beyond the ones in section 7
- Armor rules beyond a flat bonus to AC (no "replaces 10 + DEX", no cap on DEX)
- Weapon proficiency (everyone is proficient with everything)
- Rolling attacks, damage, saves or checks for a character (the dice roller,
bottom left of every page, is a plain roller that knows nothing about the
sheet)
- Conditions on the sheet, and resting
- Shared rolls: a roll made from the sheet is seen only by whoever clicked,
and is not recorded
- Applying a roll: damage, healing and initiative rolled this way are still
entered by hand
- The effects of exhaustion (its level is recorded; speed, HP maximum and
rolls are not changed)
- Automatic levelling from XP
- Alignment, languages, tool proficiencies and backgrounds-as-rules
- Encumbrance and item weight
- Currencies other than gold, and item prices
- Cents, and changing silver or copper for cash
## 20. Everything the builder offers
@@ -1022,3 +1412,340 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
| Bright colours | — |
| Plain, practical clothes | — |
<!-- END GENERATED: builder-lists -->
## 21. What each class gains as it levels
What "⬆ Level Up…" writes into Special Abilities at each level (section 6),
the Boons, and under each class what its three subclasses add. "Replaces"
means the line the character already has by that name is rewritten.
This list is written from the app's own data by `npm run docs:lists` (in `/backend`). Re-run it after changing the data; do not edit the list by hand.
<!-- BEGIN GENERATED: level-features -->
### Boons
| Boon | First time | Can be taken |
|---|---|---|
| Weapon Mastery | +1 to attack and damage rolls with weapons (add it to the numbers the site shows). | 2 times |
| Arcane Focus | +1 to spell save DC and spell attack bonus (add it to the numbers the site shows). | 2 times |
| Toughened | +1 AC (counted in your Extra AC). | Once |
| Hardy | +5 maximum HP: gain 5 Temp HP after each long rest. | 2 times |
### Barbarian
| Level | Written into Special Abilities |
|---|---|
| 2 | **Reckless Attack**: On your first attack each turn you may attack with advantage; attacks against you have advantage until your next turn.<br>**Danger Sense**: Advantage on Dexterity saves against effects you can see (traps, breath, spells). |
| 3 | **Rage** (replaces): Three times per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved. |
| 4 | **Boon** (choose one) |
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Fast Movement**: +10 ft speed when not in heavy armor (ask your DM to raise your speed). |
| 6 | **Rage** (replaces): Four times per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved. |
| 7 | **Feral Instinct**: Advantage on initiative; you can act normally in a surprise round if you rage first. |
| 8 | **Boon** (choose one) |
| 9 | **Brutal Critical**: Roll one extra weapon damage die on a critical hit.<br>**Rage** (replaces): Four times per day, for one minute: +3 melee damage, advantage on Strength checks and saves, and physical damage against you is halved. |
| 10 | No new feature |
**Barbarian subclasses** (chosen at level 3). What each adds to the table above:
| Subclass | Level | Written into Special Abilities |
|---|---|---|
| **Path of the Berserker**<br>Rage as pure fury: extra attacks now, exhaustion after. | 3 | **Frenzy**: Path of the Berserker. While raging you may make one extra melee attack as a bonus action each turn; when the rage ends, gain one level of exhaustion. |
| — | 6 | **Mindless Rage**: You can’t be charmed or frightened while raging. |
| — | 10 | **Intimidating Presence**: Action: one creature within 30 ft makes a Wisdom save (DC 8 + proficiency bonus + Charisma modifier) or is frightened of you until the end of your next turn. |
| **Path of the Totem Warrior**<br>A spirit animal walks with you and lends its strength. | 3 | **Spirit Seeker**: Path of the Totem Warrior. You can speak with beasts and sense what they sense, as a ritual with your DM.<br>**Totem Spirit**: Choose one with your DM. Bear: while raging, all damage but psychic is halved. Eagle: while raging, opportunity attacks against you have disadvantage and you can Dash as a bonus action. Wolf: while raging, allies have advantage on melee attacks against enemies next to you. |
| — | 6 | **Aspect of the Beast**: Choose one with your DM. Bear: carry twice as much, advantage on Strength checks to push, pull or lift. Eagle: see clearly up to a mile, no penalty in dim light. Wolf: track at a fast pace and move stealthily at a normal one. |
| — | 10 | **Spirit Walker**: As a ritual, ask the spirits of a place for what they know (the DM answers as a nature spirit would). |
| **Path of the Zealot**<br>A god’s fury in your blows, and death has trouble keeping you. | 3 | **Divine Fury**: Path of the Zealot. While raging, the first creature you hit each turn takes an extra 1d6 + half your level radiant or necrotic damage.<br>**Warrior of the Gods**: Spells that bring you back from the dead cost nothing to cast on you. |
| — | 6 | **Fanatical Focus**: Once per rage, reroll a saving throw you just failed. |
| — | 10 | **Zealous Presence**: Once per long rest, bonus action: up to ten allies within 60 ft have advantage on attacks and saves until your next turn. |
### Bard
| Level | Written into Special Abilities |
|---|---|
| 2 | **Jack of All Trades**: Add half your proficiency bonus (round down) to any check you are not proficient in.<br>**Song of Rest**: During a short rest, allies who spend Hit Dice heal an extra 1d6. |
| 3 | **Expertise**: Double proficiency on two proficient skills (add by hand). |
| 4 | **Boon** (choose one) |
| 5 | **Bardic Inspiration** (replaces): A few times per day, as a bonus action, give an ally a d8 to add to one attack, check or save.<br>**Font of Inspiration**: Regain Bardic Inspiration on a short rest.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
| 6 | **Countercharm**: Action: allies within 30 ft get advantage on saves against fear and charm until your next turn. |
| 7 | No new feature |
| 8 | **Boon** (choose one) |
| 9 | **Song of Rest** (replaces): During a short rest, allies who spend Hit Dice heal an extra 1d8. |
| 10 | **Bardic Inspiration** (replaces): A few times per day, as a bonus action, give an ally a d10 to add to one attack, check or save.<br>**Expertise** (replaces): Double proficiency on four proficient skills (add by hand).<br>**Magical Secrets**: Learn two more spells from any class (the DM grants them within the unlock schedule). |
**Bard subclasses** (chosen at level 3). What each adds to the table above:
| Subclass | Level | Written into Special Abilities |
|---|---|---|
| **College of Lore**<br>Knows a bit of everything, and a word to trip anyone up. | 3 | **Cutting Words**: College of Lore. Reaction: spend a Bardic Inspiration to subtract the die from an enemy’s attack, check or damage roll.<br>**Bonus Proficiencies**: Treat three extra skills of your choice as proficient (add your proficiency bonus by hand). |
| — | 6 | **Additional Magical Secrets**: Learn two spells from any class list (the DM grants them within the unlock schedule). |
| **College of Valor**<br>A skald in the front line, inspiring by example. | 3 | **Combat Inspiration**: College of Valor. A creature with your Bardic Inspiration may add the die to a weapon damage roll, or to its AC against one attack as a reaction.<br>**Battle Training**: You fight in medium armor and with a shield (the DM raises Extra AC to match what you wear). |
| — | 6 | **Extra Attack**: Attack twice when you take the Attack action. |
| **College of Swords**<br>A blade dancer whose performance is the fight itself. | 3 | **Fighting Style**: College of Swords. Choose one with your DM: +2 damage with a one-handed weapon and nothing in the other hand, or add your ability modifier to the damage of a second weapon (added by hand).<br>**Blade Flourish**: When you attack on your turn, +10 ft speed that turn. On a hit, spend a Bardic Inspiration and add the die to the damage, and choose: add it to your AC until your next turn, hit a second creature next to you for the die’s roll, or push the target 5 ft + the roll and follow it. |
| — | 6 | **Extra Attack**: Attack twice when you take the Attack action. |
### Cleric
| Level | Written into Special Abilities |
|---|---|
| 2 | **Channel Divinity**: Once per short rest, use Turn Undead or your domain’s Channel Divinity.<br>**Turn Undead**: Channel Divinity. Action: undead within 30 ft make a Wisdom save or flee from you for 1 minute. |
| 3 | No new feature |
| 4 | **Boon** (choose one) |
| 5 | **Destroy Undead**: Undead of CR 1/2 or lower that fail against your Turn Undead are destroyed.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
| 6 | **Channel Divinity** (replaces): Twice per short rest, use Turn Undead or your domain’s Channel Divinity. |
| 7 | No new feature |
| 8 | **Boon** (choose one)<br>**Destroy Undead** (replaces): Undead of CR 1 or lower that fail against your Turn Undead are destroyed. |
| 9 | No new feature |
| 10 | **Divine Intervention**: Once per week, call on your god: roll d100; if you roll your level or lower, the god helps (the DM decides how). |
**Cleric subclasses** (chosen at level 2). What each adds to the table above:
| Subclass | Level | Written into Special Abilities |
|---|---|---|
| **Life Domain**<br>The healer: more from every cure, and healing in a burst. | 2 | **Preserve Life**: Channel Divinity. Action: share out healing of 5 × your level among creatures within 30 ft (up to half their max HP each). |
| — | 6 | **Blessed Healer**: When you heal someone else with a spell, you heal yourself 2 + the spell’s level. |
| — | 8 | **Divine Strike**: Once per turn, a weapon hit deals +1d8 radiant damage. |
| **Light Domain**<br>Fire and radiance: blinds attackers and burns the dark away. | 2 | **Warding Flare**: Light Domain. Reaction, a few times per day (your Wisdom modifier, at least once): a creature within 30 ft that attacks you has disadvantage on that attack.<br>**Radiance of the Dawn**: Channel Divinity. Action: magical darkness within 30 ft is dispelled, and each enemy there takes 2d10 + your level radiant damage (Constitution save for half). |
| — | 6 | **Improved Flare**: Use Warding Flare when a creature attacks someone else within 30 ft of you. |
| — | 8 | **Potent Spellcasting**: Add your Wisdom modifier to the damage of your cleric cantrips. |
| **War Domain**<br>A soldier of the faith: extra strikes and blows that land. | 2 | **War Priest**: War Domain. A few times per day (your Wisdom modifier, at least once), after you take the Attack action, make one weapon attack as a bonus action.<br>**Guided Strike**: Channel Divinity. When you make an attack roll, add +10 to it after seeing the roll. |
| — | 6 | **War God’s Blessing**: Channel Divinity. Reaction: give +10 to an attack roll made by a creature within 30 ft. |
| — | 8 | **Divine Strike**: Once per turn, a weapon hit deals +1d8 damage of the weapon’s type. |
### Druid
| Level | Written into Special Abilities |
|---|---|
| 2 | **Wild Shape** (replaces): Twice per short rest, become a beast you have seen of CR 1/4 or less (no flying or swimming), for up to half your level in hours. Use the beast's HP; when it hits 0 you return to your own form. |
| 3 | No new feature |
| 4 | **Boon** (choose one)<br>**Wild Shape** (replaces): Twice per short rest, become a beast you have seen of CR 1/2 or less (it may swim, not fly), for up to half your level in hours. Use the beast's HP; when it hits 0 you return to your own form. |
| 5 | **Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
| 6 | No new feature |
| 7 | No new feature |
| 8 | **Boon** (choose one)<br>**Wild Shape** (replaces): Twice per short rest, become a beast you have seen of CR 1 or less (it may swim or fly), for up to half your level in hours. Use the beast's HP; when it hits 0 you return to your own form. |
| 9 | No new feature |
| 10 | No new feature |
**Druid subclasses** (chosen at level 2). What each adds to the table above:
| Subclass | Level | Written into Special Abilities |
|---|---|---|
| **Circle of the Land**<br>A keeper of old magic, at home in the wild places. | 2 | **Natural Recovery**: Once per day on a short rest, recover spent spell slots totalling up to half your level (rounded up).<br>**Bonus Cantrip**: Learn one extra cantrip (the DM grants it). |
| — | 6 | **Land’s Stride**: Ordinary difficult terrain and plants do not slow you; advantage on saves against magical plants. |
| — | 10 | **Nature’s Ward**: Immune to poison and disease; you can’t be charmed or frightened by fey or elementals. |
| **Circle of the Moon**<br>A shapeshifter who fights as the beast. | 2 | **Combat Wild Shape**: Circle of the Moon. Use Wild Shape as a bonus action. While in beast form, bonus action: spend a spell slot to heal 1d8 per slot level.<br>**Circle Forms**: Your Wild Shape can be a beast of up to CR 1; from level 6, up to CR equal to a third of your level (rounded down). |
| — | 6 | **Primal Strike**: Your attacks in beast form count as magical. |
| — | 10 | **Elemental Wild Shape**: Spend both uses of Wild Shape at once to become an air, earth, fire or water elemental. |
| **Circle of Stars**<br>Reads the night sky and wears its constellations. | 2 | **Star Map**: Circle of Stars. You know the Guidance cantrip, and cast Guiding Bolt without a slot a number of times per day equal to your proficiency bonus (the DM grants both).<br>**Starry Form**: Bonus action: spend a Wild Shape use to take a starry form for 10 minutes. Choose Archer (bonus action: ranged spell attack, 60 ft, 1d8 + Wisdom modifier radiant), Chalice (when you cast a healing spell, another creature within 30 ft heals 1d8 + Wisdom modifier) or Dragon (treat a roll of 9 or lower as 10 on Intelligence and Wisdom checks and on concentration saves). |
| — | 6 | **Cosmic Omen**: After a long rest roll a die. Even (Weal): reaction, add 1d6 to a creature’s attack, check or save. Odd (Woe): reaction, subtract 1d6. Usable a number of times per day equal to your proficiency bonus, on creatures within 30 ft. |
| — | 10 | **Twinkling Constellations**: Archer and Chalice use 2d8. In Dragon form you can fly 20 ft. At the start of each turn in Starry Form you may change constellation. |
### Fighter
| Level | Written into Special Abilities |
|---|---|
| 2 | **Action Surge**: Once per short rest, take one extra action on your turn. |
| 3 | No new feature |
| 4 | **Boon** (choose one) |
| 5 | **Extra Attack**: Attack twice when you take the Attack action. |
| 6 | **Boon** (choose one) |
| 7 | No new feature |
| 8 | **Boon** (choose one) |
| 9 | **Indomitable**: Once per long rest, reroll a failed saving throw. |
| 10 | No new feature |
**Fighter subclasses** (chosen at level 3). What each adds to the table above:
| Subclass | Level | Written into Special Abilities |
|---|---|---|
| **Champion**<br>Simple and deadly: more critical hits, more athletic. | 3 | **Improved Critical**: Your weapon attacks score a critical hit on a roll of 19 or 20. |
| — | 7 | **Remarkable Athlete**: Add half your proficiency bonus to Strength, Dexterity and Constitution checks you are not proficient in; long jump + Strength modifier feet. |
| — | 10 | **Additional Fighting Style**: Choose a second option from your Fighting Style list. (If it is +1 AC in armor, the DM raises Extra AC by 1.) |
| **Battle Master**<br>A tactician who turns each swing into a manoeuvre. | 3 | **Combat Superiority**: Battle Master. Four superiority dice (d8), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier.<br>**Manoeuvres**: Choose three with your DM: Trip Attack (add the die to damage; Strength save or prone), Disarming Attack (add the die; Strength save or it drops what it holds), Pushing Attack (add the die; Strength save or pushed 15 ft), Precision Attack (add the die to an attack roll), Riposte (reaction when a creature misses you: attack it and add the die), Parry (reaction: reduce melee damage by the die + Dexterity modifier), Rally (bonus action: an ally gains the die + Charisma modifier Temp HP), Menacing Attack (add the die; Wisdom save or frightened until your next turn). |
| — | 7 | **Know Your Enemy**: After a minute watching a creature, the DM tells you whether it is your equal, better or worse in two of: Strength, Dexterity, Constitution, AC, current HP, level.<br>**Combat Superiority** (replaces): Battle Master. Five superiority dice (d8), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier.<br>**Manoeuvres**: Choose two more with your DM (five in all) from the list on your level 3 line. |
| — | 10 | **Combat Superiority** (replaces): Battle Master. Five superiority dice (d10), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier. Choose two more manoeuvres with your DM (seven in all). |
| **Samurai**<br>Unbreakable resolve: strikes with advantage and shrugs off blows. | 3 | **Fighting Spirit**: Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 5 Temp HP.<br>**Bonus Proficiency**: Treat one of History, Insight, Performance or Persuasion as proficient (add your proficiency bonus by hand). |
| — | 7 | **Elegant Courtier**: Add your Wisdom modifier to Persuasion checks, and treat Wisdom saves as proficient (both added by hand). |
| — | 10 | **Tireless Spirit**: If you roll initiative with no uses of Fighting Spirit left, you regain one.<br>**Fighting Spirit** (replaces): Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 10 Temp HP. |
### Monk
| Level | Written into Special Abilities |
|---|---|
| 2 | **Ki**: Points equal to your level; they come back on a short rest. Ki save DC is 8 + proficiency bonus + Wisdom modifier.<br>**Flurry of Blows**: 1 ki: two unarmed strikes as a bonus action.<br>**Patient Defense**: 1 ki: Dodge as a bonus action.<br>**Step of the Wind**: 1 ki: Dash or Disengage as a bonus action, jump distance doubled.<br>**Unarmored Movement**: +10 ft speed without armor (ask your DM to raise your speed). |
| 3 | **Deflect Missiles**: Reaction: reduce ranged weapon damage by 1d10 + Dexterity modifier + your level. |
| 4 | **Boon** (choose one)<br>**Slow Fall**: Reaction: reduce falling damage by 5 × your level. |
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Stunning Strike**: 1 ki after a melee hit: the target makes a Constitution save or is stunned until your next turn.<br>**Martial Arts** (replaces): Your unarmed strikes deal 1d6 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action. |
| 6 | **Ki-Empowered Strikes**: Your unarmed strikes count as magical.<br>**Unarmored Movement** (replaces): +15 ft speed without armor (ask your DM to raise your speed). |
| 7 | **Evasion**: On a Dexterity save for half damage, take none on a success and half on a failure.<br>**Stillness of Mind**: Action: end charm or fear on yourself. |
| 8 | **Boon** (choose one) |
| 9 | **Unarmored Movement** (replaces): +15 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
| 10 | **Purity of Body**: Immune to poison and disease.<br>**Unarmored Movement** (replaces): +20 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
**Monk subclasses** (chosen at level 3). What each adds to the table above:
| Subclass | Level | Written into Special Abilities |
|---|---|---|
| **Way of the Open Hand**<br>Pure martial arts: knocks down, pushes away, heals itself. | 3 | **Open Hand Technique**: Flurry hits can knock a target prone (Dexterity save), push it 15 ft (Strength save) or stop its reactions. |
| — | 6 | **Wholeness of Body**: Once per long rest, heal 3 × your level as an action. |
| **Way of Shadow**<br>A ninja: steps between shadows and strikes unseen. | 3 | **Shadow Arts**: Way of Shadow. 2 ki: cast Darkness, Darkvision, Pass without Trace or Silence without a slot. You can also make a small illusion at will. |
| — | 6 | **Shadow Step**: Bonus action, while in dim light or darkness: teleport up to 60 ft to another dim or dark spot you can see, and have advantage on your first melee attack this turn. |
| **Way of the Four Elements**<br>Bends fire, water, earth and air with ki. | 3 | **Elemental Attunement**: Way of the Four Elements. Action: a harmless trick of fire, water, earth or air within 30 ft (light or snuff a flame, chill or warm, shape a little earth or mist).<br>**Elemental Disciplines**: Choose one with your DM: Fangs of the Fire Snake (1 ki: your unarmed reach is 10 ft this turn and hits deal fire damage; +1 ki for +1d10 fire), Fist of Unbroken Air (2 ki: one creature within 30 ft takes 3d10 bludgeoning, is pushed 20 ft and knocked prone; Strength save for half and no push), Water Whip (2 ki: one creature within 30 ft takes 3d10 bludgeoning and is pulled 25 ft or knocked prone; Dexterity save for half), Shape the Flowing River (1 ki: reshape or freeze water or ice in a 30 ft cube). |
| — | 6 | **Elemental Disciplines**: Choose one more with your DM (two in all). New at this level: Gong of the Summit (3 ki: cast Shatter), Clench of the North Wind (3 ki: cast Hold Person). |
### Paladin
| Level | Written into Special Abilities |
|---|---|
| 2 | **Divine Smite**: After a melee hit, spend a spell slot to deal +2d8 radiant (+1d8 per slot level above 1st; +1d8 more against undead and fiends).<br>**Fighting Style**: Choose one with your DM: +1 AC in armor (the DM raises Extra AC by 1), +2 damage with a one-handed weapon, or protect an adjacent ally (impose disadvantage on an attack as a reaction).<br>**Spellcasting**: Paladin spells unlock: you now have spell slots. |
| 3 | **Divine Health**: Immune to disease. |
| 4 | **Boon** (choose one) |
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
| 6 | **Aura of Protection**: You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious. |
| 7 | No new feature |
| 8 | **Boon** (choose one) |
| 9 | No new feature |
| 10 | **Aura of Courage**: You and allies within 10 ft can’t be frightened. |
**Paladin subclasses** (chosen at level 3). What each adds to the table above:
| Subclass | Level | Written into Special Abilities |
|---|---|---|
| **Oath of Devotion**<br>The shining knight: honesty, courage, a blessed blade. | 3 | **Sacred Weapon**: Oath of Devotion, Channel Divinity. Add your Charisma modifier to attack rolls with your weapon for 1 minute; it sheds light.<br>**Turn the Unholy**: Channel Divinity. Fiends and undead within 30 ft make a Wisdom save or flee for 1 minute. |
| — | 7 | **Aura of Devotion**: You and allies within 10 ft can’t be charmed. |
| **Oath of the Ancients**<br>A green knight who guards life, light and laughter. | 3 | **Nature’s Wrath**: Oath of the Ancients, Channel Divinity. Spectral vines restrain a creature within 10 ft (Strength or Dexterity save; it repeats the save at the end of each of its turns).<br>**Turn the Faithless**: Channel Divinity. Fey and fiends within 30 ft make a Wisdom save or flee for 1 minute. |
| — | 7 | **Aura of Warding**: You and allies within 10 ft take half damage from spells. |
| **Oath of Vengeance**<br>A relentless hunter of one sworn enemy at a time. | 3 | **Vow of Enmity**: Oath of Vengeance, Channel Divinity. Bonus action: for 1 minute you have advantage on attack rolls against one creature within 10 ft.<br>**Abjure Enemy**: Channel Divinity. One creature within 60 ft makes a Wisdom save or is frightened and cannot move for 1 minute; on a success its speed is halved. |
| — | 7 | **Relentless Avenger**: When you hit with an opportunity attack, move up to half your speed straight afterwards without provoking attacks. |
### Ranger
| Level | Written into Special Abilities |
|---|---|
| 2 | **Fighting Style**: Choose one with your DM: +2 to ranged attacks, +1 AC in armor (the DM raises Extra AC by 1), or add your ability modifier again to a paired weapon’s damage (added by hand).<br>**Spellcasting**: Ranger spells unlock: you now have spell slots. |
| 3 | **Primeval Awareness**: Spend a spell slot: sense aberrations, dragons, fey, fiends, undead and elementals within 1 mile for 1 minute per slot level. |
| 4 | **Boon** (choose one) |
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
| 6 | **Favored Enemy**: Choose a second favored enemy with your DM.<br>**Natural Explorer**: Choose a second favored terrain with your DM. |
| 7 | No new feature |
| 8 | **Boon** (choose one)<br>**Land’s Stride**: Ordinary difficult terrain and plants do not slow you. |
| 9 | No new feature |
| 10 | **Hide in Plain Sight**: Spend 1 minute camouflaging: +10 to Stealth while you stay still.<br>**Natural Explorer**: Choose a third favored terrain with your DM. |
**Ranger subclasses** (chosen at level 3). What each adds to the table above:
| Subclass | Level | Written into Special Abilities |
|---|---|---|
| **Hunter**<br>Brings down the biggest thing in the room. | 3 | **Colossus Slayer**: Hunter’s Prey. Once per turn, +1d8 damage to a creature that is already below its max HP. |
| — | 7 | **Escape the Horde**: Defensive Tactics. Opportunity attacks against you have disadvantage. |
| **Beast Master**<br>Fights beside an animal companion. | 3 | **Ranger’s Companion**: Beast Master. A beast of CR 1/4 or lower fights beside you (the DM adds it to encounters as a monster). It acts on your turn: it can move freely, and attacks, Dashes, Disengages or Helps when you use your action to tell it to. Add your proficiency bonus to its AC, attacks and damage. If it dies, bond with another over 8 hours. |
| — | 7 | **Exceptional Training**: On a turn your companion does not attack, it can Dash, Disengage or Help as your bonus action. Its attacks count as magical. |
| **Gloom Stalker**<br>An ambusher who owns the first round and the dark. | 3 | **Dread Ambusher**: Gloom Stalker. Add your Wisdom modifier to initiative (ask your DM to count it). On your first turn of a fight: +10 ft speed and one extra weapon attack, which deals +1d8 damage on a hit.<br>**Umbral Sight**: You see in darkness to 60 ft, and creatures that rely on darkvision cannot see you in the dark. |
| — | 7 | **Iron Mind**: Treat Wisdom saves as proficient (add your proficiency bonus by hand). |
### Rogue
| Level | Written into Special Abilities |
|---|---|
| 2 | **Cunning Action**: Bonus action: Dash, Disengage or Hide. |
| 3 | **Sneak Attack** (replaces): Once per turn, deal an extra 2d6 damage to a target you have advantage against, or that has one of your allies next to it. |
| 4 | **Boon** (choose one) |
| 5 | **Sneak Attack** (replaces): Once per turn, deal an extra 3d6 damage to a target you have advantage against, or that has one of your allies next to it.<br>**Uncanny Dodge**: Reaction: halve the damage of an attack that hits you. |
| 6 | **Expertise**: Double proficiency on two more proficient skills (add by hand). |
| 7 | **Sneak Attack** (replaces): Once per turn, deal an extra 4d6 damage to a target you have advantage against, or that has one of your allies next to it.<br>**Evasion**: On a Dexterity save for half damage, take none on a success and half on a failure. |
| 8 | **Boon** (choose one) |
| 9 | **Sneak Attack** (replaces): Once per turn, deal an extra 5d6 damage to a target you have advantage against, or that has one of your allies next to it. |
| 10 | **Boon** (choose one) |
**Rogue subclasses** (chosen at level 3). What each adds to the table above:
| Subclass | Level | Written into Special Abilities |
|---|---|---|
| **Thief**<br>Quick fingers, rooftops and anything that can be used. | 3 | **Fast Hands**: Bonus action: Sleight of Hand, pick a lock, disarm a trap or Use an Object.<br>**Second-Story Work**: Climbing costs no extra movement; running jumps + Dexterity modifier feet. |
| — | 9 | **Supreme Sneak**: Advantage on Stealth if you move no more than half your speed. |
| **Assassin**<br>Strikes first, and the first strike is the worst. | 3 | **Assassinate**: Assassin. Advantage on attacks against any creature that has not yet taken a turn in this fight, and any hit on a surprised creature is a critical hit.<br>**Tools of the Trade**: You are practised with disguises and poisons: advantage on checks to make or use them. |
| — | 9 | **Infiltration Expertise**: With a week and 25 gold you build a false identity with papers and a history; nobody doubts it without an obvious reason. |
| **Swashbuckler**<br>A duellist: in, strike, out, and a smile. | 3 | **Fancy Footwork**: Swashbuckler. A creature you made a melee attack against this turn cannot make opportunity attacks against you until the turn ends.<br>**Rakish Audacity**: Add your Charisma modifier to initiative (ask your DM to count it). You can Sneak Attack a creature with nobody else next to you or it, without needing advantage. |
| — | 9 | **Panache**: Action: Persuasion against a creature’s Insight. A hostile one has disadvantage on attacks against anyone but you for 1 minute; any other is charmed by you for 1 minute. |
### Sorcerer
| Level | Written into Special Abilities |
|---|---|
| 2 | **Font of Magic**: Sorcery points equal to your level; they come back on a long rest. Bonus action: turn points into a spell slot (2 points = 1st, 3 = 2nd, 5 = 3rd, 6 = 4th, 7 = 5th), or a spell slot into points equal to its level. |
| 3 | **Metamagic**: Choose two with your DM: Quickened Spell (2 points: cast an action spell as a bonus action), Twinned Spell (points = spell level: hit a second target with a single-target spell), Careful Spell (1 point: allies auto-succeed on your spell’s save), Subtle Spell (1 point: cast without words or gestures), Extended Spell (1 point: double duration), Heightened Spell (3 points: one target has disadvantage on its first save). |
| 4 | **Boon** (choose one) |
| 5 | **Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
| 6 | No new feature |
| 7 | No new feature |
| 8 | **Boon** (choose one) |
| 9 | No new feature |
| 10 | **Metamagic**: Choose a third option with your DM. |
**Sorcerer subclasses** (chosen at level 3). What each adds to the table above:
| Subclass | Level | Written into Special Abilities |
|---|---|---|
| **Draconic Bloodline**<br>Dragon blood: tougher skin and one element at your call. | 3 | **Dragon Ancestor**: Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier (ask your DM about Extra AC). |
| — | 6 | **Elemental Affinity**: Draconic Bloodline. Add your Charisma modifier to one damage roll of your dragon’s element; spend 1 sorcery point to resist that element for 1 hour. |
| **Wild Magic**<br>Raw chaos: luck you can bend, and surprises you cannot. | 3 | **Wild Magic Surge**: Wild Magic. After you cast a sorcerer spell of 1st level or higher, the DM may have you roll a d20: on a 1, something strange happens (the DM decides, or rolls on a table of their own).<br>**Tides of Chaos**: Once per long rest, gain advantage on one attack, check or save. The DM may give it back by calling for a Wild Magic Surge. |
| — | 6 | **Bend Luck**: Reaction, 2 sorcery points: add or subtract 1d4 from another creature’s attack, check or save. |
| **Storm Sorcery**<br>Wind and lightning: never stands still for long. | 3 | **Tempestuous Magic**: Storm Sorcery. Bonus action, just before or after casting a spell of 1st level or higher: fly 10 ft without provoking opportunity attacks.<br>**Wind Speaker**: You speak the tongue of air, water, earth and fire elementals. |
| — | 6 | **Heart of the Storm**: You resist lightning and thunder damage. When you start casting a lightning or thunder spell of 1st level or higher, creatures of your choice within 10 ft take half your level in lightning or thunder damage.<br>**Storm Guide**: Action: stop rain falling within 20 ft of you. Bonus action: choose which way the wind blows within 100 ft until your next turn. |
### Warlock
| Level | Written into Special Abilities |
|---|---|
| 2 | **Eldritch Invocations**: Choose two with your DM: Agonizing Blast (add your Charisma modifier to each Eldritch Blast hit), Devil’s Sight (see normally in darkness, even magical darkness, to 120 ft), Repelling Blast (Eldritch Blast hits push the target 10 ft away), Mask of Many Faces (cast Disguise Self at will), Beguiling Influence (proficiency in Deception and Persuasion, added by hand), Eyes of the Rune Keeper (read any writing). |
| 3 | **Pact Boon**: Choose one with your DM: Pact of the Blade (summon a weapon of your choice as an action; it counts as magical), Pact of the Chain (gain a small familiar spirit: an imp, a sprite or a cat-shaped shadow), or Pact of the Tome (a book of three extra cantrips from any class, which the DM grants). |
| 4 | **Boon** (choose one) |
| 5 | **Eldritch Invocations**: Choose one more with your DM (three in all). From level 5 the list adds Thirsting Blade (Pact of the Blade: attack twice with your pact weapon) and One with Shadows (become invisible while standing still in dim light or darkness).<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
| 6 | No new feature |
| 7 | **Eldritch Invocations**: Choose one more with your DM (four in all). From level 5 the list adds Thirsting Blade (Pact of the Blade: attack twice with your pact weapon) and One with Shadows (become invisible while standing still in dim light or darkness). |
| 8 | **Boon** (choose one) |
| 9 | **Eldritch Invocations**: Choose one more with your DM (five in all). From level 5 the list adds Thirsting Blade (Pact of the Blade: attack twice with your pact weapon) and One with Shadows (become invisible while standing still in dim light or darkness). |
| 10 | No new feature |
**Warlock subclasses** (chosen at level 3). What each adds to the table above:
| Subclass | Level | Written into Special Abilities |
|---|---|---|
| **The Fiend**<br>A pact with something from the hells: power fed by the fallen. | 3 | **Dark One’s Blessing**: The Fiend. When you bring a hostile creature to 0 HP, gain Temp HP equal to your Charisma modifier + your level (the DM adds it). |
| — | 6 | **Patron’s Luck**: Once per short rest, add a d10 to an ability check or saving throw after seeing the roll. |
| — | 10 | **Patron’s Resilience**: After each rest, choose one damage type: you resist it until you choose another. |
| **The Archfey**<br>A fey lord’s favourite: glamour, vanishing and beguilement. | 3 | **Fey Presence**: The Archfey. Once per short rest, action: each creature in a 10 ft cube from you makes a Wisdom save or is charmed or frightened (your choice) until the end of your next turn. |
| — | 6 | **Misty Escape**: Once per short rest, reaction when you take damage: turn invisible and teleport up to 60 ft. You stay invisible until your next turn, or until you attack or cast. |
| — | 10 | **Beguiling Defenses**: You can’t be charmed. When a creature tries, turn it back: it makes a Wisdom save or is charmed by you for 1 minute. |
| **The Great Old One**<br>Something vast and unknowable whispers through you. | 3 | **Awakened Mind**: The Great Old One. Speak mind to mind with any creature you can see within 30 ft; it needs no shared language, only to know one. |
| — | 6 | **Entropic Ward**: Once per short rest, reaction: an attack against you has disadvantage. If it misses, your next attack against that creature has advantage. |
| — | 10 | **Thought Shield**: Nobody reads your thoughts unless you allow it. You resist psychic damage, and whoever deals it to you takes the same amount. |
### Wizard
| Level | Written into Special Abilities |
|---|---|
| 2 | No new feature |
| 3 | No new feature |
| 4 | **Boon** (choose one) |
| 5 | **Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
| 6 | No new feature |
| 7 | No new feature |
| 8 | **Boon** (choose one) |
| 9 | No new feature |
| 10 | No new feature |
**Wizard subclasses** (chosen at level 2). What each adds to the table above:
| Subclass | Level | Written into Special Abilities |
|---|---|---|
| **School of Evocation**<br>Blasting, with friends left standing in the middle of it. | 2 | **Evocation Savant**: School of Evocation. Copying evocation spells costs half the time and gold.<br>**Sculpt Spells**: When you cast an evocation spell that hits an area, choose 1 + the spell’s level creatures to automatically save and take no damage. |
| — | 6 | **Potent Cantrip**: Creatures that save against your damaging cantrips still take half damage. |
| — | 10 | **Empowered Evocation**: Add your Intelligence modifier to the damage of your evocation spells. |
| **School of Abjuration**<br>Wards and counters: a shield of magic that takes the hits. | 2 | **Abjuration Savant**: School of Abjuration. Copying abjuration spells costs half the time and gold.<br>**Arcane Ward**: When you cast an abjuration spell of 1st level or higher, a ward appears with HP of twice your level + your Intelligence modifier; it takes damage before you do (the DM tracks it as Temp HP). It lasts until a long rest; later abjuration spells restore twice their level to it. |
| — | 6 | **Projected Ward**: Reaction: when a creature within 30 ft takes damage, your Arcane Ward takes it instead. |
| — | 10 | **Improved Abjuration**: Add your proficiency bonus to ability checks made as part of an abjuration spell (Counterspell, Dispel Magic). |
| **School of Illusion**<br>Tricks of light and sound that hold up to a second look. | 2 | **Illusion Savant**: School of Illusion. Copying illusion spells costs half the time and gold.<br>**Improved Minor Illusion**: You know Minor Illusion (the DM grants it), and it makes a sound and an image with one casting. |
| — | 6 | **Malleable Illusions**: Action: change what an illusion of yours looks like while it lasts, if it lasts a minute or more. |
| — | 10 | **Illusory Self**: Once per short rest, reaction when a creature attacks you: a copy of you takes the blow and the attack misses. |
<!-- END GENERATED: level-features -->
+326 -45
View File
@@ -19,9 +19,10 @@ This describes the code as it stands. The rules live in
7. [Initiative, turns and rounds](#7-initiative-turns-and-rounds)
8. [Conditions](#8-conditions)
9. [Rewards](#9-rewards)
10. [What each side sees](#10-what-each-side-sees)
11. [Permissions](#11-permissions)
12. [What is not modelled](#12-what-is-not-modelled)
10. [Trade](#10-trade)
11. [What each side sees](#11-what-each-side-sees)
12. [Permissions](#12-permissions)
13. [What is not modelled](#13-what-is-not-modelled)
---
@@ -46,11 +47,12 @@ time from the top of the page.
| | Battle | NPC quest |
|---|---|---|
| For | A fight | A scene built around people and an objective |
| Layout | Everyone in one list, in initiative order | Three groups: "People you meet", "The party", "Creatures" |
| Layout | Everyone in one list, in initiative order; once in progress, those who have had their turn move to "Next Round" at the bottom | Three groups: "People you meet", "The party", "Creatures" |
| Rounds and turns | Yes | No |
| Initiative | The d20 roll, typed in per combatant; the bonus is added | The same; each group lists highest first |
| Start button | Begin Battle | Begin Quest |
| End button | End Battle | Complete Quest |
| Trade (section 10) | No | Yes, while in progress |
Everything else (hit points, conditions, rewards, the scene description, DM
notes) works the same in both.
@@ -75,17 +77,25 @@ 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,
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.
**Archive.** "Archive" (on the list or the encounter page) puts an encounter
away without changing it. It moves to the DM's "Archived" section and
disappears from the players' list; a player whose character is in it can still
@@ -111,17 +121,74 @@ The DM adds people from the "Add to the scene" panel.
| Kind | Comes from | Can be added more than once |
|---|---|---|
| Character | The campaign's finished characters | No |
| Character | The campaign's finished characters, except dead ones | No |
| Monster | The campaign's monster list ([MONSTERS.md](MONSTERS.md)) | Yes |
| NPC | The campaign's NPC list ([NPCS.md](NPCS.md)) | No |
**Several monsters at once.** With "Monster" chosen there is a number beside
Add, 1 to 20. More than one adds that many copies, numbered: "Goblin 1" to
"Goblin 4". Adding more later carries on from the highest number in the scene
("Goblin 5"). One on its own keeps the plain name. An initiative roll typed
in is given to all of them; left empty, each can be rolled separately
(section 7).
**The turn strip.** With two or more in the scene, a row of small buttons
runs across the top of the page, one per combatant, in the order they act. It
stays under the top bar while the cards scroll (on a phone it scrolls
sideways). Each button has the name, HP where the viewer may see the numbers
(otherwise the initiative total), a thin health bar and "⚑" with the number of
conditions. Whoever is on turn is gold and marked ▶, a target is red and
marked 🎯, the viewer's own characters are blue and marked ★, and in a battle
those who have acted this round are dimmed. A click scrolls to that card. The
fallen are not on it.
**Finding your own character.** A player's own characters stand out: their
cards have a blue border and background and say "★ Your character", and their
buttons on the turn strip are blue and starred. The DM sees the same for any
character of their own.
**Sheet.** A character's card has a "📋 Sheet" button for the DM and for that
character's own player. It opens the character panel on that character,
beside the encounter: saving throws, every skill, passive scores, special
abilities, everything they hold, what is equipped and what each thing does
([CHARACTERS.md](CHARACTERS.md), section 14). The floating 🧙 button opens the
same panel from anywhere. A card's details also list what the character's
equipped gear is doing; the AC, speed and other numbers on the card already
include it.
**The spotlight.** On a wide screen the column beside the cards starts with
one combatant's whole stat block: whoever is **on turn** in a battle. It is
everything their card has under "details", so the DM reads the attacker's
numbers there while scrolling to a target. **📌 Pin** on any card with details
puts that combatant there instead (in a quest too, where nobody is on turn);
"✕ Unpin" or a second click on Pin goes back to whoever is on turn. Pinning is
for the viewer's own screen only. The side column stays in view and scrolls
on its own. On a screen narrower than 960px there is no spotlight and no Pin:
the list is one column and the card on turn is at its top.
**Rolling.** The underlined numbers on a card and in the spotlight roll when
clicked: a character's saves, skills, ability checks, initiative, attacks and
damage, and for the DM a monster's or NPC's attack and damage and a monster's
ability scores. "🎲 Call for a roll…" in the header (DM) rolls one check or
save for all the characters in the scene at once
([CHARACTERS.md](CHARACTERS.md), "Rolling from the sheet").
**Keys (DM, battle in progress).** **N** is Next Turn. **S** opens the sheet of
the character on turn, or pins the monster or NPC on turn to the spotlight.
Keys do nothing while the cursor is in a field.
Each has a card. The card's left edge is coloured by kind, and it shows:
- **Characters:** level, species and class; HP, AC, speed, initiative bonus,
passive perception, proficiency bonus and spell save DC. Under "details":
ability scores, saves, the weapons and spells the character can use (with
attack and damage numbers), and special abilities. The name links to the
character sheet.
ability scores, the weapon and items the character has **equipped**
(with attack and damage numbers), spellcasting numbers with the spell slots
left, the spells they can cast, **saving throws and all eighteen skills**
(● proficient, ◆ expertise) with passive Perception, Investigation and
Insight, and special abilities. A weapon that is not equipped is not on the
card and cannot be used; "No weapon equipped" shows when there is none.
Equipping is changed from "📋 Sheet", at any point in the scene. The name
links to the character sheet.
- **Monsters:** for the DM, AC, speed, attack and damage, and under "details"
ability scores, trait and notes.
- **NPCs:** role, attitude and description; for the DM also AC, attack,
@@ -131,6 +198,10 @@ Details start open for the DM and closed for players. Weapons and spells
above a character's level are left out, because the character cannot use
them.
A **dead character cannot be added** to any encounter: they are left out of
the list to pick from and the server refuses them, until they are revived. A
character who dies stays in the encounters they were already in.
The DM removes someone with "Remove" on their card. Removing whoever is on
turn passes the turn to the next combatant.
@@ -149,8 +220,7 @@ The DM types an amount and clicks Damage or Heal on a card.
monsters and NPCs it depends on the campaign setting "Players see the exact
hit points of monsters and NPCs": on (the default) shows the numbers, off
shows only the health bar. The DM always sees the numbers.
- At 0 HP a monster or NPC is marked **Down** and dimmed. It keeps its place
in the order; nothing is removed automatically.
- At 0 HP a monster or NPC is **out of the fight**: see "The fallen" below.
- A **character** at 0 HP is **Dying** instead, and its card grows a death-save
box: see below.
- A character's temporary HP is shown beside its HP but is not used up
@@ -173,12 +243,54 @@ the card is the same one as on the sheet, and both change the same character.
tallies.
- **Dead** after three failures, or when one blow leaves as much as the
character's max HP still to take after reaching 0. Healing is refused; the
DM clicks **Revive at 1 HP**.
DM clicks **Revive at 1 HP**, or an ally revives them (below).
- The DM can click any pip to set a tally by hand.
Nothing rolls or advances by itself: the app does not prompt for a save when
the turn comes round, and a dying, stable or dead character keeps its place
in the order.
Nothing rolls by itself: the app does not prompt for a save when the turn
comes round. A dying or stable character keeps its place in the order and
still gets its turn.
### The fallen
A combatant is **out of the fight** when it is:
- a **monster or NPC at 0 HP** (one with hit points to track), or
- a **dead character**. Dying and stable characters are not: they stay in
view.
What that does:
- **Players no longer see it.** Its card is gone from their screen, along
with any target mark on it; the server does not send it to them at all.
- **The DM's screen leaves it out as well**, until the DM ticks **Show dead**
above the list (the box shows how many there are, and only appears when
there are any). Ticked, they are listed in a group of their own at the
bottom, "Out of the fight, hidden from players", each with a "Hidden from
players" badge, and everything on the card still works. The tick is not
remembered: it is off again when the page is reopened.
- **It has no turn.** Next Turn, Next Round and Begin Battle pass over it.
- It stays in the encounter: nothing is removed, and gold is still split with
a dead character.
It is worked out from hit points each time, not stored, so it undoes itself:
heal a monster or NPC above 0, or revive a character, and they are back on
everyone's screen and back in the turn order.
**Reviving a dead character.** Players see dead characters by name only, in a
**☠ Fallen** box above the list. There are two ways back:
- The **DM** ticks Show dead and clicks **Revive at 1 HP** on the dead
character's card (or does it on the sheet), at any time.
- An **ally** tries it: in a battle in progress, the player whose character is
**on turn** gets a **Try revive** button beside each name in the Fallen box,
if that character is capable. Capable means on their feet (above 0 HP) and
knowing a spell that is ticked **Revives** in the campaign's spell list
([SPELLS.md](SPELLS.md)), at a level they can cast. The library's Revivify
(level 3) and Raise Dead (level 5) are ticked already.
Either way the character comes back at 1 HP with the death saves cleared. The
app does not use up the spell, check how long ago they died, or end the
reviver's turn.
## 7. Initiative, turns and rounds
@@ -186,21 +298,34 @@ Initiative is entered in battles and quests alike. Turns and rounds are for
battles only.
- Each combatant has an **initiative roll** field. Type what the d20 showed,
1 to 20; the field accepts nothing else. The app does not roll the die, but
it adds the bonus for you and shows the total, which can go past 20 (a 20 on
1 to 20; the field accepts nothing else. The app adds the bonus for you and
shows the total, which can go past 20 (a 20 on
the die with a +3 bonus is 23): a character's initiative
bonus from their sheet, a monster's Dexterity modifier, nothing for an NPC.
- The DM can enter the roll for anyone, when adding them or on the card. A
player can enter the roll for **their own character** on its card.
- **🎲 Roll initiative for N monsters and NPCs**, in "Add to the scene" while
any of them has no roll, rolls a d20 for each of those and adds their
bonus. Monsters added together each get their own roll. Rolls already
entered are left alone, and characters are left to their players.
- The list is ordered by the total, highest first; ties keep the order they
were added in. Anyone who has not rolled yet waits at the bottom.
- The bonus is worked out when the roll is entered. If a character's sheet
changes afterwards, enter the roll again to pick up the new bonus.
- **Begin Battle** puts the first combatant on turn. Their card is
highlighted and the header names them.
- **Next Turn** passes the turn to the next in the order. After the last
highlighted, the header names them, and their stats fill the spotlight
(section 5).
- **Next Turn** (or the **N** key) passes the turn to the next in the order. After the last
combatant it returns to the top and the **round** goes up by one.
- **Next Round** skips straight to the top of the next round.
- Anyone out of the fight (section 6, "The fallen") is passed over.
- **The list follows the turn.** While a battle is in progress, whoever is on
turn is at the top of "Initiative order" with those still to act this round
beneath them. Each combatant whose turn has passed moves down to a **Next
Round** section at the bottom, still in initiative order. When the round
rolls over everyone is back in "Initiative order". Everyone sees the same
arrangement. Before the battle begins, and once it is complete, it is one
plain list.
### Targets
@@ -212,13 +337,17 @@ healed) right now. One target is an ordinary attack; more than one is an
to unmark. Each one marked is added to the targets.
- The **DM** can mark anyone at any time, in a battle or a quest. A **player**
can while their own character is on turn in a battle, so they can show the
DM who they are going for.
DM who they are going for. A player cannot mark someone who is out of the
fight, and is not shown a mark the DM leaves on one.
- The combatant **on turn** cannot be marked: nobody targets themselves, and
their card has no Target button until the turn passes. To heal or hurt them
on their own turn, use the buttons on their card.
- A target's card is outlined in red for everyone and reads "Targeted by
<whoever is on turn>".
- While anyone is marked the DM gets an **attack bar** above the list, which
stays in view while scrolling. It shows who is attacking whom, each target's
AC and HP, and the attacker's attacks: a character's equipped weapons (all
of them if none are equipped) and damaging spells, or a monster's or NPC's
AC and HP, and the attacker's attacks: a character's equipped weapons (none,
if nothing is equipped) and damaging spells, or a monster's or NPC's
attack and damage. The amount box is ready to type in; **Damage** (or
Enter) and **Heal** change HP exactly as the buttons on a card do.
- Targets stay marked after damage, for a second attack. They are cleared
@@ -229,8 +358,10 @@ healed) right now. One target is an ordinary attack; more than one is an
An attack on several targets is harder to land and its damage is shared.
- **Harder to hit.** Every target's AC counts as **2 higher for each target
beyond the first**:
- **Harder to hit.** Every target's AC counts as higher for each target
beyond the first, by the campaign's **area attack AC penalty**. The DM sets
it in the campaign settings, from 0 (no penalty) to 10; it starts at 2, which
gives:
| Targets | Added to each AC |
|---|---|
@@ -251,20 +382,47 @@ An attack on several targets is harder to land and its damage is shared.
- A target with no hit points to track is left out of the sharing.
The app does not roll the attack or decide whether it hits: the DM compares
the roll with the AC shown and types the damage. The +2 is fixed in the code
(`AREA_AC_STEP` in `frontend/src/pages/EncounterPage.tsx`).
the roll with the AC shown and types the damage.
## 8. Conditions
The DM adds a condition to any card from the "+ Condition" list, and removes
one by clicking its ✕. Everyone sees them.
one by clicking its ✕. Everyone sees them. **Hovering over a condition shows
its rule** (on the card, in the spotlight, on the sheet and on the party
overview).
**A character's conditions are the character's own.** One given on an
encounter card is on their sheet and in their panel too, is still there when
the encounter ends, and shows on their card in the next one, until the DM
removes it (from any of those places). A monster's or an NPC's conditions
belong to that one card.
A character's card also shows **✨ Inspired** and **Exhaustion N** when they
apply, and under "details" (and in the spotlight) their **class counters**:
Rage, Ki, Channel Divinity and the rest, with a Use button for the DM and for
that character's own player ([CHARACTERS.md](CHARACTERS.md), section 7).
The list: Blinded, Charmed, Deafened, Frightened, Grappled, Incapacitated,
Invisible, Paralyzed, Petrified, Poisoned, Prone, Restrained, Stunned,
Unconscious, Concentrating.
Conditions are labels to remind the table. They do not change any numbers,
and they do not expire on their own.
**Timed conditions.** The "rounds" box beside "+ Condition" says how long the
next condition added lasts. Its chip shows what is left ("Frightened 2 rd").
Each time the **round goes up** (Next Turn past the last combatant, or Next
Round) every timed condition loses a round, and one that reaches 0 comes off
by itself, from the character's sheet too. The DM is told what wore off
("Frightened wore off Aria."). Given for 1 round, a condition lasts until the
next round begins; a condition added with the box empty stays until the DM
removes it. The countdown belongs to the encounter: it does not run outside
one, and removing a condition by hand drops its countdown.
**Concentration.** "Concentrating" marks someone holding a spell. When the DM
applies damage to them, from their card or the attack bar, a notice gives the
save to call for: Constitution, DC 10 or half the damage if that is higher,
with their Constitution save bonus (a monster's Constitution modifier). The
site does not roll it or end the spell: on a failure the DM removes the chip.
Conditions are reminders for the table. They do not change any numbers.
## 9. Rewards
@@ -272,7 +430,7 @@ The DM lines up loot in the Rewards panel, on any encounter at any time.
| Reward | What the DM sets |
|---|---|
| Gold | An amount |
| Coins or cash | An amount, and which: gold, silver, copper or cash ($) |
| Weapon | A weapon from the pool, a level (not below its starting level), a quantity |
| Item | An item from the pool, a level, a quantity |
@@ -283,9 +441,9 @@ Each can carry a short note ("Goblin chief's purse").
| Button | What happens |
|---|---|
| Award (pick a character) | The whole reward goes to that character. It can be any finished character in the campaign, not only those in the encounter. |
| Split (gold only) | Shared evenly between the characters in the encounter. Any remainder goes one gold each, highest initiative first: 101 gold between two is 51 and 50. |
| Split (coins and cash only) | Shared evenly between the characters in the encounter. Any remainder goes one each, highest initiative first: 101 gold between two is 51 and 50. |
- **Gold** is added to the character's balance with a money-log entry reading
- **Coins or cash** are added to the character's balance with a money-log entry reading
"Reward: <encounter name>".
- A **weapon or item** is added to what the character holds, at the reward's
level, stacking with any they already have. If the character is below that
@@ -296,22 +454,128 @@ Each can carry a short note ("Goblin chief's purse").
Until a reward is awarded only the DM can see it. Afterwards players see what
it was and who received it.
## 10. What each side sees
### Experience and difficulty
The DM's **Experience** card, above Rewards, reads the fight and hands out
its XP. Players see none of it.
**What the monsters are worth.** Each monster's challenge rating
([MONSTERS.md](MONSTERS.md)) is worth a set amount of XP, and the card adds
them up, standing or fallen. A monster whose challenge is empty or is not a
rating ("boss") is not counted, and the card says how many are not. NPCs have
no challenge rating and are never counted.
| Challenge | XP | Challenge | XP | Challenge | XP | Challenge | XP |
|---|---|---|---|---|---|---|---|
| 0 | 10 | 3 | 700 | 9 | 5,000 | 15 | 13,000 |
| 1/8 | 25 | 4 | 1,100 | 10 | 5,900 | 16 | 15,000 |
| 1/4 | 50 | 5 | 1,800 | 11 | 7,200 | 17 | 18,000 |
| 1/2 | 100 | 6 | 2,300 | 12 | 8,400 | 18 | 20,000 |
| 1 | 200 | 7 | 2,900 | 13 | 10,000 | 19 | 22,000 |
| 2 | 450 | 8 | 3,900 | 14 | 11,500 | 20 | 25,000 |
Ratings 21 to 30 run from 33,000 to 155,000. "0.5" is read as 1/2, and "CR 3"
as 3.
**How hard it is.** Several monsters are harder than their XP says, so to
judge the fight the total is multiplied for their number: ×1 for one, ×1.5
for two, ×2 for 3 to 6, ×2.5 for 7 to 10, ×3 for 11 to 14, ×4 for 15 or more.
That figure is held against what the characters in the scene can take, each
adding their own level's thresholds:
| Level | Easy | Medium | Hard | Deadly |
|---|---|---|---|---|
| 1 | 25 | 50 | 75 | 100 |
| 2 | 50 | 100 | 150 | 200 |
| 3 | 75 | 150 | 225 | 400 |
| 4 | 125 | 250 | 375 | 500 |
| 5 | 250 | 500 | 750 | 1,100 |
| 6 | 300 | 600 | 900 | 1,400 |
| 7 | 350 | 750 | 1,100 | 1,700 |
| 8 | 450 | 900 | 1,400 | 2,100 |
| 9 | 550 | 1,100 | 1,600 | 2,400 |
| 10 | 600 | 1,200 | 1,900 | 2,800 |
(Levels 11 to 20 continue to 2,800 / 5,700 / 8,500 / 12,700 at level 20.) The
fight is **Trivial** below the party's Easy figure, then **Easy**,
**Medium**, **Hard**, and **Deadly** from the Deadly figure up. The rating is
a badge on the card and in the page header, beside the round, and it moves as
monsters and characters are added. There is no rating until the scene has at
least one character and one monster with a challenge rating.
**Awarding it.** "Award N XP each" shares the XP evenly between **every
character in the encounter**, rounded down, and adds it to each one's XP. The
amount is what the monsters are worth (not the multiplied figure) unless the
DM types another: for a quest, a clever escape, or a scene with nothing rated
in it. It can be awarded at any point, and **once** per encounter; the card
then reads "✓ 75 XP awarded: 37 each". A mistake is put right on the
characters' sheets. XP never levels anyone up by itself
([CHARACTERS.md](CHARACTERS.md), section 6).
## 10. Trade
In an NPC quest the party can **change money** and **buy things**. Both take
someone to deal with and the time to do it, so:
- Trading is open only in an **NPC quest that is in progress**. Before "Begin
Quest" and after "Complete Quest" everything on offer is still shown, but
the buttons are off.
- There is **no trading in a battle**. Switching a quest to a battle hides the
Trade card and the server refuses any trade; switching back brings it back.
- An NPC trades only while they are **in the scene and not at 0 HP**.
- The character trading has to be **in the encounter** and alive.
The "Trade" card sits beside the scene, above Rewards. It appears for players
when there is something to trade, and always for the DM in a quest.
| Part of the card | What it does |
|---|---|
| Who is trading | Picks the character. A player chooses among their own characters in the scene; the DM among all of them. Their purse is shown beside it |
| 💱 A money changer | One row per NPC in the scene who has an exchange rate. Type an amount, choose "gold into cash" or "cash into gold", and the button shows what comes back |
| 🛒 "<NPC> sells" | Everything that NPC has for sale: what it is, its level and dice, the price, and how many are left. "Buy" buys one |
| 🛒 "For sale here" | Things sold by the scene itself rather than by anyone in it |
| Set up (DM only) | Puts weapons and items from the campaign pools up for sale at this encounter, and edits their price and stock |
- **Changing money** uses the NPC's own rate, the same both ways, and only
gold and cash: no silver, no copper, no change. The amount handed over has
to be a whole multiple of that side of the rate. The full rules and
examples are in [NPCS.md](NPCS.md), section 5.
- **Buying** pays the price from the character's purse (coins make change; a
cash price takes cash only), adds the weapon or item to what the character
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**, 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.
- Removing an NPC from the scene takes their rate and wares out of it.
Deleting the encounter deletes its own wares; what was bought stays bought.
## 11. What each side sees
| | DM | Players |
|---|---|---|
| Scene description | Yes | Yes |
| DM notes | Yes | Never |
| Characters | Full card | Full card |
| A character's saves, skills and passive scores | Yes | Yes, as on the sheet |
| A character's inspiration, exhaustion, conditions and class counters | Yes, and can change them | Yes; a player can spend their own character's counters |
| A character's inventory, spells and purse | Yes, for everyone | Their own characters only |
| Monsters | Full stat block | Name, HP, conditions |
| NPCs | Everything | Name, role, attitude, description, HP bar |
| Monsters and NPCs at 0 HP | With "Show dead" ticked, marked hidden | Nothing |
| Dead characters | Full card with "Show dead" ticked, marked hidden | The name only, in the Fallen box |
| Rewards | All | Only awarded ones |
| Difficulty and the monsters' XP | Yes | Never (awarded XP shows on their own sheet) |
| Exchange rates and things for sale | Yes | Yes, for NPCs they can see and for the scene itself |
| Controls (add, damage, turns, rewards) | Yes | None |
The server only sends a player what the right-hand column lists, so hidden
information cannot be dug out of the page.
## 11. Permissions
## 12. Permissions
| Action | DM | Players |
|---|---|---|
@@ -325,22 +589,39 @@ information cannot be dug out of the page.
| Enter an initiative roll | Yes, for anyone | For their own character only |
| Mark targets | Yes, any time | On their own character's turn |
| Enter a death save, or stabilise | Yes, for anyone | For their own character only |
| Set death-save tallies by hand, revive the dead | Yes | No |
| Set death-save tallies by hand | Yes | No |
| Revive a dead character outright | Yes | No |
| Try to revive a dead character | Yes, for the character on turn | On their own character's turn, if it has a reviving spell |
| Advance turns and rounds | Yes | No |
| Roll initiative for the monsters and NPCs | Yes | No |
| Call for a roll from all the characters | Yes | No |
| See the difficulty and what the monsters are worth; award XP | Yes | No |
| Click a number to roll it | Yes | Yes, for what they can see |
| Give a condition a number of rounds | Yes | No |
| Add, remove or award rewards | Yes | No |
| Set exchange rates, and put things up for sale | Yes | No |
| Change money or buy, in a quest in progress | Yes, for any character in it | For their own character in it |
People outside the campaign cannot open its encounters.
## 12. What is not modelled
## 13. What is not modelled
- Rolling initiative, attacks, damage or saves (use the dice roller, bottom
left of every page)
- Applying an attack's damage automatically
- Rolling initiative for characters into their field (a player clicks their
Init chip to roll it, then types what came up)
- Applying an attack's damage automatically: attacks and damage are rolled
with a click ([CHARACTERS.md](CHARACTERS.md), "Rolling from the sheet") and
the DM enters the result
- Rolls that everyone at the table sees
- Death saves for monsters and NPCs, and prompting for a save on a dying
character's turn
- Conditions with durations or mechanical effects
- A roll, a spell slot or a time limit for reviving the dead
- The mechanical effects of conditions, durations counted in anything but
rounds, and rolling a concentration save
- Maps, positions, distances and areas of effect
- Experience points and encounter difficulty
- Spells as rewards
- Adjusting difficulty for a very small or very large party, and awarding
XP to some of the characters only or in unequal shares
- Spells as rewards, and spells for sale
- Selling to an NPC, haggling, and buying more than one of a thing in a
single step
- Renaming an encounter from the screen
- A log of what happened during the encounter
+200 -14
View File
@@ -14,10 +14,12 @@ level up the same way weapons do), [SPELLS.md](SPELLS.md),
3. [A character's inventory](#3-a-characters-inventory)
4. [Item levels](#4-item-levels)
5. [Dice on items](#5-dice-on-items)
- [Effects: armor and other gear that changes the sheet](#effects-armor-and-other-gear-that-changes-the-sheet)
6. [Starting items](#6-starting-items)
7. [Items as rewards](#7-items-as-rewards)
7. [Items as rewards and for sale](#7-items-as-rewards-and-for-sale)
8. [Permissions](#8-permissions)
9. [What is not modelled](#9-what-is-not-modelled)
10. [The complete item library](#10-the-complete-item-library)
---
@@ -27,13 +29,18 @@ level up the same way weapons do), [SPELLS.md](SPELLS.md),
anything that is not a weapon or a spell.
- A character's **inventory** is the items it holds from that pool. Players
cannot type in items of their own.
- The DM creates items and hands them out. Players can only tick Equipped.
- The DM creates items and hands them out. Players can only tick Equipped,
and only so much can be equipped at once (section 3).
- Items are **descriptive**. Holding or equipping one never changes a number
on the sheet.
## 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 |
|---|---|
@@ -43,13 +50,26 @@ The pool is the "Items Pool" card on the campaign page (DM only).
| Starts at level | 1 to 9. The lowest level this item can be (section 4) |
| Growth | Standard, Powerful or Legendary. Only matters if the item has dice |
| 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:** the "+ Add Item" form. There is no built-in library of items.
- **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:** 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
@@ -67,8 +87,39 @@ On a character's sheet, the DM picks an item from the pool and clicks Give.
- Quantity cannot go below 1; to take the last one away the DM uses ✕.
- **Using up** an item (drinking a potion) is not automatic: the DM lowers the
quantity or removes it.
- **Equipped** is a label. It does not change Armor Class or anything else;
armor is represented by the single "armor bonus" number on the sheet.
- **Equipped** is what switches an item's effects on (section 5), and what
shows the item on the character's encounter card.
- **Only what is equipped counts.** A weapon has to be equipped to be used
in an encounter, and gear only gives its effects while equipped. What is
not equipped stays in the pack: still the character's, still listed on the
sheet and in the character panel, but not in hand.
- **There is a limit on how much can be equipped at once**, set per campaign
in Campaign Settings ("Equipped at once"):
| Setting | Starts at | Means |
|---|---|---|
| Things equipped at once | 2 | Weapons and items together |
| Of which weapons | 1 | So: one weapon, or one weapon and one item, or two items |
Armor and shields are items and count toward the limit. With the starting
numbers a character can have a weapon and a shield, or a weapon and armor,
but not all three; a DM who wants armor, shield and weapon together sets
the first number to 3.
- **Putting something on when the limit is reached is refused**, with a
message naming what is already equipped. Take something off first.
- **Equipping can be changed at any time, including in the middle of an
encounter**, by the owning player or the DM: on the sheet, or in the
character panel (the 🧙 button, or "🎒 Inventory" on the character's
encounter card).
- Lowering the limits takes nothing off anyone. A character with more on
than the new limit keeps it until they change what they have equipped.
- Everything arrives unequipped: gear from the DM, from a reward, bought, or
given by another character.
- The whole pack is always listed: on the sheet, and in the **character
panel** (the floating 🧙 button, on every page), which the owning player and
the DM can open from anywhere, including from the character's card in an
encounter. Things the character is too low level to use are shown and
marked "needs level N", not hidden.
**Passing items on.** A character's owner (or the DM) can give some or all of
a held stack to another character in the campaign from the "Give to Another
@@ -111,6 +162,48 @@ rungs per level depending on Growth. The full ladder is in
The dice are shown beside the item in the inventory. Nothing is rolled or
applied automatically; use the dice roller.
### Effects: armor and other gear that changes the sheet
Armor, shields, rings, cloaks and the like are ordinary items that have
**effects**. An effect is a whole number, positive for a bonus and negative
for a penalty, on one of the sheet's numbers:
| It can change | Examples |
|---|---|
| AC | Chain mail +6, a shield +2, a cursed cloak −1 |
| Speed | Heavy armor −10, boots of speed +10. Speed never goes below 0 |
| Initiative | +2 |
| Max HP | +5. Max HP never goes below 1 |
| Passive Perception | +5 |
| An ability score (STR, DEX, CON, INT, WIS, CHA) | Gauntlets +2 STR. The score changes, so its modifier and everything built on it (saves, skills, attacks, HP from CON, AC from DEX) change with it. A score never goes below 1 |
| All saving throws, or one ability's saves | A ring: +1 to all saves |
| All skill checks, or one skill | Noisy armor: −2 Stealth |
An item can have up to 12 effects, one per number, each from −100 to +100,
plus a **note** for what is not a number ("Disadvantage on Stealth",
"Resistance to fire").
- **They apply on their own.** While a character has the item **equipped**
and is high enough level to use it, its effects are part of every number
the app shows for that character: the sheet, the character panel, the party
overview and their encounter card. Nothing has to be typed in.
- **Unequipping takes them away again**, as does giving the item away, the DM
removing it, or the character dropping below its level.
- **Effects add up.** Two different items with +1 AC give +2. Carrying two of
the same item does not double it: a stack counts once.
- **The sheet says where each change comes from.** A "From Equipped Gear"
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**, 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.
Weapons can carry effects in exactly the same way
([WEAPONS.md](WEAPONS.md), section 2).
## 6. Starting items
In the builder's equipment step a new character can pick items that are:
@@ -121,13 +214,20 @@ In the builder's equipment step a new character can pick items that are:
Starting items and starting weapons share the campaign's "starting items"
allowance. Each arrives at level 1, quantity 1, unequipped.
## 7. Items as rewards
## 7. Items as rewards and for sale
On an encounter the DM can line up an item as a reward, at a chosen level and
quantity, and later award it to a character. Awarding adds it to the
character's inventory, stacking with any they already hold. See
[ENCOUNTERS.md](ENCOUNTERS.md).
The DM can also put an item **up for sale**, at a level, a price (in gold,
silver, copper or cash) and an optional stock. An NPC can sell it, wherever
that NPC is met, or an encounter can sell it itself. Players see what is for
sale and buy it in an NPC quest that is in progress: the price leaves their
purse and the item joins their inventory. See [NPCS.md](NPCS.md), section 5,
and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
## 8. Permissions
| Action | DM | Owning player | Other players |
@@ -136,16 +236,102 @@ character's inventory, stacking with any they already hold. See
| Add, change or remove pool items | Yes | No | No |
| Give an item from the pool, or take one away | Yes | No | No |
| Give a held item to another character | Yes | Yes | No |
| Put an item up for sale | Yes | No | No |
| Buy an item that is for sale, in a quest in progress | Yes, for any character in it | Yes, if the character is in it | No |
| Change quantity or level | Yes | No | No |
| Equip or unequip | Yes | Yes, if the character can use it | No |
| See a character's inventory | Yes | Yes | Yes |
## 9. What is not modelled
- Any effect of an item on stats (AC, speed, abilities)
- Armor and shields as items
- Armor rules beyond a flat bonus: armor does not replace 10 + DEX, cap the
DEX bonus or need a Strength score (set the AC effect to match)
- Effects on attack rolls, damage, spell save DC or proficiency bonus
- Effects that depend on something (only at night, only against undead), or
that grow with the item's level
- Advantage and disadvantage as rules (write them in the note)
- Using up items automatically, charges, and attunement
- Weight, carrying capacity and containers
- Prices, buying and selling (record the gold change in the money log)
- A price on the item itself: a price is set each time it is put up for sale
- Selling items back (record the gold change in the money log)
- Swaps as a single step (each side gives separately), and asking the
recipient to accept
- Hands and body slots: the limit is a plain count, so nothing stops two
suits of armor being equipped if the count allows it
- Swapping automatically: equipping at the limit is refused, not exchanged
## 10. The complete item library
Everything "📚 From Library" offers. "While equipped" is what the item does
to the sheet of whoever has it on.
Armor is a flat bonus to AC on top of 10 + Dexterity: each suit's bonus is its
standard AC less 10. What a flat bonus cannot say (heavy armor ignoring
Dexterity, a Strength requirement, disadvantage on Stealth) is in the note,
for the DM to apply or to build into the numbers of their own copy.
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: item-library -->
### Light Armor (3)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Padded Armor | +1 AC | Disadvantage on Stealth checks. | — | Quilted layers of cloth and batting. |
| Leather Armor | +1 AC | — | — | Boiled leather breastplate and shoulder guards over soft leather. |
| Studded Leather | +2 AC | — | — | Tough leather set with close rivets and spikes. |
### Medium Armor (5)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Hide Armor | +2 AC | Standard rules: counts at most +2 from Dexterity. | — | Thick furs and pelts. |
| Chain Shirt | +3 AC | Standard rules: counts at most +2 from Dexterity. | — | Interlocking rings worn between layers of clothing. |
| Scale Mail | +4 AC | Disadvantage on Stealth checks. Standard rules: counts at most +2 from Dexterity. | — | A coat and leggings of leather covered in overlapping metal scales. |
| Breastplate | +4 AC | Standard rules: counts at most +2 from Dexterity. | — | A fitted metal chest piece worn with supple leather. |
| Half Plate | +5 AC | Disadvantage on Stealth checks. Standard rules: counts at most +2 from Dexterity. | — | Shaped metal plates covering most of the body. |
### Heavy Armor (4)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Ring Mail | +4 AC | Disadvantage on Stealth checks. Standard rules: adds no Dexterity. | — | Leather armor with heavy rings sewn into it. |
| Chain Mail | +6 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 13. | — | Interlocking metal rings over a layer of quilted fabric, with gauntlets. |
| Splint Armor | +7 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 15. | — | Narrow vertical strips of metal riveted to a leather backing. |
| Plate Armor | +8 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 15. | — | Shaped, interlocking metal plates covering the whole body. |
### Shields (2)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Shield | +2 AC | — | — | Wood or metal, carried in one hand. |
| Tower Shield | +3 AC, −5 Speed | — | — | A wall of oak and iron, as tall as its bearer. |
### Worn Gear (10)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Ring of Protection | +1 AC, +1 all saves | — | — | A plain band that turns blows aside. |
| Cloak of Protection | +1 AC, +1 all saves | — | — | A heavy cloak that seems to catch what was meant for you. |
| Cloak of Elvenkind | +5 Stealth | With the hood up, others have disadvantage on Perception checks to see you. | — | With the hood up, its colour shifts to match what is behind it. |
| Boots of Striding | +10 Speed | — | — | Light, tireless boots. |
| Gauntlets of Might | +2 STR | — | — | Iron-banded gauntlets that lend a giant’s grip. |
| Gloves of Thievery | +5 Sleight of Hand | — | — | Thin grey gloves that all but vanish when worn. |
| Amulet of Health | +2 CON | — | — | A red stone on a golden chain, warm against the skin. |
| Headband of Intellect | +2 INT | — | — | A silver circlet set with a pale blue stone. |
| Sentinel Charm | +2 Initiative, +5 Passive Perception | — | — | A brass eye on a cord that never quite closes. |
| Stone of Good Luck | +1 all saves, +1 all skill checks | — | — | A polished agate that sits well in a pocket. |
### Supplies (8)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Healing Potion | — | — | 2d4+2 | A red liquid that glimmers when shaken. Drink it to regain hit points. |
| Rope (50 ft) | — | — | — | Hempen rope. Holds a climber. |
| Torch | — | Held in a hand to give light. | — | Burns for an hour, lighting 20 feet brightly. |
| Hooded Lantern | — | Held in a hand to give light. | — | Bright light for 30 feet; the hood dims it to a glow. |
| Rations (1 day) | — | — | — | Dried meat, hard biscuit, nuts. |
| Bedroll | — | — | — | A blanket roll for sleeping rough. |
| Thieves’ Tools | — | — | — | A file, picks, a small mirror, narrow scissors and pliers. |
| Healer’s Kit | — | — | — | Bandages, salves and splints. Steadies a dying creature without a check. |
<!-- END GENERATED: item-library -->
+52 -28
View File
@@ -36,37 +36,54 @@ This describes the code as it stands. The built-in list is
| Name | Up to 80 characters |
| AC | Armor Class |
| Max HP | Hit points each copy starts an encounter with |
| Speed | In feet. 30 unless set otherwise |
| Speed | Walking speed in feet. 30 unless set otherwise. Other speeds (fly, swim, climb) go in Notes |
| Ability scores | STR, DEX, CON, INT, WIS, CHA. 10 unless set otherwise |
| Attack bonus | Added to the monster's attack roll |
| Damage | Dice for its main attack, e.g. `1d6+2` |
| Challenge rating | A rough danger rating, e.g. `1/4`, `2` |
| Damage | Dice for its main attack, e.g. `1d6+2`, with the damage type if you like: `1d6+2 necrotic` |
| Challenge rating | A rough danger rating, e.g. `1/4`, `2`. It sets what the monster is worth in XP and how hard an encounter is ([ENCOUNTERS.md](ENCOUNTERS.md), section 9); a monster with none, or with text that is not a rating, is left out of both |
| Special trait | A short note such as "Pack Tactics" |
| Notes | Anything else: what it attacks with, how it behaves |
None of these are calculated or used in formulas. They are reference numbers
shown to the DM during a fight. A monster's ability modifiers are worked out
from its scores for display only.
Apart from the challenge rating, none of these are used in formulas. They
are reference numbers shown to the DM during a fight. A monster's ability
modifiers are worked out from its scores for display, for its initiative
bonus (Dexterity) and for the rolls a click makes (section 5).
## 3. Managing monsters
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" asks for name, AC, max HP, attack bonus
and damage. Speed is set to 30 and every ability score to 10; challenge
rating, trait and notes are left empty.
- **Adding from the bestiary:** "📚 From Library" copies a complete stat block.
- **Changing:** there is no edit form (the API accepts changes, but no screen
offers them). Remove and re-add, or add from the library.
- **Removing:** the ✕ button, immediately, with no confirmation. Copies
- **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.
- 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 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 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.
## 4. The built-in bestiary
38 monsters, from harmless to dangerous. Each library row shows challenge
rating, AC, HP, attack and damage, plus its trait and notes, before you add
it. Adding copies the monster into your campaign; the copy is yours, and later
38 monsters, from harmless to dangerous, every one with a complete stat
block. Each library row shows challenge rating, AC, HP, speed, attack, damage
and the six ability scores, plus its trait and notes, before you add it. Adding copies the monster into your campaign; the copy is yours, and later
changes to the library do not affect it.
The full bestiary, weakest first. "Attack" is the bonus added to the
@@ -120,11 +137,14 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
## 5. Monsters in encounters
On an encounter page, the DM chooses "Monster" in the "Add to the scene"
panel, picks one, and types its initiative.
panel, picks one, says how many, and types its initiative or leaves it to be
rolled ("🎲 Roll initiative for N monsters and NPCs" rolls a d20 plus the
Dexterity modifier for each that has no roll).
- The same monster can be added **any number of times**. Each copy has its
own HP and conditions. Copies share the monster's name; the screen has no
way to rename one (the API allows it).
- The same monster can be added **any number of times**, up to 20 in one go.
Each copy has its own HP and conditions. Copies added together are numbered
("Goblin 1" to "Goblin 4"); one added on its own has the monster's name. The
screen has no way to rename one (the API allows it).
- A copy starts at the monster's max HP. Its HP stays between 0 and its max.
- At 0 HP a copy is marked **Down** and dimmed, but keeps its place in the
initiative order until the DM removes it.
@@ -132,7 +152,10 @@ panel, picks one, and types its initiative.
The DM's card for a monster shows AC, speed, attack bonus and damage at a
glance, and under "details" its six ability scores with modifiers, its trait
and its notes.
and its notes. **Attack, Damage and each ability score roll when clicked**: a
d20 plus the attack bonus, the damage dice, or a d20 plus the ability's
modifier. Of damage written with more after the dice ("2d8+3 fire") the dice
are rolled; damage that does not start with dice is shown but does not roll.
## 6. What players see
@@ -146,8 +169,9 @@ and its notes.
This is enforced by the server: stat blocks are never sent to a player's
browser.
Players do see a monster's exact HP numbers. If you would rather keep those
secret, that is not currently possible.
Players see a monster's exact HP numbers unless the campaign setting "Players
see the exact hit points of monsters and NPCs in encounters" is unticked.
Then they get the health bar only.
## 7. Permissions
@@ -163,8 +187,8 @@ secret, that is not currently possible.
## 8. What is not modelled
- Multiple attacks, spells, legendary actions and resistances (use Notes)
- Rolling a monster's attacks or damage (use the dice roller)
- Saving throw bonuses and skills
- Experience points for defeating monsters
- Encounter difficulty calculated from challenge ratings
- Trade: monsters do not sell things or change money (NPCs do)
- Saving throw proficiencies and skills (on an encounter card a click on an
ability score rolls a d20 with its modifier, for a check or a save)
- XP typed in for a monster: it always comes from the challenge rating
- Monster hit dice, and rolling HP rather than using the fixed value
+109 -19
View File
@@ -1,7 +1,8 @@
# NPCs
How non-player characters work in this app: what the DM records about them,
how they are brought into a scene, and what players can and cannot see.
how they are brought into a scene, what they sell and the rate they change
money at, and what players can and cannot see.
This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
[MONSTERS.md](MONSTERS.md).
@@ -12,10 +13,11 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
2. [What an NPC holds](#2-what-an-npc-holds)
3. [Managing NPCs](#3-managing-npcs)
4. [NPCs in encounters](#4-npcs-in-encounters)
5. [What players see](#5-what-players-see)
6. [NPC or monster?](#6-npc-or-monster)
7. [Permissions](#7-permissions)
8. [What is not modelled](#8-what-is-not-modelled)
5. [Trade: changing money and selling](#5-trade-changing-money-and-selling)
6. [What players see](#6-what-players-see)
7. [NPC or monster?](#7-npc-or-monster)
8. [Permissions](#8-permissions)
9. [What is not modelled](#9-what-is-not-modelled)
---
@@ -27,6 +29,8 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
- An NPC does nothing by sitting in the list. Players meet one when the DM
**adds it to an encounter**.
- NPCs are mainly for talking to. Combat stats are optional.
- An NPC can also **trade**: change gold and cash at a rate of their own, and
sell weapons and items (section 5).
## 2. What an NPC holds
@@ -38,22 +42,34 @@ This describes the code as it stands. Related: [ENCOUNTERS.md](ENCOUNTERS.md),
| Description | What the party can see and knows | Everyone |
| DM notes | Secrets, motives, what they are hiding | **DM only** |
| AC | Optional | DM only |
| HP | Optional. Without it the NPC has no health bar | See section 5 |
| HP | Optional. Without it the NPC has no health bar | See section 6 |
| Attack bonus | Optional | DM only |
| Damage | Optional dice, e.g. `1d6` | DM only |
| Exchange rate | Optional: so much gold for so much cash (section 5) | Everyone, in an encounter |
| Wares | Optional: weapons and items for sale, each with a price (section 5) | Everyone, in an encounter |
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. Only the name
is required.
- **Changing:** there is no edit form. To change an NPC, remove it and add it
again (the API accepts changes, but no screen offers them).
- **Removing:** the ✕ button, after a confirmation. This also **removes the
NPC from every encounter** it was in.
- **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:** "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
@@ -73,7 +89,73 @@ An NPC's card shows its attitude as a coloured badge, its role under its
name, and its description in full. The DM's card also shows AC, attack,
damage and the DM notes.
## 5. What players see
## 5. Trade: changing money and selling
Only the DM sets trade up. Players see it and use it on the encounter page,
in the "Trade" card. The rules for when trading is open are in
[ENCOUNTERS.md](ENCOUNTERS.md), section 10: in short, in an **NPC quest that
is in progress**, never in a battle, and only with an NPC who is in the scene
and not at 0 HP.
### Changing money
An NPC with an **exchange rate** changes gold into cash ($) and cash into
gold. The rate is two whole numbers, "this much gold = this much cash", and
each NPC has their own, so one money changer can be a better deal than
another.
| Rate | Hand over | Get |
|---|---|---|
| 1 gold = $5 | 3 gold | $15 |
| 1 gold = $5 | $20 | 4 gold |
| 2 gold = $15 | 6 gold | $45 |
| 2 gold = $15 | $30 | 4 gold |
- The same rate is used both ways.
- **Only gold and cash.** Silver and copper are not taken, a gold coin is not
broken to make up an amount, and no change is given.
- What is handed over has to be a whole multiple of that side of the rate: at
2 gold = $15, gold goes in twos and cash in fifteens.
- 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 "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
An NPC's **wares** are weapons and items from the campaign's own pools, each
put up for sale with:
| Field | What it is |
|---|---|
| What | One weapon or item from the pool |
| Level | The gear level it is handed over at, not below its starting level |
| Price | For one, in gold, silver, copper or cash |
| Stock | How many can be bought. Empty means no limit |
- Wares belong to the NPC, not to an encounter: they are on offer in every
quest that NPC is in.
- Buying pays the price from the character's purse, adds the weapon or item
to what they hold (stacking with any they already have) and lowers the
stock. A price in coins is paid with change, exactly like any other spend;
a price in cash takes cash only.
- At 0 stock a ware shows as sold out and cannot be bought. It stays listed
until the DM removes it.
- The ✕ beside a ware stops it being sold. Things already bought stay bought.
- "Edit" beside a ware changes its price, the coin (or cash) the price is in,
and its stock: restock a sold-out ware, or clear the stock for no limit.
What is sold, its level and who sells it cannot be changed; remove the ware
and put it up again for that.
- Removing a weapon or item from the campaign pool removes it from sale
everywhere.
A stall or a machine with nobody behind it can sell things too: those wares
belong to an encounter rather than an NPC, and are set up on the encounter
page. See [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
## 6. What players see
Players only ever see an NPC through an encounter it is in:
@@ -81,6 +163,7 @@ Players only ever see an NPC through an encounter it is in:
|---|---|
| Name, role, attitude, description | DM notes |
| Health bar and conditions, if the NPC has HP | AC, attack bonus, damage |
| The exchange rate, and everything the NPC has for sale with its price and stock | The NPC list, and wares of NPCs who are not in the scene |
This is enforced by the server, not just hidden on screen: the hidden fields
are never sent to a player's browser.
@@ -88,7 +171,7 @@ are never sent to a player's browser.
Changing an NPC's attitude changes what players see straight away, so
flipping a "Friendly" NPC to "Hostile" is itself a reveal.
## 6. NPC or monster?
## 7. NPC or monster?
| | NPC | Monster |
|---|---|---|
@@ -99,11 +182,12 @@ flipping a "Friendly" NPC to "Hostile" is itself a reveal.
| Combat stats | Optional, basic | Full stat block |
| Can be in an encounter more than once | No | Yes (a pack of goblins) |
| Built-in library | No | Yes |
| Can change money and sell things | Yes | No |
A named villain the party will both talk to and fight works well as an NPC
with HP, AC, attack and damage filled in.
## 7. Permissions
## 8. Permissions
| Action | DM | Players |
|---|---|---|
@@ -111,12 +195,18 @@ with HP, AC, attack and damage filled in.
| Add or remove NPCs | Yes | No |
| Add an NPC to an encounter | Yes | No |
| Change an NPC's HP or conditions in an encounter | Yes | No |
| See an NPC in an encounter | Everything | Name, role, attitude, description, health bar |
| See an NPC in an encounter | Everything | Name, role, attitude, description, health bar, exchange rate, wares |
| Set an NPC's exchange rate | Yes | No |
| Put things up for sale, or take them down | Yes | No |
| Change money with an NPC, or buy from one | Yes, for any character in the scene | For their own character in the scene |
## 8. What is not modelled
## 9. What is not modelled
- Ability scores, saves, skills and spells for NPCs
- NPC inventory, gold and shops
- An NPC's own purse: they never run short of gold or cash
- Selling things to an NPC, haggling, and different rates for buying and
selling cash
- Changing silver or copper for cash
- Where an NPC is (they are not tied to locations)
- Relationships, reputation and attitude toward individual characters
- A record of which NPCs the party has met, outside of encounters
+233
View File
@@ -0,0 +1,233 @@
# World Seeds
A world seed is a ready-made campaign: locations, weapons, items, spells,
monsters, NPCs with their wares, encounters with their rewards, and notes. A
DM starts a campaign from one by typing its code.
This describes the code as it stands. Related: [ITEMS.md](ITEMS.md),
[WEAPONS.md](WEAPONS.md), [SPELLS.md](SPELLS.md), [MONSTERS.md](MONSTERS.md),
[NPCS.md](NPCS.md), [ENCOUNTERS.md](ENCOUNTERS.md).
## Contents
1. [The basics](#1-the-basics)
2. [Starting a campaign from a code](#2-starting-a-campaign-from-a-code)
3. [Adding a world](#3-adding-a-world)
4. [What a seed file holds](#4-what-a-seed-file-holds)
5. [Checking a seed](#5-checking-a-seed)
6. [The database seed](#6-the-database-seed)
7. [The worlds that come with the app](#7-the-worlds-that-come-with-the-app)
---
## 1. The basics
- Every `.js` file in `/backend/seeds` is one world. The file says what its
**code** is: six capital letters or digits.
- Anyone who is logged in and knows a code can create that world as a new
campaign. They become its DM and owner, exactly as with a blank campaign.
- The same code can be used any number of times. Each use makes a separate
campaign with its own invite code; nothing is shared between copies.
- A seed only says what a campaign **starts** as. Once created, the campaign
is the DM's to change, and changing the seed file later does not touch
campaigns already made from it.
- The folder is read each time a code is entered, so a new file is live as
soon as it is on the server. No restart, no migration.
## 2. Starting a campaign from a code
On the **Campaigns** page, click **+ New Campaign**. Under the form for a
blank campaign is **Or start from a ready-made world**: type the code and
click **Create From Code**. Capitals and spaces do not matter (`aldenf`,
`ALDENF` and `ald-enf` are the same code).
The whole world is created in one go, and you land on its campaign page. If
anything in it fails, nothing is created.
| Reply | Meaning |
|---|---|
| "A world code is 6 letters or numbers" | What was typed is not six characters long |
| "There is no world with that code" | No valid seed file has that code |
The API is `POST /campaigns/from-seed` with `{ "code": "ALDENF" }`. It
answers with the new campaign, as `POST /campaigns` does.
## 3. Adding a world
1. Write the world as a file in `/backend/seeds`, for example
`/backend/seeds/my-world.js` (section 4).
2. Run `npm run seeds:check` (section 5) and fix whatever it reports.
3. Put the file on the server. The code works straight away.
A file with a mistake in it is never offered: its code simply answers "There
is no world with that code" until the file is fixed. Two files cannot share a
code; the one whose file name comes first keeps it.
To retire a world, delete its file. Campaigns already made from it stay.
## 4. What a seed file holds
A seed file is a JavaScript module that exports one object. Only `code` and
`campaign.name` are required; every list can be left out.
```js
module.exports = {
code: 'MYWRLD',
campaign: { name: 'My World', description: '...', startingGold: 20, startingItemCount: 2, startingSpellCount: 2 },
locations: [{ name: 'The Old Mill', description: '...' }],
weapons: [
'dagger', // a copy from the site library
{ library: 'longsword', isStartingOption: true }, // a copy, with changes
{ name: 'Millstone Hammer', damageDice: '2d6', damageType: 'bludgeoning', attackAbility: 'STR', minLevel: 3 },
],
items: [
{ library: 'shield', isStartingOption: true },
{ name: 'Miller’s Charm', description: '...', effects: [{ stat: 'SKILL_PERCEPTION', amount: 1 }] },
],
spells: [{ library: 'fire-bolt', isStartingOption: true }, 'fireball'],
monsters: ['goblin', { name: 'Mill Wraith', ac: 13, maxHp: 30, attackBonus: 4, damageDice: '1d8+2 necrotic' }],
npcs: [
{
name: 'Old Hob', role: 'Miller', disposition: 'FRIENDLY', description: '...', dmNotes: '...',
wares: [{ item: 'Shield', price: 10 }, { weapon: 'Dagger', price: 2, stock: 3 }],
},
],
encounters: [
{
name: 'The Mill at Night', mode: 'BATTLE', location: 'The Old Mill', description: '...', dmNotes: '...',
monsters: [['Goblin', 3], ['Mill Wraith', 1, 'The Miller’s Wife', 40]],
npcs: ['Old Hob'],
rewards: [{ gold: 25 }, { silver: 40 }, { weapon: 'Millstone Hammer', note: 'Behind the wheel' }],
},
],
sharedNotes: ['Welcome to My World...'],
dmNotes: ['The secret of the mill...'],
};
```
The file is ordinary JavaScript, so it can build its lists however it likes
(the Hollowcrown seed adds the whole spell library with a `map`).
### Top level
| Field | What it is |
|---|---|
| `code` | Six capital letters or digits. What a DM types |
| `campaign` | The campaign's settings: `name`, and optionally `description`, `startingGold`, `startingItemCount`, `startingSpellCount`, `skipMotivationRoll`, `showMonsterHp`, `areaAttackAcPenalty`, `maxEquipped`, `maxEquippedWeapons`, `subclassesEnabled`, `multiclassEnabled`, `diceMode`, `autoRollMonsters`, `autoRollNpcs`, `trackXp`, `hpOnLevelUp`, `abilityScoreMethod`, `rollVisibility`, `hideMonsterRolls`, `critRule`. Anything left out takes the app's default |
| `locations` | `{ name, description }` |
| `weapons`, `items`, `spells`, `monsters` | The pools. Each entry is a row or a library copy (below) |
| `npcs` | NPC rows, each optionally with `wares` |
| `encounters` | In the order they should list |
| `sharedNotes`, `dmNotes` | Note bodies, in reading order: the first is shown at the top. Shared notes are for everyone, DM notes for the DM |
### Pool entries
An entry in `weapons`, `items`, `spells` or `monsters` is one of:
- **A library key** such as `'longsword'`: a copy of that entry from the
site's built-in library (`src/game/data/*Library.js`).
- **A library copy with changes**: `{ library: 'longsword', isStartingOption: true, minLevel: 2 }`.
Any field of the row can be changed, including its name.
- **A full row**, with the same fields the app's own forms have:
| Pool | Fields (required in bold) |
|---|---|
| Weapon | **`name`**, **`damageDice`**, **`damageType`**, **`attackAbility`** (`STR`, `DEX` or `FINESSE`), `properties`, `rangeNormal`, `rangeLong`, `damageScaling` (1 to 3), `minLevel` (1 to 9), `isStartingOption`, `effects`, `effectNote` |
| Item | **`name`**, `description`, `damageDice`, `damageScaling`, `minLevel`, `isStartingOption`, `effects`, `effectNote` |
| Spell | **`name`**, **`level`** (0 to 9), `damageDice`, `school`, `castingTime`, `range`, `duration`, `description`, `isStartingOption`, `revives` |
| Monster | **`name`**, **`ac`**, **`maxHp`**, `speed`, `strScore` ... `chaScore`, `attackBonus`, `damageDice` (may name its type: `"1d6+2 necrotic"`), `challengeRating`, `specialTrait`, `notes` |
`effects` is a list of `{ stat, amount }`, as described in
[ITEMS.md](ITEMS.md): armor is an item with an `AC` effect.
Within a pool a name can only be used once, because everything else in the
file points at pool entries **by name**.
### NPCs
**`name`**, `role`, `disposition` (`FRIENDLY`, `NEUTRAL` or `HOSTILE`),
`description` (what players see), `dmNotes`, and for those who might fight
`ac`, `maxHp`, `attackBonus`, `damageDice`.
- **A money changer** has `exchangeGold` and `exchangeCash` together:
`exchangeGold: 1, exchangeCash: 50` is 1 gold for $50, either way.
- **A shopkeeper** has `wares`, a list of things for sale (below).
### Encounters
| Field | What it is |
|---|---|
| **`name`** | |
| `mode` | `BATTLE` (the default) or `QUEST`. Buying and money changing only happen in a quest |
| `description` | The scene, shown to the players in it |
| `dmNotes` | DM only |
| `location` | A location's name |
| `monsters` | `[monster name, how many, display name, max HP]`. The last two are optional; more than one gets "#1", "#2"... |
| `npcs` | NPC names, or `{ name, hp }` to set the hit points for this scene |
| `rewards` | Lined up unawarded (below) |
| `wares` | For sale at the scene itself, with nobody behind it: a market, a vending machine |
A **reward** is exactly one of `gold`, `silver`, `copper`, `cash` (an amount),
`weapon` or `item` (a name), with an optional `note`. A weapon or item can also
have `quantity` and `level`.
A **ware** is `{ weapon | item, price }` with optional `currency` (`GOLD`, the
default, `SILVER`, `COPPER` or `CASH`), `level` and `stock` (left out: no
limit).
A reward's or ware's `level` defaults to the entry's own starting level, and
cannot be below it.
### Limits
Text fields have the same length limits as the app's forms (for example 4,000
characters for an encounter's DM notes and 10,000 for a note), so that
everything a seed creates can still be edited on screen. The checker says
which field is over.
## 5. Checking a seed
```bash
npm run seeds:check
```
lists every file in `/backend/seeds` with its code and what it holds, or
everything wrong with it:
```
✓ aldenfell.js — code ALDENF — Aldenfell
24 weapons (16 starting), 31 items (10 starting), 45 spells (30 starting), 21 locations
15 monsters, 13 NPCs (8 wares), 38 encounters (40 rewards, 9 wares), 23 notes
✗ my-world.js
npcs.0.desciption: Unrecognized key: "desciption"
Encounter "The Mill at Night": there is no monster called "Gobblin"
```
It checks the things that would otherwise only show up as a half-working
campaign:
- A field name that is not one of the fields above (a typo).
- A library key that is not in the library.
- The same name twice in one pool.
- An encounter, reward or ware that names a location, monster, NPC, weapon or
item the file does not have.
- A reward that is not exactly one thing, or gear handed out below its
starting level.
- An exchange rate with only one half.
`npm test` runs the same check on every seed in the folder.
## 6. The database seed
`npx prisma db seed` creates the admin account if it is missing, and nothing
else. It never adds a campaign: worlds come from codes, in the app. See the
README for the account it creates and `--reset`.
## 7. The worlds that come with the app
| Code | World | From |
|---|---|---|
| `ALDENF` | Aldenfell | The Aldenfell Campaign Book |
| `HOLLOW` | Hollowcrown: The Rift Between Worlds | The Hollowcrown Dungeon Master's Book (V3) |
Change a world's code by editing `code` in its file.
+109 -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,16 +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) |
| Casting time, range, duration | Stored for library spells but not shown on any screen |
| Starting option | Whether a brand-new character may pick it in the builder (section 9) |
| Revives | Whether it brings back the dead. A character who can cast one may revive a dead ally on their turn in a battle ([ENCOUNTERS.md](ENCOUNTERS.md), section 6). Ticked already on the library's Revivify and Raise Dead |
| Casting time, range, duration | Stored for library spells. Shown to the DM in the spell's pop-out on the campaign page, and nowhere else |
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 and what it does), or from the library.
- **Changing:** damage (the drop-down in the table) and the starting-option
tick box. 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
@@ -83,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:
@@ -106,6 +113,10 @@ one shown.
A spell added from the library is **not** a starting option until ticked. The
library has nothing above level 5; add higher spells by hand.
Each library spell can be added once. A spell the list already has by that
name is left out of the library list, and comes back if the campaign's spell
is renamed or deleted.
## 4. Granting spells to characters
On a character's sheet, the DM picks a spell from the list and clicks Grant.
@@ -172,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:
@@ -183,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 |
|---|---|---|---|
@@ -193,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
@@ -206,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.
+133 -61
View File
@@ -19,7 +19,7 @@ This describes the code as it stands. The formulas live in
6. [How damage grows](#6-how-damage-grows)
7. [Attack and damage numbers](#7-attack-and-damage-numbers)
8. [Starting weapons](#8-starting-weapons)
9. [Weapons as rewards](#9-weapons-as-rewards)
9. [Weapons as rewards and for sale](#9-weapons-as-rewards-and-for-sale)
10. [Permissions](#10-permissions)
11. [What is not modelled](#11-what-is-not-modelled)
12. [The complete weapon library](#12-the-complete-weapon-library)
@@ -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,20 +52,30 @@ 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.
- **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. The built-in library
"📚 From Library" offers 37 standard weapons to copy into the pool:
"📚 From Library" offers 41 weapons to copy into the pool:
| Category | Count | Examples |
|---|---|---|
@@ -69,12 +83,23 @@ The pool is the "Weapons Pool" card on the campaign page (DM only).
| Simple ranged | 4 | Shortbow, Light Crossbow, Dart, Sling |
| Martial melee | 18 | Longsword 1d8, Rapier 1d8, Greataxe 1d12, Greatsword 2d6 |
| Martial ranged | 5 | Longbow, Heavy Crossbow, Hand Crossbow |
| Paired | 4 | Paired Daggers 2d4, Paired Shortswords 2d6, Paired Scimitars 2d6, Paired Handaxes 2d6 |
**Paired weapons.** A character has one weapon equipped at a time (section
4), so fighting with a weapon in each hand is not two weapons: it is **one
weapon made as a pair**, whose damage is both blades. The library has four,
and a DM makes others the same way with "+ Add Weapon" ("Twin Hammers",
2d4 bludgeoning).
A weapon added from the library arrives with starting level 1, Standard
growth, and **not** ticked as a starting option. Change those in the table
afterwards. Every library weapon is listed in
[section 12](#12-the-complete-weapon-library).
Each library weapon can be added once. A weapon the pool already has by that
name is left out of the library list, and comes back if the pool's weapon is
renamed or deleted.
## 4. Giving weapons to characters
On a character's sheet, the DM picks a weapon from the pool and clicks Give.
@@ -84,8 +109,33 @@ On a character's sheet, the DM picks a weapon from the pool and clicks Give.
keeps the level it has. A character holds one stack per weapon, so two
copies of the same weapon at different levels is not possible.
- The DM can take a weapon away with ✕ on the sheet.
- The owning player ticks **Equipped**. Equipped is a label only: unequipped
weapons still show their numbers, and nothing limits how many are equipped.
- The owning player, or the DM, ticks **Equipped**.
- **Only what is equipped counts.** A weapon has to be equipped to be used
in an encounter, and gear only gives its effects while equipped. What is
not equipped stays in the pack: still the character's, still listed on the
sheet and in the character panel, but not in hand.
- **There is a limit on how much can be equipped at once**, set per campaign
in Campaign Settings ("Equipped at once"):
| Setting | Starts at | Means |
|---|---|---|
| Things equipped at once | 2 | Weapons and items together |
| Of which weapons | 1 | So: one weapon, or one weapon and one item, or two items |
Armor and shields are items and count toward the limit. With the starting
numbers a character can have a weapon and a shield, or a weapon and armor,
but not all three; a DM who wants armor, shield and weapon together sets
the first number to 3.
- **Putting something on when the limit is reached is refused**, with a
message naming what is already equipped. Take something off first.
- **Equipping can be changed at any time, including in the middle of an
encounter**, by the owning player or the DM: on the sheet, or in the
character panel (the 🧙 button, or "🎒 Inventory" on the character's
encounter card).
- Lowering the limits takes nothing off anyone. A character with more on
than the new limit keeps it until they change what they have equipped.
- Everything arrives unequipped: gear from the DM, from a reward, bought, or
given by another character.
**Passing a weapon on.** A character's owner (or the DM) can give a held
weapon to another character in the campaign from the "Give to Another
@@ -115,7 +165,8 @@ This is a house rule of this app, not standard D&D.
| 9 and above | 1–9 |
A weapon above its holder's level is still held, but it shows "Requires level
N", cannot be equipped, and is left out of quick stats and encounters. If the
N", cannot be equipped, and so is never on its holder's encounter card. The
character panel lists it, marked "needs level N". If the
DM levels a weapon past its holder, or lowers the character's level, it is
unequipped automatically.
@@ -185,13 +236,20 @@ In the builder's equipment step a new character can pick weapons that are:
Starting weapons and starting items share the campaign's "starting items"
allowance. They arrive at level 1, unequipped.
## 9. Weapons as rewards
## 9. Weapons as rewards and for sale
On an encounter the DM can line up a weapon as a reward, at a chosen level
(not below its starting level) and quantity, and later award it to a
character. Awarding adds it to what the character holds, exactly as giving it
on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
The DM can also put a weapon **up for sale**, at a level, a price (in gold,
silver, copper or cash) and an optional stock. An NPC can sell it, wherever
that NPC is met, or an encounter can sell it itself. Players see what is for
sale and buy it in an NPC quest that is in progress: the price leaves their
purse and the weapon joins what they hold, unequipped. See
[NPCS.md](NPCS.md), section 5, and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
## 10. Permissions
| Action | DM | Owning player | Other players |
@@ -201,6 +259,8 @@ on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
| Add, change or remove pool entries | Yes | No | No |
| Give a weapon from the pool, or take one away | Yes | No | No |
| Give a held weapon to another character | Yes | Yes | No |
| Put a weapon up for sale | Yes | No | No |
| Buy a weapon that is for sale, in a quest in progress | Yes, for any character in it | Yes, if the character is in it | No |
| Change a held weapon's level or quantity | Yes | No | No |
| Equip or unequip | Yes | Yes, if the character can use it | No |
| See a character's weapons | Yes | Yes | Yes |
@@ -208,74 +268,86 @@ on the sheet would. See [ENCOUNTERS.md](ENCOUNTERS.md).
## 11. What is not modelled
- Weapon proficiency by class (everyone is proficient with everything)
- Properties: two-handed, versatile, light, reach, loading, thrown
- Properties: two-handed, versatile, light, reach, loading, thrown (a
two-handed weapon counts as one thing equipped, like any other)
- Range and ammunition
- Critical hits, advantage, and rolling the attack itself (use the dice roller)
- Magic bonuses such as +1 weapons (use a higher level or growth instead)
- Price and weight
- Magic bonuses to a weapon's own attack or damage, such as +1 weapons (use a
higher level or growth instead)
- A price on the weapon itself (a price is set each time it is put up for sale), and weight
## 12. The complete weapon library
Every weapon "📚 From Library" offers. Base damage is the damage at level 1.
Properties and range are carried into the campaign but not shown or used.
"While equipped" is the weapon's effects and note, which are carried in too.
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: weapon-library -->
### Simple Melee (10)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Club | 1d4 | bludgeoning | Strength | light | — |
| Dagger | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, light, thrown | 20/60 ft |
| Greatclub | 1d8 | bludgeoning | Strength | two-handed | — |
| Handaxe | 1d6 | slashing | Strength | light, thrown | 20/60 ft |
| Javelin | 1d6 | piercing | Strength | thrown | 30/120 ft |
| Light Hammer | 1d4 | bludgeoning | Strength | light, thrown | 20/60 ft |
| Mace | 1d6 | bludgeoning | Strength | — | — |
| Quarterstaff | 1d6 | bludgeoning | Strength | versatile (1d8) | — |
| Sickle | 1d4 | slashing | Strength | light | — |
| Spear | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft |
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Club | 1d4 | bludgeoning | Strength | light | — | — |
| Dagger | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, light, thrown | 20/60 ft | — |
| Greatclub | 1d8 | bludgeoning | Strength | two-handed | — | — |
| Handaxe | 1d6 | slashing | Strength | light, thrown | 20/60 ft | — |
| Javelin | 1d6 | piercing | Strength | thrown | 30/120 ft | — |
| Light Hammer | 1d4 | bludgeoning | Strength | light, thrown | 20/60 ft | — |
| Mace | 1d6 | bludgeoning | Strength | — | — | — |
| Quarterstaff | 1d6 | bludgeoning | Strength | versatile (1d8) | — | — |
| Sickle | 1d4 | slashing | Strength | light | — | — |
| Spear | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft | — |
### Simple Ranged (4)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Light Crossbow | 1d8 | piercing | Dexterity | ammunition, loading, two-handed | 80/320 ft |
| Dart | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, thrown | 20/60 ft |
| Shortbow | 1d6 | piercing | Dexterity | ammunition, two-handed | 80/320 ft |
| Sling | 1d4 | bludgeoning | Dexterity | ammunition | 30/120 ft |
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Light Crossbow | 1d8 | piercing | Dexterity | ammunition, loading, two-handed | 80/320 ft | — |
| Dart | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, thrown | 20/60 ft | — |
| Shortbow | 1d6 | piercing | Dexterity | ammunition, two-handed | 80/320 ft | — |
| Sling | 1d4 | bludgeoning | Dexterity | ammunition | 30/120 ft | — |
### Martial Melee (18)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Battleaxe | 1d8 | slashing | Strength | versatile (1d10) | — |
| Flail | 1d8 | bludgeoning | Strength | — | — |
| Glaive | 1d10 | slashing | Strength | heavy, reach, two-handed | — |
| Greataxe | 1d12 | slashing | Strength | heavy, two-handed | — |
| Greatsword | 2d6 | slashing | Strength | heavy, two-handed | — |
| Halberd | 1d10 | slashing | Strength | heavy, reach, two-handed | — |
| Lance | 1d12 | piercing | Strength | reach, special | — |
| Longsword | 1d8 | slashing | Strength | versatile (1d10) | — |
| Maul | 2d6 | bludgeoning | Strength | heavy, two-handed | — |
| Morningstar | 1d8 | piercing | Strength | — | — |
| Pike | 1d10 | piercing | Strength | heavy, reach, two-handed | — |
| Rapier | 1d8 | piercing | Strength or Dexterity (finesse) | finesse | — |
| Scimitar | 1d6 | slashing | Strength or Dexterity (finesse) | finesse, light | — |
| Shortsword | 1d6 | piercing | Strength or Dexterity (finesse) | finesse, light | — |
| Trident | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft |
| War Pick | 1d8 | piercing | Strength | — | — |
| Warhammer | 1d8 | bludgeoning | Strength | versatile (1d10) | — |
| Whip | 1d4 | slashing | Strength or Dexterity (finesse) | finesse, reach | — |
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Battleaxe | 1d8 | slashing | Strength | versatile (1d10) | — | — |
| Flail | 1d8 | bludgeoning | Strength | — | — | — |
| Glaive | 1d10 | slashing | Strength | heavy, reach, two-handed | — | — |
| Greataxe | 1d12 | slashing | Strength | heavy, two-handed | — | — |
| Greatsword | 2d6 | slashing | Strength | heavy, two-handed | — | — |
| Halberd | 1d10 | slashing | Strength | heavy, reach, two-handed | — | — |
| Lance | 1d12 | piercing | Strength | reach, special | — | — |
| Longsword | 1d8 | slashing | Strength | versatile (1d10) | — | — |
| Maul | 2d6 | bludgeoning | Strength | heavy, two-handed | — | — |
| Morningstar | 1d8 | piercing | Strength | — | — | — |
| Pike | 1d10 | piercing | Strength | heavy, reach, two-handed | — | — |
| Rapier | 1d8 | piercing | Strength or Dexterity (finesse) | finesse | — | — |
| Scimitar | 1d6 | slashing | Strength or Dexterity (finesse) | finesse, light | — | — |
| Shortsword | 1d6 | piercing | Strength or Dexterity (finesse) | finesse, light | — | — |
| Trident | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft | — |
| War Pick | 1d8 | piercing | Strength | — | — | — |
| Warhammer | 1d8 | bludgeoning | Strength | versatile (1d10) | — | — |
| Whip | 1d4 | slashing | Strength or Dexterity (finesse) | finesse, reach | — | — |
### Martial Ranged (5)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Blowgun | 1 | piercing | Dexterity | ammunition, loading | 25/100 ft |
| Hand Crossbow | 1d6 | piercing | Dexterity | ammunition, light, loading | 30/120 ft |
| Heavy Crossbow | 1d10 | piercing | Dexterity | ammunition, heavy, loading, two-handed | 100/400 ft |
| Longbow | 1d8 | piercing | Dexterity | ammunition, heavy, two-handed | 150/600 ft |
| Net | - | special | Dexterity | special, thrown | 5/15 ft |
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Blowgun | 1 | piercing | Dexterity | ammunition, loading | 25/100 ft | — |
| Hand Crossbow | 1d6 | piercing | Dexterity | ammunition, light, loading | 30/120 ft | — |
| Heavy Crossbow | 1d10 | piercing | Dexterity | ammunition, heavy, loading, two-handed | 100/400 ft | — |
| Longbow | 1d8 | piercing | Dexterity | ammunition, heavy, two-handed | 150/600 ft | — |
| Net | - | special | Dexterity | special, thrown | 5/15 ft | — |
### Paired (4)
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Paired Daggers | 2d4 | piercing | Strength or Dexterity (finesse) | finesse, light, paired | — | A dagger in each hand: takes both hands. |
| Paired Shortswords | 2d6 | piercing | Strength or Dexterity (finesse) | finesse, light, paired | — | A shortsword in each hand: takes both hands. |
| Paired Scimitars | 2d6 | slashing | Strength or Dexterity (finesse) | finesse, light, paired | — | A scimitar in each hand: takes both hands. |
| Paired Handaxes | 2d6 | slashing | Strength | light, paired | — | A handaxe in each hand: takes both hands. |
<!-- END GENERATED: weapon-library -->
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.7.0",
"version": "1.22.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -18,12 +18,14 @@
"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": "~6.0.2",
"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>
);
}
+268 -19
View File
@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
import { apiClient } from './client';
import type {
BuildOptions,
@@ -12,13 +12,18 @@ import type {
Encounter,
EncounterMode,
EncounterParticipant,
GearEffect,
GoldTransaction,
Item,
LevelUpPlan,
Location,
Monster,
Note,
Npc,
SaleOffer,
QuickStats,
RestResult,
RestType,
Spell,
Weapon,
} from './types';
@@ -60,6 +65,16 @@ export function useCreateCampaign() {
});
}
// A ready-made world (a seed file on the server) as a new campaign, by its code.
export function useCreateCampaignFromSeed() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (code: string) =>
(await apiClient.post<{ campaign: Campaign }>('/campaigns/from-seed', { code })).data.campaign,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaigns'] }),
});
}
export function useUpdateCampaign(campaignId: string) {
const qc = useQueryClient();
return useMutation({
@@ -154,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);
@@ -185,6 +200,95 @@ export function useDeathSave(characterId: string) {
});
}
// The slot tracker. Spending a slot is the player's (or the DM's); setting how
// many are spent, and giving them all back, are the DM's alone.
export type SpellSlotAction = { action: 'use'; level: number } | { action: 'set'; level: number; used: number } | { action: 'restore' };
export function useSpellSlots(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: SpellSlotAction) =>
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/spell-slots`, data)).data.character,
onSuccess: (character) => {
invalidateCharacter(qc, characterId); // the sheet and its quick stats
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
},
});
}
// The class counters. Spending is for the character's own player and the DM,
// one use at a time or `amount` from a pool; setting how many are spent, and
// giving them all back, are the DM's alone.
export type ResourceAction = { action: 'use'; key: string; amount?: number } | { action: 'set'; key: string; used: number } | { action: 'restore' };
export function useResources(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: ResourceAction) =>
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/resources`, data)).data.character,
onSuccess: (character) => {
invalidateCharacter(qc, characterId);
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows its counters
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
},
});
}
// Inspiration, exhaustion and conditions. The DM sets all three; the
// character's own player may spend inspiration (set it to false).
export function useCharacterState(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { inspiration?: boolean; exhaustion?: number; conditions?: string[] }) =>
(await apiClient.patch<{ character: CharacterDetail }>(`/characters/${characterId}`, data)).data.character,
onSuccess: (character) => {
invalidateCharacter(qc, characterId);
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows all of it
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
},
});
}
// What a character's next level brings. DM only.
export function useLevelUpPlan(characterId: string, enabled: boolean) {
return useQuery({
queryKey: ['character', characterId, 'level-up'],
enabled,
gcTime: 0, // never open on the plan for a level the character has already reached
queryFn: async () => (await apiClient.get<{ plan: LevelUpPlan }>(`/characters/${characterId}/level-up`)).data.plan,
});
}
// Takes the character up one level, with the choices that go with it.
export function useLevelUp(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[] }) =>
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/level-up`, data)).data.character,
onSuccess: (character) => {
invalidateCharacter(qc, characterId);
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
},
});
}
// A short or long rest for any of the party at once. On a short rest each
// character spends `hitDice` Hit Dice; `rolled` is what they added up to at
// the table, and left out the site rolls them.
export function useRest(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { type: RestType; characters: { characterId: string; hitDice?: number; rolled?: number | null }[] }) =>
(await apiClient.post<{ type: RestType; results: RestResult[] }>(`/campaigns/${campaignId}/rest`, data)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['encounter'] });
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
},
});
}
export function useDeleteCharacter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
@@ -463,12 +567,22 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) {
isStartingOption?: boolean;
damageDice?: string | null;
damageScaling?: number;
revives?: boolean;
minLevel?: number;
name?: string;
description?: string | null;
exchangeGold?: number | null;
exchangeCash?: number | null;
effects?: GearEffect[];
effectNote?: string | null;
}) =>
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, pool] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, pool] });
// What an entry does while equipped changes the sheet of whoever holds it.
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
},
});
}
@@ -536,6 +650,20 @@ export function useCreateMonster(campaignId: string) {
});
}
// Changes a monster's stat block. Encounters read it live, so copies already
// in a scene show the new numbers (their HP stays as it was).
export function useUpdateMonster(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: { id: string } & Partial<Omit<Monster, 'id' | 'campaignId'>>) =>
(await apiClient.patch<{ monster: Monster }>(`/campaigns/${campaignId}/monsters/${id}`, data)).data.monster,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'monsters'] });
qc.invalidateQueries({ queryKey: ['encounter'] });
},
});
}
export function useDeleteMonster(campaignId: string) {
const qc = useQueryClient();
return useMutation({
@@ -567,6 +695,14 @@ export function useAddWeaponFromLibrary(campaignId: string) {
});
}
export function useAddItemFromLibrary(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) => (await apiClient.post<{ item: Item }>(`/campaigns/${campaignId}/items/from-library`, { key })).data.item,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'items'] }),
});
}
export function useAddSpellFromLibrary(campaignId: string) {
const qc = useQueryClient();
return useMutation({
@@ -632,7 +768,8 @@ export function useCampaignEncounters(campaignId: string | undefined) {
export function useCreateEncounter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; mode?: EncounterMode }) =>
// Where it goes in the campaign's order: the top, the bottom (the default), or straight after another.
mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string }) =>
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, data)).data.encounter,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }),
});
@@ -691,12 +828,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 }) =>
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiativeRoll?: number; count?: number }) =>
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['encounter', encounterId] }),
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
}
@@ -705,7 +851,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),
});
}
@@ -713,8 +859,17 @@ 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),
});
}
// Rolls a d20 for every monster and NPC that has no initiative roll yet. DM only.
export function useRollInitiative(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await apiClient.post<{ rolled: number }>(`/encounters/${encounterId}/roll-initiative`)).data,
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
}
@@ -722,7 +877,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),
});
}
@@ -737,8 +892,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) {
@@ -756,7 +911,20 @@ 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),
});
}
// The character on turn trying to bring a dead ally back; the server decides
// whether they have the means.
export function useReviveParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (participantId: string) => (await apiClient.post(`/encounters/${encounterId}/participants/${participantId}/revive`)).data,
onSuccess: (data) => {
keepEncounter(qc, encounterId, data);
qc.invalidateQueries({ queryKey: ['character'] }); // the revived character's sheet
},
});
}
@@ -764,7 +932,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),
});
}
@@ -781,27 +949,42 @@ 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),
});
}
// Awarding is what actually changes the character: a gold ledger entry, or
// the weapon/item added to what they hold.
// Hands out the encounter's XP, shared evenly between the characters in it:
// what its monsters are worth, or the amount given. Once only. DM only.
export function useAwardXp(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (xp?: number) =>
(await apiClient.post<{ xp: number; share: number; awardedTo: string[] }>(`/encounters/${encounterId}/award-xp`, xp ? { xp } : {})).data,
onSuccess: (data) => {
keepEncounter(qc, encounterId, data);
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['campaign'] });
},
});
}
export function useAwardReward(encounterId: string) {
const qc = useQueryClient();
return useMutation({
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'] });
},
@@ -829,6 +1012,72 @@ export function useCreateNpc(campaignId: string) {
});
}
// --------------------------------------------------------------------------
// Trade — an NPC's exchange rate and wares are set up by the DM; in a quest
// in progress, characters in the scene change money and buy
// --------------------------------------------------------------------------
export type NewWare = ({ npcId: string } | { encounterId: string }) &
({ weaponId: string } | { itemId: string }) & { price: number; currency: Currency; level?: number; stock: number | null };
// Wares reach the screen inside the NPC list and inside each encounter.
function invalidateWares(qc: QueryClient, campaignId: string) {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'npcs'] });
qc.invalidateQueries({ queryKey: ['encounter'] });
}
export function useCreateWare(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: NewWare) => (await apiClient.post<{ ware: SaleOffer }>(`/campaigns/${campaignId}/wares`, data)).data.ware,
onSuccess: () => invalidateWares(qc, campaignId),
});
}
// The price and what is left can change; what is sold, and by whom, cannot.
export function usePatchWare(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: { id: string; price?: number; currency?: Currency; stock?: number | null }) =>
(await apiClient.patch<{ ware: SaleOffer }>(`/campaigns/${campaignId}/wares/${id}`, data)).data.ware,
onSuccess: () => invalidateWares(qc, campaignId),
});
}
export function useDeleteWare(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (wareId: string) => apiClient.delete(`/campaigns/${campaignId}/wares/${wareId}`),
onSuccess: () => invalidateWares(qc, campaignId),
});
}
// Both change what a character holds, so the sheet and the party list refresh too.
function invalidateAfterTrade(qc: QueryClient, encounterId: string, data: unknown) {
keepEncounter(qc, encounterId, data);
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['campaign'] });
}
// Changes gold into cash or cash into gold with an NPC in the scene, at their rate.
export function useExchangeWithNpc(encounterId: string) {
const qc = useQueryClient();
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: (data) => invalidateAfterTrade(qc, encounterId, data),
});
}
export function useBuyWare(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ wareId, ...data }: { wareId: string; characterId: string; quantity?: number }) =>
(await apiClient.post(`/encounters/${encounterId}/wares/${wareId}/buy`, data)).data,
onSuccess: (data) => invalidateAfterTrade(qc, encounterId, data),
});
}
// --------------------------------------------------------------------------
// Notes
// --------------------------------------------------------------------------
+328 -12
View File
@@ -40,6 +40,12 @@ export type BuildStage =
| 'COMPLETE';
export type DiceMode = 'DIGITAL' | 'PHYSICAL';
// The campaign's dice: BOTH leaves it to each character, and any roll can be typed in.
export type CampaignDiceMode = DiceMode | 'BOTH';
export type HpMethod = 'AVERAGE' | 'ROLL';
export type ScoreMethod = 'ROLL_4D6' | 'STANDARD_ARRAY' | 'POINT_BUY';
export type RollVisibility = 'ALL' | 'OWN';
export type CritRule = 'DOUBLE_DICE' | 'MAX_PLUS_ROLL';
// The fixed lists the builder's story steps are made of (served by the API so
// the page never keeps its own copy).
@@ -93,6 +99,25 @@ export interface Campaign {
startingSpellCount: number;
skipMotivationRoll: boolean;
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
// Added to each target's AC for every target beyond the first in an area attack; 0 is off.
areaAttackAcPenalty: number;
// Off: nobody chooses a subclass; each class follows its first one, unnamed.
subclassesEnabled: boolean;
// How many weapons and items a character can have equipped together, and
// how many of those can be weapons. Only equipped gear is used in a fight.
maxEquipped: number;
maxEquippedWeapons: number;
multiclassEnabled: boolean;
diceMode: CampaignDiceMode;
// On: a monster's (or NPC's) turn comes with its attack already rolled, for the DM to accept or change.
autoRollMonsters: boolean;
autoRollNpcs: boolean;
trackXp: boolean; // off: no XP anywhere, nobody shown as due a level
hpOnLevelUp: HpMethod;
abilityScoreMethod: ScoreMethod;
rollVisibility: RollVisibility;
hideMonsterRolls: boolean; // the DM's monster and NPC rolls stay with the DM until put into play
critRule: CritRule;
ownerId: string;
archivedAt: string | null; // set while the campaign is archived
role: 'DM' | 'PLAYER';
@@ -106,7 +131,9 @@ export interface CampaignMemberSummary {
}
// The coins a character carries. 1 gold = 10 silver = 100 copper.
export type Currency = 'GOLD' | 'SILVER' | 'COPPER';
export type Coin = 'GOLD' | 'SILVER' | 'COPPER';
// Coins, plus cash ($): modern money for crossover worlds, with no worth in coins.
export type Currency = Coin | 'CASH';
// Where a character stands at 0 HP. UP: on their feet. DYING: rolling death
// saves, three of either kind settles it. STABLE: at 0 HP but no longer
@@ -119,7 +146,71 @@ export interface DeathSaves {
failures: number;
}
export interface CharacterSummary {
// One slot level on the slot tracker: how many slots there are, and how many
// are spent.
export interface SpellSlotLevel {
level: number; // 1 is a 1st-level slot
max: number;
used: number;
remaining: number;
}
// A character's spell slots, from class and level. A warlock's (PACT) are all
// of one slot level and come back on a short rest; everyone else's on a long one.
export interface SpellSlots {
kind: 'FULL' | 'HALF' | 'PACT';
restoresOn: 'SHORT' | 'LONG';
levels: SpellSlotLevel[];
}
// One Hit Die per level, of the class's size, spent on short rests to heal.
export interface HitDice {
size: number;
count: number;
used: number;
remaining: number;
}
// A class counter (Rage, Ki, Channel Divinity...): how many uses there are,
// how many are spent, and the rest that gives them back.
export interface ClassResource {
key: string;
name: string;
max: number;
used: number;
remaining: number;
restoresOn: 'SHORT' | 'LONG';
}
// How a character is doing beyond hit points. Reminders for the table: none
// of it changes a number on the sheet.
export interface CharacterCondition {
inspiration: boolean;
exhaustion: number; // 0 to 6
conditions: string[];
}
// Where a character's XP puts them: what the next level takes (null at level
// 20), and whether they have it. Levelling is still the DM's to do.
export interface XpProgress {
nextLevelAt: number | null;
levelUpDue: boolean;
}
// How hard an encounter is for the characters in it (DM only). `xp` is what
// its monsters are worth; `adjustedXp` is that multiplied for their number,
// and is what the rating is judged on.
export interface EncounterDifficulty {
xp: number;
adjustedXp: number;
multiplier: number;
rating: 'TRIVIAL' | 'EASY' | 'MEDIUM' | 'HARD' | 'DEADLY' | null; // null with no party, or nothing rated to fight
thresholds: { easy: number; medium: number; hard: number; deadly: number } | null; // the party's; null with no party
monsters: number; // how many have a challenge rating
unrated: number; // and how many do not, left out of the rest
}
export interface CharacterSummary extends CharacterCondition {
id: string;
campaignId: string;
userId: string | null;
@@ -127,18 +218,30 @@ export interface CharacterSummary {
name: string;
species: Species | null;
characterClass: CharacterClass | null;
subclassName: string | null;
level: number;
xp: number;
xpProgress: XpProgress;
buildStage: BuildStage;
currentHp: number;
tempHp: number;
deathSaves: DeathSaves;
// The purse; all three are null for another player's character.
// The purse; all four are null for another player's character.
gold: number | null;
silver: number | null;
copper: number | null;
cash: number | null;
maxHp: number | null;
armorClass: number | null;
// Passive scores and the spell save DC; null until built (the DC also for a non-caster).
passives: { perception: number; investigation: number; insight: number } | null;
spellSaveDC: number | null;
// What the character rolls with; null until built.
checks: { abilities: Record<Ability, AbilityDetail>; saves: SaveBonuses; skills: SkillBonuses } | null;
// What is left before the next rest; both null until the character is built.
spellSlots: SpellSlots | null; // also null for a character with no spell slots
hitDice: (HitDice & { conModifier: number }) | null; // each die heals its roll plus this
resources: ClassResource[]; // the class counters; empty until built
currentLocationId: string | null;
currentLocationName: string | null;
}
@@ -157,6 +260,24 @@ export interface AbilityDetail {
modifier: number;
}
// One thing a weapon or item does to whoever has it equipped: a signed amount
// on one of the sheet's numbers ("AC", "SPEED", "STR", "SAVE_DEX",
// "SKILL_STEALTH"...). The full list is GEAR_STATS on the server.
export interface GearEffect {
stat: string;
amount: number;
}
// What a character's equipped gear is doing right now. The sheet's numbers
// already include it; this says where each change comes from.
export interface ActiveGear {
effects: (GearEffect & { source: string })[];
notes: { source: string; note: string }[];
}
export type SaveBonuses = Record<Ability, { proficient: boolean; bonus: number }>;
export type SkillBonuses = Record<string, { ability: Ability; proficient: boolean; expertise: boolean; bonus: number }>;
export interface CharacterSheet {
abilities: Record<Ability, AbilityDetail>;
proficiencyBonus: number;
@@ -164,11 +285,16 @@ export interface CharacterSheet {
initiative: number;
speed: number;
hp: { max: number };
hitDice: { size: number; count: number };
saves: Record<Ability, { proficient: boolean; bonus: number }>;
skills: Record<string, { ability: Ability; proficient: boolean; expertise: boolean; bonus: number }>;
hitDice: HitDice;
saves: SaveBonuses;
skills: SkillBonuses;
passivePerception: number;
passiveInvestigation: number;
passiveInsight: number;
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
spellSlots: SpellSlots | null; // null for a character with no spell slots
resources: ClassResource[]; // the class counters, with how many of each are left
gear: ActiveGear;
}
export interface Weapon {
@@ -187,6 +313,8 @@ export interface Weapon {
damageScaling: number;
minLevel: number;
isStartingOption: boolean;
effects: GearEffect[]; // what it does to whoever has it equipped
effectNote: string | null; // and what it does that is not a number
}
export interface CharacterWeapon {
@@ -197,6 +325,8 @@ export interface CharacterWeapon {
equipped: boolean;
level: number;
scaledDamageDice: string; // the dice at this copy's level
attackBonus: number; // what this character adds to the d20 with it
damageBonus: number; // and to its dice
usable: boolean; // false while the character is below this copy's level
weapon: Weapon;
}
@@ -213,6 +343,7 @@ export interface Spell {
description: string | null;
damageDice: string | null;
isStartingOption: boolean;
revives: boolean; // brings back the dead: lets its caster revive a dead ally in a battle
}
export interface CharacterSpell {
@@ -234,6 +365,8 @@ export interface Item {
damageScaling: number;
minLevel: number;
isStartingOption: boolean;
effects: GearEffect[]; // armor is an item with an AC effect
effectNote: string | null;
}
export interface InventoryItem {
@@ -270,7 +403,7 @@ export interface SaveProficiency {
proficient: boolean;
}
export interface CharacterDetail {
export interface CharacterDetail extends CharacterCondition {
id: string;
campaignId: string;
userId: string | null;
@@ -278,8 +411,15 @@ export interface CharacterDetail {
name: string;
species: Species | null;
characterClass: CharacterClass | null;
// The path within the class: its key and name, null until one is chosen;
// what there is to choose from, and the level the class chooses at.
subclass: string | null;
subclassName: string | null;
subclassLevel: number | null;
subclassOptions: { key: string; name: string; summary: string }[];
level: number;
xp: number;
xpProgress: XpProgress;
motivation: string | null;
halfElfBonusAbility1: Ability | null;
halfElfBonusAbility2: Ability | null;
@@ -294,12 +434,14 @@ export interface CharacterDetail {
// Absent entirely (not null) in the API response for anyone but the
// owning player — a real server-enforced privacy boundary, not a UI hide.
privateNotes?: string | null;
diceMode: DiceMode;
diceMode: DiceMode; // the dice this character rolls with: the campaign's, or their own pick when the campaign leaves it to them
diceModeChoosable: boolean;
currentLocationId: string | null;
currentLocation: Location | null;
gold: number | null;
silver: number | null;
copper: number | null;
cash: number | null;
// True when this is another player's character: no gold, inventory or
// location is sent, only what the party can see across the table.
restricted: boolean;
@@ -337,6 +479,23 @@ export type EncounterStatus = 'PLANNING' | 'ACTIVE' | 'COMPLETE';
export type EncounterMode = 'BATTLE' | 'QUEST';
export type NpcDisposition = 'FRIENDLY' | 'NEUTRAL' | 'HOSTILE';
// Something for sale: a weapon or item from the campaign pools, at a price.
// It is an NPC's (on offer wherever they are met) or an encounter's own.
export interface SaleOffer {
id: string;
campaignId: string;
npcId: string | null;
encounterId: string | null;
weaponId: string | null;
itemId: string | null;
price: number; // for one, in `currency`
currency: Currency;
level: number; // the gear level it is handed over at
stock: number | null; // how many are left; null is no limit
weapon: Weapon | null;
item: Item | null;
}
// What players see of an NPC in an encounter.
export interface NpcPublic {
id: string;
@@ -344,6 +503,11 @@ export interface NpcPublic {
role: string | null;
disposition: NpcDisposition;
description: string | null;
// A money changer's rate: exchangeGold gold for exchangeCash cash, either
// way. Both null for an NPC who does not change money.
exchangeGold: number | null;
exchangeCash: number | null;
wares?: SaleOffer[]; // what they have for sale
}
// The DM's full record. dmNotes and the combat stats never reach a player.
@@ -384,6 +548,15 @@ export interface CombatWeapon {
damageType: string;
}
// An item a character has equipped, as everyone in an encounter sees it.
export interface CombatItem {
inventoryItemId: string;
name: string;
level: number;
quantity: number;
damageDice: string | null;
}
export interface CombatSpell {
characterSpellId: string;
name: string;
@@ -397,17 +570,28 @@ export interface CharacterCombatInfo {
level: number;
species: Species | null;
characterClass: CharacterClass | null;
subclassName: string | null;
armorClass: number;
tempHp: number;
deathSaves: DeathSaves;
canRevive: boolean; // has a spell that brings back the dead, at a level they can cast
speed: number;
initiativeBonus: number;
passivePerception: number;
passiveInvestigation: number;
passiveInsight: number;
proficiencyBonus: number;
abilities: Record<Ability, AbilityDetail>;
saves: Record<Ability, { proficient: boolean; bonus: number }>;
saves: SaveBonuses;
skills: SkillBonuses;
spellSlots: SpellSlots | null;
resources: ClassResource[];
inspiration: boolean;
exhaustion: number;
specialAbilities: string | null;
weapons: CombatWeapon[];
gear: ActiveGear;
weapons: CombatWeapon[]; // the equipped ones: a weapon has to be in hand to be used
items: CombatItem[]; // likewise
spells: CombatSpell[];
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
}
@@ -429,8 +613,13 @@ export interface EncounterParticipant {
maxHp: number | null;
hpPercent?: number | null;
conditions: string[];
// Rounds left on the conditions that wear off by themselves.
conditionRounds: Record<string, number>;
isMonster: boolean;
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
// Out of the fight (a dead character, or a monster or NPC at 0 HP) and so
// hidden from the players. Only the DM is ever sent one of these.
hidden: boolean;
npcId: string | null;
character?: { id: string; name: string; userId: string | null } | null;
// The DM gets the whole stat block; players only the id and name.
@@ -457,9 +646,15 @@ export interface Encounter {
location?: Pick<Location, 'id' | 'name' | 'description'> | null;
archivedAt: string | null; // archived encounters only reach the DM's list
sortOrder: number; // place in the campaign's own order, lowest first
xpAwarded?: number | null; // the XP handed out for it, in all; null until awarded
difficulty?: EncounterDifficulty; // DM only
createdAt: string;
participants?: EncounterParticipant[];
participants?: EncounterParticipant[]; // a player's list leaves out the fallen
// The dead characters in the scene. Players do not get their cards, only
// this, so that an ally can try to revive them.
fallen?: { participantId: string; characterId: string; name: string }[];
rewards?: EncounterReward[]; // players only receive the awarded ones
wares?: SaleOffer[]; // for sale at the scene itself, not by an NPC in it
}
export interface Note {
@@ -483,6 +678,18 @@ export interface LibraryWeapon {
properties: string[];
rangeNormal: number | null;
rangeLong: number | null;
effects?: GearEffect[];
effectNote?: string | null;
}
export interface LibraryItem {
key: string;
name: string;
category: string;
description: string | null;
damageDice: string | null;
effects: GearEffect[];
effectNote: string | null;
}
export interface LibrarySpell {
@@ -495,6 +702,7 @@ export interface LibrarySpell {
duration: string | null;
description: string | null;
damageDice?: string; // default damage; absent for spells that deal none
revives?: boolean; // brings back the dead
classes: CharacterClass[];
}
@@ -519,6 +727,7 @@ export interface LibraryMonster {
export interface CampaignLibrary {
weapons: LibraryWeapon[];
items: LibraryItem[];
spells: LibrarySpell[];
monsters: LibraryMonster[];
}
@@ -530,14 +739,49 @@ export interface QuickStatsWeapon {
level: number;
equipped: boolean;
quantity: number;
usable: boolean; // false while the character is below this copy's level
effects: GearEffect[];
effectNote: string | null;
attackBonus: number;
damageBonus: number;
damageDice: string;
damageType: string;
}
export interface QuickStats {
export interface QuickStatsItem {
inventoryItemId: string;
itemId: string;
name: string;
description: string | null;
level: number;
equipped: boolean;
quantity: number;
usable: boolean;
damageDice: string | null; // at this copy's level
effects: GearEffect[];
effectNote: string | null;
}
export interface QuickStatsSpell {
characterSpellId: string;
name: string;
level: number;
damageDice: string | null;
description: string | null;
prepared: boolean;
usable: boolean;
}
// A character at a glance, with everything it holds. The pack, the spell
// list and the purse are only sent to the owning player and the DM
// (`restricted` is true for anyone else).
export interface QuickStats extends CharacterCondition {
id: string;
name: string;
level: number;
species: Species | null;
characterClass: CharacterClass | null;
subclassName: string | null;
currentHp: number;
maxHp: number;
tempHp: number;
@@ -547,6 +791,78 @@ export interface QuickStats {
speed: number;
proficiencyBonus: number;
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
spellSlots: SpellSlots | null;
hitDice: HitDice;
resources: ClassResource[];
passivePerception: number;
passiveInvestigation: number;
passiveInsight: number;
abilities: Record<Ability, AbilityDetail>;
saves: SaveBonuses;
skills: SkillBonuses;
specialAbilities: string | null;
gear: ActiveGear;
weapons: QuickStatsWeapon[];
restricted: boolean;
inventory: QuickStatsItem[];
spells: QuickStatsSpell[];
purse: { gold: number; silver: number; copper: number; cash: number } | null;
}
// One of the four Boons that stand in for an ability score increase.
export interface BoonOption {
key: string;
name: string;
text: string; // the line taking it now would write
taken: number; // how many times this character already has
max: number;
available: boolean;
}
// The checklist for a character's next level (GET /characters/:id/level-up).
export interface LevelUpPlan {
fromLevel: number;
toLevel: number;
maxHp: { from: number; to: number };
// Set when a subclass has to be picked on the way to this level: the level
// the class chooses at, and each option with everything it would write.
subclass: { level: number; options: { key: string; name: string; summary: string; features: { name: string; text: string }[] }[] } | null;
// In a campaign without subclasses: the one taken without asking at this
// level, its features already among `features`. Null otherwise.
subclassTaken: string | null;
// What the class gains. An upgrade rewrites the line that has its name.
features: { name: string; text: string; upgrade: boolean }[];
boon: BoonOption[] | null; // null at a level that grants no Boon
// What the book lets a caster be granted now; null for a class with no spells.
spells: { maxSpellLevel: number; unlocksSpellLevel: number | null; newSpells: number; newCantrips: number } | null;
spellSlots: SpellSlots | null; // the slots at the new level
// Held weapons that can take the free upgrade of one level.
weapons: { weaponId: string; name: string; equipped: boolean; level: number; damageDice: string; nextDamageDice: string }[];
beyondTables: boolean; // past level 10, where the class tables stop
}
export type RestType = 'SHORT' | 'LONG';
// What a rest did for one character (POST /campaigns/:id/rest).
export interface RestResult {
characterId: string;
name: string;
skipped: string | null; // why the rest did nothing for them; the rest is absent then
hpBefore: number;
hpAfter: number;
maxHp: number;
tempHp: number;
hitDice: {
size: number;
spent: number;
rolls: number[] | null; // what each die showed, when the site rolled them
total: number;
conBonus: number;
healed: number;
left: number;
of: number;
regained: number;
};
slotsRestored: boolean;
notes: string[];
}
+5 -22
View File
@@ -1,28 +1,17 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { apiClient, getStoredToken, setAuthToken } from '../api/client';
import type { User } from '../api/types';
interface AuthContextValue {
user: User | null;
token: string | null;
loading: boolean;
login: (token: string, user: User) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
import { AuthContext } from './useAuth';
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(getStoredToken());
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// With no stored token there is nothing to check, so nothing to wait for.
const [loading, setLoading] = useState(() => getStoredToken() !== null);
useEffect(() => {
const stored = getStoredToken();
if (!stored) {
setLoading(false);
return;
}
if (!stored) return;
setAuthToken(stored);
apiClient
.get<{ user: User }>('/auth/me')
@@ -53,9 +42,3 @@ export function AuthProvider({ children }: { children: ReactNode }) {
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+18
View File
@@ -0,0 +1,18 @@
import { createContext, useContext } from 'react';
import type { User } from '../api/types';
export interface AuthContextValue {
user: User | null;
token: string | null;
loading: boolean;
login: (token: string, user: User) => void;
logout: () => void;
}
export const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+358
View File
@@ -0,0 +1,358 @@
import { Link, useMatch } from 'react-router-dom';
import { useCampaign, useCampaignCharacters, usePatchCharacterWeapon, usePatchInventoryItem, useQuickStats } from '../api/queries';
import { useAuth } from '../auth/useAuth';
import { useLastCampaignId } from '../lib/lastCampaign';
import { closeCharacterPanel, openCharacterPanel, useCharacterPanelId } from '../lib/characterPanel';
import { SPECIES_LABELS, classLabel, fmtMod, formatPurse, spellLevelLabel } from '../lib/format';
import { effectLabel, effectsSummary } from '../lib/gear';
import { speciesIcon } from '../lib/speciesIcons';
import { Spinner, ErrorBanner } from './Spinner';
import { HpBar } from './HpBar';
import { DeathSaves } from './DeathSaves';
import { SpellSlotTracker } from './SpellSlots';
import { SheetNumbers } from './SheetNumbers';
import { CharacterState } from './CharacterState';
import { Rollable } from './Rollable';
import { withBonus } from '../lib/diceRoller';
import { useState } from 'react';
import { extractErrorMessage } from '../lib/format';
import type { ActiveGear } from '../api/types';
// What equipped gear is doing, one line per thing: "Chain Mail: +6 AC · −10 Speed".
export function GearInForce({ gear }: { gear: ActiveGear }) {
const sources = [...new Set([...gear.effects.map((e) => e.source), ...gear.notes.map((n) => n.source)])];
if (sources.length === 0) return null;
return (
<>
{sources.map((source) => (
<div key={source} className="detail-row">
<strong>{source}</strong>
<span>
{[...gear.effects.filter((e) => e.source === source).map(effectLabel), ...gear.notes.filter((n) => n.source === source).map((n) => n.note)].join(' · ')}
</span>
</div>
))}
</>
);
}
function Flags({ equipped, usable, level }: { equipped: boolean; usable: boolean; level: number }) {
return (
<>
{equipped && <span className="badge badge-live" style={{ marginLeft: 6 }}>equipped</span>}
{!usable && <span className="badge badge-hostile" style={{ marginLeft: 6 }}>needs level {level}</span>}
</>
);
}
// 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, isDM }: { characterId: string; isDM: boolean }) {
const { data, isLoading, error } = useQuickStats(characterId, true);
const patchWeapon = usePatchCharacterWeapon(characterId);
const patchItem = usePatchInventoryItem(characterId);
// Why something could not be equipped: too much on already.
const [equipError, setEquipError] = useState<string | null>(null);
const equip = { onError: (err: unknown) => setEquipError(extractErrorMessage(err)), onSuccess: () => setEquipError(null) };
if (isLoading) return <Spinner label="Reading the sheet…" />;
if (error || !data) return <ErrorBanner message="Could not load this character." />;
const canEquip = !data.restricted;
const stats: [string, string | number][] = [
['AC', data.armorClass],
['Initiative', fmtMod(data.initiative)],
['Speed', data.speed],
['Prof.', fmtMod(data.proficiencyBonus)],
['Passive Perc.', data.passivePerception],
['Hit Dice', `${data.hitDice.remaining}/${data.hitDice.count} d${data.hitDice.size}`],
];
return (
<div className="mt-md">
<div className="flex-between flex-wrap">
<div>
<h3 style={{ margin: 0 }}>
<span aria-hidden="true">{speciesIcon(data.species)}</span> {data.name}
</h3>
<div className="combatant-sub">
{data.species && SPECIES_LABELS[data.species]} {classLabel(data.characterClass, data.subclassName)} · Level {data.level}
</div>
</div>
<Link to={`/characters/${data.id}`} className="btn" onClick={closeCharacterPanel}>
Full sheet
</Link>
</div>
<div className="stat-chips mt-sm">
<span className="stat-chip">
<span>HP</span>
<b>
{data.currentHp}/{data.maxHp}
{data.tempHp > 0 && ` +${data.tempHp}`}
</b>
</span>
{stats.map(([label, value]) => (
<span key={label} className="stat-chip">
<span>{label}</span>
<b>{value}</b>
</span>
))}
</div>
<div className="mt-sm">
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
</div>
{/* Read-only here; the saves are rolled on the sheet or in the encounter. */}
<DeathSaves characterId={characterId} name={data.name} deathSaves={data.deathSaves} canAct={false} isDM={false} />
<div className="stat-chips mt-sm">
{(Object.keys(data.abilities) as (keyof typeof data.abilities)[]).map((a) => (
<span key={a} className="stat-chip">
<span>{a}</span>
<b>
{data.abilities[a].final} ({fmtMod(data.abilities[a].modifier)})
</b>
</span>
))}
</div>
<div className="mt-sm">
<CharacterState
characterId={characterId}
name={data.name}
inspiration={data.inspiration}
exhaustion={data.exhaustion}
conditions={data.conditions}
resources={data.resources}
canUse={!data.restricted}
isDM={isDM}
onError={(err) => setEquipError(extractErrorMessage(err))}
/>
</div>
<details className="card mt-md" open>
<summary className="card-title">Saves and skills</summary>
<SheetNumbers
who={data.name}
saves={data.saves}
skills={data.skills}
passives={{ perception: data.passivePerception, investigation: data.passiveInvestigation, insight: data.passiveInsight }}
/>
</details>
{data.specialAbilities && (
<details className="card mt-md" open>
<summary className="card-title">Special Abilities</summary>
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{data.specialAbilities}</p>
</details>
)}
{(data.gear.effects.length > 0 || data.gear.notes.length > 0) && (
<div className="card mt-md">
<div className="card-title">From equipped gear</div>
<GearInForce gear={data.gear} />
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>Already counted in the numbers above.</p>
</div>
)}
{data.spellcasting && (
<div className="card mt-md">
<div className="card-title">Spellcasting</div>
<p style={{ margin: 0 }}>
Save DC {data.spellcasting.saveDC} ·{' '}
<Rollable label={`${data.name}: spell attack`} modifier={data.spellcasting.attackBonus}>
Attack {fmtMod(data.spellcasting.attackBonus)}
</Rollable>
</p>
{/* The character's own player and the DM spend slots here; the DM can also give them back. */}
{data.spellSlots && (
<SpellSlotTracker
characterId={characterId}
name={data.name}
slots={data.spellSlots}
canUse={!data.restricted}
isDM={isDM}
onError={(err) => setEquipError(extractErrorMessage(err))}
/>
)}
</div>
)}
{canEquip && (
<p className="combatant-sub mt-md" style={{ marginBottom: 0 }}>
Tick what is equipped. Only equipped weapons can be used in an encounter, and only equipped gear gives its effects.
</p>
)}
{equipError && <ErrorBanner message={equipError} />}
<div className="card mt-md">
<div className="card-title">Weapons · {data.weapons.length}</div>
{data.weapons.length === 0 && <p className="combatant-sub" style={{ margin: 0 }}>No weapons.</p>}
{data.weapons.map((w) => (
<div key={w.characterWeaponId} className="reward-row">
<div className="flex-between">
<div>
<strong>{w.name}</strong>
{w.quantity > 1 && <span className="combatant-sub"> ×{w.quantity}</span>}
<Flags equipped={w.equipped} usable={w.usable} level={w.level} />
<div className="combatant-sub">
Lv {w.level} ·{' '}
<Rollable label={`${data.name}: ${w.name} attack`} modifier={w.attackBonus}>
attack {fmtMod(w.attackBonus)}
</Rollable>{' '}
·{' '}
<Rollable label={`${data.name}: ${w.name} damage`} dice={withBonus(w.damageDice, w.damageBonus)}>
{w.damageDice} {fmtMod(w.damageBonus)}
</Rollable>{' '}
{w.damageType}
</div>
{effectsSummary(w.effects, w.effectNote) && <div className="combatant-sub">✦ {effectsSummary(w.effects, w.effectNote)}</div>}
</div>
{canEquip && (
<input
type="checkbox"
aria-label={`${w.name} is equipped`}
title="Equipped"
checked={w.equipped}
disabled={!w.usable || patchWeapon.isPending}
onChange={(e) => patchWeapon.mutate({ weaponId: w.weaponId, equipped: e.target.checked }, equip)}
/>
)}
</div>
</div>
))}
</div>
{data.restricted ? (
<p className="combatant-sub mt-md">Their pack, spells and purse are their own business.</p>
) : (
<>
<details className="card mt-md" open>
<summary className="card-title">Inventory · {data.inventory.length}</summary>
{data.inventory.length === 0 && <p className="combatant-sub" style={{ margin: 0 }}>Nothing carried.</p>}
{data.inventory.map((item) => (
<div key={item.inventoryItemId} className="reward-row">
<div className="flex-between">
<div>
<strong>{item.name}</strong>
<span className="combatant-sub"> ×{item.quantity}</span>
<Flags equipped={item.equipped} usable={item.usable} level={item.level} />
<div className="combatant-sub">
Lv {item.level}
{item.damageDice && ` · ${item.damageDice}`}
</div>
{effectsSummary(item.effects, item.effectNote) && <div className="combatant-sub">✦ {effectsSummary(item.effects, item.effectNote)}</div>}
{item.description && <div className="combatant-sub">{item.description}</div>}
</div>
<input
type="checkbox"
aria-label={`${item.name} is equipped`}
title="Equipped"
checked={item.equipped}
disabled={!item.usable || patchItem.isPending}
onChange={(e) => patchItem.mutate({ itemId: item.itemId, equipped: e.target.checked }, equip)}
/>
</div>
</div>
))}
</details>
<div className="card mt-md">
<div className="card-title">
<span>Money</span>
{data.purse && <span className="mono">{formatPurse(data.purse)}</span>}
</div>
</div>
{data.spells.length > 0 && (
<details className="card mt-md" open>
<summary className="card-title">Spells · {data.spells.length}</summary>
{data.spells.map((s) => (
<div key={s.characterSpellId} className="reward-row">
<strong>{s.name}</strong>
<span className="combatant-sub">
{' '}
{spellLevelLabel(s.level)}
{s.damageDice && (
<>
{' · '}
<Rollable label={`${data.name}: ${s.name}`} dice={s.damageDice}>
{s.damageDice}
</Rollable>
</>
)}
</span>
{s.prepared && <span className="badge badge-live" style={{ marginLeft: 6 }}>prepared</span>}
{!s.usable && <span className="badge badge-hostile" style={{ marginLeft: 6 }}>needs level {s.level}</span>}
{s.description && <div className="combatant-sub">{s.description}</div>}
</div>
))}
</details>
)}
</>
)}
</div>
);
}
// The floating character button, on every page, and the panel it opens. A
// player gets their own characters in the campaign they are in; the DM gets
// everyone's. Other pages can open the panel for a particular character with
// openCharacterPanel (lib/characterPanel.ts).
export function CharacterPanel() {
const { user } = useAuth();
const campaignId = useLastCampaignId() ?? undefined;
const { data: campaignData } = useCampaign(campaignId);
const { data: characters } = useCampaignCharacters(campaignId);
const onSheet = useMatch('/characters/:characterId');
const openId = useCharacterPanelId();
const isDM = campaignData?.campaign.role === 'DM';
const choices = (characters ?? []).filter((c) => c.buildStage === 'COMPLETE' && (isDM || c.userId === user?.id));
if (choices.length === 0 && !openId) return null;
// On a character's own sheet the button opens that character; otherwise the first of yours.
const fallback = choices.find((c) => c.id === onSheet?.params.characterId) ?? choices.find((c) => c.userId === user?.id) ?? choices[0];
const shown = openId ?? undefined;
return (
<>
{fallback && (
<button
className="fab-quickstats"
onClick={() => openCharacterPanel(fallback.id)}
title={isDM ? 'Characters: stats, skills and inventory' : 'Your character: stats, skills and inventory'}
aria-label="Open the character panel"
>
🧙
</button>
)}
{shown && (
<>
<div className="drawer-backdrop" onClick={closeCharacterPanel} />
<div className="drawer" role="dialog" aria-label="Character panel">
<div className="flex-between">
{choices.length > 1 && choices.some((c) => c.id === shown) ? (
<select aria-label="Which character" value={shown} onChange={(e) => openCharacterPanel(e.target.value)} style={{ flex: 1, minWidth: 0 }}>
{choices.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{isDM && c.userId === user?.id ? ' (yours)' : ''}
</option>
))}
</select>
) : (
<h2 style={{ margin: 0 }}>Character</h2>
)}
<button className="btn btn-ghost" onClick={closeCharacterPanel} aria-label="Close the character panel">
✕
</button>
</div>
<PanelBody key={shown} characterId={shown} isDM={isDM} />
</div>
</>
)}
</>
);
}
+257
View File
@@ -0,0 +1,257 @@
import { useState } from 'react';
import { useCharacterState, useResources } from '../api/queries';
import { CONDITIONS, MAX_EXHAUSTION, conditionRule, exhaustionRule } from '../lib/conditions';
import { NumberInput } from './NumberInput';
import type { ClassResource } from '../api/types';
// A counter with more uses than this is a pool (Lay on Hands, a high-level
// monk's Ki): it gets a number to spend in place of a row of pips.
const MOST_PIPS = 10;
// One condition, with its rule a hover away. `onRemove` adds the ✕.
export function ConditionChip({ name, note, onRemove }: { name: string; note?: string; onRemove?: () => void }) {
return (
<span className="condition-chip" title={conditionRule(name) || undefined}>
{name}
{note && <span className="condition-note">{note}</span>}
{onRemove && (
<button aria-label={`Remove ${name}`} onClick={onRemove}>
✕
</button>
)}
</span>
);
}
// The "+ Condition" menu: every condition not already in force.
export function ConditionPicker({ who, current, onAdd }: { who: string; current: string[]; onAdd: (condition: string) => void }) {
return (
<select aria-label={`Add a condition to ${who}`} value="" onChange={(e) => e.target.value && onAdd(e.target.value)} style={{ width: 'auto' }}>
<option value="">+ Condition</option>
{CONDITIONS.filter((c) => !current.includes(c)).map((c) => (
<option key={c} value={c} title={conditionRule(c)}>
{c}
</option>
))}
</select>
);
}
// Inspiration and exhaustion as badges, for places that only show them.
export function StateBadges({ inspiration, exhaustion }: { inspiration: boolean; exhaustion: number }) {
return (
<>
{inspiration && (
<span className="badge badge-live" title="Inspiration: spend it for advantage on one attack, check or save">
✨ Inspired
</span>
)}
{exhaustion > 0 && (
<span className="badge badge-hostile" title={exhaustionRule(exhaustion)}>
Exhaustion {exhaustion}
</span>
)}
</>
);
}
// The class counters (Rage, Ki, Channel Divinity...): pips that are filled
// while a use is left, and "Use" to spend one. `canUse` is the character's own
// player or the DM. Getting a use back is the DM's alone: for them each pip
// sets how many are left, and "All back" refills the lot.
export function ResourceTracker({
characterId,
name,
resources,
canUse,
isDM,
onError,
}: {
characterId: string;
name: string;
resources: ClassResource[];
canUse: boolean;
isDM: boolean;
onError?: (err: unknown) => void;
}) {
const mutation = useResources(characterId);
const send = (action: Parameters<typeof mutation.mutate>[0]) => mutation.mutate(action, { onError });
// What is typed into a pool's box, by counter.
const [amounts, setAmounts] = useState<Record<string, string>>({});
if (resources.length === 0) return null;
return (
<div className="resource-tracker">
{isDM && resources.some((r) => r.used > 0) && (
<div className="flex-between">
<span />
<button className="btn btn-ghost" onClick={() => send({ action: 'restore' })} disabled={mutation.isPending} title={`Give ${name} every use back`}>
All back
</button>
</div>
)}
{resources.map((r) => {
const rest = r.restoresOn === 'SHORT' ? 'short or long rest' : 'long rest';
const isPool = r.max > MOST_PIPS;
const amount = Math.max(1, Math.floor(Number(amounts[r.key])) || 1);
return (
<div key={r.key} className="slot-row">
<span className="resource-name" title={`Back on a ${rest}`}>
{r.name}
<span className="resource-rest">{r.restoresOn === 'SHORT' ? 'short rest' : 'long rest'}</span>
</span>
{isPool ? (
<span className="slot-pips" />
) : (
<span className="slot-pips" role="group" aria-label={`${r.remaining} of ${r.max} ${r.name} left`}>
{Array.from({ length: r.max }, (_, i) => i + 1).map((n) => {
const className = `slot-pip${n <= r.remaining ? ' is-filled' : ''}`;
const left = n === r.remaining ? n - 1 : n;
return isDM ? (
<button
key={n}
type="button"
className={className}
aria-label={`Set ${r.name} left to ${left}`}
disabled={mutation.isPending}
onClick={() => send({ action: 'set', key: r.key, used: r.max - left })}
/>
) : (
<span key={n} className={className} />
);
})}
</span>
)}
<span className="mono slot-count">
{r.remaining}/{r.max}
</span>
{canUse && isPool && (
<NumberInput
min={1}
max={r.max}
placeholder="1"
aria-label={`How much ${r.name} to spend`}
value={amounts[r.key] ?? ''}
onChange={(e) => setAmounts({ ...amounts, [r.key]: e.target.value })}
style={{ width: 60 }}
/>
)}
{canUse && (
<button
className="btn"
onClick={() => send({ action: 'use', key: r.key, amount: isPool ? amount : 1 })}
disabled={r.remaining === 0 || mutation.isPending}
title={r.remaining === 0 ? `None left. Back on a ${rest}` : `Spend ${isPool ? amount : 'one'}. Back on a ${rest}`}
aria-label={`Use ${r.name}`}
>
Use
</button>
)}
{isDM && isPool && (
<button
className="btn btn-ghost"
onClick={() => send({ action: 'set', key: r.key, used: Math.max(0, r.used - amount) })}
disabled={r.used === 0 || mutation.isPending}
title={`Give ${amount} back`}
aria-label={`Give ${r.name} back`}
>
+ Back
</button>
)}
</div>
);
})}
{canUse && !isDM && <p className="combatant-sub" style={{ margin: '0.3rem 0 0' }}>Once used, a rest or the DM gives it back.</p>}
</div>
);
}
// How a character is doing beyond hit points: inspiration, exhaustion,
// conditions and class counters. The DM gives and takes away all of it. The
// character's own player spends inspiration and counters. Everyone else reads.
// With `conditions` left out that row is not drawn (an encounter card has its
// own).
export function CharacterState({
characterId,
name,
inspiration,
exhaustion,
conditions,
resources,
canUse,
isDM,
onError,
}: {
characterId: string;
name: string;
inspiration: boolean;
exhaustion: number;
conditions?: string[];
resources: ClassResource[];
canUse: boolean;
isDM: boolean;
onError?: (err: unknown) => void;
}) {
const state = useCharacterState(characterId);
const set = (data: Parameters<typeof state.mutate>[0]) => state.mutate(data, { onError });
return (
<div className="character-state">
<div className="flex-row flex-wrap gap-sm">
{isDM ? (
<button
className={`btn btn-target${inspiration ? ' is-lit' : ''}`}
aria-pressed={inspiration}
disabled={state.isPending}
title={inspiration ? `Take ${name}'s inspiration away` : `Give ${name} inspiration: advantage on one attack, check or save of their choice`}
onClick={() => set({ inspiration: !inspiration })}
>
✨ {inspiration ? 'Inspired' : 'Give inspiration'}
</button>
) : inspiration ? (
<>
<StateBadges inspiration exhaustion={0} />
{canUse && (
<button className="btn btn-target" disabled={state.isPending} title="Spend it for advantage on one attack, check or save" onClick={() => set({ inspiration: false })}>
Spend
</button>
)}
</>
) : (
<span className="combatant-sub">No inspiration</span>
)}
{isDM ? (
<label className="flex-row gap-sm" title={exhaustionRule(exhaustion)}>
Exhaustion
<select
value={exhaustion}
disabled={state.isPending}
onChange={(e) => set({ exhaustion: Number(e.target.value) })}
style={{ width: 'auto' }}
>
{Array.from({ length: MAX_EXHAUSTION + 1 }, (_, level) => (
<option key={level} value={level}>
{level === 0 ? 'None' : `Level ${level}`}
</option>
))}
</select>
</label>
) : (
<StateBadges inspiration={false} exhaustion={exhaustion} />
)}
</div>
{exhaustion > 0 && <p className="combatant-sub" style={{ margin: '0.3rem 0 0', whiteSpace: 'pre-wrap' }}>{exhaustionRule(exhaustion)}</p>}
{conditions && (conditions.length > 0 || isDM) && (
<div className="flex-row flex-wrap gap-sm mt-sm">
{conditions.map((c) => (
<ConditionChip key={c} name={c} onRemove={isDM ? () => set({ conditions: conditions.filter((x) => x !== c) }) : undefined} />
))}
{isDM && <ConditionPicker who={name} current={conditions} onAdd={(c) => set({ conditions: [...conditions, c] })} />}
</div>
)}
<ResourceTracker characterId={characterId} name={name} resources={resources} canUse={canUse} isDM={isDM} onError={onError} />
</div>
);
}
+2 -2
View File
@@ -32,7 +32,7 @@ function Pips({ kind, count, onSet }: { kind: 'success' | 'failure'; count: numb
// What happens to a character at 0 HP: the death-save tallies, and the ways
// out of them. `canAct` is the character's own player or the DM; setting the
// tallies by hand and raising the dead are the DM's alone.
// tallies by hand and raising the dead from here are the DM's alone.
export function DeathSaves({
characterId,
name,
@@ -78,7 +78,7 @@ export function DeathSaves({
</>
)}
{state === 'STABLE' && <span className="combatant-sub">Out cold at 0 HP, but no longer rolling. Any healing wakes them; damage starts the saves again.</span>}
{state === 'DEAD' && <span className="combatant-sub">{isDM ? 'Healing does nothing now.' : 'Only the DM can bring them back.'}</span>}
{state === 'DEAD' && <span className="combatant-sub">{isDM ? 'Healing does nothing now.' : 'The DM can bring them back, or an ally with a reviving spell on their turn in a battle.'}</span>}
</div>
{state === 'DYING' && canAct && (
+87 -25
View File
@@ -1,43 +1,88 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { fmtMod } from '../lib/format';
import { onRollRequest, parseDice, rollD20, rollDie, type RollMode, type RollRequest } from '../lib/diceRoller';
const DICE = [4, 6, 8, 10, 12, 20, 100];
const MAX_DICE = 20;
const HISTORY_LENGTH = 8;
const MODES: { mode: RollMode; label: string; title: string }[] = [
{ mode: 'DISADVANTAGE', label: 'Disadv.', title: 'Disadvantage: roll two d20s and keep the lower' },
{ mode: 'NORMAL', label: 'Normal', title: 'One d20' },
{ mode: 'ADVANTAGE', label: 'Adv.', title: 'Advantage: roll two d20s and keep the higher' },
];
interface Roll {
id: number;
sides: number;
label?: string; // what it was for, when it was asked for from a sheet
notation: string; // "1d20+3", "1d8+1d6+2"
dice: number[]; // every die rolled, in order
modifier: number;
dice: number[];
total: number;
}
// One fair roll of an n-sided die. Uses the browser's cryptographic random
// source, discarding values that would make some faces slightly likelier.
function rollDie(sides: number): number {
const limit = Math.floor(0x100000000 / sides) * sides;
const buffer = new Uint32Array(1);
do {
crypto.getRandomValues(buffer);
} while (buffer[0] >= limit);
return (buffer[0] % sides) + 1;
// The d20 that counted, for a roll of a single d20 (a natural 20 or 1 means something).
natural: number | null;
mode: RollMode; // how a single d20 was rolled
}
function notation(count: number, sides: number, modifier: number): string {
return `${count}d${sides}${modifier ? fmtMod(modifier) : ''}`;
}
// A table-side dice roller, reachable from every page. These rolls are for
// the player's own use at the table: they are not sent to the server and do
// not feed the character builder, whose dice stay locked in server-side.
// A d20 plus a bonus, with advantage or disadvantage as chosen.
function d20Roll(modifier: number, mode: RollMode, label?: string): Omit<Roll, 'id'> {
const { kept, dice } = rollD20(mode);
return { label, notation: notation(1, 20, modifier), dice, modifier, total: kept + modifier, natural: kept, mode };
}
// What a request from a sheet comes to; null for dice that cannot be read.
function answer(request: RollRequest, mode: RollMode): Omit<Roll, 'id'> | null {
if (request.kind === 'check') return d20Roll(request.modifier, mode, request.label);
const parsed = parseDice(request.expression);
if (!parsed) return null;
const dice: number[] = [];
let total = parsed.flat;
for (const term of parsed.dice) {
for (let i = 0; i < term.count; i++) {
const die = rollDie(term.sides);
dice.push(die);
total += term.sign * die;
}
}
return { label: request.label, notation: request.expression.replace(/\s+/g, ''), dice, modifier: parsed.flat, total, natural: null, mode: 'NORMAL' };
}
// A table-side dice roller, reachable from every page. It rolls what is set
// up here, and whatever is clicked on a sheet or an encounter card (a skill,
// a save, an attack, damage: lib/diceRoller.ts), with the bonus already in.
// These rolls are for the table: they are not sent to the server and do not
// feed the character builder, whose dice stay locked in server-side.
export function DiceRoller() {
const [open, setOpen] = useState(false);
const [sides, setSides] = useState(20);
const [count, setCount] = useState(1);
const [modifier, setModifier] = useState(0);
const [mode, setMode] = useState<RollMode>('NORMAL');
const [history, setHistory] = useState<Roll[]>([]);
const push = (roll: Omit<Roll, 'id'>) => setHistory((prev) => [{ ...roll, id: (prev[0]?.id ?? 0) + 1 }, ...prev].slice(0, HISTORY_LENGTH));
// A roll asked for from elsewhere on the page: rolled, and shown. It is
// rolled the way the roller is set at that moment.
const currentMode = useRef(mode);
useEffect(() => {
currentMode.current = mode;
});
useEffect(
() =>
onRollRequest((request) => {
const roll = answer(request, currentMode.current);
if (!roll) return;
push(roll);
setOpen(true);
}),
[]
);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
@@ -46,15 +91,16 @@ export function DiceRoller() {
}, [open]);
const latest = history[0];
const singleD20 = sides === 20 && count === 1;
function roll() {
if (singleD20) return push(d20Roll(modifier, mode));
const dice = Array.from({ length: count }, () => rollDie(sides));
const total = dice.reduce((sum, d) => sum + d, 0) + modifier;
setHistory((prev) => [{ id: (prev[0]?.id ?? 0) + 1, sides, modifier, dice, total }, ...prev].slice(0, HISTORY_LENGTH));
push({ notation: notation(count, sides, modifier), dice, modifier, total: dice.reduce((sum, d) => sum + d, 0) + modifier, natural: null, mode: 'NORMAL' });
}
// The natural 20 and natural 1 only mean something on a single d20.
const natural = latest && latest.sides === 20 && latest.dice.length === 1 ? latest.dice[0] : null;
const natural = latest?.natural ?? null;
const modeNote = (r: Roll) => (r.mode === 'ADVANTAGE' ? 'advantage' : r.mode === 'DISADVANTAGE' ? 'disadvantage' : '');
return (
<>
@@ -113,29 +159,44 @@ export function DiceRoller() {
</label>
</div>
{/* For any single d20: one rolled here, and every check, save and attack clicked on a sheet. */}
<div className="dice-choices mt-sm" role="group" aria-label="How a d20 is rolled">
{MODES.map((m) => (
<button key={m.mode} className={`die-btn${m.mode === mode ? ' is-selected' : ''}`} aria-pressed={m.mode === mode} title={m.title} onClick={() => setMode(m.mode)}>
{m.label}
</button>
))}
</div>
<button className="btn btn-primary btn-block btn-lg mt-sm" onClick={roll}>
🎲 Roll {notation(count, sides, modifier)}
{singleD20 && mode !== 'NORMAL' && ` (${modeNote({ mode } as Roll)})`}
</button>
<div className="dice-result" aria-live="polite">
{latest ? (
<>
{latest.label && <div className="dice-label roll-label">{latest.label}</div>}
{/* Keyed on the roll so the pop animation replays every time, even on a repeat result. */}
<div key={latest.id} className={`roll-result animate${natural === 20 ? ' is-crit' : natural === 1 ? ' is-fumble' : ''}`}>
{latest.total}
</div>
{natural === 20 && <div className="dice-callout">Natural 20!</div>}
{natural === 1 && <div className="dice-callout is-fumble">Natural 1</div>}
{(latest.dice.length > 1 || latest.modifier !== 0) && (
{(latest.dice.length > 1 || latest.modifier !== 0 || latest.label) && (
<div className="dice-breakdown mono">
{latest.dice.join(' + ')}
{latest.natural != null && latest.dice.length > 1
? `${latest.dice.join(' / ')} → ${latest.natural} (${modeNote(latest)})`
: latest.natural != null
? latest.natural
: `${latest.notation}: ${latest.dice.join(', ')}`}
{latest.modifier !== 0 && ` ${latest.modifier > 0 ? '+' : '−'} ${Math.abs(latest.modifier)}`} = {latest.total}
</div>
)}
</>
) : (
<p className="subtitle" style={{ margin: 0 }}>
Pick a die and roll.
Pick a die and roll, or click a skill, save or attack on a sheet.
</p>
)}
</div>
@@ -151,7 +212,8 @@ export function DiceRoller() {
{history.slice(1).map((r) => (
<div key={r.id} className="flex-between dice-history-row">
<span>
{notation(r.dice.length, r.sides, r.modifier)}
{r.label ? `${r.label} · ` : ''}
{r.notation}
{r.dice.length > 1 && <span className="dice-history-dice"> ({r.dice.join(', ')})</span>}
</span>
<strong className="mono">{r.total}</strong>
+87
View File
@@ -0,0 +1,87 @@
import { useState } from 'react';
import { NumberInput } from './NumberInput';
import { GEAR_STAT_GROUPS, effectLabel, gearStatLabel } from '../lib/gear';
import type { GearEffect } from '../api/types';
// The DM's editor for what a weapon or item does to whoever has it equipped:
// a list of signed amounts on the sheet's numbers (armor is "+6 AC"), and a
// note for anything that is not a number. It holds nothing itself: the list
// and the note live with whoever renders it.
export function EffectsEditor({
effects,
note,
onChange,
}: {
effects: GearEffect[];
note: string;
onChange: (effects: GearEffect[], note: string) => void;
}) {
const [stat, setStat] = useState('AC');
const [amount, setAmount] = useState('');
function handleAdd() {
const value = Math.trunc(Number(amount));
if (!value) return;
// One line per number: adding to one already listed replaces it.
onChange([...effects.filter((e) => e.stat !== stat), { stat, amount: value }], note);
setAmount('');
}
return (
<div>
<div className="section-label">While equipped</div>
{effects.length === 0 && (
<p className="combatant-sub" style={{ marginTop: 0 }}>
No effects. Add a bonus (armor: +AC) or a penalty (heavy armor: −Speed).
</p>
)}
{effects.length > 0 && (
<div className="stat-chips" style={{ marginBottom: '0.5rem' }}>
{effects.map((effect) => (
<span key={effect.stat} className={`condition-chip ${effect.amount > 0 ? 'is-bonus' : ''}`}>
{effectLabel(effect)}
<button type="button" aria-label={`Remove the ${gearStatLabel(effect.stat)} effect`} onClick={() => onChange(effects.filter((e) => e !== effect), note)}>
✕
</button>
</span>
))}
</div>
)}
<div className="flex-row flex-wrap gap-sm">
<select aria-label="What it changes" value={stat} onChange={(e) => setStat(e.target.value)} style={{ flex: 1, minWidth: 130 }}>
{GEAR_STAT_GROUPS.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.stats.map((s) => (
<option key={s} value={s}>
{gearStatLabel(s)}
</option>
))}
</optgroup>
))}
</select>
<NumberInput
min={-100}
max={100}
placeholder="+ or −"
aria-label="By how much (negative for a penalty)"
title="By how much. A negative number is a penalty."
value={amount}
onChange={(e) => setAmount(e.target.value)}
style={{ width: 90 }}
/>
<button type="button" className="btn" onClick={handleAdd} disabled={!Math.trunc(Number(amount))}>
Add effect
</button>
</div>
<input
className="mt-sm"
style={{ width: '100%' }}
placeholder="Anything else it does (e.g. Disadvantage on Stealth)"
aria-label="What else it does, in words"
maxLength={300}
value={note}
onChange={(e) => onChange(effects, e.target.value)}
/>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { useState } from 'react';
import { Modal } from './Modal';
import { NumberInput } from './NumberInput';
import { ABILITY_LABELS, SKILL_LABELS, fmtMod } from '../lib/format';
import { rollD20, type RollMode } from '../lib/diceRoller';
import type { Ability, AbilityDetail, SaveBonuses, SkillBonuses } from '../api/types';
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
// What the modal needs to know about each character who might roll.
export interface Checker {
id: string;
name: string;
abilities: Record<Ability, AbilityDetail>;
saves: SaveBonuses;
skills: SkillBonuses;
}
interface Result {
id: string;
name: string;
dice: number[];
kept: number;
bonus: number;
total: number;
}
// A check is "SKILL:STEALTH", "SAVE:DEX" or "ABILITY:STR".
function describe(check: string): string {
const [kind, key] = check.split(':');
if (kind === 'SKILL') return SKILL_LABELS[key];
return `${ABILITY_LABELS[key]} ${kind === 'SAVE' ? 'save' : 'check'}`;
}
function bonusFor(c: Checker, check: string): number {
const [kind, key] = check.split(':');
if (kind === 'SKILL') return c.skills[key].bonus;
if (kind === 'SAVE') return c.saves[key as Ability].bonus;
return c.abilities[key as Ability].modifier;
}
// The DM calling for a roll from several characters at once: everyone makes
// a Stealth check, or a Dexterity save against a fireball. Pick the roll and
// who makes it; each gets their own d20 with their own bonus, and with a DC
// the list says who made it. Rolled in the browser, like every table roll.
export function GroupCheckModal({ characters, onClose }: { characters: Checker[]; onClose: () => void }) {
const [check, setCheck] = useState('SKILL:PERCEPTION');
const [dc, setDc] = useState('');
const [mode, setMode] = useState<RollMode>('NORMAL');
const [left, setLeft] = useState<Set<string>>(new Set()); // who sits this one out
const [results, setResults] = useState<{ check: string; dc: number | null; rows: Result[] } | null>(null);
const rolling = characters.filter((c) => !left.has(c.id));
const target = dc === '' ? null : Number(dc);
function roll() {
setResults({
check,
dc: target,
rows: rolling.map((c) => {
const { kept, dice } = rollD20(mode);
const bonus = bonusFor(c, check);
return { id: c.id, name: c.name, dice, kept, bonus, total: kept + bonus };
}),
});
}
const passed = results && results.dc != null ? results.rows.filter((r) => r.total >= results.dc!).length : 0;
return (
<Modal title="Call for a roll" subtitle="One d20 each, with each character's own bonus" onClose={onClose}>
<div className="flex-row flex-wrap gap-sm">
<select aria-label="What to roll" value={check} onChange={(e) => setCheck(e.target.value)} style={{ flex: 1, minWidth: 180 }}>
<optgroup label="Skills">
{Object.keys(SKILL_LABELS).map((skill) => (
<option key={skill} value={`SKILL:${skill}`}>
{SKILL_LABELS[skill]}
</option>
))}
</optgroup>
<optgroup label="Saving throws">
{ABILITIES.map((a) => (
<option key={a} value={`SAVE:${a}`}>
{ABILITY_LABELS[a]} save
</option>
))}
</optgroup>
<optgroup label="Ability checks">
{ABILITIES.map((a) => (
<option key={a} value={`ABILITY:${a}`}>
{ABILITY_LABELS[a]} check
</option>
))}
</optgroup>
</select>
<NumberInput
min={1}
max={40}
placeholder="DC"
title="The number to reach. Leave empty to just see the totals."
aria-label="Difficulty class"
value={dc}
onChange={(e) => setDc(e.target.value)}
style={{ width: 70 }}
/>
<select aria-label="How the d20s are rolled" value={mode} onChange={(e) => setMode(e.target.value as RollMode)} style={{ width: 'auto' }}>
<option value="NORMAL">Normal</option>
<option value="ADVANTAGE">Advantage</option>
<option value="DISADVANTAGE">Disadvantage</option>
</select>
</div>
<div className="level-spells mt-sm">
{characters.map((c) => (
<label key={c.id} className="checkbox-row level-choice">
<input
type="checkbox"
checked={!left.has(c.id)}
onChange={(e) => {
const next = new Set(left);
if (e.target.checked) next.delete(c.id);
else next.add(c.id);
setLeft(next);
}}
/>
<span>
{c.name} <span className="combatant-sub mono">{fmtMod(bonusFor(c, check))}</span>
</span>
</label>
))}
</div>
<button className="btn btn-primary btn-block mt-sm" onClick={roll} disabled={rolling.length === 0}>
🎲 Roll {describe(check)} for {rolling.length}
</button>
{results && (
<div className="mt-md" aria-live="polite">
<div className="section-label">
{describe(results.check)}
{results.dc != null && ` · DC ${results.dc} · ${passed} of ${results.rows.length} made it`}
</div>
{results.rows.map((r) => {
const made = results.dc == null ? null : r.total >= results.dc;
return (
<div key={r.id} className="detail-row">
<span>
{r.name}{' '}
<span className="combatant-sub mono">
{r.dice.length > 1 ? `${r.dice.join(' / ')} → ${r.kept}` : r.kept} {r.bonus >= 0 ? '+' : '−'} {Math.abs(r.bonus)}
{r.kept === 20 && ' · natural 20'}
{r.kept === 1 && ' · natural 1'}
</span>
</span>
<span>
<strong className="mono">{r.total}</strong>
{made != null && (
<span className={`badge ${made ? 'badge-live' : 'badge-hostile'}`} style={{ marginLeft: 6 }}>
{made ? 'made it' : 'failed'}
</span>
)}
</span>
</div>
);
})}
{results.dc != null && (
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>
A group check succeeds when at least half make it{passed * 2 >= results.rows.length ? ': this one does.' : ': this one does not.'}
</p>
)}
</div>
)}
</Modal>
);
}
+4 -2
View File
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom';
// Short explanations for the stat labels sprinkled across the sheet, quick
// stats, party table, and builder — "what is this, and how is it worked
// out" for someone who doesn't already know D&D 5e by heart.
export const STAT_HELP: Record<string, string> = {
const STAT_HELP: Record<string, string> = {
AC: 'Armor Class — how hard you are to hit. 10 + your Dexterity modifier + any armor bonus.',
HP: 'Hit Points — damage you can take before falling unconscious. Max HP is set by your class hit die, Constitution modifier, and level.',
TEMP_HP: 'Temporary Hit Points — an extra buffer that absorbs damage first and disappears when it runs out (it does not stack with more temp HP).',
@@ -16,7 +16,7 @@ export const STAT_HELP: Record<string, string> = {
SKILL_BONUS: 'Skill Bonus — added when you roll to use a skill. The governing ability modifier, plus your proficiency bonus if you are proficient (doubled if you have expertise).',
EXPERTISE: 'Expertise — doubles your proficiency bonus for this skill, on top of being proficient.',
ABILITY_MODIFIER: 'Ability Modifier — the number added to rolls that use this ability. floor((score − 10) / 2).',
ARMOR_BONUS: 'Armor Bonus — a flat bonus to AC from equipped armor or a shield, added on top of 10 + Dex modifier.',
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item.',
SPEED: 'Speed — how far you can move on your turn, in feet. Set by your species, unless overridden.',
SPELL_SAVE_DC: 'Spell Save DC — the difficulty a target must beat to resist your spell. 8 + proficiency bonus + your spellcasting ability modifier.',
SPELL_ATTACK_BONUS: 'Spell Attack Bonus — added when you make a spell attack roll. Proficiency bonus + your spellcasting ability modifier.',
@@ -24,6 +24,8 @@ export 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.',
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
// A row of links to the sections of a long page. It finds them itself: any
// element with a `data-jump="Label"` attribute that is on the page right now
// gets a link, so sections only some people see need no special handling.
export function JumpNav() {
const [labels, setLabels] = useState<string[]>([]);
// Sections come and go as data loads, so the page is watched for them.
useEffect(() => {
const look = () => {
const found = [...document.querySelectorAll<HTMLElement>('[data-jump]')].map((el) => el.dataset.jump ?? '');
setLabels((current) => (current.join('|') === found.join('|') ? current : found));
};
const observer = new MutationObserver(look);
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-jump'] });
look();
return () => observer.disconnect();
}, []);
if (labels.length < 3) return null;
return (
<nav className="jump-nav" aria-label="Sections of this page">
{labels.map((label) => (
<button
key={label}
type="button"
className="jump-link"
onClick={() => document.querySelector(`[data-jump="${label}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
>
{label}
</button>
))}
</nav>
);
}
+49 -10
View File
@@ -1,10 +1,41 @@
import { Link, Outlet } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { Suspense } from 'react';
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
import { useAuth } from '../auth/useAuth';
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
import { DiceRoller } from './DiceRoller';
import { CharacterPanel } from './CharacterPanel';
export function Layout() {
const { user, logout } = useAuth();
// The campaign pages say which campaign they are in their address; sheets
// and encounters report theirs themselves (useRememberCampaign).
const onCampaign = useMatch('/campaigns/:campaignId/*');
useRememberCampaign(onCampaign?.params.campaignId);
const campaignId = useLastCampaignId();
// The same destinations twice: along the top bar on a wide screen, and as a
// bar along the bottom, in reach of a thumb, on a phone.
const destinations = [
{ to: '/campaigns', end: true, icon: '🗺', label: 'Campaigns' },
...(campaignId
? [
{ to: `/campaigns/${campaignId}`, end: true, icon: '🏰', label: 'Campaign' },
{ to: `/campaigns/${campaignId}/party`, end: true, icon: '👥', label: 'Party' },
]
: []),
{ to: '/account', end: true, icon: '👤', label: 'Account' },
];
const links = (className: string) =>
destinations.map((d) => (
<NavLink key={d.to} to={d.to} end={d.end} className={({ isActive }) => `${className}${isActive ? ' is-active' : ''}`}>
<span className="nav-icon" aria-hidden>
{d.icon}
</span>
<span>{d.label}</span>
</NavLink>
));
return (
<div className="app-shell">
<header className="topbar">
@@ -14,23 +45,31 @@ export function Layout() {
</span>
The Sunken Reach
</Link>
<nav className="topbar-nav">
{user && (
{user && (
<nav className="topbar-nav" aria-label="Main">
<div className="topbar-links">{links('topbar-link')}</div>
<div className="topbar-user">
<Link to="/account" title="Account settings">
{user.displayName}
</Link>
<span className="topbar-name">{user.displayName}</span>
<button className="btn btn-ghost" onClick={logout}>
Log out
</button>
</div>
)}
</nav>
</nav>
)}
</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">
{links('bottom-nav-link')}
</nav>
)}
<DiceRoller />
{user && <CharacterPanel />}
</div>
);
}
+219
View File
@@ -0,0 +1,219 @@
import { useState, type ReactNode } from 'react';
import { useLevelUp, useLevelUpPlan } from '../api/queries';
import { extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format';
import { Modal } from './Modal';
import { Spinner, ErrorBanner } from './Spinner';
import type { CharacterDetail, LevelUpPlan, Spell } from '../api/types';
// One numbered step of the checklist.
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
return (
<div className="level-step">
<h3 className="section-label" style={{ margin: 0 }}>
{n}. {title}
</h3>
{children}
</div>
);
}
function allowance(spells: NonNullable<LevelUpPlan['spells']>): string {
const parts = [];
if (spells.newSpells > 0) parts.push(`${spells.newSpells} new spell${spells.newSpells === 1 ? '' : 's'} of up to ${slotLevelLabel(spells.maxSpellLevel)} level`);
if (spells.newCantrips > 0) parts.push(`${spells.newCantrips} extra cantrip`);
return parts.length > 0 ? `The book allows ${parts.join(' and ')}.` : 'No new spell at this level.';
}
// The levelling-up checklist for one character, one level at a time: the
// level, HP, the class's new special abilities (with a subclass or a Boon
// where the level calls for one), new spells and fresh slots, and the free weapon upgrade. The
// site does every step; the DM makes the choices and says yes.
export function LevelUpModal({ character, campaignSpells, onClose }: { character: CharacterDetail; campaignSpells: Spell[]; onClose: () => void }) {
const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true);
const levelUp = useLevelUp(character.id);
const [subclass, setSubclass] = useState('');
const [boon, setBoon] = useState('');
// undefined until picked: the equipped weapon is offered first.
const [weaponChoice, setWeaponChoice] = useState<string | undefined>(undefined);
const [spellIds, setSpellIds] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
if (isLoading || !plan) {
return (
<Modal title={`Level up ${character.name}`} onClose={onClose}>
{planError ? <ErrorBanner message={extractErrorMessage(planError)} /> : <Spinner label="Reading the class tables…" />}
</Modal>
);
}
const weaponId = weaponChoice ?? plan.weapons.find((w) => w.equipped)?.weaponId ?? plan.weapons[0]?.weaponId ?? '';
const known = new Set(character.spells.map((s) => s.spellId));
const grantable = plan.spells
? campaignSpells.filter((s) => !known.has(s.id) && s.level <= plan.spells!.maxSpellLevel).sort((a, b) => a.level - b.level || a.name.localeCompare(b.name))
: [];
const pickedBoon = plan.boon?.find((b) => b.key === boon);
const pickedSubclass = plan.subclass?.options.find((option) => option.key === subclass);
const owed = plan.subclass && !subclass ? 'Choose a subclass first' : plan.boon && !boon ? 'Choose a Boon first' : null;
function toggleSpell(id: string) {
setSpellIds((current) => {
const next = new Set(current);
if (!next.delete(id)) next.add(id);
return next;
});
}
async function handleLevelUp() {
setError(null);
try {
await levelUp.mutateAsync({ subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
onClose();
} catch (err) {
setError(extractErrorMessage(err));
}
}
return (
<Modal title={`Level up ${character.name}`} subtitle={`Level ${plan.fromLevel} → ${plan.toLevel} · the levelling-up checklist`} wide onClose={onClose}>
{error && <ErrorBanner message={error} />}
<Step n={1} title="Level">
<p>
Level <strong>{plan.fromLevel}</strong> → <strong>{plan.toLevel}</strong>. Proficiency bonus, saves, skills and spell DC follow by themselves.
</p>
</Step>
<Step n={2} title="Hit points">
<p>
Max HP <strong>{plan.maxHp.from}</strong> → <strong>{plan.maxHp.to}</strong>, and healed to full. One more Hit Die.
</p>
</Step>
<Step n={3} title="Special abilities">
{plan.subclass && (
<div role="radiogroup" aria-label="Subclass" style={{ marginBottom: '0.6rem' }}>
<p style={{ margin: '0 0 0.3rem' }}>
<strong>Subclass</strong>{' '}
<span className="combatant-sub">
{plan.subclass.level === plan.toLevel
? `chosen at level ${plan.subclass.level}. Choose one:`
: `owed since level ${plan.subclass.level}; everything it has given so far is written now. Choose one:`}
</span>
</p>
{plan.subclass.options.map((option) => (
<label key={option.key} className="checkbox-row level-choice">
<input type="radio" name="subclass" value={option.key} checked={subclass === option.key} onChange={() => setSubclass(option.key)} />
<span>
<strong>{option.name}</strong>
<span className="combatant-sub" style={{ display: 'block' }}>{option.summary}</span>
</span>
</label>
))}
{pickedSubclass?.features.map((feature) => (
<div key={feature.name + feature.text} className="reward-row">
<strong>{feature.name}</strong>
<span className="badge" style={{ marginLeft: 6 }}>{pickedSubclass.name}</span>
<div className="combatant-sub">{feature.text}</div>
</div>
))}
</div>
)}
{plan.features.length === 0 && !plan.boon && !plan.subclass && (
<p className="combatant-sub">
{plan.beyondTables ? 'The class tables stop at level 10. Add anything new to the special abilities by hand.' : 'No new feature at this level.'}
</p>
)}
{plan.features.map((feature) => (
<div key={feature.name + feature.text} className="reward-row">
<strong>{feature.name}</strong>
{feature.upgrade && <span className="badge" style={{ marginLeft: 6 }}>replaces the line it has now</span>}
<div className="combatant-sub">{feature.text}</div>
</div>
))}
{plan.boon && (
<div className="mt-sm" role="radiogroup" aria-label="Boon">
<p style={{ margin: '0 0 0.3rem' }}>
<strong>Boon</strong> <span className="combatant-sub">in place of an ability score increase. Choose one:</span>
</p>
{plan.boon.map((option) => (
<label key={option.key} className="checkbox-row level-choice" style={option.available ? undefined : { opacity: 0.55 }}>
<input type="radio" name="boon" value={option.key} checked={boon === option.key} disabled={!option.available} onChange={() => setBoon(option.key)} />
<span>
<strong>{option.name}</strong>
{option.taken > 0 && <span className="badge" style={{ marginLeft: 6 }}>{option.available ? `taken ${option.taken} of ${option.max}` : 'already taken'}</span>}
<span className="combatant-sub" style={{ display: 'block' }}>{option.text}</span>
</span>
</label>
))}
{pickedBoon?.key === 'TOUGHENED' && <p className="combatant-sub">Extra AC goes up by 1 with it.</p>}
{pickedBoon?.key === 'HARDY' && <p className="combatant-sub">The Temp HP is given now, and again after every long rest taken with the Rest button.</p>}
</div>
)}
{(plan.features.length > 0 || plan.boon || plan.subclass) && (
<p className="combatant-sub">Written into Special Abilities for you. Where a line says "choose", edit it there once the player has.</p>
)}
</Step>
<Step n={4} title="Spells">
{!plan.spells ? (
<p className="combatant-sub">This class has no spells.</p>
) : (
<>
<p>
{plan.spellSlots ? (
<>
Fresh spell slots: <strong>{slotsSummary(plan.spellSlots)}</strong>.
</>
) : (
'No spell slots yet.'
)}{' '}
{plan.spells.unlocksSpellLevel && <strong>{slotLevelLabel(plan.spells.unlocksSpellLevel)}-level spells unlock. </strong>}
{allowance(plan.spells)}
</p>
{grantable.length === 0 ? (
<p className="combatant-sub">Nothing in the campaign's spell list to grant that {character.name} does not already know. Spells can be granted on the sheet later.</p>
) : (
<div className="level-spells">
{grantable.map((spell) => (
<label key={spell.id} className="checkbox-row level-choice">
<input type="checkbox" checked={spellIds.has(spell.id)} onChange={() => toggleSpell(spell.id)} />
<span>
{spell.name} <span className="combatant-sub">{spellLevelLabel(spell.level)}</span>
</span>
</label>
))}
</div>
)}
<p className="combatant-sub">
{spellIds.size > 0 && `${spellIds.size} picked. `}A player may also swap one known spell for another: take the old one away on the sheet.
</p>
</>
)}
</Step>
<Step n={5} title="Gear">
{plan.weapons.length === 0 ? (
<p className="combatant-sub">No weapon can take the free upgrade: it goes to a held weapon of standard growth that is below level {plan.toLevel}.</p>
) : (
<label className="flex-row flex-wrap gap-sm">
Free upgrade for the main weapon
<select value={weaponId} onChange={(e) => setWeaponChoice(e.target.value)} style={{ flex: 1, minWidth: 180 }}>
{plan.weapons.map((w) => (
<option key={w.weaponId} value={w.weaponId}>
{w.name}: Lv {w.level} → {w.level + 1} ({w.damageDice} → {w.nextDamageDice}){w.equipped ? ' · equipped' : ''}
</option>
))}
<option value="">No upgrade</option>
</select>
</label>
)}
</Step>
<div className="modal-foot">
<button className="btn btn-primary" onClick={handleLevelUp} disabled={levelUp.isPending || Boolean(owed)}>
{levelUp.isPending ? 'Levelling…' : (owed ?? `Level up to ${plan.toLevel}`)}
</button>
</div>
</Modal>
);
}
+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
);
}
+21 -27
View File
@@ -1,37 +1,17 @@
import { useMemo, useState } from 'react';
// Small generic pager shared by every list-turned-table in the app, so page
// state isn't hand-rolled per screen. Resets to page 1 whenever the
// underlying item count shrinks below the current page (e.g. after a delete
// or a filter change).
export function usePagination<T>(items: T[] | undefined, pageSize = 10) {
const [page, setPage] = useState(1);
const all = items || [];
const pageCount = Math.max(1, Math.ceil(all.length / pageSize));
const safePage = Math.min(page, pageCount);
const pageItems = useMemo(() => all.slice((safePage - 1) * pageSize, safePage * pageSize), [all, safePage, pageSize]);
return {
page: safePage,
pageCount,
pageItems,
total: all.length,
setPage,
nextPage: () => setPage((p) => Math.min(pageCount, p + 1)),
prevPage: () => setPage((p) => Math.max(1, p - 1)),
};
}
// The controls for a paged list; the page state itself is hooks/usePagination.
export function Pagination({
page,
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 (
@@ -39,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>
+1 -1
View File
@@ -1,5 +1,5 @@
import { Navigate, Outlet } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { useAuth } from '../auth/useAuth';
import { Spinner } from './Spinner';
export function ProtectedRoute() {
@@ -1,98 +0,0 @@
import { useState } from 'react';
import { useQuickStats } from '../api/queries';
import { Spinner, ErrorBanner } from './Spinner';
import { fmtMod } from '../lib/format';
import { HpBar } from './HpBar';
import { DeathSaves } from './DeathSaves';
export function QuickStatsFab({ characterId }: { characterId: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button className="fab-quickstats" onClick={() => setOpen(true)} title="Quick Stats" aria-label="Open quick stats">
⚡
</button>
{open && <QuickStatsDrawer characterId={characterId} onClose={() => setOpen(false)} />}
</>
);
}
export function QuickStatsDrawer({ characterId, onClose }: { characterId: string; onClose: () => void }) {
const { data, isLoading, error } = useQuickStats(characterId, true);
return (
<>
<div className="drawer-backdrop" onClick={onClose} />
<div className="drawer">
<div className="flex-between">
<h2 style={{ margin: 0 }}>Quick Stats</h2>
<button className="btn btn-ghost" onClick={onClose}>
✕
</button>
</div>
{isLoading && <Spinner label="Reading the sheet…" />}
{error && <ErrorBanner message="Could not load quick stats." />}
{data && (
<div className="mt-md">
<h3 style={{ margin: 0 }}>{data.name}</h3>
<div className="grid grid-cols-3 mt-sm">
<div className="stat-block">
<span className="stat-label">HP</span>
<span className="stat-value">
{data.currentHp}/{data.maxHp}
</span>
{data.tempHp > 0 && <span className="stat-sub">+{data.tempHp} temp</span>}
</div>
<div className="stat-block">
<span className="stat-label">AC</span>
<span className="stat-value">{data.armorClass}</span>
</div>
<div className="stat-block">
<span className="stat-label">Initiative</span>
<span className="stat-value">{fmtMod(data.initiative)}</span>
</div>
</div>
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
{/* Read-only here; the saves are rolled on the sheet or in the encounter. */}
<DeathSaves characterId={characterId} name={data.name} deathSaves={data.deathSaves} canAct={false} isDM={false} />
{data.spellcasting && (
<div className="card mt-md">
<div className="card-title">Spellcasting</div>
<p style={{ margin: 0 }}>
Save DC {data.spellcasting.saveDC} · Attack {fmtMod(data.spellcasting.attackBonus)}
</p>
</div>
)}
<div className="card mt-md">
<div className="card-title">Weapons</div>
{data.weapons.length === 0 && <p style={{ color: 'var(--text-muted)', margin: 0 }}>No weapons equipped.</p>}
{data.weapons.map((w) => (
<div key={w.characterWeaponId} className="flex-between mt-sm">
<div>
<strong>{w.name}</strong>
{w.equipped && <span className="badge" style={{ marginLeft: 6 }}>equipped</span>}
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Lv {w.level} · {w.damageDice} {w.damageType} {fmtMod(w.damageBonus)}
</p>
</div>
<span className="stat-value" style={{ fontSize: '1.1rem' }}>
{fmtMod(w.attackBonus)}
</span>
</div>
))}
</div>
{data.specialAbilities && (
<div className="card mt-md">
<div className="card-title">Special Abilities</div>
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{data.specialAbilities}</p>
</div>
)}
</div>
)}
</div>
</>
);
}
+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>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
import { leadingDice, rollCheck, rollDice } from '../lib/diceRoller';
// A number that rolls when clicked: a d20 plus `modifier` (a check, a save,
// an attack), or the dice in `dice` (damage). The roll opens in the dice
// roller under `label`. Of dice written with more after them ("2d8+3 fire")
// the dice are rolled; text that does not start with dice (a DM may type
// anything) is shown as it is.
export function Rollable({
label,
modifier,
dice,
className = '',
children,
}: {
label: string;
modifier?: number;
dice?: string;
className?: string;
children: ReactNode;
}) {
const readable = dice === undefined ? null : leadingDice(dice);
if (dice !== undefined && !readable) return <span className={className}>{children}</span>;
return (
<button
type="button"
className={`rollable ${className}`}
title={readable ? `Roll ${readable}: ${label}` : `Roll a d20 for ${label}`}
onClick={() => (readable ? rollDice(label, readable) : rollCheck(label, modifier ?? 0))}
>
{children}
</button>
);
}
+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>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { ABILITY_LABELS, SKILL_LABELS, fmtMod } from '../lib/format';
import type { Ability, SaveBonuses, SkillBonuses } from '../api/types';
import { Rollable } from './Rollable';
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
// Saving throws, skills and passive scores in one compact block: what the DM
// asks for mid-fight. Proficient lines are marked ● and expertise ◆. Used by
// the character panel and the encounter screen, so they always agree. A click
// on a line rolls it in the dice roller, under `who`'s name.
export function SheetNumbers({
who,
saves,
skills,
passives,
}: {
who: string;
saves: SaveBonuses;
skills: SkillBonuses;
passives: { perception: number; investigation: number; insight: number };
}) {
return (
<div className="sheet-numbers">
<div className="section-label">Saving throws</div>
<div className="number-grid is-saves">
{ABILITIES.map((a) => (
<Rollable key={a} label={`${who}: ${ABILITY_LABELS[a]} save`} modifier={saves[a].bonus} className={`number-row${saves[a].proficient ? ' is-proficient' : ''}`}>
<span>
<span className="prof-mark" aria-hidden="true">{saves[a].proficient ? '●' : '○'}</span> {a}
</span>
<b className="mono">{fmtMod(saves[a].bonus)}</b>
</Rollable>
))}
</div>
<div className="section-label">Skills</div>
<div className="number-grid">
{Object.entries(skills).map(([skill, detail]) => (
<Rollable key={skill} label={`${who}: ${SKILL_LABELS[skill]}`} modifier={detail.bonus} className={`number-row${detail.proficient ? ' is-proficient' : ''}`}>
<span>
<span className="prof-mark" aria-hidden="true">{detail.expertise ? '◆' : detail.proficient ? '●' : '○'}</span> {SKILL_LABELS[skill]}{' '}
<span className="number-ability">{detail.ability}</span>
</span>
<b className="mono">{fmtMod(detail.bonus)}</b>
</Rollable>
))}
</div>
<div className="stat-chips mt-sm">
<span className="stat-chip">
<span>Passive Perception</span>
<b>{passives.perception}</b>
</span>
<span className="stat-chip">
<span>Passive Investigation</span>
<b>{passives.investigation}</b>
</span>
<span className="stat-chip">
<span>Passive Insight</span>
<b>{passives.insight}</b>
</span>
</div>
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>● proficient · ◆ expertise · click a line to roll it</p>
</div>
);
}
+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>
);
}
+196
View File
@@ -0,0 +1,196 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useBuyWare, useExchangeWithNpc } from '../api/queries';
import { NumberInput } from './NumberInput';
import { WaresEditor } from './Wares';
import { stockLabel, wareName } from '../lib/wares';
import { formatMoney, formatPurse } from '../lib/format';
import type { CharacterSummary, Encounter, Item, SaleOffer, Weapon } from '../api/types';
type ExchangeSide = 'GOLD' | 'CASH';
// Trade in a quest: changing gold and cash with an NPC who does that, at
// their own rate, and buying what the NPCs in the scene — or the scene itself
// — have for sale. Everyone sees what is on offer; a player trades for their
// own character, the DM for anyone. Trading is open only while the quest is in
// progress, and there is none in a battle. The DM sets it all up: an NPC's
// rate and wares on the campaign page, the scene's own wares here.
export function TradePanel({
encounter,
isDM,
userId,
characters,
weapons,
items,
onError,
}: {
encounter: Encounter;
isDM: boolean;
userId: string | undefined;
characters: CharacterSummary[];
weapons: Weapon[];
items: Item[];
onError: (err: unknown) => void;
}) {
const exchange = useExchangeWithNpc(encounter.id);
const buy = useBuyWare(encounter.id);
const [traderId, setTraderId] = useState('');
const [amounts, setAmounts] = useState<Record<string, string>>({});
const [sides, setSides] = useState<Record<string, ExchangeSide>>({});
const [done, setDone] = useState<string | null>(null);
if (encounter.mode !== 'QUEST') return null;
const here = (encounter.participants ?? []).filter((p) => !p.hidden);
const npcs = here.flatMap((p) => (p.npc ? [p.npc] : []));
const changers = npcs.filter((npc) => npc.exchangeGold && npc.exchangeCash);
const stalls: [string, string, SaleOffer[]][] = [
...npcs.map((npc): [string, string, SaleOffer[]] => [npc.id, `${npc.name} sells`, npc.wares ?? []]),
['here', 'For sale here', encounter.wares ?? []],
];
const openStalls = stalls.filter(([, , wares]) => wares.length > 0);
if (!isDM && changers.length === 0 && openStalls.length === 0) return null;
// Who can do the trading: a player's own characters in the scene, or for the DM anyone's.
const traders = here.flatMap((p) => (p.character && (isDM || p.character.userId === userId) ? [p.character] : []));
const trader = traders.find((c) => c.id === traderId) ?? traders[0];
const purse = characters.find((c) => c.id === trader?.id);
const open = encounter.status === 'ACTIVE';
const busy = exchange.isPending || buy.isPending;
function handleExchange(npc: (typeof changers)[number]) {
const from = sides[npc.id] ?? 'GOLD';
const amount = Math.floor(Number(amounts[npc.id]));
if (!trader || !(amount > 0)) return;
setDone(null);
exchange.mutate(
{ characterId: trader.id, npcId: npc.id, from, amount },
{
onError,
onSuccess: ({ received, currency }) => {
setDone(`${trader.name} changed ${formatMoney(amount, from, true)} for ${formatMoney(received, currency, true)} with ${npc.name}.`);
setAmounts((a) => ({ ...a, [npc.id]: '' }));
},
}
);
}
function handleBuy(ware: SaleOffer) {
if (!trader) return;
setDone(null);
buy.mutate(
{ wareId: ware.id, characterId: trader.id },
{ onError, onSuccess: () => setDone(`${trader.name} bought ${(ware.weapon ?? ware.item)?.name} for ${formatMoney(ware.price, ware.currency, true)}.`) }
);
}
return (
<div className="card">
<div className="card-title">Trade</div>
{!open && (
<p className="combatant-sub" style={{ marginTop: 0 }}>
{encounter.status === 'PLANNING' ? 'Trading opens once the quest is in progress.' : 'The quest is over; trading is closed.'}
</p>
)}
{traders.length > 0 && (
<div className="flex-row flex-wrap gap-sm">
<select aria-label="Who is trading" value={trader?.id ?? ''} onChange={(e) => setTraderId(e.target.value)} style={{ flex: 1, minWidth: 110 }}>
{traders.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{purse && purse.gold !== null && <span className="mono">{formatPurse(purse)}</span>}
</div>
)}
{traders.length === 0 && (changers.length > 0 || openStalls.length > 0) && (
<p className="combatant-sub">{isDM ? 'Add a character to the scene to trade for them.' : 'Your character has to be in the scene to trade.'}</p>
)}
{done && <p className="combatant-sub">✓ {done}</p>}
{changers.map((npc) => {
const from = sides[npc.id] ?? 'GOLD';
const to = from === 'GOLD' ? 'CASH' : 'GOLD';
const rate = { GOLD: npc.exchangeGold ?? 1, CASH: npc.exchangeCash ?? 1 };
const amount = Math.floor(Number(amounts[npc.id]));
const whole = amount > 0 && amount % rate[from] === 0;
return (
<div key={npc.id} className="reward-row">
<strong>💱 {npc.name}</strong>
<span className="combatant-sub">
{' '}
changes {formatMoney(rate.GOLD, 'GOLD', true)} for {formatMoney(rate.CASH, 'CASH')}, either way. No silver or copper.
</span>
<div className="flex-row flex-wrap gap-sm mt-sm">
<NumberInput
min={1}
max={1000000}
placeholder="Amount"
aria-label={`How much to change with ${npc.name}`}
value={amounts[npc.id] ?? ''}
onChange={(e) => setAmounts((a) => ({ ...a, [npc.id]: e.target.value }))}
style={{ width: 90 }}
/>
<select
aria-label={`What to hand ${npc.name}`}
value={from}
onChange={(e) => setSides((s) => ({ ...s, [npc.id]: e.target.value as ExchangeSide }))}
>
<option value="GOLD">gold into cash</option>
<option value="CASH">cash into gold</option>
</select>
<button className="btn" disabled={!open || !trader || !whole || busy} onClick={() => handleExchange(npc)}>
{whole ? `Get ${formatMoney((amount / rate[from]) * rate[to], to, true)}` : 'Exchange'}
</button>
</div>
{amount > 0 && !whole && (
<div className="combatant-sub">
Hand over {from.toLowerCase()} in multiples of {rate[from]}.
</div>
)}
</div>
);
})}
{openStalls.map(([key, label, wares]) => (
<div key={key} className="mt-sm">
<div className="section-label">🛒 {label}</div>
{wares.map((ware) => (
<div key={ware.id} className="reward-row">
<div className="flex-between">
<span>
<strong>{wareName(ware)}</strong>
<span className="combatant-sub"> · {stockLabel(ware)}</span>
</span>
<button className="btn" disabled={!open || !trader || ware.stock === 0 || busy} onClick={() => handleBuy(ware)}>
Buy · {formatMoney(ware.price, ware.currency)}
</button>
</div>
{ware.item?.description && <div className="combatant-sub">{ware.item.description}</div>}
</div>
))}
</div>
))}
{isDM && (
<div className="mt-md">
<div className="section-label">Set up: for sale at this encounter</div>
<p className="combatant-sub" style={{ marginTop: 0 }}>
For a stall or a machine with nobody behind it. An NPC's wares and exchange rate are set under "Trade" on the{' '}
<Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link>, and follow the NPC into every quest.
</p>
<WaresEditor
campaignId={encounter.campaignId}
seller={{ encounterId: encounter.id }}
wares={encounter.wares ?? []}
weapons={weapons}
items={items}
onError={onError}
/>
</div>
)}
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
import { useState } from 'react';
import { useCreateWare, useDeleteWare, usePatchWare } from '../api/queries';
import { NumberInput } from './NumberInput';
import { COIN_LABELS, CURRENCIES, formatMoney } from '../lib/format';
import { scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import { stockLabel, wareName } from '../lib/wares';
import type { Currency, Item, SaleOffer, Weapon } from '../api/types';
// One ware in the DM's list. "Edit" swaps the line for its price and stock.
function WareRow({ campaignId, ware, onError }: { campaignId: string; ware: SaleOffer; onError: (err: unknown) => void }) {
const patchWare = usePatchWare(campaignId);
const deleteWare = useDeleteWare(campaignId);
const [editing, setEditing] = useState(false);
const [price, setPrice] = useState('');
const [currency, setCurrency] = useState<Currency>(ware.currency);
const [stock, setStock] = useState('');
function startEditing() {
setPrice(String(ware.price));
setCurrency(ware.currency);
setStock(ware.stock === null ? '' : String(ware.stock));
setEditing(true);
}
function handleSave() {
const amount = Math.floor(Number(price));
if (!(amount > 0)) return;
patchWare.mutate({ id: ware.id, price: amount, currency, stock: stock === '' ? null : Number(stock) }, { onError, onSuccess: () => setEditing(false) });
}
return (
<div style={{ padding: '0.3rem 0', borderBottom: '1px solid var(--border-soft)' }}>
<div className="flex-between">
<span>
{wareName(ware)} — <strong className="mono">{formatMoney(ware.price, ware.currency)}</strong>
<span className="combatant-sub"> · {stockLabel(ware)}</span>
</span>
<span className="flex-row">
<button className="btn btn-ghost" aria-label={`Edit the price and stock of ${wareName(ware)}`} onClick={() => (editing ? setEditing(false) : startEditing())}>
{editing ? 'Cancel' : 'Edit'}
</button>
<button className="btn btn-ghost" aria-label={`Stop selling ${wareName(ware)}`} onClick={() => deleteWare.mutate(ware.id, { onError })}>
✕
</button>
</span>
</div>
{editing && (
<div className="flex-row flex-wrap gap-sm mt-sm">
<NumberInput min={1} max={1000000} placeholder="Price" aria-label="Price for one" value={price} onChange={(e) => setPrice(e.target.value)} style={{ width: 90 }} />
<select aria-label="Price is in" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
<NumberInput
min={0}
max={9999}
placeholder="Stock (empty = no limit)"
aria-label="How many are in stock"
title="How many can be bought. Leave empty for no limit."
value={stock}
onChange={(e) => setStock(e.target.value)}
style={{ flex: 1, minWidth: 120 }}
/>
<button className="btn btn-primary" onClick={handleSave} disabled={!price || patchWare.isPending}>
Save
</button>
</div>
)}
</div>
);
}
// The DM's list of what one seller has for sale — an NPC, or an encounter
// itself — with a form to put something from the campaign pools up for sale.
export function WaresEditor({
campaignId,
seller,
wares,
weapons,
items,
onError,
}: {
campaignId: string;
seller: { npcId: string } | { encounterId: string };
wares: SaleOffer[];
weapons: Weapon[];
items: Item[];
onError: (err: unknown) => void;
}) {
const createWare = useCreateWare(campaignId);
const [kind, setKind] = useState<'item' | 'weapon'>('item');
const [poolId, setPoolId] = useState('');
const [level, setLevel] = useState('');
const [price, setPrice] = useState('');
const [currency, setCurrency] = useState<Currency>('GOLD');
const [stock, setStock] = useState('');
const pool: (Weapon | Item)[] = kind === 'weapon' ? weapons : items;
const picked = pool.find((entry) => entry.id === poolId);
function handleAdd() {
const amount = Math.floor(Number(price));
if (!picked || !(amount > 0)) return;
createWare.mutate(
{
...seller,
...(kind === 'weapon' ? { weaponId: picked.id } : { itemId: picked.id }),
price: amount,
currency,
level: level ? Number(level) : undefined,
stock: stock === '' ? null : Number(stock),
},
{
onError,
onSuccess: () => {
setPoolId('');
setLevel('');
setPrice('');
setStock('');
},
}
);
}
return (
<div>
{wares.length === 0 && (
<p className="combatant-sub" style={{ marginTop: 0 }}>
Nothing for sale yet.
</p>
)}
{wares.map((ware) => (
<WareRow key={ware.id} campaignId={campaignId} ware={ware} onError={onError} />
))}
<div className="flex-row flex-wrap gap-sm mt-sm">
<select
aria-label="What kind of thing to sell"
value={kind}
onChange={(e) => {
setKind(e.target.value as typeof kind);
setPoolId('');
setLevel('');
}}
>
<option value="item">Item</option>
<option value="weapon">Weapon</option>
</select>
<select
aria-label={kind === 'weapon' ? 'Weapon to sell' : 'Item to sell'}
value={poolId}
onChange={(e) => {
setPoolId(e.target.value);
setLevel('');
}}
style={{ flex: 1, minWidth: 110 }}
>
<option value="">{pool.length ? 'Choose…' : 'The pool is empty'}</option>
{pool.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</select>
{picked && (
<select aria-label="Level" value={level || picked.minLevel} onChange={(e) => setLevel(e.target.value)}>
{Array.from({ length: MAX_GEAR_LEVEL - picked.minLevel + 1 }, (_, i) => picked.minLevel + i).map((l) => (
<option key={l} value={l}>
Lv {l}
{picked.damageDice ? ` · ${scaledDamageDice(picked.damageDice, l, picked.damageScaling, picked.minLevel)}` : ''}
</option>
))}
</select>
)}
</div>
<div className="flex-row flex-wrap gap-sm mt-sm">
<NumberInput min={1} max={1000000} placeholder="Price" aria-label="Price for one" value={price} onChange={(e) => setPrice(e.target.value)} style={{ width: 90 }} />
<select aria-label="Price is in" value={currency} onChange={(e) => setCurrency(e.target.value as Currency)}>
{CURRENCIES.map((c) => (
<option key={c} value={c}>
{COIN_LABELS[c]}
</option>
))}
</select>
<NumberInput
min={0}
max={9999}
placeholder="Stock (empty = no limit)"
aria-label="How many are in stock"
title="How many can be bought. Leave empty for no limit."
value={stock}
onChange={(e) => setStock(e.target.value)}
style={{ flex: 1, minWidth: 120 }}
/>
<button className="btn btn-primary" onClick={handleAdd} disabled={!picked || !price || createWare.isPending}>
Put up for sale
</button>
</div>
</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} />
</>
);
}
+44 -16
View File
@@ -1,18 +1,25 @@
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/AuthContext';
import { useAuth } from '../auth/useAuth';
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);
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useRef } from 'react';
// Single-key shortcuts for a page: { n: () => …, s: () => … }. A key does
// nothing while the cursor is in a field, while a modifier is held, or when
// its entry is undefined (so a page can switch one off by leaving it out).
export function useHotkeys(keys: Record<string, (() => void) | undefined>) {
const latest = useRef(keys);
useEffect(() => {
latest.current = keys;
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey || e.altKey || e.repeat) return;
const target = e.target as HTMLElement | null;
if (target && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))) return;
const action = latest.current[e.key.toLowerCase()];
if (!action) return;
e.preventDefault();
action();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
}
+26
View File
@@ -0,0 +1,26 @@
import { useMemo, useState } from 'react';
// Small generic pager shared by every list-turned-table in the app, so page
// state isn't hand-rolled per screen. Resets to page 1 whenever the
// underlying item count shrinks below the current page (e.g. after a delete
// or a filter change).
export function usePagination<T>(items: T[] | undefined, pageSize = 10) {
const [page, setPage] = useState(1);
const total = items?.length ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, pageCount);
const pageItems = useMemo(
() => (items || []).slice((safePage - 1) * pageSize, safePage * pageSize),
[items, safePage, pageSize],
);
return {
page: safePage,
pageCount,
pageItems,
total,
setPage,
nextPage: () => setPage((p) => Math.min(pageCount, p + 1)),
prevPage: () => setPage((p) => Math.max(1, p - 1)),
};
}

Some files were not shown because too many files have changed in this diff Show More