Compare commits

...
36 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 9d0a4a2252 V1.29.0: free roam by invitation, no slot no spell, weapons change at the right moment
- Free roam no longer adds the whole party: the DM adds who is there
- A spell with no slot of its level or higher left cannot be declared; it
  takes the lowest slot left at or above its level. Cantrips take none
- In a battle in progress a player changes the weapon in hand only as the
  battle begins or on their own turn after their attack has landed
- Spells are listed cantrips first, then by level, with the level shown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:05:25 +02:00
joshuaandClaude Opus 5.5 555765916f V1.28.0: free roam, and completed encounters out of the way
- Free roam: a third kind of encounter. The DM ticks the nearby locations,
  the whole party is in it from the start, and each player sends their own
  character to a place
- A place shows who is found there and what is for sale; a character shops
  (a place's own shop, or an NPC's wares and exchange) only where they stand
- Locations can have a shop of their own; an NPC can be "found at" a location
- Completed encounters are hidden from the campaign's list until "Show
  completed" is ticked; moving an encounter works on the list as shown

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:30:16 +02:00
joshuaandClaude Opus 5.5 9b3637576d V1.27.0: battles that run themselves: track, targets, hit or miss, saves, auto-rolls
- Turn-order track: names coloured by PC, NPC or monster, this round, a flag
  where it ends, and the next round
- Target by clicking a name on the track, the card, or keys 1-9; whoever is
  on turn and every target show what they have in hand
- Attacks and spells are declared, rolled and applied: type or roll the d20
  and the app says hit, miss or critical; a save spell asks each target for
  its save against the caster's DC; the DM overrules anything and applies
- Critical damage by the campaign's rule; a spell spends its slot; a
  concentration save is called for
- Auto-rolled monster and NPC attacks: aimed (weighted), rolled, and offered
  to the DM to accept, edit or discard; hidden from players until accepted
- Monsters can have named attacks and multiattacks; spells say how they land
- A "What now" line tells the DM (and each player) what the scene waits on

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:14:04 +02:00
joshuaandClaude Opus 5.5 8811f14732 V1.26.0: dice that tumble, rolled by the server, seen by the DM
- Each die is drawn as its own shape, tumbles in and lands on its result;
  a natural 20 glows, a natural 1 shakes; respects reduced motion
- In a campaign the server makes every digital roll and keeps it
- The DM sees each roll as it lands: an "Everyone's rolls" tab in the
  roller, a pop-up beside the dice button, and a list on the encounter page
- Players see everyone's rolls or only their own, by the campaign setting;
  the DM can roll hidden
- Physical dice are typed in, one box per die, and checked; with "both",
  each person chooses in the roller
- game/dice.js: parsing, rolling, checking typed dice, critical damage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:46:22 +02:00
joshuaandClaude Opus 5.5 c3f2b0dfa7 V1.25.0: the DM approves a level, each player takes their own
- "Level up..." beside Rest approves a level for any of the party at once;
  with XP tracked, those who have the XP start ticked
- Each player is told with a notice and picks for themselves in the checklist:
  class (multiclass), subclass, Boon, spells within the level's allowance,
  the weapon upgrade; a waiting level shows on the sheet and campaign page
- The DM sees who still has picks to make, and can finish or take back any
- Hit points on a level up can be rolled (campaign setting): by the site, or
  at the table and typed in with physical dice
- Toasts: notices that slide in over any page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:30:31 +02:00
joshuaandClaude Opus 5.5 86ef106969 V1.24.0: multiclassing, by each class's own level
- With the campaign setting on, the level-up checklist asks which class gains
  the level; a new class needs both classes' ability requirements (2014 rules)
- HP, Hit Dice, features, Boons, subclasses and counters go by each class's
  own level; spell slots add up to a caster level, pact slots apart
- A first level in a new class writes its level-1 gifts and what it teaches
- Eldritch Knight and Arcane Trickster cast from level 3 with real slots
- The sheet shows each class's road to 20 and suggested multiclass routes
  (Hexadin, Sorlock, fighter dip...) with the character's progress and scores

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:14:57 +02:00
joshuaandClaude Opus 5.5 18d9e7301c V1.23.0: every subclass, 43 species, the Artificer, and the road to level 20
- All 123 official subclasses (2014 books and the 2024 Player's Handbook), each
  with its book and features up to level 20; one file per class
- The builder has a subclass step after the class: pick one, let a die suggest
  one, or decide later; its gifts arrive at the class's choice level
- 2024 updates (Wild Heart, Warrior of the Elements) offer a one-click switch
- The Road Ahead: a level 1-20 timeline on every sheet and in the builder
- 33 new species (2024 Aasimar and Goliath, Monsters of the Multiverse) with
  chosen +2/+1 or +1/+1/+1 bonuses; species roll is now a d8 by family
- The Artificer: class, half-caster slots from level 1, features, Flash of Genius
- Class tables and counters run to level 20; a fifth Boon, Resolve

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:02:51 +02:00
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
joshuaandClaude Opus 5.5 f8cee43ca2 V1.7.0: area attacks on several targets
- More than one combatant can be marked as a target; the Target button now
  adds to the targets instead of moving the mark.
- Several targets make an area attack, which is harder to land: every
  target's AC counts as 2 higher for each target beyond the first. The
  attack bar shows the AC to beat, and each target's card names the penalty.
- The DM unticks any target the roll missed, then applies one total: split
  evenly between those hit (remainder to the first marked), or the full
  amount to each. Each share is shown before it is applied.
- The single target column from V1.6.0 becomes a list, carried over by the
  migration.
- All packages at version 1.7.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:01:42 +02:00
joshuaandClaude Opus 5.5 e4456b0df2 V1.6.0: targets and an attack bar in encounters
- One combatant at a time can be marked as the target with the Target
  button on its card. The DM can mark anyone at any time; a player can
  while their own character is on turn in a battle.
- The target's card is outlined for everyone and names who is going for it.
- While a target is marked the DM gets an attack bar above the list: who is
  attacking whom, the target's AC and HP, the attacker's weapons, spells or
  monster attack, and a box to apply damage or healing straight away.
- The target is cleared when the turn or round moves on, when the encounter
  begins, ends or is reopened, or when the target is removed.
- All packages at version 1.6.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:53:18 +02:00
joshuaandClaude Opus 5.5 ec48b65481 V1.5.0: death saves for characters at 0 HP
- A character at 0 HP is Dying, Stable or Dead. The tallies live on the
  character, so the sheet and the encounter card always agree.
- Death saves are typed in as the d20 showed: 10 or more succeeds, less
  fails, a 1 fails twice, a 20 puts the character back up at 1 HP. Three
  successes make them stable; three failures kill.
- Damage taken at 0 HP is a failed save and ends stable. A blow that leaves
  as much as max HP still to take after reaching 0 kills outright.
- Healing from 0 clears the tallies. The dead cannot be healed; the DM
  revives them at 1 HP.
- A player rolls and stabilises for their own character; the DM for anyone,
  and only the DM sets tallies by hand or revives.
- The party overview, campaign list and quick stats show the state.
- All packages at version 1.5.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:37:38 +02:00
joshuaandClaude Opus 5.5 4362b7423e V1.4.0: silver and copper, with automatic change
- A character's purse holds gold, silver and copper as separate coins:
  1 gold = 10 silver = 100 copper. The money log records each entry's coin.
- Spending and giving are paid from the whole purse: coins of the kind
  named go first, then the nearest bigger coin is broken (change comes back
  in as few coins as possible), then smaller coins are counted out. A
  payment is refused only when the whole purse isn't worth enough.
- Exchange converts one coin into another at the fixed worth.
- Encounter rewards and gifts between characters can be in any coin; the
  party overview shows the whole purse.
- All packages at version 1.4.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:56:24 +02:00
joshuaandClaude Opus 5.5 98a89185c1 V1.3.1: initiative in quests, tooltips no longer clipped, deploy script
- Quests take initiative rolls like battles do; each quest group lists
  highest first. The total is roll plus bonus and can exceed 20 (test added).
- Info tooltips are drawn against the viewport, so the party overview
  table (or any scrolling container) can't cut them off.
- backend: `npm run deploy` applies migrations and regenerates the Prisma
  client in one step; README explains why both are needed on a server.
- All packages at version 1.3.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:28:24 +02:00
joshuaandClaude Opus 5.5 932e755895 V1.3.0: Hollowcrown seed, player visibility rules, initiative rolls, number limits, temp HP
- Hollowcrown: The Rift Between Worlds as its own seed (npm run
  seed:hollowcrown), built from the campaign book: pools, 58 monsters,
  57 NPCs, 28 locations, 72 encounters with rewards, and DM notes. Shared
  seeding logic moved to prisma/seedCampaign.js.
- What players can see: only encounters their own character is in; only
  the pool entries their character holds (plus starting options while
  building); only locations their character has been to; no invite code;
  no gold, inventory or money log of other players' characters. Socket
  events carry ids only and campaign rooms are members-only.
- Encounters have a location; while one is in progress its characters are
  there, and visits are remembered.
- Campaign setting to show players a health bar only for monsters and NPCs.
- Initiative is entered as the d20 roll (1-20) and the bonus is added;
  players can enter their own character's roll.
- Number fields enforce their limits when typing, and the API enforces
  the same limits.
- Temp HP shows on HP bars and absorbs damage before real HP.
- All packages at version 1.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 10:53:48 +02:00
joshuaandClaude Opus 5.5 2637a510df Set frontend package version to 1.0.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 09:05:57 +02:00
joshuaandClaude Opus 5.5 28e1a84b64 V1.2.0: archive/delete campaigns and encounters, encounter sorting, change password, fuller seed
- Campaigns: DM can archive/restore; owner can delete. Archived campaigns
  move off the main list and stop accepting invite codes.
- Encounters: DM can archive/restore and delete, and arrange the campaign's
  own order; the list can also be sorted by name, status, type or date.
  Archived encounters are hidden from players' lists.
- Account page with change password (POST /auth/password).
- Seed is now additive (safe on a live database; --reset for the old wipe)
  and builds a fuller Aldenfell: earned weapons, item pool, spells, NPCs,
  and 28 battle/quest scenes with descriptions, DM notes and rewards.
- Vite preview host config for the deployed site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 08:58:54 +02:00
joshuaandClaude Opus 5.5 4a6a09bae8 V1.1.0: gear levels, encounter overhaul, guided builder, trading, docs
Characters
- Builder: pick-then-Next on every step with live preview, Back button,
  plain-language help throughout, and "Suggest for me" for ability scores
- Motivation is picked from a list; new steps for special abilities,
  background and appearance, all composed from fixed lists and DM-only
  to edit afterwards
- Copy a character to another campaign
- Characters can give gold, weapons, items and spells to each other
- Money tracker: gold only changes through a logged ledger
- Fixes: level/XP/location are DM-only, HP stays within 0..max, physical
  dice ability rolls, every die is rolled once

Gear
- DM-managed item pool; weapons, items and spells are handed out by the DM
- Starting-option flag per pool entry
- Gear levels 1-9 per held copy with a damage ladder, growth rate and
  starting level; spells gated by spell level with a fixed damage value
- Spell library carries default damage; damage is picked from a list

Encounters
- Battle and NPC quest modes, turn tracking, scene description, DM notes
- NPCs, richer combatant cards, conditions from a list
- Rewards (gold, weapons, items) lined up and awarded, with gold splitting
- Character HP is shared between the sheet and the encounter
- Monster stat blocks and NPC secrets are never sent to players

Other
- Dice roller on every page
- Docs for characters, weapons, spells, items, encounters, monsters and
  NPCs, with full lists generated from the data (npm run docs:lists)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:47:28 +02:00
joshuaandClaude Opus 5.5 94067f1268 V1.0.1: Aldenfell campaign seed, content library, party overview
- Seed now resets the database and seeds the Aldenfell campaign from the
  campaign book: admin DM user, default weapons, locations, bestiary,
  pre-built encounters, and DM/shared notes
- Default weapon/spell/monster library, character profile fields,
  locations and dice mode (new migration)
- Party overview page, pagination, info tips, build preview calc
- Remove stale demo-login hint; notes preserve line breaks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:24:48 +02:00
196 changed files with 51503 additions and 3656 deletions
+1
View File
@@ -6,3 +6,4 @@ build/
*.log
.DS_Store
.idea/
temp/
+194 -17
View File
@@ -2,21 +2,163 @@
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.
See `CLAUDE_BUILD_PROMPT.md` and `/docs` for the full spec this was built
from (`GAME_RULES_REFERENCE.md` is the authoritative rules/math reference;
`ROLL_YOUR_CHARACTER.md` is the character-builder flow spec).
## How it works
`/docs` describes the app as it currently behaves, one subject per file:
| Document | Covers |
|---|---|
| `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, 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
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 its **full catalogue of official subclasses** (123, from the
2014 books and the 2024 Player’s Handbook), with features up to level 20.
One is picked in the builder (a die can suggest one) or in the level-up
checklist at level 2 or 3, and shown beside the class everywhere; the DM can
change it from the sheet, and switch a 2014 path to its 2024 update. Every
sheet shows **the road ahead**: what each level from 1 to 20 gives.
- **Stricter battles.** A spell with no slot of its level or higher left
cannot be cast (cantrips never need one), and a player changes the weapon
in hand only as the battle begins or on their own turn after attacking.
- **Free roam**, a third kind of encounter beside battles and quests: the DM
ticks the nearby locations and adds the characters who are there, and
each player sends their own character to a place to meet who is found there
and shop (a place's own shop, or an NPC's wares) where they stand.
- **Completed encounters are hidden** from the campaign's list until "Show
completed" is ticked.
- **Faster battles.** A colour-coded **turn-order track** (PCs blue, NPCs
green, monsters red) shows this round, a 🏁 flag where it ends, and the
next. Click a name (or press 1–9) to **target**. Whoever is on turn picks a
**weapon or spell**; the d20 is typed in or rolled and the app says **hit,
miss or critical**, or, for a spell, asks each target for its **saving
throw** against the caster's DC. The DM overrules anything and applies it
with one click. With **auto-roll** on, a monster's or NPC's turn opens with
its attack already aimed and rolled, to accept, edit or discard. A "What
now" line always says what the scene is waiting on.
- **Dice that roll.** Each die is drawn as its own shape, tumbles in and
lands on its result; a natural 20 glows. In a campaign the server makes
every roll and the **DM sees each one** as it lands, in the roller's
"Everyone's rolls" tab, beside the dice button, and on the encounter page.
A campaign can play with digital dice, real dice typed in (and checked), or
both, and can keep each player's rolls between them and the DM.
- **Multiclassing** (a campaign setting): the level-up checklist asks which
class gains the level, checks both classes' ability requirements, and works
out HP, Hit Dice, spell slots, counters and features by each class's own
level. The sheet suggests well-known multiclass routes from where the
character stands. Eldritch Knights and Arcane Tricksters now have their
spell slots.
- **43 species** (the 2014 core, the 2024 Aasimar and Goliath, and the species
of Monsters of the Multiverse) and **13 classes**, the Artificer included. A campaign can do without them: untick "Characters choose a
subclass" in its Settings and every class follows one path, unasked.
- **Campaign settings** come in four groups. **Characters**: starting gold
and gear, how ability scores are made (4d6, standard array or point buy),
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…** (DM, beside Rest) approves a level for any of the party at
once. Each player gets a notice, sees what the level gives and makes their
own picks in the checklist: HP (the average, or a roll where the campaign
rolls), the class's new special abilities, a subclass, a Boon, spells and
the free weapon upgrade. A level waiting shows on the sheet and the
campaign page until it is taken; the DM sees who still has picks to make,
and can finish any of them.
- The character sheet is long, so it starts with a row of **section links**
that jump straight to that part of the page.
- On a small screen every table (weapons, items, spells, monsters, the party
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
@@ -28,7 +170,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 # creates a demo campaign + demo users (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
@@ -44,9 +187,39 @@ npm run dev:backend # Express + Socket.io on http://localhost:4000
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 demo
users — see the seed script's console output for credentials and invite
code), and start playing.
Open the frontend URL, register an account (or log in with the seeded admin
account — see the seed script's console output for the credentials), and
start playing.
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.
## Worlds
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.
| 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
("Account" in the navigation).
## Tests
@@ -55,8 +228,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
@@ -66,14 +240,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)
+14 -12
View File
@@ -1,34 +1,36 @@
{
"name": "backend",
"version": "1.0.0",
"version": "1.29.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",
"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,26 @@
-- CreateEnum
CREATE TYPE "DiceMode" AS ENUM ('DIGITAL', 'PHYSICAL');
-- AlterTable
ALTER TABLE "characters" ADD COLUMN "appearance" TEXT,
ADD COLUMN "background" TEXT,
ADD COLUMN "currentLocationId" TEXT,
ADD COLUMN "diceMode" "DiceMode" NOT NULL DEFAULT 'DIGITAL',
ADD COLUMN "privateNotes" TEXT;
-- CreateTable
CREATE TABLE "locations" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "locations_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "locations" ADD CONSTRAINT "locations_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "characters" ADD CONSTRAINT "characters_currentLocationId_fkey" FOREIGN KEY ("currentLocationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,95 @@
-- AlterTable: starting-option flags. Existing pool entries keep the previous
-- behaviour (everything was offered at character creation); new ones opt in.
ALTER TABLE "weapons" ADD COLUMN "isStartingOption" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "spells" ADD COLUMN "isStartingOption" BOOLEAN NOT NULL DEFAULT false;
UPDATE "weapons" SET "isStartingOption" = true;
UPDATE "spells" SET "isStartingOption" = true;
-- CreateTable
CREATE TABLE "items" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"isStartingOption" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "items_pkey" PRIMARY KEY ("id")
);
-- Move free-text inventory into the campaign item pool: one pool item per
-- distinct name (case-insensitive) per campaign, then point each row at it.
INSERT INTO "items" ("id", "campaignId", "name", "description")
SELECT DISTINCT ON (c."campaignId", lower(i."name"))
'itm_' || md5(c."campaignId" || ':' || lower(i."name")),
c."campaignId", i."name", i."description"
FROM "inventory_items" i
JOIN "characters" c ON c."id" = i."characterId"
ORDER BY c."campaignId", lower(i."name"), i."id";
ALTER TABLE "inventory_items" ADD COLUMN "itemId" TEXT;
UPDATE "inventory_items" i
SET "itemId" = 'itm_' || md5(c."campaignId" || ':' || lower(i."name"))
FROM "characters" c
WHERE c."id" = i."characterId";
-- A character that held the same item on several rows keeps one, with the
-- quantities summed.
UPDATE "inventory_items" keep
SET "quantity" = agg.total, "equipped" = agg.any_equipped
FROM (
SELECT "characterId", "itemId", min("id") AS keep_id, sum("quantity")::int AS total, bool_or("equipped") AS any_equipped
FROM "inventory_items"
GROUP BY "characterId", "itemId"
) agg
WHERE keep."id" = agg.keep_id;
DELETE FROM "inventory_items" d
USING (
SELECT "characterId", "itemId", min("id") AS keep_id
FROM "inventory_items"
GROUP BY "characterId", "itemId"
) agg
WHERE d."characterId" = agg."characterId" AND d."itemId" = agg."itemId" AND d."id" <> agg.keep_id;
ALTER TABLE "inventory_items" ALTER COLUMN "itemId" SET NOT NULL;
ALTER TABLE "inventory_items" DROP COLUMN "name";
ALTER TABLE "inventory_items" DROP COLUMN "description";
-- CreateTable
CREATE TABLE "gold_transactions" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"amount" INTEGER NOT NULL,
"balanceAfter" INTEGER NOT NULL,
"reason" TEXT,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "gold_transactions_pkey" PRIMARY KEY ("id")
);
-- Give characters that already hold gold an opening ledger entry.
INSERT INTO "gold_transactions" ("id", "characterId", "amount", "balanceAfter", "reason")
SELECT 'gtx_' || md5("id"), "id", "gold", "gold", 'Opening balance'
FROM "characters"
WHERE "gold" > 0;
-- CreateIndex
CREATE UNIQUE INDEX "inventory_items_characterId_itemId_key" ON "inventory_items"("characterId", "itemId");
-- CreateIndex
CREATE INDEX "gold_transactions_characterId_createdAt_idx" ON "gold_transactions"("characterId", "createdAt");
-- AddForeignKey
ALTER TABLE "items" ADD CONSTRAINT "items_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "inventory_items" ADD CONSTRAINT "inventory_items_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "gold_transactions" ADD CONSTRAINT "gold_transactions_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "gold_transactions" ADD CONSTRAINT "gold_transactions_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,21 @@
-- AlterTable: how many ladder rungs a weapon climbs per level (1 standard, 2 powerful, 3 legendary)
ALTER TABLE "weapons" ADD COLUMN "damageScaling" INTEGER NOT NULL DEFAULT 1;
-- AlterTable: the level of the copy a character holds
ALTER TABLE "character_weapons" ADD COLUMN "level" INTEGER NOT NULL DEFAULT 1;
-- AlterTable: items can optionally carry dice (damage, healing, ...) that scale the same way
ALTER TABLE "items" ADD COLUMN "damageDice" TEXT,
ADD COLUMN "damageScaling" INTEGER NOT NULL DEFAULT 1;
-- AlterTable
ALTER TABLE "inventory_items" ADD COLUMN "level" INTEGER NOT NULL DEFAULT 1;
-- AlterTable: spells get a fixed damage value; their classic level is the requirement
ALTER TABLE "spells" ADD COLUMN "damageDice" TEXT;
-- A spell above its holder's level can no longer be prepared.
UPDATE "character_spells" cs
SET "prepared" = false
FROM "spells" s, "characters" c
WHERE s."id" = cs."spellId" AND c."id" = cs."characterId" AND s."level" > c."level";
@@ -0,0 +1,7 @@
-- AlterTable: the level a weapon or item starts at (its dice are the damage at this level)
ALTER TABLE "weapons" ADD COLUMN "minLevel" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "items" ADD COLUMN "minLevel" INTEGER NOT NULL DEFAULT 1;
-- Gear levels now stop at 9.
UPDATE "character_weapons" SET "level" = 9 WHERE "level" > 9;
UPDATE "inventory_items" SET "level" = 9 WHERE "level" > 9;
@@ -0,0 +1,69 @@
-- CreateEnum
CREATE TYPE "EncounterMode" AS ENUM ('BATTLE', 'QUEST');
-- CreateEnum
CREATE TYPE "NpcDisposition" AS ENUM ('FRIENDLY', 'NEUTRAL', 'HOSTILE');
-- CreateEnum
CREATE TYPE "RewardType" AS ENUM ('GOLD', 'WEAPON', 'ITEM');
-- AlterTable
ALTER TABLE "encounter_participants" ADD COLUMN "npcId" TEXT;
-- AlterTable
ALTER TABLE "encounters" ADD COLUMN "activeParticipantId" TEXT,
ADD COLUMN "description" TEXT,
ADD COLUMN "dmNotes" TEXT,
ADD COLUMN "mode" "EncounterMode" NOT NULL DEFAULT 'BATTLE';
-- CreateTable
CREATE TABLE "npcs" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT,
"disposition" "NpcDisposition" NOT NULL DEFAULT 'NEUTRAL',
"description" TEXT,
"dmNotes" TEXT,
"ac" INTEGER,
"maxHp" INTEGER,
"attackBonus" INTEGER,
"damageDice" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "npcs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "encounter_rewards" (
"id" TEXT NOT NULL,
"encounterId" TEXT NOT NULL,
"type" "RewardType" NOT NULL,
"gold" INTEGER,
"weaponId" TEXT,
"itemId" TEXT,
"quantity" INTEGER NOT NULL DEFAULT 1,
"level" INTEGER NOT NULL DEFAULT 1,
"note" TEXT,
"awardedTo" TEXT,
"awardedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "encounter_rewards_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "npcs" ADD CONSTRAINT "npcs_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_rewards" ADD CONSTRAINT "encounter_rewards_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_rewards" ADD CONSTRAINT "encounter_rewards_weaponId_fkey" FOREIGN KEY ("weaponId") REFERENCES "weapons"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_rewards" ADD CONSTRAINT "encounter_rewards_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "items"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_participants" ADD CONSTRAINT "encounter_participants_npcId_fkey" FOREIGN KEY ("npcId") REFERENCES "npcs"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- AlterEnum: three story steps between skills and equipment
ALTER TYPE "BuildStage" ADD VALUE 'CHOOSING_ABILITIES' BEFORE 'CHOOSING_EQUIPMENT';
ALTER TYPE "BuildStage" ADD VALUE 'CHOOSING_BACKGROUND' BEFORE 'CHOOSING_EQUIPMENT';
ALTER TYPE "BuildStage" ADD VALUE 'CHOOSING_APPEARANCE' BEFORE 'CHOOSING_EQUIPMENT';
@@ -0,0 +1,2 @@
-- AlterTable: a campaign can be put away without deleting it
ALTER TABLE "campaigns" ADD COLUMN "archivedAt" TIMESTAMP(3);
@@ -0,0 +1,12 @@
-- AlterTable: an encounter can be put away, and has a place in the campaign's own order
ALTER TABLE "encounters" ADD COLUMN "archivedAt" TIMESTAMP(3);
ALTER TABLE "encounters" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
-- Existing encounters keep the order the list has always shown: newest first.
UPDATE "encounters" AS e
SET "sortOrder" = r.pos
FROM (
SELECT id, ROW_NUMBER() OVER (PARTITION BY "campaignId" ORDER BY "createdAt" DESC, id) - 1 AS pos
FROM "encounters"
) AS r
WHERE e.id = r.id;
@@ -0,0 +1,23 @@
-- AlterTable: whether players see a monster's exact hit points or only its health bar
ALTER TABLE "campaigns" ADD COLUMN "showMonsterHp" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable: where an encounter takes place
ALTER TABLE "encounters" ADD COLUMN "locationId" TEXT;
ALTER TABLE "encounters" ADD CONSTRAINT "encounters_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- CreateTable: every location a character has been to
CREATE TABLE "character_location_visits" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"firstVisitedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "character_location_visits_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "character_location_visits_characterId_locationId_key" ON "character_location_visits"("characterId", "locationId");
ALTER TABLE "character_location_visits" ADD CONSTRAINT "character_location_visits_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "character_location_visits" ADD CONSTRAINT "character_location_visits_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Characters have been to wherever they are now.
INSERT INTO "character_location_visits" ("id", "characterId", "locationId")
SELECT 'visit_' || "id", "id", "currentLocationId" FROM "characters" WHERE "currentLocationId" IS NOT NULL;
@@ -0,0 +1,9 @@
-- AlterTable: the d20 a combatant rolled for initiative; "initiative" is now roll + bonus
ALTER TABLE "encounter_participants" ADD COLUMN "initiativeRoll" INTEGER;
-- Initiatives typed in before this were totals. Keep them as they are, and
-- treat the nearest possible die face as the roll so those combatants still
-- count as having rolled.
UPDATE "encounter_participants"
SET "initiativeRoll" = LEAST(GREATEST("initiative", 1), 20)
WHERE "initiative" <> 0;
@@ -0,0 +1,10 @@
-- CreateEnum: the three coins. Silver is worth 0.1 gold, copper 0.01 gold.
CREATE TYPE "Currency" AS ENUM ('GOLD', 'SILVER', 'COPPER');
-- AlterTable: a character's purse holds each coin separately
ALTER TABLE "characters" ADD COLUMN "silver" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "characters" ADD COLUMN "copper" INTEGER NOT NULL DEFAULT 0;
-- AlterTable: every ledger entry and coin reward is in one coin; all existing ones are gold
ALTER TABLE "gold_transactions" ADD COLUMN "currency" "Currency" NOT NULL DEFAULT 'GOLD';
ALTER TABLE "encounter_rewards" ADD COLUMN "currency" "Currency" NOT NULL DEFAULT 'GOLD';
@@ -0,0 +1,4 @@
-- AlterTable: death saves, counted while a character is at 0 HP
ALTER TABLE "characters" ADD COLUMN "deathSaveSuccesses" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "deathSaveFailures" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "stable" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,2 @@
-- AlterTable: the combatant currently marked as the target
ALTER TABLE "encounters" ADD COLUMN "targetParticipantId" TEXT;
@@ -0,0 +1,9 @@
-- AlterTable: several combatants can be marked as targets at once (an area attack)
ALTER TABLE "encounters" ADD COLUMN "targetParticipantIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- Carry over a single target marked before this.
UPDATE "encounters"
SET "targetParticipantIds" = ARRAY["targetParticipantId"]
WHERE "targetParticipantId" IS NOT NULL;
ALTER TABLE "encounters" DROP COLUMN "targetParticipantId";
@@ -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;
@@ -0,0 +1,47 @@
-- More species: the 2024 Aasimar and Goliath, and the species of Monsters of
-- the Multiverse. Most of them choose their ability bonuses (+2/+1 or
-- +1/+1/+1), so a Half-Elf's two +1 picks become one general set of chosen
-- bonuses, { ability: amount }. Existing Half-Elves keep theirs.
ALTER TYPE "Species" ADD VALUE 'AASIMAR';
ALTER TYPE "Species" ADD VALUE 'GOLIATH';
ALTER TYPE "Species" ADD VALUE 'AARAKOCRA';
ALTER TYPE "Species" ADD VALUE 'BUGBEAR';
ALTER TYPE "Species" ADD VALUE 'CENTAUR';
ALTER TYPE "Species" ADD VALUE 'CHANGELING';
ALTER TYPE "Species" ADD VALUE 'DEEP_GNOME';
ALTER TYPE "Species" ADD VALUE 'DUERGAR';
ALTER TYPE "Species" ADD VALUE 'ELADRIN';
ALTER TYPE "Species" ADD VALUE 'FAIRY';
ALTER TYPE "Species" ADD VALUE 'FIRBOLG';
ALTER TYPE "Species" ADD VALUE 'AIR_GENASI';
ALTER TYPE "Species" ADD VALUE 'EARTH_GENASI';
ALTER TYPE "Species" ADD VALUE 'FIRE_GENASI';
ALTER TYPE "Species" ADD VALUE 'WATER_GENASI';
ALTER TYPE "Species" ADD VALUE 'GITHYANKI';
ALTER TYPE "Species" ADD VALUE 'GITHZERAI';
ALTER TYPE "Species" ADD VALUE 'GOBLIN';
ALTER TYPE "Species" ADD VALUE 'HARENGON';
ALTER TYPE "Species" ADD VALUE 'HOBGOBLIN';
ALTER TYPE "Species" ADD VALUE 'KENKU';
ALTER TYPE "Species" ADD VALUE 'KOBOLD';
ALTER TYPE "Species" ADD VALUE 'LIZARDFOLK';
ALTER TYPE "Species" ADD VALUE 'MINOTAUR';
ALTER TYPE "Species" ADD VALUE 'SEA_ELF';
ALTER TYPE "Species" ADD VALUE 'SHADAR_KAI';
ALTER TYPE "Species" ADD VALUE 'SHIFTER';
ALTER TYPE "Species" ADD VALUE 'TABAXI';
ALTER TYPE "Species" ADD VALUE 'TORTLE';
ALTER TYPE "Species" ADD VALUE 'TRITON';
ALTER TYPE "Species" ADD VALUE 'YUAN_TI';
ALTER TYPE "Species" ADD VALUE 'OWLIN';
ALTER TYPE "Species" ADD VALUE 'SATYR';
ALTER TABLE "characters" ADD COLUMN "speciesBonusChoices" JSONB NOT NULL DEFAULT '{}';
UPDATE "characters"
SET "speciesBonusChoices" = jsonb_build_object("halfElfBonusAbility1"::text, 1, "halfElfBonusAbility2"::text, 1)
WHERE "halfElfBonusAbility1" IS NOT NULL AND "halfElfBonusAbility2" IS NOT NULL;
ALTER TABLE "characters" DROP COLUMN "halfElfBonusAbility1",
DROP COLUMN "halfElfBonusAbility2";
@@ -0,0 +1,5 @@
-- The Artificer joins the classes, and the builder gets a step to choose a
-- subclass right after the class.
ALTER TYPE "BuildStage" ADD VALUE 'CHOOSING_SUBCLASS' AFTER 'ROLLING_CLASS';
ALTER TYPE "CharacterClass" ADD VALUE 'ARTIFICER';
@@ -0,0 +1,19 @@
-- Multiclassing: levels a character takes in classes besides their first.
-- CreateTable
CREATE TABLE "character_class_levels" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"characterClass" "CharacterClass" NOT NULL,
"level" INTEGER NOT NULL DEFAULT 1,
"subclass" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "character_class_levels_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "character_class_levels_characterId_characterClass_key" ON "character_class_levels"("characterId", "characterClass");
-- AddForeignKey
ALTER TABLE "character_class_levels" ADD CONSTRAINT "character_class_levels_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- Level-ups the DM approves for players to take, and rolled HP on a level up.
-- AlterTable
ALTER TABLE "characters" ADD COLUMN "hpRollAdjust" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "levelUpsPending" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,28 @@
-- Rolls made at the table's dice roller, kept so the DM can see each one.
-- CreateTable
CREATE TABLE "dice_rolls" (
"id" TEXT NOT NULL,
"campaignId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"rollerName" TEXT NOT NULL,
"label" TEXT,
"notation" TEXT NOT NULL,
"dice" JSONB NOT NULL,
"modifier" INTEGER NOT NULL DEFAULT 0,
"total" INTEGER NOT NULL,
"natural" INTEGER,
"mode" TEXT NOT NULL DEFAULT 'NORMAL',
"physical" BOOLEAN NOT NULL DEFAULT false,
"hidden" BOOLEAN NOT NULL DEFAULT false,
"encounterId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "dice_rolls_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "dice_rolls_campaignId_createdAt_idx" ON "dice_rolls"("campaignId", "createdAt");
-- AddForeignKey
ALTER TABLE "dice_rolls" ADD CONSTRAINT "dice_rolls_campaignId_fkey" FOREIGN KEY ("campaignId") REFERENCES "campaigns"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,51 @@
-- Battle actions: an attack or spell from being declared to its damage
-- landing; named attacks for monsters; and how each spell lands (a spell
-- attack, or a saving throw). Spells already in campaigns that came from
-- the library are marked by name.
-- AlterTable
ALTER TABLE "monsters" ADD COLUMN "attacks" JSONB NOT NULL DEFAULT '[]';
-- AlterTable
ALTER TABLE "spells" ADD COLUMN "attackRoll" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "halfOnSave" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "saveAbility" "Ability";
-- CreateTable
CREATE TABLE "encounter_actions" (
"id" TEXT NOT NULL,
"encounterId" TEXT NOT NULL,
"round" INTEGER NOT NULL,
"actorParticipantId" TEXT,
"actorName" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"name" TEXT NOT NULL,
"detail" JSONB NOT NULL,
"targets" JSONB NOT NULL,
"damage" JSONB,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"auto" BOOLEAN NOT NULL DEFAULT false,
"hidden" BOOLEAN NOT NULL DEFAULT false,
"createdById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"appliedAt" TIMESTAMP(3),
CONSTRAINT "encounter_actions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "encounter_actions_encounterId_status_idx" ON "encounter_actions"("encounterId", "status");
-- AddForeignKey
ALTER TABLE "encounter_actions" ADD CONSTRAINT "encounter_actions_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- How the library's spells land, for copies already in campaigns.
UPDATE "spells" SET "attackRoll" = true WHERE lower("name") IN ('chill touch', 'eldritch blast', 'fire bolt', 'produce flame', 'ray of frost', 'shocking grasp', 'thorn whip', 'guiding bolt', 'witch bolt', 'scorching ray', 'spiritual weapon', 'contagion');
UPDATE "spells" SET "saveAbility" = 'STR', "halfOnSave" = false WHERE lower("name") IN ('entangle', 'gust of wind');
UPDATE "spells" SET "saveAbility" = 'DEX', "halfOnSave" = true WHERE lower("name") IN ('burning hands', 'flaming sphere', 'call lightning', 'fireball', 'lightning bolt', 'guardian of faith', 'ice storm', 'wall of fire', 'flame strike');
UPDATE "spells" SET "saveAbility" = 'DEX', "halfOnSave" = false WHERE lower("name") IN ('acid splash', 'sacred flame', 'faerie fire', 'web', 'sleet storm');
UPDATE "spells" SET "saveAbility" = 'CON', "halfOnSave" = true WHERE lower("name") IN ('thunderwave', 'moonbeam', 'shatter', 'blight', 'cloudkill', 'cone of cold', 'insect plague');
UPDATE "spells" SET "saveAbility" = 'CON', "halfOnSave" = false WHERE lower("name") IN ('poison spray', 'blindness/deafness', 'levitate', 'stinking cloud');
UPDATE "spells" SET "saveAbility" = 'WIS', "halfOnSave" = true WHERE lower("name") IN ('spirit guardians');
UPDATE "spells" SET "saveAbility" = 'WIS', "halfOnSave" = false WHERE lower("name") IN ('vicious mockery', 'animal friendship', 'charm person', 'command', 'hold person', 'suggestion', 'fear', 'slow', 'confusion', 'polymorph', 'dominate person', 'hold monster');
UPDATE "spells" SET "saveAbility" = 'CHA', "halfOnSave" = false WHERE lower("name") IN ('bane', 'zone of truth', 'banishment');
@@ -0,0 +1,39 @@
-- Free roam: a kind of encounter where the party wanders between places the
-- DM has laid out. A location can have NPCs found there and things for sale.
-- AlterEnum
ALTER TYPE "EncounterMode" ADD VALUE 'FREE_ROAM';
-- AlterTable
ALTER TABLE "npcs" ADD COLUMN "locationId" TEXT;
-- AlterTable
ALTER TABLE "sale_offers" ADD COLUMN "locationId" TEXT;
-- CreateTable
CREATE TABLE "encounter_locations" (
"id" TEXT NOT NULL,
"encounterId" TEXT NOT NULL,
"locationId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "encounter_locations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "encounter_locations_encounterId_locationId_key" ON "encounter_locations"("encounterId", "locationId");
-- CreateIndex
CREATE INDEX "sale_offers_locationId_idx" ON "sale_offers"("locationId");
-- AddForeignKey
ALTER TABLE "npcs" ADD CONSTRAINT "npcs_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_encounterId_fkey" FOREIGN KEY ("encounterId") REFERENCES "encounters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "encounter_locations" ADD CONSTRAINT "encounter_locations_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sale_offers" ADD CONSTRAINT "sale_offers_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "locations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+577 -20
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")
}
// ---------------------------------------------------------------------------
@@ -32,6 +32,39 @@ enum Species {
TIEFLING
HALF_ORC
ORC
AASIMAR
GOLIATH
AARAKOCRA
BUGBEAR
CENTAUR
CHANGELING
DEEP_GNOME
DUERGAR
ELADRIN
FAIRY
FIRBOLG
AIR_GENASI
EARTH_GENASI
FIRE_GENASI
WATER_GENASI
GITHYANKI
GITHZERAI
GOBLIN
HARENGON
HOBGOBLIN
KENKU
KOBOLD
LIZARDFOLK
MINOTAUR
SEA_ELF
SHADAR_KAI
SHIFTER
TABAXI
TORTLE
TRITON
YUAN_TI
OWLIN
SATYR
}
enum CharacterClass {
@@ -47,6 +80,7 @@ enum CharacterClass {
WARLOCK
DRUID
MONK
ARTIFICER
}
enum Ability {
@@ -83,9 +117,13 @@ enum BuildStage {
NOT_STARTED
ROLLING_SPECIES
ROLLING_CLASS
CHOOSING_SUBCLASS
ROLLING_MOTIVATION
ASSIGNING_SCORES
ASSIGNING_PROFICIENCIES
CHOOSING_ABILITIES
CHOOSING_BACKGROUND
CHOOSING_APPEARANCE
CHOOSING_EQUIPMENT
COMPLETE
}
@@ -96,11 +134,83 @@ enum EncounterStatus {
COMPLETE
}
// What kind of scene an encounter is: a fight run in initiative order, a
// quest scene built around NPCs and an objective, or free roam, where the
// party wanders between places the DM has laid out (to shop, to meet people).
enum EncounterMode {
BATTLE
QUEST
FREE_ROAM
}
enum NpcDisposition {
FRIENDLY
NEUTRAL
HOSTILE
}
enum RewardType {
GOLD
WEAPON
ITEM
}
// 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 {
DM_ONLY
SHARED
}
enum DiceMode {
DIGITAL
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
// ---------------------------------------------------------------------------
@@ -117,6 +227,7 @@ model User {
charactersOwned Character[] @relation("PlayerCharacters")
charactersCreated Character[] @relation("CreatedBy")
notesAuthored Note[]
goldTransactions GoldTransaction[]
@@map("users")
}
@@ -130,7 +241,43 @@ model Campaign {
startingItemCount Int @default(0)
startingSpellCount Int @default(0)
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.
archivedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -138,13 +285,83 @@ model Campaign {
characters Character[]
weapons Weapon[]
spells Spell[]
items Item[]
monsters Monster[]
npcs Npc[]
encounters Encounter[]
saleOffers SaleOffer[]
notes Note[]
locations Location[]
diceRolls DiceRoll[]
@@map("campaigns")
}
// A roll made at the table's dice roller, kept so the table (and above all
// the DM) can see it. Digital rolls are made by the server; physical ones are
// typed in and checked against their dice. Who sees a roll follows the
// campaign's rollVisibility; a `hidden` one is the DM's alone.
model DiceRoll {
id String @id @default(cuid())
campaignId String
userId String
rollerName String // who rolled, as shown: kept so a roll outlives a renamed account
label String? // what it was for: "Aria: Stealth"
notation String // "1d20+3", "2d6+1d8+2"
// Every die, in order: [{ sides, value, kept, sign }]. `kept` is false for
// the d20 that advantage or disadvantage dropped.
dice Json
modifier Int @default(0)
total Int
natural Int? // the d20 that counted, for a roll of a single d20
mode String @default("NORMAL") // NORMAL | ADVANTAGE | DISADVANTAGE
physical Boolean @default(false) // rolled at the table and typed in
hidden Boolean @default(false) // the DM's alone
encounterId String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
@@index([campaignId, createdAt])
@@map("dice_rolls")
}
model Location {
id String @id @default(cuid())
campaignId String
name String
description String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
characters Character[]
encounters Encounter[]
visits CharacterLocationVisit[]
// Who is found here, what is for sale here, and the free-roam scenes it is a stop in.
npcs Npc[]
wares SaleOffer[]
freeRoams EncounterLocation[]
@@index([campaignId])
@@map("locations")
}
// A location a character has been to. Written whenever a character is put
// somewhere (by the DM, or by an encounter held there), and never removed by
// moving on: it is what decides which locations a player may see.
model CharacterLocationVisit {
id String @id @default(cuid())
characterId String
locationId String
firstVisitedAt DateTime @default(now())
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
@@unique([characterId, locationId])
@@map("character_location_visits")
}
model CampaignMember {
id String @id @default(cuid())
campaignId String
@@ -156,6 +373,7 @@ model CampaignMember {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([campaignId, userId])
@@index([userId])
@@map("campaign_members")
}
@@ -171,7 +389,17 @@ 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)
// Levels the DM has approved that the character has not yet taken: each is
// one level-up checklist waiting for its picks. The player resolves them
// (or the DM does), one at a time.
levelUpsPending Int @default(0)
// Where the campaign rolls HP on a level up: how far the rolls so far are
// from the average they replace, in all (a 2 on a d8 is -3). Added to max HP.
hpRollAdjust Int @default(0)
xp Int @default(0)
motivation String?
@@ -183,17 +411,53 @@ model Character {
wisScore Int @default(10)
chaScore Int @default(10)
// Half-Elf's flexible +1/+1 choice; null unless species = HALF_ELF
halfElfBonusAbility1 Ability?
halfElfBonusAbility2 Ability?
// The ability bonuses the player chose for their species, on top of its
// fixed ones, as { ability: amount }: a Half-Elf's +1/+1, a Goblin's +2/+1...
// Empty for a species with nothing to choose (game/data/species.js).
speciesBonusChoices Json @default("{}")
currentHp Int @default(0)
tempHp Int @default(0)
armorBonus Int @default(0) // flat AC bonus from equipped armor/shield
// Death saves, counted while the character is at 0 HP (utils/hp.js). Three
// failures is dead; three successes sets `stable` and clears the tallies.
deathSaveSuccesses Int @default(0)
deathSaveFailures Int @default(0)
stable Boolean @default(false) // at 0 HP but no longer rolling
// What has been spent since the last rest. Spell slots by slot level (the
// first entry is 1st-level slots); how many there are comes from class and
// level (game/calculations.js, spellSlotsFor). A character has one Hit Die
// per level, spent on short rests.
spellSlotsUsed Int[] @default([])
hitDiceUsed Int @default(0)
// Class counters spent since the rest that gives them back, as
// { key: used } (game/classResources.js says which a character has).
resourcesUsed Json @default("{}")
// How the character is doing beyond HP. Reminders for the table: none of
// them changes a number on the sheet. Conditions are the character's own,
// so they outlast the encounter they were given in.
inspiration Boolean @default(false)
exhaustion Int @default(0) // 0 to 6
conditions String[] @default([])
armorBonus Int @default(0) // a flat AC adjustment the DM sets by hand, on top of what equipped gear gives
speedOverride Int?
specialAbilities String? // free-text list shown in the quick-stats panel
// 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 (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?
privateNotes String? // visible to the owning player only — not even the DM
diceMode DiceMode @default(DIGITAL) // chosen once, before the first build roll
currentLocationId String?
buildStage BuildStage @default(NOT_STARTED)
buildState Json? // in-progress dice rolls / pending choices while building
@@ -201,21 +465,45 @@ model Character {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
user User? @relation("PlayerCharacters", fields: [userId], references: [id])
createdBy User @relation("CreatedBy", fields: [createdById], references: [id])
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
user User? @relation("PlayerCharacters", fields: [userId], references: [id])
createdBy User @relation("CreatedBy", fields: [createdById], references: [id])
currentLocation Location? @relation(fields: [currentLocationId], references: [id], onDelete: SetNull)
skillProficiencies CharacterSkillProficiency[]
saveProficiencies CharacterSaveProficiency[]
inventoryItems InventoryItem[]
goldTransactions GoldTransaction[]
weapons CharacterWeapon[]
spells CharacterSpell[]
notes Note[]
encounterEntries EncounterParticipant[]
locationVisits CharacterLocationVisit[]
// Levels in classes other than characterClass, for a multiclassed character
// (game/multiclass.js). The first class's own level is `level` less these.
classLevels CharacterClassLevel[]
@@index([campaignId])
@@index([userId])
@@map("characters")
}
// A class a character has taken levels in besides their first (characterClass),
// with its own level and subclass. A single-class character has none.
model CharacterClassLevel {
id String @id @default(cuid())
characterId String
characterClass CharacterClass
level Int @default(1)
subclass String?
createdAt DateTime @default(now())
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
@@unique([characterId, characterClass])
@@map("character_class_levels")
}
model CharacterSkillProficiency {
id String @id @default(cuid())
characterId String
@@ -255,11 +543,28 @@ model Weapon {
properties String[] // e.g. ["finesse", "light", "thrown"]
rangeNormal Int?
rangeLong Int?
// damageDice is the level-1 damage; a held copy's dice climb the damage
// ladder (game/calculations.js) by this many rungs per level above 1.
damageScaling Int @default(1)
// The level this weapon starts at: a held copy is never below it, and
// damageDice is the damage at this level.
minLevel Int @default(1)
// 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")
}
@@ -269,6 +574,7 @@ model CharacterWeapon {
weaponId String
quantity Int @default(1)
equipped Boolean @default(false)
level Int @default(1) // this copy's level; the character must be at least this level to use it
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
weapon Weapon @relation(fields: [weaponId], references: [id], onDelete: Cascade)
@@ -277,19 +583,66 @@ model CharacterWeapon {
@@map("character_weapons")
}
// Campaign-level item pool, DM-managed like weapons and spells.
model Item {
id String @id @default(cuid())
campaignId String
name String
description String?
damageDice String? // optional dice (damage, healing...) at its starting level; scales like a weapon's
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")
}
// An Item from the campaign pool held by a character.
model InventoryItem {
id String @id @default(cuid())
characterId String
name String
description String?
itemId String
quantity Int @default(1)
equipped Boolean @default(false)
level Int @default(1)
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@unique([characterId, itemId])
@@map("inventory_items")
}
// Ledger behind a character's purse: every change to a coin balance is one
// row here. (The name predates silver and copper.)
model GoldTransaction {
id String @id @default(cuid())
characterId String
currency Currency @default(GOLD) // which coin this entry is in
amount Int // signed: positive = gained, negative = spent
balanceAfter Int // that coin's balance afterwards
reason String?
createdById String?
createdAt DateTime @default(now())
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([characterId, createdAt])
@@map("gold_transactions")
}
// ---------------------------------------------------------------------------
// Spells
// ---------------------------------------------------------------------------
@@ -298,16 +651,29 @@ model Spell {
id String @id @default(cuid())
campaignId String
name String
level Int // 0 = cantrip
level Int // 0 = cantrip. Also the character level needed to use the spell.
damageDice String? // fixed; spells don't level up
school String?
castingTime String?
range String?
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)
// How it lands in a battle (game/combat.js). attackRoll: the caster rolls a
// spell attack against AC. saveAbility: each target saves against the
// caster's spell DC with this ability, taking half on a success when
// halfOnSave. Neither: it simply takes effect.
attackRoll Boolean @default(false)
saveAbility Ability?
halfOnSave Boolean @default(false)
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
known CharacterSpell[]
@@index([campaignId])
@@map("spells")
}
@@ -346,48 +712,237 @@ model Monster {
challengeRating String?
specialTrait String?
notes String?
// Named attacks, for a monster with more than the one above: a list of
// { name, attackBonus, damageDice, damageType?, count? }. `count` is how
// many times it is made each turn (a multiattack). Empty: the single
// attackBonus/damageDice above is its attack.
attacks Json @default("[]")
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
encounterAppearances EncounterParticipant[]
@@index([campaignId])
@@map("monsters")
}
// A non-player character the DM runs: a quest-giver, shopkeeper, rival...
// Combat stats are optional — most NPCs are there to be talked to.
model Npc {
id String @id @default(cuid())
campaignId String
name String
role String? // e.g. "Innkeeper", "Captain of the Watch"
disposition NpcDisposition @default(NEUTRAL)
description String? // what the party can see and knows
dmNotes String? // secrets, motives — never sent to players
ac Int?
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?
// Where this NPC is usually found. In a free-roam scene they are met (and
// traded with) by whoever goes there.
locationId String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
encounterAppearances EncounterParticipant[]
wares SaleOffer[]
@@index([campaignId])
@@map("npcs")
}
model Encounter {
id String @id @default(cuid())
campaignId String
name String
mode EncounterMode @default(BATTLE)
status EncounterStatus @default(PLANNING)
currentRound Int @default(1)
// Whose turn it is in a battle; null until the fight starts.
activeParticipantId String?
// Who is being attacked (or healed) right now, shown to everyone, in the
// order they were picked. More than one makes it an area attack. Picked by
// the DM or by the player on turn, and cleared when the turn passes.
targetParticipantIds String[] @default([])
description String? // the scene / objective, shown to everyone
dmNotes String? // never sent to players
// Where the scene takes place. While the encounter is in progress, the
// characters in it are there too.
locationId String?
// Set while archived: kept in full, but off the campaign's list — players
// no longer see it there at all.
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
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
participants EncounterParticipant[]
rewards EncounterReward[]
wares SaleOffer[]
actions EncounterAction[]
// Free roam: the places the party can go to in this scene.
places EncounterLocation[]
@@index([campaignId])
@@map("encounters")
}
// A row per combatant in an encounter — either a character or a monster.
// currentHp/maxHp are snapshotted here (not just read off Character/Monster)
// so damage tracking during a fight doesn't need to mutate the character's or
// monster's permanent record until the DM chooses to apply it back.
// A place that is a stop in a free-roam scene: one of the campaign's
// locations, picked by the DM as somewhere the party can go right now.
model EncounterLocation {
id String @id @default(cuid())
encounterId String
locationId String
createdAt DateTime @default(now())
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
location Location @relation(fields: [locationId], references: [id], onDelete: Cascade)
@@unique([encounterId, locationId])
@@map("encounter_locations")
}
// An attack or a spell in a battle, from the moment it is declared to the
// moment its damage lands (game/combat.js). A player declares their own on
// their turn; the DM declares anyone's; and with the campaign's auto-roll
// settings the server declares a monster's or NPC's, already rolled. While
// PENDING it gathers what it needs (a d20 against each target's AC, or each
// target's saving throw, and the damage); the DM then applies it, changed as
// they see fit, or discards it. `hidden` keeps it from the players until it
// is applied.
model EncounterAction {
id String @id @default(cuid())
encounterId String
round Int
actorParticipantId String?
actorName String
kind String // ATTACK (a d20 against AC) | SAVE (targets save against a DC) | AUTO (it just lands)
name String // "Longsword", "Fireball", "Bite"
// What it rolls with: { attackBonus?, saveAbility?, saveDC?, halfOnSave?,
// damageDice?, damageBonus, damageType?, characterId?, slotLevel? }.
detail Json
// One entry per target: { participantId, name, kind, ac, penalty, saveBonus,
// d20, outcome (set by the DM to overrule the dice), damage (likewise) }.
targets Json
// The damage once rolled or entered: { total, dice?, modifier?, crit? }.
damage Json?
status String @default("PENDING") // PENDING | APPLIED | DISCARDED
auto Boolean @default(false) // declared and rolled by the server
hidden Boolean @default(false)
createdById String?
createdAt DateTime @default(now())
appliedAt DateTime?
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
@@index([encounterId, status])
@@map("encounter_actions")
}
// Loot the DM lines up for an encounter and later hands to a character. Until
// it is awarded it is the DM's secret; awarding it is what actually changes
// the character (gold ledger entry, or the weapon/item added to what they hold).
model EncounterReward {
id String @id @default(cuid())
encounterId String
type RewardType
gold Int? // GOLD: how many coins
currency Currency @default(GOLD) // GOLD: which coin they are
weaponId String? // WEAPON
itemId String? // ITEM
quantity Int @default(1)
level Int @default(1) // gear level the weapon/item is handed over at
note String?
awardedTo String? // who received it, as display text ("Aria", "Aria, Bo")
awardedAt DateTime?
createdAt DateTime @default(now())
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([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), to an
// encounter itself (a stall, a vending machine), or to a location (a shop
// that is always there, open to whoever goes to it in a free-roam scene).
// Only the DM sets these up; players see them, and buy, in a quest or
// free-roam encounter in progress (encounters.routes.js).
model SaleOffer {
id String @id @default(cuid())
campaignId String
npcId String? // exactly one of npcId / encounterId / locationId is set
encounterId String?
locationId 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)
location Location? @relation(fields: [locationId], references: [id], onDelete: Cascade)
weapon Weapon? @relation(fields: [weaponId], references: [id], onDelete: Cascade)
item Item? @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@index([campaignId])
@@index([npcId])
@@index([encounterId])
@@index([locationId])
@@map("sale_offers")
}
// 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
// the columns here only hold the last values seen (used if the character is
// later deleted and the row loses its link).
model EncounterParticipant {
id String @id @default(cuid())
encounterId String
characterId String?
monsterId String?
npcId String?
displayName String // denormalized so a monster can be renamed per-instance, e.g. "Goblin #2"
// The d20 rolled for initiative (1–20); null until it is rolled.
initiativeRoll Int?
// The roll plus the combatant's initiative bonus, worked out when the roll
// is entered. This is what the turn order sorts on.
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)
character Character? @relation(fields: [characterId], references: [id], onDelete: SetNull)
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")
}
@@ -408,5 +963,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")
}
+41 -57
View File
@@ -1,70 +1,54 @@
// Seeds a demo campaign for local dev. Species/classes/skills are NOT seeded
// as rows — they're fixed game-rule tables ported into src/game/data/*.js and
// used directly by the calculation engine. Weapons ARE seeded, but only into
// this demo campaign (weapons are per-campaign in the schema, since a DM can
// add homebrew weapons).
const path = require('path');
// The database seed: makes sure the admin account exists, and nothing else.
//
// 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
//
// 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.
//
// 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 { PrismaClient } = require('@prisma/client');
const { generateInviteCode } = require('../src/utils/inviteCode');
const prisma = require('../src/db');
const { loadSeeds } = require('../src/seeds');
const prisma = new PrismaClient();
const weapons = require(path.join(__dirname, '..', 'seed-data', 'weapons.json'));
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 main() {
const passwordHash = await bcrypt.hash('password123', 12);
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.');
}
const dm = await prisma.user.upsert({
where: { email: 'dm@example.com' },
update: {},
create: { email: 'dm@example.com', passwordHash, displayName: 'Dungeon Master' },
});
const player = await prisma.user.upsert({
where: { email: 'player@example.com' },
update: {},
create: { email: 'player@example.com', passwordHash, displayName: 'Test Player' },
});
let campaign = await prisma.campaign.findFirst({ where: { name: 'The Sunken Reach (Demo)' } });
if (!campaign) {
campaign = await prisma.campaign.create({
data: {
name: 'The Sunken Reach (Demo)',
description: 'A demo campaign seeded for local development.',
inviteCode: generateInviteCode(),
startingGold: 15,
startingItemCount: 2,
startingSpellCount: 2,
ownerId: dm.id,
members: {
create: [
{ userId: dm.id, role: 'DM' },
{ userId: player.id, role: 'PLAYER' },
],
},
},
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.');
}
for (const weapon of weapons) {
const existing = await prisma.weapon.findFirst({ where: { campaignId: campaign.id, name: weapon.name } });
if (!existing) {
await prisma.weapon.create({ data: { ...weapon, campaignId: campaign.id } });
}
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}`);
}
console.log('Seed complete.');
console.log(` DM login: dm@example.com / password123`);
console.log(` Player login: player@example.com / password123`);
console.log(` Campaign invite code: ${campaign.inviteCode}`);
}
main()
seed({ reset: process.argv.includes('--reset') })
.catch((err) => {
console.error(err);
process.exit(1);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
.finally(() => prisma.$disconnect());
+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`,
);
}
+180
View File
@@ -0,0 +1,180 @@
// 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.
//
// npm run docs:lists (from /backend)
//
// Each list lives between a pair of markers in a doc:
// <!-- BEGIN GENERATED: name --> ... <!-- END GENERATED: name -->
// Only the text between the markers is replaced; everything else in the docs
// is written by hand.
const fs = require('fs');
const path = require('path');
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');
const cell = (value) => (value === null || value === undefined || value === '' ? '—' : String(value).replace(/\|/g, '\\|'));
const table = (headers, rows) =>
[`| ${headers.join(' | ')} |`, `|${headers.map(() => '---').join('|')}|`, ...rows.map((r) => `| ${r.map(cell).join(' | ')} |`)].join('\n');
const signed = (n) => (n >= 0 ? `+${n}` : `${n}`);
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
function spellLibrary() {
const levels = [...new Set(SPELL_LIBRARY.map((s) => s.level))].sort((a, b) => a - b);
return levels
.map((level) => {
const spells = SPELL_LIBRARY.filter((s) => s.level === level).sort((a, b) => a.name.localeCompare(b.name));
const heading = level === 0 ? `### Cantrips (${spells.length})` : `### Level ${level} (${plural(spells.length, 'spell')})`;
const who = level === 0 ? 'Usable by any character.' : `Needs character level ${level}.`;
return `${heading}\n\n${who}\n\n${table(['Spell', 'School', 'Damage', 'What it does'], spells.map((s) => [s.name, s.school, s.damageDice, s.description]))}`;
})
.join('\n\n');
}
const ATTACK_WITH = { STR: 'Strength', DEX: 'Dexterity', FINESSE: 'Strength or Dexterity (finesse)' };
function weaponLibrary() {
const categories = [...new Set(WEAPON_LIBRARY.map((w) => w.category))];
return categories
.map((category) => {
const weapons = WEAPON_LIBRARY.filter((w) => w.category === category);
const rows = weapons.map((w) => [
w.name,
w.damageDice,
w.damageType,
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', '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');
}
function monsterLibrary() {
const rows = MONSTER_LIBRARY.map((m) => [
m.name,
m.challengeRating,
m.ac,
m.maxHp,
m.speed,
signed(m.attackBonus),
m.damageDice,
[m.strScore, m.dexScore, m.conScore, m.intScore, m.wisScore, m.chaScore].join(' / '),
m.specialTrait,
m.notes,
]);
return table(['Monster', 'CR', 'AC', 'HP', 'Speed', 'Attack', 'Damage', 'STR / DEX / CON / INT / WIS / CHA', 'Trait', 'Notes'], rows);
}
function builderLists() {
const abilities = (list) => list.map((a) => `**${a.name}** — ${a.text}`).join('<br>');
const groups = (list) =>
list
.map((g) => {
const pick = g.min === g.max ? `pick ${g.min}` : `pick ${g.min} to ${g.max}`;
return `**${g.label}** — ${g.prompt} (${pick})\n\n${table(['Option', 'What it adds'], g.options.map((o) => [o.label, o.text]))}`;
})
.join('\n\n');
return [
`### Motivations\n\n${options.MOTIVATIONS.map((m) => `- ${m.label}`).join('\n')}`,
`### Special abilities granted by species\n\n${table(['Species', 'Abilities'], Object.keys(SPECIES).map((key) => [SPECIES[key].name, abilities(options.SPECIES_TRAITS[key])]))}`,
`### Special abilities granted by class\n\n${table(['Class', 'Abilities'], Object.keys(CLASSES).sort().map((key) => [CLASSES[key].name, abilities(options.CLASS_FEATURES[key])]))}`,
`### Talents (pick ${options.TALENT_PICKS})\n\n${table(['Talent', 'What it does'], options.TALENTS.map((t) => [t.name, t.text]))}`,
`### Background questions\n\n${groups(options.BACKGROUND_GROUPS)}`,
`### Appearance questions\n\n${groups(options.APPEARANCE_GROUPS)}`,
].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}<br>_${subclass.source}${subclass.replaces ? `, updates ${SUBCLASSES[key].find((s) => s.key === subclass.replaces).name}` : ''}_` : '',
level,
subclass.features[level].map(line).join('<br>'),
])
);
return [
`### ${CLASSES[key].name}`,
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, 'level-features': levelFeatures },
};
for (const [file, sections] of Object.entries(SECTIONS)) {
const filePath = path.join(DOCS, file);
const original = fs.readFileSync(filePath, 'utf8');
const newline = original.includes('\r\n') ? '\r\n' : '\n';
let text = original.replace(/\r\n/g, '\n');
for (const [name, render] of Object.entries(sections)) {
const begin = `<!-- BEGIN GENERATED: ${name} -->`;
const end = `<!-- END GENERATED: ${name} -->`;
const from = text.indexOf(begin);
const to = text.indexOf(end);
if (from === -1 || to === -1 || to < from) throw new Error(`${file}: missing the "${name}" markers`);
text = `${text.slice(0, from + begin.length)}\n${render()}\n${text.slice(to)}`;
}
const output = text.replace(/\n/g, newline);
if (output !== original) fs.writeFileSync(filePath, output);
console.log(`${output === original ? 'unchanged' : 'updated '} docs/${file}`);
}
+13 -12
View File
@@ -1,14 +1,15 @@
[
{ "key": "FIGHTER", "name": "Fighter", "hitDie": 10, "primaryAbility": "Str/Dex", "saveProficiencies": ["STR", "CON"], "spellcastingAbility": null },
{ "key": "BARBARIAN", "name": "Barbarian", "hitDie": 12, "primaryAbility": "Str", "saveProficiencies": ["STR", "CON"], "spellcastingAbility": null },
{ "key": "ROGUE", "name": "Rogue", "hitDie": 8, "primaryAbility": "Dex", "saveProficiencies": ["DEX", "INT"], "spellcastingAbility": null },
{ "key": "RANGER", "name": "Ranger", "hitDie": 10, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": "WIS" },
{ "key": "WIZARD", "name": "Wizard", "hitDie": 6, "primaryAbility": "Int", "saveProficiencies": ["INT", "WIS"], "spellcastingAbility": "INT" },
{ "key": "SORCERER", "name": "Sorcerer", "hitDie": 6, "primaryAbility": "Cha", "saveProficiencies": ["CON", "CHA"], "spellcastingAbility": "CHA" },
{ "key": "CLERIC", "name": "Cleric", "hitDie": 8, "primaryAbility": "Wis", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "WIS" },
{ "key": "PALADIN", "name": "Paladin", "hitDie": 10, "primaryAbility": "Str/Cha", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "CHA" },
{ "key": "BARD", "name": "Bard", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["DEX", "CHA"], "spellcastingAbility": "CHA" },
{ "key": "WARLOCK", "name": "Warlock", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "CHA" },
{ "key": "DRUID", "name": "Druid", "hitDie": 8, "primaryAbility": "Wis", "saveProficiencies": ["INT", "WIS"], "spellcastingAbility": "WIS" },
{ "key": "MONK", "name": "Monk", "hitDie": 8, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": null }
{ "key": "FIGHTER", "name": "Fighter", "hitDie": 10, "primaryAbility": "Str/Dex", "saveProficiencies": ["STR", "CON"], "spellcastingAbility": null, "skillChoices": { "count": 2, "options": ["ACROBATICS", "ANIMAL_HANDLING", "ATHLETICS", "HISTORY", "INSIGHT", "INTIMIDATION", "PERCEPTION", "SURVIVAL"] } },
{ "key": "BARBARIAN", "name": "Barbarian", "hitDie": 12, "primaryAbility": "Str", "saveProficiencies": ["STR", "CON"], "spellcastingAbility": null, "skillChoices": { "count": 2, "options": ["ANIMAL_HANDLING", "ATHLETICS", "INTIMIDATION", "NATURE", "PERCEPTION", "SURVIVAL"] } },
{ "key": "ROGUE", "name": "Rogue", "hitDie": 8, "primaryAbility": "Dex", "saveProficiencies": ["DEX", "INT"], "spellcastingAbility": null, "skillChoices": { "count": 4, "options": ["ACROBATICS", "ATHLETICS", "DECEPTION", "INSIGHT", "INTIMIDATION", "INVESTIGATION", "PERCEPTION", "PERFORMANCE", "PERSUASION", "SLEIGHT_OF_HAND", "STEALTH"] } },
{ "key": "RANGER", "name": "Ranger", "hitDie": 10, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": "WIS", "skillChoices": { "count": 3, "options": ["ANIMAL_HANDLING", "ATHLETICS", "INSIGHT", "INVESTIGATION", "NATURE", "PERCEPTION", "STEALTH", "SURVIVAL"] } },
{ "key": "WIZARD", "name": "Wizard", "hitDie": 6, "primaryAbility": "Int", "saveProficiencies": ["INT", "WIS"], "spellcastingAbility": "INT", "skillChoices": { "count": 2, "options": ["ARCANA", "HISTORY", "INSIGHT", "INVESTIGATION", "MEDICINE", "RELIGION"] } },
{ "key": "SORCERER", "name": "Sorcerer", "hitDie": 6, "primaryAbility": "Cha", "saveProficiencies": ["CON", "CHA"], "spellcastingAbility": "CHA", "skillChoices": { "count": 2, "options": ["ARCANA", "DECEPTION", "INSIGHT", "INTIMIDATION", "PERSUASION", "RELIGION"] } },
{ "key": "CLERIC", "name": "Cleric", "hitDie": 8, "primaryAbility": "Wis", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "WIS", "skillChoices": { "count": 2, "options": ["HISTORY", "INSIGHT", "MEDICINE", "PERSUASION", "RELIGION"] } },
{ "key": "PALADIN", "name": "Paladin", "hitDie": 10, "primaryAbility": "Str/Cha", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "CHA", "skillChoices": { "count": 2, "options": ["ATHLETICS", "INSIGHT", "INTIMIDATION", "MEDICINE", "PERSUASION", "RELIGION"] } },
{ "key": "BARD", "name": "Bard", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["DEX", "CHA"], "spellcastingAbility": "CHA", "skillChoices": { "count": 3, "options": ["ACROBATICS", "ANIMAL_HANDLING", "ARCANA", "ATHLETICS", "DECEPTION", "HISTORY", "INSIGHT", "INTIMIDATION", "INVESTIGATION", "MEDICINE", "NATURE", "PERCEPTION", "PERFORMANCE", "PERSUASION", "RELIGION", "SLEIGHT_OF_HAND", "STEALTH", "SURVIVAL"] } },
{ "key": "WARLOCK", "name": "Warlock", "hitDie": 8, "primaryAbility": "Cha", "saveProficiencies": ["WIS", "CHA"], "spellcastingAbility": "CHA", "skillChoices": { "count": 2, "options": ["ARCANA", "DECEPTION", "HISTORY", "INTIMIDATION", "INVESTIGATION", "NATURE", "RELIGION"] } },
{ "key": "DRUID", "name": "Druid", "hitDie": 8, "primaryAbility": "Wis", "saveProficiencies": ["INT", "WIS"], "spellcastingAbility": "WIS", "skillChoices": { "count": 2, "options": ["ARCANA", "ANIMAL_HANDLING", "INSIGHT", "MEDICINE", "NATURE", "PERCEPTION", "RELIGION", "SURVIVAL"] } },
{ "key": "MONK", "name": "Monk", "hitDie": 8, "primaryAbility": "Dex/Wis", "saveProficiencies": ["STR", "DEX"], "spellcastingAbility": null, "skillChoices": { "count": 2, "options": ["ACROBATICS", "ATHLETICS", "HISTORY", "INSIGHT", "RELIGION", "STEALTH"] } },
{ "key": "ARTIFICER", "name": "Artificer", "hitDie": 8, "primaryAbility": "Int", "saveProficiencies": ["CON", "INT"], "spellcastingAbility": "INT", "skillChoices": { "count": 2, "options": ["ARCANA", "HISTORY", "INVESTIGATION", "MEDICINE", "NATURE", "PERCEPTION", "SLEIGHT_OF_HAND"] } }
]
+43 -10
View File
@@ -1,12 +1,45 @@
[
{ "key": "HUMAN", "name": "Human", "abilityBonuses": { "STR": 1, "DEX": 1, "CON": 1, "INT": 1, "WIS": 1, "CHA": 1 }, "speed": 30, "notes": null },
{ "key": "HALF_ELF", "name": "Half-Elf", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 2 }, "speed": 30, "notes": "Fixed +2 CHA plus a flexible +1/+1 to two other abilities of the player's choice. The builder UI must prompt for this choice; do not hardcode a default." },
{ "key": "ELF", "name": "Elf", "abilityBonuses": { "STR": 0, "DEX": 2, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 30, "notes": null },
{ "key": "GNOME", "name": "Gnome", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 0, "INT": 2, "WIS": 0, "CHA": 0 }, "speed": 25, "notes": null },
{ "key": "DWARF", "name": "Dwarf", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 2, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 25, "notes": null },
{ "key": "HALFLING", "name": "Halfling", "abilityBonuses": { "STR": 0, "DEX": 2, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 25, "notes": null },
{ "key": "DRAGONBORN", "name": "Dragonborn", "abilityBonuses": { "STR": 2, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 1 }, "speed": 30, "notes": null },
{ "key": "TIEFLING", "name": "Tiefling", "abilityBonuses": { "STR": 0, "DEX": 0, "CON": 0, "INT": 1, "WIS": 0, "CHA": 2 }, "speed": 30, "notes": null },
{ "key": "HALF_ORC", "name": "Half-Orc", "abilityBonuses": { "STR": 2, "DEX": 0, "CON": 1, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 30, "notes": null },
{ "key": "ORC", "name": "Orc", "abilityBonuses": { "STR": 2, "DEX": 0, "CON": 1, "INT": 0, "WIS": 0, "CHA": 0 }, "speed": 30, "notes": null }
{"key": "HUMAN", "name": "Human", "abilityBonuses": {"STR": 1, "DEX": 1, "CON": 1, "INT": 1, "WIS": 1, "CHA": 1}, "speed": 30, "notes": null},
{"key": "HALF_ELF", "name": "Half-Elf", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 2}, "speed": 30, "flexible": {"patterns": [[1, 1]], "exclude": ["CHA"]}, "notes": "Fixed +2 CHA plus a flexible +1/+1 to two other abilities of the player's choice. The builder UI must prompt for this choice; do not hardcode a default."},
{"key": "ELF", "name": "Elf", "abilityBonuses": {"STR": 0, "DEX": 2, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "notes": null},
{"key": "GNOME", "name": "Gnome", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 2, "WIS": 0, "CHA": 0}, "speed": 25, "notes": null},
{"key": "DWARF", "name": "Dwarf", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 2, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 25, "notes": null},
{"key": "HALFLING", "name": "Halfling", "abilityBonuses": {"STR": 0, "DEX": 2, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 25, "notes": null},
{"key": "DRAGONBORN", "name": "Dragonborn", "abilityBonuses": {"STR": 2, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 1}, "speed": 30, "notes": null},
{"key": "TIEFLING", "name": "Tiefling", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 1, "WIS": 0, "CHA": 2}, "speed": 30, "notes": null},
{"key": "HALF_ORC", "name": "Half-Orc", "abilityBonuses": {"STR": 2, "DEX": 0, "CON": 1, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "notes": null},
{"key": "ORC", "name": "Orc", "abilityBonuses": {"STR": 2, "DEX": 0, "CON": 1, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "notes": null},
{"key": "AASIMAR", "name": "Aasimar", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "GOLIATH", "name": "Goliath", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 35, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "AARAKOCRA", "name": "Aarakocra", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "BUGBEAR", "name": "Bugbear", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "CENTAUR", "name": "Centaur", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 40, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "CHANGELING", "name": "Changeling", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "DEEP_GNOME", "name": "Deep Gnome", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "DUERGAR", "name": "Duergar", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "ELADRIN", "name": "Eladrin", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "FAIRY", "name": "Fairy", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "FIRBOLG", "name": "Firbolg", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "AIR_GENASI", "name": "Air Genasi", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 35, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "EARTH_GENASI", "name": "Earth Genasi", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "FIRE_GENASI", "name": "Fire Genasi", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "WATER_GENASI", "name": "Water Genasi", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "GITHYANKI", "name": "Githyanki", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "GITHZERAI", "name": "Githzerai", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "GOBLIN", "name": "Goblin", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "HARENGON", "name": "Harengon", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "HOBGOBLIN", "name": "Hobgoblin", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "KENKU", "name": "Kenku", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "KOBOLD", "name": "Kobold", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "LIZARDFOLK", "name": "Lizardfolk", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "MINOTAUR", "name": "Minotaur", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "SEA_ELF", "name": "Sea Elf", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "SHADAR_KAI", "name": "Shadar-kai", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "SHIFTER", "name": "Shifter", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "TABAXI", "name": "Tabaxi", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "TORTLE", "name": "Tortle", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "TRITON", "name": "Triton", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "YUAN_TI", "name": "Yuan-ti", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "OWLIN", "name": "Owlin", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 30, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."},
{"key": "SATYR", "name": "Satyr", "abilityBonuses": {"STR": 0, "DEX": 0, "CON": 0, "INT": 0, "WIS": 0, "CHA": 0}, "speed": 35, "flexible": {"patterns": [[2, 1], [1, 1, 1]]}, "notes": "Flexible: +2 to one ability and +1 to another, or +1 to three, as the player chooses. The builder UI must prompt for this choice."}
]
+935
View File
@@ -0,0 +1,935 @@
// 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',
description:
'A kingdom of knights and dragons is standing on top of something that’s waking up — and you’re the ones who find out first. ' +
'Heroic adventure first, real consequences, and horror as a spice rather than the whole meal.',
startingGold: 15,
startingItemCount: 2,
startingSpellCount: 2,
};
// ---------------------------------------------------------------------------
// Gazetteer
// ---------------------------------------------------------------------------
const locations = [
// Highcrown
{ name: 'Highcrown — The Founding Chapel', description: 'Site of Session One’s bell mystery. Its cellar connects to the Sunken Vaults. Quiet by day, a good recurring “something’s off” location throughout Act 1.' },
{ name: 'Highcrown — The Palace of Aldenfell', description: 'King Aldric’s seat. Court is held weekly; an audience without a sponsor (a knight, a noble, a Concord representative) takes real effort. Princess Isolde keeps a smaller, more accessible private study here.' },
{ name: 'Highcrown — The Grey Dawn Hall', description: 'Order of the Grey Dawn headquarters. Recruitment, training yards, and Ser Kavan Thorne’s office. The natural starting point for a Fighter/Paladin-leaning party.' },
{ name: 'Highcrown — The Concord Exchange', description: 'The Merchant Concord’s trading hall. Job boards, contracts, and Mireille Costa’s usual haunt. Best source of quick, paying side work.' },
{ name: 'Highcrown — The Lantern Market', description: 'Highcrown’s main marketplace, busiest during the Founding Fair. Gear, rumors, and pickpockets in equal measure. The campaign opens (E1) and closes here.' },
// Greywood
{ name: 'Greywood — The Warden’s Lodge', description: 'Ashwen and the other Wardens’ base, a day’s ride from Highcrown. First place to hear rumors about the old shrines acting strangely.' },
{ name: 'Greywood — The Old Shrine Sites', description: 'Scattered pre-kingdom shrines throughout the forest; several sit almost directly above Hollow Below cracks. Livestock/crop failures (a Stage 1 omen) cluster near these. → Side Quest: The Warden’s Silence.' },
{ name: 'Greywood — The Feywild Grove', description: 'A grove where the boundary with the Feywild is thin. Home to a reclusive, capricious fey court — a “be careful what you promise” side encounter, unrelated to the main plot.' },
{ name: 'Greywood — The Sunken Library', description: 'Ruins of a pre-kingdom archive, half-collapsed and root-choked. Contains fragments of the truth about the Hollow King’s origin — a place for the party to piece the mystery together on their own terms.' },
// Spine
{ name: 'The Spine — Threndir’s Eyrie', description: 'High on the eastern peaks, reachable by a hard climb or careful flight/magic. Threndir Ashclaw grants an audience to anyone who approaches openly and unarmed-in-spirit.' },
{ name: 'The Spine — Sevrisk’s Hollow', description: 'A lower, damper cave system claimed by the young green dragon Sevrisk. Far more dangerous to simply wander into.' },
{ name: 'The Spine — Stonejaw Hold', description: 'Korgath’s clan settlement in a Spine foothill valley. Outsiders are tested, not attacked on sight. → Side Quest: Trial of Stone.' },
{ name: 'The Spine — Ironvein', description: 'A mining town at the Spine’s base, Aldenfell’s main source of iron. Practical resupply stop with its own local rumors (miners going quiet, deeper tunnels avoided).' },
// Salt Coast & Bramble Marches
{ name: 'Salt Coast — Tidewatch', description: 'The Salt Coast’s largest port town, proudly independent-minded. Good base for coastal side content. → Side Quest: What the Tide Brought In.' },
{ name: 'Salt Coast — Smuggler’s Cove', description: 'A hidden inlet south of Tidewatch, used for exactly what the name suggests. → Side Quest: The Smugglers’ Tithe.' },
{ name: 'Bramble Marches — Greyford Keep', description: 'A border fortress garrisoned by Aldenfell troops watching the disputed line. Tense, under-resourced, a pressure-cooker location (E11, E16).' },
{ name: 'Bramble Marches — The Sorrow Camp', description: 'A refugee camp just inside Aldenfell territory, swollen by people fleeing the Marches’ border tension. → Side Quest: Refugees of the Marches.' },
// Hollow Below
{ name: 'Hollow Below — The Sunken Vaults (Layer 1)', description: 'Old crypts beneath Highcrown, reached from the Founding Chapel’s cellar or three other points in the city. Low real danger, mundane-spooky. Session One’s crawl (E3) happens here.' },
{ name: 'Hollow Below — The Root (Layer 2)', description: 'Natural caves beneath the Greywood and the Spine’s lower slopes. Geography stops being reliable. Hollowed, Bellcrawlers, and the Root Stalker live here. Danger is real.' },
{ name: 'Hollow Below — The Weeping Passage', description: 'A Root landmark: a long tunnel where the walls audibly “weep” — a faint, wrong, almost-voice sound. Set-piece location for the party’s first Hollowed.' },
{ name: 'Hollow Below — The Hollow Court (Layer 3)', description: 'The seal itself. Off-limits until Act 4. Gravity, light, and memory all bend near it. The Hollow King’s Shadow operates here.' },
];
// ---------------------------------------------------------------------------
// Bestiary (Aldenfell-specific stat blocks). attackBonus/damageDice hold the
// creature's primary attack; the rest lives in specialTrait/notes.
// ---------------------------------------------------------------------------
const monsters = [
{
name: 'Threndir Ashclaw (old copper dragon)', challengeRating: '8', ac: 17, maxHp: 165, speed: 40,
strScore: 23, dexScore: 12, conScore: 17, intScore: 16, wisScore: 13, chaScore: 17, attackBonus: 11, damageDice: '2d10+6',
specialTrait: 'Legendary talker — always offers to negotiate before fighting unless attacked first. Acid breath (recharge 5–6): 60-ft line, DC 15 Dex, 10d8 acid (half on success).',
notes: 'Huge dragon, 3,900 XP. Climb 40 ft, fly 80 ft. Bite +11 (2d10+6 piercing), Claw +11 (2d6+6 slashing), Tail +11 (2d8+6 bludgeoning). Saves Dex +5, Con +7, Wis +5, Cha +7. Skills Deception +7, Insight +5, Perception +9, Stealth +5. Blindsight 60 ft, darkvision 120 ft, passive Perception 19. Wants proof the crown can still be trusted — the intended “win” is a negotiation.',
},
{
name: 'Sevrisk (young green dragon)', challengeRating: '5', ac: 18, maxHp: 136, speed: 40,
strScore: 19, dexScore: 12, conScore: 17, intScore: 16, wisScore: 13, chaScore: 15, attackBonus: 7, damageDice: '2d6+4',
specialTrait: 'Poison breath (recharge 5–6): 30-ft cone, DC 14 Con, 8d6 poison (half on success).',
notes: 'Large dragon, 1,800 XP. Swim 40 ft, fly 80 ft. Bite +7 (2d6+4 piercing +2d6 poison), Claw +7 (2d4+4 slashing). Territorial and opportunistic — a straightforward combat threat, in contrast to Threndir.',
},
{
name: 'Stonejaw Hill Giant', challengeRating: '5', ac: 13, maxHp: 105, speed: 40,
strScore: 21, dexScore: 8, conScore: 19, intScore: 5, wisScore: 9, chaScore: 6, attackBonus: 8, damageDice: '3d8+5',
specialTrait: 'Honor-bound — fights fairly and expects the same.',
notes: 'Huge giant, 1,800 XP each (Korgath Stonejaw’s Spine Clan warband). Greatclub +8 (3d8+5 bludgeoning), Rock +8, 60/240 ft (3d10+5 bludgeoning). Passive Perception 9, speaks Giant. A trial or contest (Trial of Stone) fits better than an ambush.',
},
{
name: 'Spine Wyvern', challengeRating: '3', ac: 13, maxHp: 68, speed: 20,
strScore: 19, dexScore: 10, conScore: 16, intScore: 5, wisScore: 12, chaScore: 6, attackBonus: 6, damageDice: '2d6+4',
specialTrait: 'Territorial, not evil — attacks anything lingering near a nest, ignores parties that move on.',
notes: 'Large dragon, 700 XP. Fly 80 ft. Bite +6 (2d6+4 piercing), Stinger +6 (2d6+4 piercing, DC 15 Con or poisoned 1 minute). Perception +4, darkvision 60 ft, passive Perception 14.',
},
{
name: 'Reef Terror', challengeRating: '3', ac: 13, maxHp: 68, speed: 20,
strScore: 19, dexScore: 10, conScore: 16, intScore: 5, wisScore: 12, chaScore: 6, attackBonus: 6, damageDice: '2d6+4',
specialTrait: 'Territorial — drags fishers under near its reef.',
notes: 'Reskinned Spine Wyvern for Side Quest: What the Tide Brought In — fins instead of wings, no flight, swim speed instead. Bite +6 (2d6+4 piercing), Stinger +6 (2d6+4 piercing, DC 15 Con or poisoned). NOT connected to the Hollow King.',
},
{
name: 'Greywood Direwolf', challengeRating: '1', ac: 14, maxHp: 37, speed: 50,
strScore: 17, dexScore: 15, conScore: 15, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 5, damageDice: '2d6+3',
specialTrait: 'Pack tactics — advantage if an ally is within 5 ft of the target and not incapacitated.',
notes: 'Large beast, 200 XP each. Bite +5 (2d6+3 piercing, DC 13 Str or prone). Perception +3, Stealth +4. Use 3–4 at once — the clearest Action Economy lesson for a new table.',
},
{
name: 'Highcrown Cutthroat', challengeRating: '1/8', ac: 12, maxHp: 11, speed: 30,
strScore: 11, dexScore: 12, conScore: 12, intScore: 10, wisScore: 10, chaScore: 10, attackBonus: 3, damageDice: '1d6+1',
specialTrait: null,
notes: 'Medium humanoid, 25 XP each. Shortsword +3 (1d6+1 piercing), Dagger (thrown) +3, 20/60 (1d4+1 piercing). Ordinary desperate people — kill them, scare them off, or find out why they’re desperate?',
},
{
name: 'Bandit Captain', challengeRating: '2', ac: 15, maxHp: 65, speed: 30,
strScore: 15, dexScore: 16, conScore: 14, intScore: 10, wisScore: 10, chaScore: 10, attackBonus: 5, damageDice: '1d6+3',
specialTrait: 'Multiattack (two scimitar attacks).',
notes: 'Medium humanoid, 450 XP. Scimitar +5 (1d6+3 slashing), Dagger (thrown) +5, 20/60 (1d4+3 piercing). Leads a band of 4–6 cutthroats.',
},
{
name: 'Hollowed', challengeRating: '1', ac: 12, maxHp: 27, speed: 30,
strScore: 12, dexScore: 10, conScore: 12, intScore: 10, wisScore: 10, chaScore: 8, attackBonus: 3, damageDice: '1d6+1',
specialTrait: 'Almost right — DC 15 Insight notices the wrongness (no blink, a repeated phrase, no reflection) before it acts. Hollow whisper — 1/day speaks one sentence in Vessa Hollow’s voice to its target.',
notes: 'Medium humanoid, 200 XP. HORROR register. Slam +3 (1d6+1 bludgeoning); on a hit DC 12 Wis save or frightened 1 round. Deception +3 while passing as themselves. The horror is in the roleplay, not the numbers.',
},
{
name: 'Bellcrawler', challengeRating: '2', ac: 14, maxHp: 32, speed: 30,
strScore: 12, dexScore: 15, conScore: 16, intScore: 3, wisScore: 12, chaScore: 4, attackBonus: 5, damageDice: '2d6+2',
specialTrait: 'Mimic bell-toll — lures the curious. Stillness — motionless and silent the instant it’s observed; DC 17 Perception to spot while still.',
notes: 'Small monstrosity, 450 XP. HORROR register. Climb 30 ft. Bite +5 (2d6+2 piercing), Skitter (disengage as a bonus action). Stealth/dread, not a straight fight.',
},
{
name: 'Root Stalker', challengeRating: '4', ac: 15, maxHp: 78, speed: 30,
strScore: 17, dexScore: 14, conScore: 13, intScore: 6, wisScore: 14, chaScore: 6, attackBonus: 6, damageDice: '2d8+3',
specialTrait: 'Silent approach — advantage on its first attack each combat. Grasping roots — on a hit, target restrained (DC 14 Str to escape) until the Stalker is damaged.',
notes: 'Large aberration, 1,100 XP. HORROR register — signature Aldenfell monster. Burrow 20 ft. Multiattack (two Root Lash +6, 2d8+3 bludgeoning). Tremorsense 60 ft (blind beyond), passive Perception 12. Run as a slow, dreadful stalk.',
},
{
name: 'The Hollow King’s Shadow', challengeRating: '7', ac: 14, maxHp: 90, speed: 30,
strScore: 10, dexScore: 14, conScore: 10, intScore: 16, wisScore: 16, chaScore: 18, attackBonus: 8, damageDice: '3d8',
specialTrait: 'Not yet real — at 0 HP it disperses for the scene, never dies. Echoing dread — starting a turn within 30 ft: DC 15 Wis or frightened. Passes through walls.',
notes: 'Large undead (incorporeal), 2,900 XP but uncounted — DO NOT run as a fair fight. Fly 30 ft (hover). Chilling touch +8 (3d8 necrotic). Treat HP as a ticking clock: interrupt, banish, or outlast it (close a ritual circle, reach sunlight/a warded threshold, survive N rounds). Save its true, killable form for Act 4.',
},
{
name: 'Skeleton', challengeRating: '1/4', ac: 13, maxHp: 13, speed: 30,
strScore: 10, dexScore: 14, conScore: 15, intScore: 6, wisScore: 8, chaScore: 5, attackBonus: 4, damageDice: '1d6+2',
specialTrait: 'Undead — vulnerable to bludgeoning',
notes: 'Ordinary restless dead for the Sunken Vaults’ Restless Ossuary (Room 4). Not tied to the Hollow King; DC 12 Religion identifies that proper deconsecration would settle them.',
},
{
name: 'Feral Guard Dog', challengeRating: '1/8', ac: 12, maxHp: 5, speed: 40,
strScore: 13, dexScore: 14, conScore: 12, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 3, damageDice: '1d6+1',
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 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 },
{ name: 'Grey Dawn Longsword', damageDice: '1d10', damageType: 'slashing', attackAbility: 'STR', properties: ['versatile (1d12)'], rangeNormal: null, rangeLong: null, minLevel: 3, damageScaling: 2 },
{ name: 'Greyford Halberd', damageDice: '1d12', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'reach', 'two-handed'], rangeNormal: null, rangeLong: null, minLevel: 3, damageScaling: 1 },
{ name: 'Stonejaw Maul', damageDice: '2d8', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['heavy', 'two-handed'], rangeNormal: null, rangeLong: null, minLevel: 4, damageScaling: 2 },
{ name: 'Threndir’s Fang', damageDice: '1d8', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light'], rangeNormal: null, rangeLong: null, minLevel: 5, damageScaling: 2 },
{ name: 'Hollowbane', damageDice: '2d8', damageType: 'radiant', attackAbility: 'FINESSE', properties: ['finesse', 'versatile (2d10)'], rangeNormal: null, rangeLong: null, minLevel: 7, damageScaling: 3 },
];
// ---------------------------------------------------------------------------
// 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', 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.', 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.' },
];
// ---------------------------------------------------------------------------
// Spells, pulled from the default library (src/game/data/spellsLibrary.js) by
// key. A new character can only pick starting spells of their own level or
// lower, so the starting set is cantrips and 1st level; the rest are for the
// DM to teach later.
// ---------------------------------------------------------------------------
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',
'thorn-whip', 'vicious-mockery',
'bless', 'burning-hands', 'charm-person', 'command', 'cure-wounds', 'detect-magic', 'entangle', 'faerie-fire',
'guiding-bolt', 'healing-word', 'mage-armor', 'magic-missile', 'shield', 'sleep', 'thunderwave',
],
earned: [
'hold-person', 'invisibility', 'lesser-restoration', 'misty-step', 'moonbeam', 'scorching-ray', 'shatter',
'spiritual-weapon',
'counterspell', 'daylight', 'dispel-magic', 'fireball', 'remove-curse', 'revivify',
'banishment',
],
};
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
// plausible.
// ---------------------------------------------------------------------------
const npcs = [
{
name: 'King Aldric Veyrmont', role: 'King of Aldenfell', disposition: 'NEUTRAL',
description: 'An aging king with a soldier’s posture and tired eyes. Courteous, proud, and quick to steer talk away from anything that sounds like bad news.',
dmNotes: 'Tired, proud, in denial — cornered, not ill. Pays well for quiet problem-solving and reacts badly to blunt truth. An audience needs a sponsor (a knight, a noble, a Concord representative). The crown fractures publicly at Stage 3.',
},
{
name: 'Princess Isolde Veyrmont', role: 'Heir to the throne', disposition: 'FRIENDLY',
description: 'Sharp, pragmatic and well liked. She keeps a small private study at the palace and has a habit of asking capable outsiders exactly the right question.',
dmNotes: 'The best long-term ally, and she recruits outsiders on purpose. Already suspects something is deeply wrong, so she is the most likely to believe the party early. Reporting to her at E10B sets up Act 3’s politics. Rumour says she will move against her father — misleading.',
},
{
name: 'Ser Kavan Thorne', role: 'Knight-Commander of the Grey Dawn', disposition: 'FRIENDLY',
description: 'Rigid, honorable and loyal to a fault. He runs Grey Dawn Hall by the book and judges people by whether they do what they said they would.',
dmNotes: 'Recruits the party (E4A → Grey Dawn track). SECRET: the Order’s founding is tied to the original sacrifice, and Thorne does not know. He struggles when evidence points at the crown; whether and how the party tells him is theirs to choose. If Ressa Vane escorted them (E9C) he already suspects by E10B.',
ac: 18, maxHp: 65, attackBonus: 6, damageDice: '1d8+4',
},
{
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. 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',
description: 'Terse and watchful, with a longbow that never seems to be out of reach. She answers to the forest, not to the crown.',
dmNotes: 'Suspicious, not hostile — a slow-burn ally. The first to notice the old shrines acting strangely (E4C → The Warden’s Silence). After that quest she is convinced something is deeply wrong, whatever happened to her Warden.',
ac: 15, maxHp: 44, attackBonus: 6, damageDice: '1d8+3',
},
{
name: 'Threndir Ashclaw', role: 'Old copper dragon of the Spine', disposition: 'NEUTRAL',
description: 'Vast, scarred and weary, coiled in an eyrie high on the eastern peaks. He grants an audience to anyone who approaches openly, and he prefers words to fire.',
dmNotes: 'Blunt, and wants the kingdom to survive. He wants proof the crown can still be trusted — the intended “win” is a negotiation (Persuasion DC 15–18). Always offers to talk unless attacked first. If it does come to a fight, use the “Threndir Ashclaw (old copper dragon)” monster stat block.',
},
{
name: 'Korgath Stonejaw', role: 'Chieftain of the Stonejaw clan', disposition: 'NEUTRAL',
description: 'A hill giant chieftain with a voice like a rockslide and no patience for politics. Outsiders in his hold are tested, not attacked.',
dmNotes: 'Honor-bound: trials, contests and bargains, never ambushes. Offers the Trial of Stone instead of an agreement; success is a standing alliance (Spine Clans track), failure earns a second, harder trial. Nothing sinister here — it is a genuine test of character.',
ac: 13, maxHp: 105, attackBonus: 8, damageDice: '3d8+5',
},
{
name: 'Vessa Hollow', role: 'A voice in the dark', disposition: 'NEUTRAL',
description: 'A woman’s voice — calm, patient, almost kind. She does not blink. She speaks as though she has been waiting a very long time and can wait a little longer.',
dmNotes: 'SECRET: one of the people Highcrown’s founders sacrificed, and the Hollow King’s sympathetic “face”. She believes waking the rest is justice. Unsettling, never mustache-twirling; DC 16 Insight confirms she believes what she says. Use her in dreams and omens long before she appears. Negotiating with her is a legitimate outcome. She does not fight — any combat near her uses Hollowed guardians.',
},
{
name: 'Corporal Ressa Vane', role: 'Grey Dawn warden escort', disposition: 'FRIENDLY',
description: 'A steady, plain-spoken corporal of the Grey Dawn, assigned to see you safely through the tunnels. She writes things down.',
dmNotes: 'Only appears if the party took Grey Dawn recruitment (E4A → E9C). She lets them skip to Root Room 2, and she reports everything back to Grey Dawn Hall — which matters at E10B and E15.',
ac: 16, maxHp: 27, attackBonus: 4, damageDice: '1d8+2',
},
{
name: 'The Bell-Ringer', role: 'Bell-ringer of the Founding Chapel', disposition: 'FRIENDLY',
description: 'A stooped, soft-spoken man who has rung the chapel bell every hour for most of his life. Badly shaken, and very grateful to see a friendly face.',
dmNotes: 'Found alive in Vault Room 5 (E3). He saw “someone who looked like his neighbor, except wrong”. Returns in The Bell-Ringer’s Debt: he owes a Highcrown lender who operates under the corrupted house’s protection (not knowable yet). A loyal low-key ally either way.',
},
{
name: 'A Frightened Local', role: 'Witness outside the chapel', disposition: 'NEUTRAL',
description: 'A market porter who was passing the chapel when the bell stopped. Pale, talking too fast, and not keen to go back inside.',
dmNotes: 'DC 12 Persuasion or Insight gets the account of “a woman who wasn’t blinking”. If the party talks to the crowd first (E2C) they get three witness accounts but no script lead, and their E10 Insight check is at advantage.',
},
{
name: 'The Lender', role: 'Highcrown moneylender', disposition: 'HOSTILE',
description: 'A neat, unhurried man who keeps his ledgers tidier than his conscience. He never raises his voice; he has collectors for that.',
dmNotes: 'Holds the bell-ringer’s debt. Operates under the corrupted noble house’s protection — digging into who backs him is harder but plants a clue toward the house before Act 2. Can be paid off, intimidated, or exposed.',
ac: 12, maxHp: 22, attackBonus: 3, damageDice: '1d4+1',
},
{
name: 'Greyford’s Commander', role: 'Commander of Greyford Keep', disposition: 'NEUTRAL',
description: 'An exhausted officer holding a border fortress with half the troops and a third of the supplies it needs. Blunt, overworked, and out of patience.',
dmNotes: 'Wants the Sorrow Camp “moved along” — and is not wrong about the strategic liability. No clean villain in Refugees of the Marches. Whether Greyford is supplied (The Smugglers’ Tithe outcome) decides rescue vs recovery at E11 and whether the keep holds at E16.',
ac: 17, maxHp: 52, attackBonus: 5, damageDice: '1d8+3',
},
];
// ---------------------------------------------------------------------------
// Encounters — pre-built (PLANNING) scenes for the beats of the Campaign Book,
// in book order. BATTLE scenes run in initiative order; QUEST scenes are built
// around NPCs and an objective.
// location — a location's name: where the scene is. Its characters move
// there when it begins.
// description — the scene, shown to the players in it: no spoilers.
// dmNotes — DCs, branches and secrets, DM only.
// monsters — [monster name, count, optional display name]; a count above
// one gets "#n" suffixes.
// npcs — NPC names.
// rewards — lined up unawarded, so they stay the DM's secret until
// 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 = [
{
name: 'E1 — The Founding Fair', mode: 'QUEST', location: 'Highcrown — The Lantern Market',
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',
description: 'The Founding Chapel’s bell has rung every hour for as long as anyone remembers. It has stopped. A crowd is gathering outside, and someone is asking for volunteers to go in and look.',
dmNotes: 'No break-in. The bell rope is neatly cut and left coiled; the bell-ringer is missing, not dead; a line of unfamiliar script is scratched inside the bell (Hollow King iconography). DC 13 Investigation finds the rope and script; DC 12 Persuasion/Insight gets the “woman who wasn’t blinking”.\n• E2A investigate immediately: full clues; skip Vault Room 1.\n• E2B distracted first: the guard missed the script; DC 13 Investigation to find it; start at Room 1.\n• E2C talk to the crowd first: three witness accounts, no script lead; E10 Insight later at advantage.\nThe trail leads to the cellar → E3.',
npcs: ['Ser Kavan Thorne', 'A Frightened Local'],
},
{
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 Investigation spots the locket.',
monsters: [['Feral Guard Dog', 1]],
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)',
description: 'Bones stacked floor to ceiling, and an offering box furred with dust. The air is still. Then it isn’t.',
dmNotes: 'Vault Room 4, a dead end. One or two skeletons rise halfway through the room — ordinary restless dead, not tied to the Hollow King. DC 12 Religion: proper deconsecration would settle them without a fight. DC 13 to open the offering box.',
monsters: [['Skeleton', 2]],
rewards: [{ gold: 25, note: 'Offering box — DC 13 to open' }],
},
{
name: 'E3 — Sunken Vaults: The Bell-Ringer’s Refuge', mode: 'QUEST', location: 'Hollow Below — The Sunken Vaults (Layer 1)',
description: 'A side chamber with a bedroll, a cold lantern, and a frightened old man who very much hopes you are who you look like.',
dmNotes: 'Vault Room 5. The bell-ringer is alive but shaken: he saw “someone who looked like his neighbor, except wrong”. Getting him out is the win — the bell rings again, the fair goes on, nothing is solved.\nRoom 3 (Smugglers’ Cache) is on the way: DC 14 Investigation ties the crates’ sigil to a lender linked to the corrupted house.\nRoom 6 (The Descent) leads to the Root: wrong, cold air. Signal the tone shift; the party can always turn back. → E4.',
npcs: ['The Bell-Ringer'],
rewards: [
{ gold: 40, note: 'Room 3 — trade goods from the smugglers’ cache' },
{ item: 'Smugglers’ Ledger Page', note: 'Room 3 — DC 14 Investigation' },
],
},
{
name: 'E4 — Aftermath & Faction Hooks', mode: 'QUEST',
description: 'Word travels fast in Highcrown. By the next morning three different people have sent for you, and each of them has work.',
dmNotes: 'All open doors; they can run in parallel.\n• E4A Grey Dawn recruitment (Kavan) → Grey Dawn ending track; unlocks Ressa Vane at E9C.\n• E4B Concord job board (Mireille) → The Smugglers’ Tithe.\n• E4C Warden contact (Ashwen) → The Warden’s Silence.\n• E4D Caravan to the Spine → Trial of Stone → Spine Clans track.\n• E4E Investigate independently (DC 15 Investigation) → Independent track.\nAfter two or more threads and one E5 omen, advance the clock to Stage 2 (E8).',
npcs: ['Ser Kavan Thorne', 'Mireille Costa', 'Ashwen'],
rewards: [
{ item: 'Grey Dawn Signet', note: 'E4A — if they accept recruitment' },
{ 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.',
dmNotes: 'Optional side quest, Lv 1–2. The collectors are ordinary desperate people: kill them, scare them off, or find out why they are desperate. Beats: hear the bell-ringer out → track the collectors → pay the debt, intimidate the lender, or dig into who backs him (harder, more rewarding — a clue toward the corrupted house before Act 2).',
monsters: [['Highcrown Cutthroat', 3]],
npcs: ['The Bell-Ringer', 'The Lender'],
rewards: [
{ gold: 30, note: 'The collectors’ take for the week' },
{ item: 'Bell-Ringer’s Keepsake', note: 'Given once the debt is dealt with' },
],
},
{
name: 'Side Quest — The Smugglers’ Tithe: Tidewatch', mode: 'QUEST', location: 'Salt Coast — Tidewatch',
description: 'Tidewatch, the Salt Coast’s largest port: proud, salt-stained, and suspicious of anyone asking questions. Someone is skimming the town’s quiet trade, and the Concord wants to know who.',
dmNotes: 'Lv 1–3. Mireille hires the party to learn why a cut of Tidewatch’s smuggled goods vanishes before reaching buyers. TRUTH: a Bandit Captain is extorting the smugglers and funnelling the “tithe” to arm one side of the Bramble Marches conflict. Smugglers distrust outsiders. Track a shipment to Smuggler’s Cove → next scene.\nOutcome decides whether Greyford is supplied at E11/E16.',
npcs: ['Mireille Costa'],
rewards: [{ gold: 100, note: 'Mireille’s fee — paid in full if it ends quietly' }],
},
{
name: 'Side Quest — The Smugglers’ Tithe: Smuggler’s Cove', mode: 'BATTLE', location: 'Salt Coast — Smuggler’s Cove',
description: 'A hidden inlet south of Tidewatch. Lanterns hooded, a longboat drawn up on the shingle, and a crew that was not expecting company.',
dmNotes: 'Fight, negotiate, or both. The Captain leads from the front (multiattack) and bolts for the boat below half HP. End it quietly (Mireille pays well) or expose where the tithe was headed (pulls the Marches plot forward).',
monsters: [['Bandit Captain', 1], ['Highcrown Cutthroat', 4]],
rewards: [
{ gold: 60, note: 'The tithe chest' },
{ weapon: 'Smuggler’s Cutlass', note: 'The Captain’s own blade' },
],
},
{
name: 'Side Quest — The Warden’s Silence: Direwolf Pack', mode: 'BATTLE', location: 'Greywood — The Old Shrine Sites',
description: 'The trail to the shrine runs through old growth where the light barely reaches the ground. The forest has gone quiet, and there are eyes between the trees.',
dmNotes: 'Lv 1–2. The pack is agitated by the shrine, not evil — DC 13 Animal Handling or Nature to back away without a fight. Pack tactics make three wolves the clearest action-economy lesson for a new table.',
monsters: [['Greywood Direwolf', 3]],
},
{
name: 'Side Quest — The Warden’s Silence: The Shrine', mode: 'BATTLE', location: 'Greywood — The Old Shrine Sites',
description: 'A mossed-over shrine older than the kingdom, split by a crack you could lose an arm in. The missing Warden is standing in front of it. She turns and greets you by name.',
dmNotes: 'HORROR register — signal it. The Warden was Hollowed after investigating the crack alone: the party’s first, sympathetic Hollowed. DC 15 Insight notices the wrongness (no blink, a repeated phrase) before it acts. Fight, restrain and bring back to Ashwen (hard questions), or it escapes to report back. Either way Ashwen is convinced.',
monsters: [['Hollowed', 1, 'The Missing Warden']],
rewards: [
{ item: 'Warden’s Charm', note: 'From Ashwen, whatever the outcome' },
{ weapon: 'Warden’s Longbow', note: 'The missing Warden’s bow — Ashwen says it should be used' },
],
},
{
name: 'Side Quest — Trial of Stone', mode: 'QUEST', location: 'The Spine — Stonejaw Hold',
description: 'Stonejaw Hold, a giant clan’s valley in the Spine foothills. The chieftain has heard you out. He does not offer agreement. He offers a trial.',
dmNotes: 'Lv 2–4. A genuine test of character, nothing sinister. Trials (per PC or shared): non-lethal wrestling with a giant (the Hill Giant here — HP is a “pin” clock, nobody dies); a test of honesty; proving their word is good (retrieve something, deliver a message honestly). Beats: arrive → formal challenge → trial → feast.\nSuccess = standing alliance (Spine Clans track, unlocks E9D and E11C). Failure is not fatal: Korgath offers a second, harder trial.',
monsters: [['Stonejaw Hill Giant', 1, 'Stonejaw Champion']],
npcs: ['Korgath Stonejaw'],
rewards: [
{ item: 'Stonejaw Oath-Token', note: 'On passing the trial' },
{ weapon: 'Stonejaw Maul', note: 'Feast-gift for whoever wrestled the champion' },
],
},
{
name: 'Side Quest — Refugees of the Marches', mode: 'QUEST', location: 'Bramble Marches — The Sorrow Camp',
description: 'The Sorrow Camp: hundreds of people under canvas just inside the border, and a garrison at Greyford Keep that wants them gone. Food sent for the camp is not all arriving.',
dmNotes: 'Lv 2–4, the clearest moral-choice quest. No clean villain: the commander is not wrong about the strategic liability, and the refugees have nowhere else to go. Beats: tension between camp and garrison → find who is skimming aid (the Captain’s crew) → confront them → broker, or fail to broker, an arrangement.\nIntroduce a named refugee NPC here — they are at risk at E16B.',
monsters: [['Bandit Captain', 1, 'Aid-Skimmer Boss'], ['Highcrown Cutthroat', 2]],
npcs: ['Greyford’s Commander'],
rewards: [{ gold: 50, note: 'Recovered aid money — keeping it is a choice the camp will notice' }],
},
{
name: 'Side Quest — What the Tide Brought In: The Reef', mode: 'BATTLE', location: 'Salt Coast — Tidewatch',
description: 'A fishing boat washed up empty this morning: sails intact, catch in the hold, crew gone. The fishers will point out the reef. None of them will row you there.',
dmNotes: 'Lv 2–3, horror-lite. NOT connected to the Hollow King — a deliberate palate-cleanser. DC 13 Investigation on the boat finds claw marks, not violence. Confront it at sea (it swims, the party does not) or lure it to the shallows. Outcome: safe fishing reopens and real goodwill in Tidewatch.',
monsters: [['Reef Terror', 1]],
rewards: [
{ gold: 80, note: 'Tidewatch’s collection for whoever cleared the reef' },
{ item: 'Potion of Healing', quantity: 2, note: 'From the harbour apothecary' },
],
},
{
name: 'Audience — Threndir’s Eyrie', mode: 'QUEST', location: 'The Spine — Threndir’s Eyrie',
description: 'A hard climb above the snow line, and a cave mouth wide enough to sail a ship into. The dragon inside already knows you are here. He has not moved, and he has not told you to leave.',
dmNotes: 'A negotiation, not a fight: Threndir wants proof the crown can still be trusted (Persuasion DC 15–18; real evidence from E10 lowers it). He talks first unless attacked. If it goes wrong, add the “Threndir Ashclaw (old copper dragon)” monster to this scene — and remember he is far beyond a low-level party.',
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.',
dmNotes: 'HORROR register — signal the tone shift. Root Room 2. DC 14 Survival: the dripping is a Hollowed’s footsteps. The Bellcrawler uses Stillness (DC 17 Perception to spot) and mimics the bell to lure the curious.\n• E9A retreat: keep what they learned, full rest before re-entry.\n• E9B push through: the marking here matches the bell script (DC 16 Arcana/Religion).\n• E9C (took E4A): Ressa Vane escorts and reports back — remove her from the scene otherwise.\n• E9D (won Trial of Stone): a Spine guide enters via Room 4.',
monsters: [['Bellcrawler', 1]],
npcs: ['Corporal Ressa Vane'],
},
{
name: 'E9 — The Root: Hollowed Nest', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
description: 'A cavern kept unnervingly tidy: bedrolls squared away, boots in a row. Two people sit facing the wall. They stand when you enter, and smile at exactly the same moment.',
dmNotes: 'Root Room 3. DC 15 Insight before combat. Consider making one a named missing person. Slam hits force DC 12 Wis or frightened for a round; each can whisper one sentence in Vessa’s voice. The letter (DC 14) hints at the corrupted house.',
monsters: [['Hollowed', 2]],
rewards: [
{ gold: 20, note: 'Personal effects' },
{ item: 'Half-Burned Letter', note: 'DC 14 Investigation' },
],
},
{
name: 'E9 — The Root: Root Stalker’s Warren', mode: 'BATTLE', location: 'Hollow Below — The Root (Layer 2)',
description: 'The roots in this chamber are as thick as a man’s waist, and they are not growing from anything. One of them shifts when your light touches it.',
dmNotes: 'Root Room 4, a dead end (optional secret passage to Room 6). DC 14 Perception or its first attack has advantage. Run it as a slow, dreadful stalk: tremorsense, burrow, grasping roots (restrained, DC 14 Str). E9D: DC 13 Survival or it wakes at the entrance.',
monsters: [['Root Stalker', 1]],
rewards: [
{ gold: 60, note: 'From what the roots grew around' },
{ 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.',
dmNotes: 'Root Room 6. DC 15 Perception to place them; DC 13 Dex on the ledges or fall 2d6. They skitter (bonus-action disengage) and freeze when watched — stealth and dread, not a straight fight. → Room 7, the stair to the Hollow Court: let them see it and turn back.',
monsters: [['Bellcrawler', 3]],
rewards: [
{ gold: 90, note: 'Scattered along the ledges' },
{ item: 'Potion of Healing', note: 'In a fallen pack — the book has the coin or the potion, DM’s choice' },
],
},
{
name: 'E10 — The House Unmasked', mode: 'QUEST', location: 'Highcrown — The Palace of Aldenfell',
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 — 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',
description: 'Greyford Keep. The gate stands open. No smoke from the chimneys, no one on the walls, no bodies. A kettle is still warm on a guardroom stove.',
dmNotes: 'DC 13 Perception: drag marks — the defenders left. The Smugglers’ Tithe outcome decides rescue vs recovery. E11C (Spine allied): the clans are already here and share Root Stalker migration intel. Remove the Commander from the scene if this is a recovery.',
npcs: ['Greyford’s Commander'],
rewards: [
{ gold: 70, note: 'Trade goods in the keep’s stores (2d6×10 gp)' },
{ weapon: 'Greyford Halberd', note: 'From the armoury' },
],
},
{
name: 'E12 — A Line Thins', mode: 'BATTLE',
description: 'The ground splits somewhere you thought was safe, and something comes up through it that should have stayed far below.',
dmNotes: 'A Root Stalker surfaces near a location the party did not secure — pick it from what they left undone. DC 15 Arcana/Religion: this is deliberate erosion from below. The root-fiber scrap is a key item (needed at E14/E18/E19). Then E13: the clock turns to Stage 3.',
monsters: [['Root Stalker', 1]],
rewards: [{ item: 'Root-Fiber Scrap', note: 'Key item — cut from the Stalker' }],
},
{
name: 'E14 — The Break', mode: 'BATTLE',
description: 'The crack has become a breach. People are coming out of it — neighbours, guards, people you have met — walking calmly, smiling, and not blinking.',
dmNotes: 'Two to four Hollowed (scale to the party). DC 15 Arcana: the breach is shaped for something to come through.\n• E14A seal it (DC 17 Arcana or the chapel ritual): closes, but a debt comes due at E17.\n• E14B evacuate instead: no deaths, but the breach stays open, worsening omens until E18.\nThe seal-stone fragment is a key item (needed at E19).',
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.',
dmNotes: 'DO NOT run as a fair fight. At 0 HP it disperses for the scene and never dies; treat its HP as a ticking clock. Give the party a way out: close a ritual circle, reach sunlight or a warded threshold, or survive N rounds. Echoing dread: DC 15 Wis or frightened within 30 ft. Save its true, killable form for Act 4.',
monsters: [['The Hollow King’s Shadow', 1]],
},
{
name: 'E19 — Vessa’s Seat', mode: 'QUEST', location: 'Hollow Below — The Hollow Court (Layer 3)',
description: 'A single worn stone seat at the heart of the Hollow Court, and a woman who has been expecting you. She asks you to sit. She says she would like to explain.',
dmNotes: 'Court Room 4. The party learns the Hollow King’s true nature from Vessa: the imprisoned will of everyone the founders sacrificed — a suppression of many, not a prison for one. DC 16 Insight: she believes what she says. A strong candidate for a negotiated outcome.\nRoot-fiber + seal-stone (DC 15 Arcana, automatic with a Religion-proficient caster) → advantage on the first check at E22. Then E20: the clock turns to Stage 4.',
npcs: ['Vessa Hollow'],
},
{
name: 'E21 — Rally Point', mode: 'QUEST',
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' },
{ 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 — 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)',
description: 'The final stretch of the Hollow Court, where gravity, light and memory have all stopped agreeing with one another. The things that live here know you are coming.',
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'],
},
];
// ---------------------------------------------------------------------------
// Notes. Listed in reading order; the seed timestamps them so the first entry
// shows at the top of the campaign's note list.
// ---------------------------------------------------------------------------
const sharedNotes = [
`Welcome to Aldenfell — Player Primer
You’re a stranger arriving in Highcrown, the capital, right as the yearly Founding Fair kicks off — a good excuse for adventurers from every corner of the kingdom to cross paths in one place.
The setting: Aldenfell is a kingdom of knightly orders, old noble houses, and a royal court that’s held things together for generations. To the west, the ancient Greywood forest keeps its own secrets and its own guardians. To the north and east, the mountains of the Spine are home to giant clans and dragons who are neighbors and political players, not just monsters to slay. The Salt Coast to the south is full of independent-minded fishing towns and rumors of things in the water. A world built for classic adventure — quests, courts, monsters, treasure — with real weight behind the choices you make.
A note on tone: most of this campaign plays like a bright, exciting fantasy adventure. Every so often, especially if you go looking underground, it gets genuinely unsettling. You’ll always get a clear signal before things turn scary, so lean in when it happens.
Building your character: we’ll roll to discover your species and class, then roll your ability scores. Knights, wandering scholars, forest-raised rangers, self-taught spellcasters — everything has a place here.`,
];
const dmNotes = [
`Overview & Tone
Aldenfell looks whole from the road and is rotting from underneath. Knights ride under bright banners, dragons nest in the high peaks, the court still feasts — but something old and hungry is stirring beneath the capital, and every faction is maneuvering around that fact instead of dealing with it. The party arrives as the cracks start showing.
Tone pillars (in priority order):
• Heroic adventure first — the classic fantasy toybox, played straight and fun.
• Real consequences — choices stick; burned bridges stay burned; stalling on the central threat visibly worsens the world. Not punishing, just earned.
• Horror as a spice — surface world plays bright; horror spikes in telegraphed places (the Hollow Below). Always signal before a scene turns scary.
Tone dial (out of 5): Heroism 5 · Intrigue 3 · Combat 4 · Horror 3 (5 in the Underworld) · Comedy 2.
Regions: Highcrown (capital, home base) · The Greywood (old forest, fey, Wardens) · The Spine (dragons & giants) · The Salt Coast (smuggling, sea monsters) · The Bramble Marches (contested border) · The Hollow Below (underneath everything).`,
`The Central Conflict — Campaign Clock
Something sealed beneath the capital centuries ago — the Hollow King — is waking. It doesn’t attack at first; it corrupts. Use this as a VISIBLE clock; it advances only between major story beats.
Stage 1 — Omens: livestock/crops fail near old shrines; shared identical nightmares in Highcrown; minor nobles acting strangely loyal to no one. (Session One sits here.)
Stage 2 — Cracks: the boundary thins in mappable places; monsters surface near cracks; a Bramble Marches faction goes quiet; the corrupted noble house is revealed.
Stage 3 — Open wound: a crack becomes a breach; a town/district is lost or quarantined; the crown publicly acknowledges it and fractures; factions jockey for power.
Stage 4 — The Hollow King wakes: endgame, shaped by what the party did in stages 1–3.
Real consequences: an ally lost in stage 2 stays lost; a faction alienated in stage 1 won’t help in stage 3; a region left to fall stays changed.
Branch points to track: faction(s) sided with · named NPCs alive/allied/dead/estranged · regions with lasting damage · how much the public knows by Act 3.`,
`Factions
• The Crown — King Aldric Veyrmont, aging, cornered, in denial. Heir Princess Isolde Veyrmont: capable, well-liked, quietly building her own circle — a strong ally for the party.
• Order of the Grey Dawn — premier knightly order sworn to the crown. Split between traditionalists and a faction that thinks the court is hiding something. “You’re recruited” is a clean session-one hook.
• The Merchant Concord — trade coalition running Highcrown’s economy. Neutral, pragmatic, pays for side quests, quick to abandon anyone bad for business.
• Wardens of the Greywood — rangers/druids who answer to no crown. Suspicious, not hostile; first to notice the cracks near the old shrines.
• Spine Clans & dragon eyries — several giant clans and named dragons treating Aldenfell as a neighboring power. “Talk your way out of this” and “earn an ally” scenes.
• A corrupted noble house — pick a minor house NOW; don’t reveal until Stage 2. Let their behavior be subtly off from session one.`,
`Notable NPCs
• King Aldric Veyrmont — ruler. Tired, proud, in denial. Pays well for quiet problem-solving; reacts badly to blunt truth.
• Princess Isolde Veyrmont — heir. Sharp, pragmatic. Best long-term ally; recruits capable outsiders on purpose.
• Ser Kavan Thorne — Grey Dawn knight-commander. Rigid, honorable, loyal to a fault. Recruits the party; struggles when evidence points at the crown.
• Mireille Costa — Merchant Concord rep. Charming, transactional, always has a job.
• Ashwen — Warden of the Greywood. Terse, watchful. First to notice the shrines; a slow-burn ally.
• Threndir Ashclaw — old, weary copper dragon of the Spine. Blunt, wants the kingdom to survive. Talks before fighting.
• Korgath Stonejaw — giant clan chieftain. Honor-bound, no patience for politics. Trials, contests, bargains.
• Vessa Hollow — a voice, not yet a face. Calm, almost kind, wrong. Use in dreams/omens long before she appears.
• Corporal Ressa Vane — Grey Dawn warden escort (E9C); reports everything back to Grey Dawn Hall.
• The bell-ringer — rescued in Session One; returns in The Bell-Ringer’s Debt.`,
`DM SECRETS & TWISTS (eyes only)
• What the Hollow King is: the accumulated, imprisoned will of everyone Highcrown’s founders sacrificed to secure their claim to the land. The seal is a suppression of many, not a prison for one. The resolution is about what the party does with that truth (bury it, expose it, make amends) — not just a sword fight.
• Vessa Hollow: one of the sacrificed, the Hollow King’s sympathetic “face.” She believes waking the rest is justice. Unsettling, never mustache-twirling. Negotiating with her is a legitimate outcome.
• The corrupted noble house: choose whichever minor house you introduced most casually in Act 1. Their head bargained with Vessa years ago for prosperity and believes they’re managing the influence rather than being used.
• Ser Kavan Thorne: the Grey Dawn’s founding is tied to the original sacrifice. Thorne doesn’t know. Whether/how the party tells him is theirs to choose.
• Princess Isolde: already suspects something is deeply wrong; most likely to believe the party early.`,
`Session One — The Bell That Wasn’t Ringing
Setup: all PCs are in Highcrown for the Founding Fair. Build characters at the table (Roll-Your-Character System) — can be session one’s first half.
Hook: the old chapel bell, which has rung every hour for as long as anyone remembers, stops. A city guard (or Ser Kavan Thorne) gathers volunteers to check the chapel.
What they find: no break-in. The bell rope is neatly cut and left coiled. The bell-ringer is missing, not dead. A line of unfamiliar script is scratched inside the bell (Hollow King iconography). A frightened local saw “a woman who wasn’t blinking.” The trail leads to the Sunken Vaults beneath the chapel.
Light dungeon crawl: 2–4 easy/medium encounters (feral guard dog, low-level undead) — nothing horror-register yet. The bell-ringer is found alive but shaken, having seen “someone who looked like his neighbor, except wrong.”
Ending: the bell rings again, the fair goes on, nothing is solved. They leave with a taste of combat, one unanswered mystery, and at least one NPC connection.`,
`Act 1: Omens — E1–E8
E1 Founding Fair (Lantern Market): character creation, no combat → E2.
E2 The Bell That Wasn’t Ringing (Founding Chapel): DC 13 Investigation finds the cut rope & script; DC 12 Persuasion/Insight gets the “woman who wasn’t blinking.”
• E2A investigate immediately: full clues; skip Vault Room 1.
• E2B distracted first: guard missed the script; DC 13 Investigation to find it; start Room 1.
• E2C talk to the crowd first: 3 witness accounts, no script lead; E10 Insight later at advantage.
E3 The Sunken Vaults: run Vault Rooms 1–6; bell-ringer found in Room 5 → E4.
E4 Aftermath & Faction Hooks (all open doors, can run in parallel):
• E4A Grey Dawn recruitment → Grey Dawn ending track.
• E4B Concord job board → The Smugglers’ Tithe.
• E4C Warden contact → The Warden’s Silence.
• E4D Caravan to the Spine → Trial of Stone → Spine Clans track.
• E4E Investigate independently (DC 15 Investigation) → Independent track.
E5 Omens Montage: 2–3 short scenes across sessions (shrine livestock, shared nightmares — DC 12 Insight, the odd noble).
E6 The Bell-Ringer’s Debt (optional side quest).
E7 A Closer Look: investigating the noble house hits dead ends — unless E2C, then DC 13 Insight catches a servant who flinched.
E8 Turning the Page: after 2+ E4 threads and one E5 omen, advance clock to Stage 2 → E9.`,
`Act 2: Cracks — E9–E13
E9 First Descent (The Root, Weeping Passage). Signal the tone shift. DC 14 Survival notices the dripping is a Hollowed’s footsteps.
• E9A retreat: keep what they learned, full rest before re-entry.
• E9B push through alone: Root Rooms 1–4; Weeping Passage marking matches the bell script.
• E9C (took E4A): Corporal Ressa Vane escorts, skips to Room 2 — but reports back (matters at E10B, E15).
• E9D (won Trial of Stone): Spine guide enters via Room 4; hears the legend of “the sleeper under the hollow crown”; DC 13 Survival or wake the Root Stalker at the entrance.
E10 The House Unmasked (Highcrown). Ledger: every shrine “donation” falls three days after a livestock death.
• E10A confront directly: DC 16 Persuasion/Intimidation with proof, DC 20 without.
• E10B report to crown/Isolde/Kavan: sets up Act 3 politics; if E9C, Kavan already suspected (trust bump).
• E10C (E2C + servant thread): private DC 13 check at advantage → confession + sealed letter; the house never learns it’s suspected. Best route into Act 3.
E11 Silence on the Border (Greyford Keep): gate open, no bodies. DC 13 Perception: drag marks — the defenders left. Smugglers’ Tithe outcome decides rescue vs recovery. E11C (Spine allied): clans already there, share Root Stalker migration intel. Treasure: 2d6×10 gp trade goods + masterwork weapon.
E12 A Line Thins: a Root Stalker surfaces near a location the party didn’t secure. DC 15 Arcana/Religion: deliberate erosion from below. Key item: root-fiber scrap (needed for E14/E18).
E13 The Clock Turns → Stage 3.`,
`Act 3: Open Wound — E14–E20
E14 The Break: the E12 crack becomes a breach; 2–4 Hollowed pour out. DC 15 Arcana: shaped for something to come through.
• E14A seal it (DC 17 Arcana or chapel ritual): closes, but a debt comes due at E17.
• E14B evacuate instead: no deaths, but the breach stays open, worsening omens until E18.
Key item: seal-stone fragment (needed at E19).
E15 The House Falls (or Doesn’t):
• E15A (after E10A): house prepared — DC 18 to break the cover story, or fight guards + a Hollowed retainer.
• E15B (after E10B): public tribunal — DC 16 Persuasion/Insight for exile over execution.
• E15C (after E10C): strike the cult infrastructure directly → E15C-1 capture a cult lieutenant who reveals the Hollow Court’s surface entrance.
E16 What the Marches Cost (Greyford Keep):
• E16A reinforced (E11C): the keep holds; Spine standing rises.
• E16B under-supplied: the keep falls; a named refugee NPC dies unless DC 16 Athletics/Survival rescue.
E17 The Debt Comes Due: pay off E14A, unresolved side quests, or ignored omens — or a quiet breather.
E18 Into the Hollow Court: Court Rooms 1–3. E18A standard entry via the Root’s Descent; E18B (lieutenant captured) skip to Room 2.
E19 Vessa’s Seat (Court Room 4): the party learns the Hollow King’s true nature from Vessa. Root-fiber + seal-stone (DC 15 Arcana, auto with a Religion-proficient caster) → advantage on the first check at E22.
E20 The Clock Turns Again → Stage 4.`,
`Act 4: The Hollow King Wakes — E21–E27
E21 Rally Point (Highcrown or Greyford): who shows up depends on the branch list.
• E21A Grey Dawn track: Kavan + wardens, by-the-book.
• E21B Spine Clans track: war-band; advantage on the first Survival check at E22.
• E21C Independent: no reinforcements; every choice stays theirs.
• Multiple tracks: groups arrive and disagree — the party mediates.
Treasure: a minor magic item as a going-away gift.
E22 The Seal Chamber (Court Room 5) — the key branch:
• E22A reinforce (DC 18 Arcana/Religion): Sealed Ending; the seal-stone becomes a unique magic item.
• E22B break it deliberately: E23 on the party’s terms — the only path to true victory.
• E22C do nothing: seal fails anyway; E24–E25 are harder.
E23 The Waking Begins (Court Room 6): E22B = no surprise, full resources; E22C = apply one escalation stage first.
E24 What the Court Remembers: vision encounter; DC 14 Insight recognizes manipulation vs truth.
E25 The Last Approach: gauntlet of 2–3 horror-register creatures; allied faction fights alongside.
E26 The Hollow King: build the stat block from DM Secrets, scaled to party. Grey Dawn = disciplined line; Spine Clans = terrain & ritual (collapse passages, the Memory Pool); Independent = hardest, entirely theirs.
E27 Endings: Sealed (bought time) · Grey Dawn (institution absorbs the heroes) · Spine Clans (credit finally given) · Independent (their own story). Close back at the Lantern Market, a year later.`,
`Side Quest — The Smugglers’ Tithe
Salt Coast (Tidewatch, Smuggler’s Cove) · Lv 1–3 · Highcrown Cutthroats, Bandit Captain
Hook: Mireille Costa hires the party to learn why a cut of Tidewatch’s smuggled goods is vanishing before reaching buyers.
Truth: a Bandit Captain is extorting the smugglers and funneling the “tithe” to arm one side of the Bramble Marches conflict.
Beats: investigate Tidewatch (smugglers distrust outsiders) → track a shipment to Smuggler’s Cove → confront the crew (fight, negotiate, or both).
Outcomes: end it quietly (Mireille pays well) or expose where the tithe was headed (pulls the Marches plot forward). Decides whether Greyford is supplied at E11/E16.`,
`Side Quest — The Warden’s Silence
The Greywood (Warden’s Lodge, an Old Shrine) · Lv 1–2 · Greywood Direwolves, one Hollowed
Hook: Ashwen asks the party to check on a Warden who stopped reporting in from a shrine site.
Truth: the Warden was Hollowed after investigating the shrine’s crack alone — the party’s first, sympathetic Hollowed.
Beats: track the Warden → direwolf pack on the way (agitated, not evil) → the Hollowed Warden at the shrine; DC 15 Insight reveals the wrongness.
Outcomes: fight, or restrain and bring back to Ashwen (hard questions), or it escapes to report back. Either way Ashwen is convinced something is deeply wrong.`,
`Side Quest — Trial of Stone
The Spine (Stonejaw Hold) · Lv 2–4 · Korgath Stonejaw’s Hill Giants
Hook: the party needs Korgath’s clan as an ally; he offers a trial instead of agreement.
Truth: nothing sinister — a genuine test of character.
Trials (per PC or shared): non-lethal wrestling with a giant; a test of honesty; proving their word is good (retrieve something, deliver a message honestly).
Beats: arrive → formal challenge → trial → feast on success.
Outcomes: success = standing alliance (Spine Clans track). Failure isn’t fatal — Korgath offers a second, harder trial.`,
`Side Quest — The Bell-Ringer’s Debt
Highcrown · Lv 1–2 · Highcrown Cutthroats · Follows Session One
Hook: the rescued bell-ringer owes a Highcrown underworld lender; interest is compounding fast.
Truth: the lender operates under the corrupted noble house’s protection (not knowable yet).
Beats: hear him out → track the collectors → pay the debt, intimidate the lender, or dig into who backs the lender (harder, more rewarding).
Outcomes: a loyal low-key ally either way. Digging plants a connect-the-dots clue toward the corrupted house before Act 2.`,
`Side Quest — Refugees of the Marches
Bramble Marches (Greyford Keep, the Sorrow Camp) · Lv 2–4 · Cutthroats or a Bandit Captain · The clearest moral-choice quest
Hook: the Sorrow Camp is overwhelmed and preyed on by aid-skimmers; Greyford’s commander wants the camp “moved along.”
Truth: no clean villain — the commander isn’t wrong about the strategic liability; the refugees have nowhere else to go.
Beats: tension between camp and garrison → find who’s skimming aid → confront them → broker (or fail to broker) an arrangement.
Outcomes: genuinely branching — lasting goodwill, or a hardened Marches conflict. Introduce a named refugee NPC here (at risk at E16B).`,
`Side Quest — What the Tide Brought In
Salt Coast (Tidewatch) · Lv 2–3 · Reef Terror (reskinned Spine Wyvern) · Horror-lite
Hook: a fishing boat washes up empty — sails intact, catch in the hold, crew gone.
Truth: NOT connected to the Hollow King (a deliberate palate-cleanser). A territorial sea creature drags fishers under near a reef.
Beats: DC 13 Investigation finds claw marks, not violence → spooked fishers give the reef’s location → confront at sea or lure to shallows.
Outcomes: safe fishing reopens and real goodwill in Tidewatch — an uncomplicated “you helped real people” win.`,
`Dungeon — Sunken Vaults (Layer 1)
1. The Collapsed Stair (entry) — spiral stair, rusted gate on one hinge. No monster. DC 12 Perception: fresh footprints (the bell-ringer’s). → Room 2.
2. The Old Crypt Hall — hub; sealed niches, some open. DC 13 Investigation: just old theft. Good spot for a feral dog or low undead. Treasure: silver locket 15 gp (DC 12). → 3, 4, stair to 6.
3. Smugglers’ Cache — crates with an unknown merchant sigil. DC 14 Investigation ties it to a lender linked to the corrupted house. Treasure: ~40 gp trade goods + ledger page with partial name. Dead end.
4. The Restless Ossuary — 1–2 skeletons rise halfway through (ordinary dead). DC 12 Religion: deconsecration would settle them. Treasure: offering box (DC 13) with 25 gp. Dead end.
5. The Bell-Ringer’s Refuge — bedroll, cold lantern. The bell-ringer is found here in E3; otherwise a squatter’s camp. Branch off Room 2 or 6.
6. The Descent — ragged hole, wrong cold air. Transition to the Root; signal the tone shift. The party can always turn back. → Root Room 1.`,
`Dungeon — The Root (Layer 2)
1. The Threshold — almost-regular dripping. Nothing attacks. DC 15 Perception: the rhythm changes when someone speaks. Retreat always safe. → 2.
2. The Weeping Passage — walls “weep.” E9B: marking matches the bell script (DC 16 Arcana/Religion). Maybe one Bellcrawler using Stillness (DC 17 Perception). → 3 (east), 4 (south).
3. Hollowed Nest — tidy cavern, bedrolls. 1–2 Hollowed; DC 15 Insight before combat. Maybe a named missing person. Treasure: ~20 gp effects + (DC 14) half-burned letter hinting at the corrupted house. → 2, 5.
4. Root Stalker’s Warren — moving roots. Root Stalker lair; DC 14 Perception or its first attack has advantage. Treasure: 60 gp + masterwork dagger (50 gp). Dead end (optional secret passage to 6).
5. The Drowned Shrine — pre-kingdom altar in black water. DC 16 History/Religion: older than Aldenfell. Treasure: silver relic 75 gp (DC 13 Athletics/Investigation). → 2, crawl to 6.
6. Bellcrawler Gallery — ledges over a drop; bell-toll lure. 2–3 Bellcrawlers; DC 15 Perception; DC 13 Dex or fall 2d6. Treasure: 90 gp + potion of healing. → 2, 7.
7. The Descent to the Hollow Court — a stair too regular to exist. Off-limits before Act 4; let them see it and turn back. → Court Room 1.`,
`Dungeon — The Hollow Court (Layer 3)
1. The Bent Stair — the stair disagrees with itself. DC 15 Wis save or lose track of time (optionally lose 10 minutes). No monster. → 2.
2. The Memory Pool — each PC sees a personal vision tied to their backstory/choices (payoff for the branch list). No damage; can choose not to look. DC 17 Insight to understand it. → 3.
3. The Court of Whispers — overlapping voices; Vessa may speak plainly here. DC 16 Insight: she believes what she says. Any combat uses Hollowed guardians, not Vessa. → 4.
4. Vessa’s Seat — a single worn stone seat. Meet or confront Vessa; strong candidate for a negotiated outcome. → 5.
5. The Seal Chamber — a wound stitched shut, coming undone. DC 18 Arcana/Religion reveals the seal is a suppression of many. Optional DC 14 Wis saves vs disorientation. Treasure: the campaign’s single most significant magic item. → 6.
6. The Waking — the end of the dungeon. Don’t decide its contents until Act 3 is complete.`,
`DM Tables — Skill DCs, Treasure, Downtime
DCs: Very Easy 5 · Easy 10 · Medium 15 · Hard 20 · Very Hard 25 · Nearly Impossible 30.
Recurring: spot a Hollowed (Insight 15) · Hollow Below wrongness (Perception 14–17) · Hollow King iconography (Religion/Arcana 16) · read a political room (Insight 13) · talk down a dragon/giant (Persuasion 15–18) · track (Survival 13) · simple lock/trap (12) · recall lore (History 14) · haggle (Persuasion 12) · resist horror fear (Wis save 14–15).
Treasure Lv 1–4 (d10): 1 2d10×5 sp · 2 1d6×10 gp · 3 trade good 10–30 gp · 4 masterwork weapon/tool 50 gp · 5 2d6×10 gp · 6 NPC keepsake · 7 potion of healing · 8 1d4×25 gp gems · 9 1st-level scroll · 10 minor magic item.
Treasure Lv 5–8 (d10): 1 2d6×25 gp · 2 art object 50–150 gp · 3 masterwork armor · 4 1d6×50 gp gems · 5 greater healing · 6 2nd/3rd-level scroll · 7 uncommon magic item · 8 3d6×25 gp · 9 faction reward · 10 rare magic item.
Treasure Lv 9+ (d8): 1 3d6×50 gp · 2 rare item · 3 very rare item · 4 relic 500+ gp · 5 4th/5th-level scroll · 6 faction favor · 7 supreme healing · 8 DM’s choice.
Downtime (~1 week each): Carousing (1d6×10 gp, DC 12 Persuasion for a rumor/contact) · Crafting (half price in materials) · Training (~25 gp/week) · Faction work (standing, not gold) · Research (Sunken Library or faction records, DC 15 Investigation/History for one clue) · Recuperation (roleplay closure).`,
`DM Tables — Rumors
Act 1 (d8): 1 “The bell stopped once before, generations back.” (Misleading — never happened) · 2 “Old man Farrow’s cows went dry past the shrine road.” (True) · 3 “Princess Isolde’s been asking sharp questions of the temple scholars.” (True) · 4 “Ser Kavan turned away a recruit for asking about the crown.” (False) · 5 “There’s gold in the old crypts under the city.” (Misleading — modest and dangerous) · 6 “A noble house has been paying off debts all over the city.” (True — the corrupted house) · 7 “The Wardens haven’t been seen at the eastern shrine in weeks.” (True) · 8 “Something’s wrong with the water past Tidewatch.” (True — What the Tide Brought In)
Act 2 (d8): 1 “Salt Coast smugglers work for someone bigger now.” (True) · 2 “A Warden went missing near the shrines.” (True/resolved) · 3 “Something down there talked back.” (True) · 4 “That noble house is buying property near the docks.” (Misleading — the reason is worse than greed) · 5 “The King’s been ill.” (False — cornered, in denial) · 6 “The Spine dragons are stirring — war’s coming.” (False) · 7 “Refugees are pouring into the Marches.” (True) · 8 “A missing person was seen acting strange, then vanished.” (True — Hollowed)
Act 3–4 (d6): 1 “The crown’s lost control of [district].” (True) · 2 “Isolde will move against her father.” (Misleading) · 3 “The old families always knew what’s under the city.” (True) · 4 “A faction says the party caused all this.” (Depends on reputation) · 5 “The sky over Highcrown is different at night.” (True) · 6 “It’s almost over, one way or another.” (True)`,
`DM Tables — Random Encounters by Region (d6)
Highcrown: 1 Cutthroat pickpocket (DC 13 Perception) · 2 Grey Dawn patrol · 3 Concord merchant with a small job · 4 street preacher of omens (free rumor) · 5 quiet day · 6 a noble retinue — DC 12 Insight notices something off if it’s the corrupted house.
The Greywood: 1 1d3 Greywood Direwolves · 2 Warden patrol · 3 unmarked old shrine · 4 harmless fey mischief · 5 traveler in trouble · 6 quiet — chance for a long rest.
The Spine: 1 Spine Wyvern (avoidable) · 2 Stonejaw trading party · 3 signs of dragons · 4 treacherous pass (DC 14 Athletics or detour) · 5 a curious lone giant · 6 empty country.
The Salt Coast: 1 smugglers wary of witnesses · 2 short-handed fishing crew · 3 storm (DC 12 Survival for shelter) · 4 rumor of something in the water · 5 petty lord’s toll collector · 6 calm seas.
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 = { code: 'ALDENF', campaign, locations, monsters, weapons, items, spells, npcs, encounters, sharedNotes, dmNotes };
File diff suppressed because one or more lines are too long
+10 -1
View File
@@ -1,16 +1,24 @@
const express = require('express');
const cors = require('cors');
const compression = require('compression');
const authRoutes = require('./routes/auth.routes');
const campaignsRoutes = require('./routes/campaigns.routes');
const charactersRoutes = require('./routes/characters.routes');
const encountersRoutes = require('./routes/encounters.routes');
const notesRoutes = require('./routes/notes.routes');
const referenceRoutes = require('./routes/reference.routes');
function createApp() {
const app = express();
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 }));
@@ -20,6 +28,7 @@ function createApp() {
app.use('/characters', charactersRoutes);
app.use('/encounters', encountersRoutes);
app.use('/notes', notesRoutes);
app.use('/reference', referenceRoutes);
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
+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;
+152 -22
View File
@@ -4,19 +4,26 @@
const crypto = require('crypto');
const { SPECIES } = require('./data/species');
const { CLASSES } = require('./data/classes');
const { SPECIES_TRAITS, CLASS_FEATURES, TALENTS, TALENT_PICKS } = require('./data/characterOptions');
const ALL_SPECIES = Object.keys(SPECIES);
const ALL_CLASSES = Object.keys(CLASSES);
// A d8 picks a family of species; the player chooses one from it.
const SPECIES_ROLL_DIE = 8;
const SPECIES_ROLL_TABLE = {
1: ['HUMAN', 'HALF_ELF'],
2: ['ELF', 'GNOME'],
3: ['DWARF', 'HALFLING'],
4: ['DRAGONBORN', 'TIEFLING'],
5: ['HALF_ORC', 'ORC'],
6: 'WILDCARD',
1: ['HUMAN', 'HALF_ELF', 'ELF', 'HALF_ORC'],
2: ['DWARF', 'GNOME', 'HALFLING', 'DEEP_GNOME', 'DUERGAR'],
3: ['DRAGONBORN', 'TIEFLING', 'AASIMAR', 'GOLIATH'],
4: ['ORC', 'GOBLIN', 'HOBGOBLIN', 'BUGBEAR', 'KOBOLD'],
5: ['ELADRIN', 'SEA_ELF', 'SHADAR_KAI', 'GITHYANKI', 'GITHZERAI', 'CHANGELING'],
6: ['AIR_GENASI', 'EARTH_GENASI', 'FIRE_GENASI', 'WATER_GENASI', 'TRITON', 'TORTLE'],
7: ['TABAXI', 'KENKU', 'AARAKOCRA', 'HARENGON', 'LIZARDFOLK', 'OWLIN', 'MINOTAUR', 'CENTAUR', 'SATYR', 'SHIFTER', 'FIRBOLG', 'FAIRY', 'YUAN_TI'],
8: 'WILDCARD',
};
// 7 offers the Artificer, or one reroll; whatever the reroll gives is final.
const CLASS_ROLL_DIE = 8;
const CLASS_ROLL_TABLE = {
1: ['FIGHTER', 'BARBARIAN'],
2: ['ROGUE', 'RANGER'],
@@ -24,53 +31,176 @@ const CLASS_ROLL_TABLE = {
4: ['CLERIC', 'PALADIN'],
5: ['BARD', 'WARLOCK'],
6: ['DRUID', 'MONK'],
7: 'REROLL',
7: 'ARTIFICER_OR_REROLL',
8: 'WILDCARD',
};
const MOTIVATION_TABLE = {
1: 'Revenge for a past wrong',
2: 'Seeking fortune or wealth',
3: 'Protecting someone they love',
4: 'Redemption for a past mistake',
5: 'Driven by curiosity or knowledge',
6: 'Duty or oath to a cause or order',
};
function rollDie(sides) {
return crypto.randomInt(1, sides + 1);
}
// Standard method: 4d6, drop the lowest, sum the rest.
function rollAbilityScore() {
const dice = Array.from({ length: 4 }, () => rollDie(6));
// Standard method: 4d6, drop the lowest, sum the rest. Shared by both the
// digital path (dice rolled here) and the physical path (dice typed in by
// the roller, validated by validateManualRoll and passed in already-rolled).
function abilityScoreFromDice(dice) {
const sorted = [...dice].sort((a, b) => a - b);
const dropped = sorted[0];
const total = sorted.slice(1).reduce((a, b) => a + b, 0);
return { dice, dropped, total };
}
function rollAbilityScore() {
const dice = Array.from({ length: 4 }, () => rollDie(6));
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).
function randomSample(options, count) {
const pool = [...options];
const picked = [];
for (let i = 0; i < count && pool.length > 0; i++) {
const idx = crypto.randomInt(0, pool.length);
picked.push(pool.splice(idx, 1)[0]);
}
return picked;
}
// Auto-generates skill proficiencies for a class per its SRD "choose N from
// this list" rule (classes.js `skillChoices`) — replaces the manual
// checkbox pick the builder used to require.
function autoSelectSkills(characterClass) {
const data = characterClass ? CLASSES[characterClass] : null;
const choices = data ? data.skillChoices : null;
if (!choices) return [];
const picked = randomSample(choices.options, choices.count);
return picked.map((skill) => ({ skill, proficient: true, expertise: false }));
}
// Validates a physically-rolled die value a player types in against the
// step's real die range — physical-dice-mode's equivalent of trusting
// crypto.randomInt's output range for the digital path.
function validateManualRoll(sides, value) {
return Number.isInteger(value) && value >= 1 && value <= sides;
}
// ---------------------------------------------------------------------------
// Story steps: background, appearance and special abilities are composed from
// picks out of fixed lists, never typed in.
// ---------------------------------------------------------------------------
// `picks` maps each group's key to the option keys chosen in it. Returns a
// message describing the first problem, or null if the picks are valid.
function validatePicks(groups, picks) {
for (const group of groups) {
const chosen = picks[group.key] || [];
if (!Array.isArray(chosen) || new Set(chosen).size !== chosen.length) return `${group.label}: pick each option once.`;
if (chosen.some((key) => !group.options.some((o) => o.key === key))) return `${group.label}: that is not one of the options.`;
if (chosen.length < group.min || chosen.length > group.max) {
const range = group.min === group.max ? `${group.min}` : `${group.min} to ${group.max}`;
return `${group.label}: pick ${range}.`;
}
}
return null;
}
// One line per group, in the groups' order: "Upbringing: Soldier — Served in
// an army…" or, for several picks, "Personality: Brave, Curious".
function composeFromPicks(groups, picks) {
const lines = [];
for (const group of groups) {
const chosen = group.options.filter((o) => (picks[group.key] || []).includes(o.key));
if (chosen.length === 0) continue;
const detail = chosen.length === 1 && chosen[0].text ? ` — ${chosen[0].text}` : '';
lines.push(`${group.label}: ${chosen.map((o) => o.label).join(', ')}${detail}`);
}
return lines.join('\n');
}
function validateTalents(talentKeys) {
const valid = new Set(talentKeys).size === talentKeys.length && talentKeys.every((key) => TALENTS.some((t) => t.key === key));
return valid && talentKeys.length === TALENT_PICKS ? null : `Pick ${TALENT_PICKS} different talents from the list.`;
}
// Everything a new character can do beyond the numbers: what its species and
// class grant automatically, then the talents the player picked.
function composeSpecialAbilities(species, characterClass, talentKeys = []) {
const abilities = [
...(SPECIES_TRAITS[species] || []),
...(CLASS_FEATURES[characterClass] || []),
...TALENTS.filter((t) => talentKeys.includes(t.key)),
];
// A trait granted twice over (two sources of Darkvision, say) is listed once.
const seen = new Set();
return abilities
.filter((a) => !seen.has(a.name) && seen.add(a.name))
.map((a) => `${a.name} — ${a.text}`)
.join('\n');
}
function speciesChoicesForRoll(roll) {
const entry = SPECIES_ROLL_TABLE[roll];
if (entry === 'WILDCARD') return { type: 'wildcard', options: ALL_SPECIES };
return { type: 'choice', options: entry };
}
function classChoicesForRoll(roll) {
// `rerolled`: this roll is itself the reroll a 7 allowed, so it can't be
// rerolled again.
function classChoicesForRoll(roll, { rerolled = false } = {}) {
const entry = CLASS_ROLL_TABLE[roll];
if (entry === 'WILDCARD') return { type: 'wildcard', options: ALL_CLASSES };
if (entry === 'REROLL') return { type: 'reroll', options: [] };
if (entry === 'ARTIFICER_OR_REROLL') return { type: 'choice', options: ['ARTIFICER'], canReroll: !rerolled };
return { type: 'choice', options: entry };
}
module.exports = {
SPECIES_ROLL_TABLE,
SPECIES_ROLL_DIE,
CLASS_ROLL_TABLE,
MOTIVATION_TABLE,
CLASS_ROLL_DIE,
ALL_SPECIES,
ALL_CLASSES,
rollDie,
rollAbilityScore,
abilityScoreFromDice,
STANDARD_ARRAY,
POINT_BUY_BUDGET,
POINT_BUY_COSTS,
pointBuyCost,
effectiveDiceMode,
speciesChoicesForRoll,
classChoicesForRoll,
randomSample,
autoSelectSkills,
validateManualRoll,
validatePicks,
composeFromPicks,
validateTalents,
composeSpecialAbilities,
};
+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');
});
});
+373 -35
View File
@@ -5,6 +5,18 @@
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,
THIRD_CASTER_SLOTS,
PACT_SLOTS,
CASTER_KIND,
CLASS_SLOT_TABLES,
SUBCLASS_CASTER_KIND,
MAX_SLOT_LEVEL,
} = require('./data/spellSlots');
const ABILITIES = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
@@ -16,20 +28,18 @@ function proficiencyBonus(level) {
return 2 + Math.floor((level - 1) / 4);
}
// Half-Elf's flexible +1/+1 lands on whichever two abilities the player picked
// (halfElfBonusAbilities), on top of the fixed +2 CHA in the species table.
function speciesAbilityBonus(ability, species, halfElfBonusAbilities = []) {
// A species' fixed bonus, plus whatever the player chose for its flexible
// bonuses ({ ability: amount }: a Half-Elf's +1/+1, a Goblin's +2/+1...).
// Choices count only for a species that has some to make.
function speciesAbilityBonus(ability, species, choices = {}) {
const speciesData = species ? SPECIES[species] : null;
if (!speciesData) return 0;
let bonus = speciesData.abilityBonuses[ability] || 0;
if (species === 'HALF_ELF' && halfElfBonusAbilities.includes(ability)) {
bonus += 1;
}
return bonus;
const chosen = speciesData.flexible ? Number((choices || {})[ability]) || 0 : 0;
return (speciesData.abilityBonuses[ability] || 0) + chosen;
}
function finalAbilityScore(rolledScore, ability, species, halfElfBonusAbilities = []) {
return rolledScore + speciesAbilityBonus(ability, species, halfElfBonusAbilities);
function finalAbilityScore(rolledScore, ability, species, choices = {}) {
return rolledScore + speciesAbilityBonus(ability, species, choices);
}
function speciesSpeed(species) {
@@ -54,6 +64,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;
}
@@ -116,9 +131,171 @@ function getClassHitDieSize(characterClass) {
return data ? data.hitDie : 8;
}
function getClassSpellcastingAbility(characterClass) {
// The ability a class casts with; a subclass that casts in a class that does
// not (Eldritch Knight, Arcane Trickster) uses Intelligence.
function getClassSpellcastingAbility(characterClass, subclass = null) {
const data = characterClass ? CLASSES[characterClass] : null;
return data ? data.spellcastingAbility : null;
if (data && data.spellcastingAbility) return data.spellcastingAbility;
return SUBCLASS_CASTER_KIND[subclass] ? 'INT' : null;
}
// How a class casts (FULL, HALF, THIRD or PACT), counting a casting subclass;
// null for none.
function casterKind(characterClass, subclass = null) {
return CASTER_KIND[characterClass] || SUBCLASS_CASTER_KIND[subclass] || null;
}
// ---------------------------------------------------------------------------
// 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, subclass = null) {
const kind = casterKind(characterClass, subclass);
if (!kind) return null;
const row = clampLevel(level) - 1;
if (kind === 'PACT') {
const [count, slotLevel] = PACT_SLOTS[row];
return { kind, restoresOn: 'SHORT', max: Array.from({ length: slotLevel }, (_, i) => (i === slotLevel - 1 ? count : 0)) };
}
const table = CLASS_SLOT_TABLES[characterClass] || { FULL: FULL_CASTER_SLOTS, HALF: HALF_CASTER_SLOTS, THIRD: THIRD_CASTER_SLOTS }[kind];
return { kind, restoresOn: 'LONG', max: [...table[row]] };
}
// The slot tracker: for each slot level the character has, how many slots
// 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 = [], subclass = null) {
return slotStateOf(spellSlotsFor(characterClass, level, subclass), used);
}
// The slot tracker for a set of slots (spellSlotsFor's shape).
function slotStateOf(slots, used = []) {
if (!slots) return null;
const levels = slots.max
.map((max, i) => {
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, subclass = null) {
const slots = spellSlotsFor(characterClass, level, subclass);
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 };
}
/**
@@ -127,6 +304,8 @@ function getClassSpellcastingAbility(characterClass) {
* 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
*/
@@ -141,18 +320,24 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
intScore = 10,
wisScore = 10,
chaScore = 10,
halfElfBonusAbility1 = null,
halfElfBonusAbility2 = null,
speciesBonusChoices = {},
armorBonus = 0,
speedOverride = null,
spellSlotsUsed = [],
hitDiceUsed = 0,
hpRollAdjust = 0,
} = character;
const halfElfBonusAbilities = [halfElfBonusAbility1, halfElfBonusAbility2].filter(Boolean);
const rolled = { STR: strScore, DEX: dexScore, CON: conScore, INT: intScore, WIS: wisScore, CHA: chaScore };
const gear = activeGear(character);
// 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, speciesBonusChoices) + fromGear(ability));
abilities[ability] = {
rolled: rolled[ability],
final,
@@ -166,7 +351,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,45 +364,174 @@ 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}`),
};
}
// Every class the character has levels in, the first first. One entry for
// most characters; a multiclassed one adds up per class (multiclass.js).
const classes = multiclass.classLevels(character);
const multi = classes.length > 1;
const hitDieSize = getClassHitDieSize(characterClass);
const spellcastingAbility = getClassSpellcastingAbility(characterClass);
const casters = classes
.map((entry) => ({ ...entry, ability: getClassSpellcastingAbility(entry.characterClass, entry.subclass) }))
.filter((entry) => entry.ability && (casterKind(entry.characterClass, entry.subclass) !== 'THIRD' || entry.level >= 3));
const casting = (ability) => ({
ability,
saveDC: spellSaveDC(profBonus, abilities[ability].modifier),
attackBonus: spellAttackBonus(profBonus, abilities[ability].modifier),
});
const spellcastingAbility = casters.length > 0 ? casters[0].ability : null;
return {
abilities,
proficiencyBonus: profBonus,
armorClass: armorClass(abilities.DEX.modifier, armorBonus),
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: {
// hpRollAdjust: how far HP rolled on level-ups is from the average counted here.
max: Math.max(
1,
(multi ? multiclass.multiclassHpMax(classes, abilities.CON.modifier) : hpMax(level, hitDieSize, abilities.CON.modifier)) + (Number(hpRollAdjust) || 0) + fromGear('MAX_HP')
),
},
hitDice: multi ? multiclass.multiclassHitDice(classes, hitDiceUsed) : hitDiceState(characterClass, level, hitDiceUsed),
saves,
skills,
passivePerception: passivePerception(skills.PERCEPTION.bonus),
spellcasting: spellcastingAbility
? {
ability: spellcastingAbility,
saveDC: spellSaveDC(profBonus, abilities[spellcastingAbility].modifier),
attackBonus: spellAttackBonus(profBonus, abilities[spellcastingAbility].modifier),
}
: null,
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 ? casting(spellcastingAbility) : null,
// A multiclassed caster's DC and attack for each class that casts.
spellcastingByClass: casters.map((entry) => ({ characterClass: entry.characterClass, ...casting(entry.ability) })),
// The slot tracker; null for a character with no spell slots.
spellSlots: multi ? slotStateOf(multiclass.multiclassSpellSlots(classes), spellSlotsUsed) : spellSlotState(characterClass, level, spellSlotsUsed, character.subclass),
// A multiclassed warlock's pact slots, apart from the others; null otherwise.
pactSlots: multi ? multiclass.pactSlotsOf(classes) : null,
// The class counters (Rage, Ki...), with how many of each are left.
resources: multi ? multiclass.multiclassResources(classes, character.resourcesUsed, abilities, profBonus) : resourceState(character, abilities, profBonus),
};
}
// ---------------------------------------------------------------------------
// Gear levels (house rule, not SRD). A held weapon or item has its own level,
// 1-9 (the same span as spell levels), and its dice climb a fixed ladder as it
// levels:
//
// 1d4 1d6 1d8 1d10 1d12 2d6 1d6+1d8 2d8 1d8+1d10 2d10 ...
//
// Each rung is the smallest possible step up in average damage: +1, or +0.5
// where the ladder has to add a die (1d12 -> 2d6, 2d12 -> 3d8, ...).
// ---------------------------------------------------------------------------
const MAX_GEAR_LEVEL = 9;
const DIE_SIZES = [4, 6, 8, 10, 12];
// Rungs a piece of gear climbs per level: 1 = standard, 2 = powerful, 3 = legendary.
const MAX_DAMAGE_SCALING = 3;
function formatDice(sizes) {
const counts = new Map();
for (const size of sizes) counts.set(size, (counts.get(size) || 0) + 1);
return [...counts].map(([size, count]) => `${count}d${size}`).join('+');
}
// k dice whose sizes are as even as possible, `steps` die-size steps above k d4s.
function evenDice(k, steps) {
const sizes = [];
for (let i = 0; i < k; i++) {
const share = Math.floor(steps / k) + (i >= k - (steps % k) ? 1 : 0);
sizes.push(DIE_SIZES[share]);
}
return sizes;
}
function buildDamageLadder(rungs) {
const ladder = [];
let k = 1;
let steps = 0;
while (ladder.length < rungs) {
const maxSteps = k * (DIE_SIZES.length - 1);
if (steps > maxSteps) {
// Out of room at this many dice: add one, landing just above the last rung.
const previousAverage = ladder[ladder.length - 1].average;
k += 1;
steps = Math.floor(previousAverage - k * 2.5) + 1;
continue;
}
ladder.push({ dice: formatDice(evenDice(k, steps)), average: k * 2.5 + steps });
steps += 1;
}
return ladder;
}
// Far longer than max level at legendary scaling needs, so heavy base dice
// (a "10d6" item, say) still have room to climb.
const DAMAGE_LADDER = buildDamageLadder(120);
// Average roll of a dice expression like "1d8", "2d6", "1d6+1d8" or "2d4+2".
// Returns null for anything else (free text is allowed in the pool).
function diceAverage(expression) {
if (typeof expression !== 'string' || !expression.trim()) return null;
let total = 0;
for (const term of expression.replace(/\s+/g, '').toLowerCase().replace(/-/g, '+-').split('+')) {
if (!term) continue;
const die = /^(-?)(\d*)d(\d+)$/.exec(term);
if (die) {
total += (die[1] ? -1 : 1) * (Number(die[2]) || 1) * ((Number(die[3]) + 1) / 2);
} else if (/^-?\d+$/.test(term)) {
total += Number(term);
} else {
return null;
}
}
return total;
}
function clampGearLevel(level) {
return Math.min(Math.max(Math.round(Number(level) || 1), 1), MAX_GEAR_LEVEL);
}
// Dice for a piece of gear at a given level. `minLevel` is the level the gear
// starts at (most gear: 1; a greatsword might only exist from level 3). At its
// starting level it is always the base dice exactly as the DM wrote them;
// above that it climbs `scaling` rungs per level, starting from the highest
// rung that doesn't beat the base (so "1d4+1" starts from 1d6, and a flat "1"
// starts below the ladder). Dice the ladder can't read (free text) never scale.
function scaledDamageDice(baseDice, level = 1, scaling = 1, minLevel = 1) {
const levelsGained = clampGearLevel(level) - clampGearLevel(minLevel);
if (levelsGained <= 0) return baseDice;
const baseAverage = diceAverage(baseDice);
if (baseAverage === null) return baseDice;
let baseRung = -1;
while (baseRung + 1 < DAMAGE_LADDER.length && DAMAGE_LADDER[baseRung + 1].average <= baseAverage) baseRung += 1;
const rungsPerLevel = Math.min(Math.max(Math.round(Number(scaling) || 1), 1), MAX_DAMAGE_SCALING);
const rung = Math.min(baseRung + levelsGained * rungsPerLevel, DAMAGE_LADDER.length - 1);
return DAMAGE_LADDER[rung].dice;
}
// A character can use gear and spells of their own level or lower. For
// weapons and items the required level is the held copy's level; for spells
// it is the classic spell level (cantrips, level 0, are open to everyone).
function canUseGear(characterLevel, requiredLevel) {
return characterLevel >= requiredLevel;
}
/**
* Attack/damage bonus for a specific weapon against a character's abilities —
* used by the quick-stats panel and weapon list.
* used by the quick-stats panel and weapon list. `gearLevel` is the level of
* the copy the character holds.
*/
function computeWeaponStats(weapon, abilities, profBonus, proficient = true) {
function computeWeaponStats(weapon, abilities, profBonus, proficient = true, gearLevel = 1) {
const strMod = abilities.STR.modifier;
const dexMod = abilities.DEX.modifier;
return {
attackBonus: weaponAttackBonus({ attackAbility: weapon.attackAbility, strMod, dexMod, proficient, profBonus }),
damageBonus: weaponDamageBonus({ attackAbility: weapon.attackAbility, strMod, dexMod }),
damageDice: weapon.damageDice,
damageDice: scaledDamageDice(weapon.damageDice, gearLevel, weapon.damageScaling, weapon.minLevel),
damageType: weapon.damageType,
};
}
@@ -233,6 +547,7 @@ module.exports = {
saveBonus,
skillBonus,
passivePerception,
passiveScore,
armorClass,
initiative,
hpMax,
@@ -245,6 +560,29 @@ module.exports = {
getClassSaveProficiencies,
getClassHitDieSize,
getClassSpellcastingAbility,
MAX_LEVEL,
MAX_SLOT_LEVEL,
spellSlotsFor,
slotStateOf,
casterKind,
spellSlotState,
maxSpellLevel,
hitDiceState,
challengeXp,
encounterDifficulty,
xpProgress,
GEAR_STATS,
activeGear,
computeCharacterSheet,
computeWeaponStats,
MAX_GEAR_LEVEL,
MAX_DAMAGE_SCALING,
DAMAGE_LADDER,
diceAverage,
scaledDamageDice,
canUseGear,
};
// multiclass.js and this file use each other, so it is required here, at
// the end, and its functions are looked up when called.
const multiclass = require('./multiclass');
+125 -6
View File
@@ -14,6 +14,10 @@ const {
weaponDamageBonus,
computeCharacterSheet,
computeWeaponStats,
DAMAGE_LADDER,
diceAverage,
scaledDamageDice,
canUseGear,
} = require('./calculations');
describe('abilityModifier', () => {
@@ -54,10 +58,20 @@ describe('finalAbilityScore / species bonuses', () => {
});
test('half-elf gets fixed +2 CHA plus chosen +1/+1', () => {
expect(finalAbilityScore(10, 'CHA', 'HALF_ELF', ['STR', 'INT'])).toBe(12);
expect(finalAbilityScore(10, 'STR', 'HALF_ELF', ['STR', 'INT'])).toBe(11);
expect(finalAbilityScore(10, 'INT', 'HALF_ELF', ['STR', 'INT'])).toBe(11);
expect(finalAbilityScore(10, 'WIS', 'HALF_ELF', ['STR', 'INT'])).toBe(10);
expect(finalAbilityScore(10, 'CHA', 'HALF_ELF', { STR: 1, INT: 1 })).toBe(12);
expect(finalAbilityScore(10, 'STR', 'HALF_ELF', { STR: 1, INT: 1 })).toBe(11);
expect(finalAbilityScore(10, 'INT', 'HALF_ELF', { STR: 1, INT: 1 })).toBe(11);
expect(finalAbilityScore(10, 'WIS', 'HALF_ELF', { STR: 1, INT: 1 })).toBe(10);
});
test('a Multiverse species takes the bonuses its player chose', () => {
expect(finalAbilityScore(10, 'DEX', 'GOBLIN', { DEX: 2, CON: 1 })).toBe(12);
expect(finalAbilityScore(10, 'CON', 'GOBLIN', { DEX: 2, CON: 1 })).toBe(11);
expect(finalAbilityScore(10, 'STR', 'GOBLIN', { DEX: 2, CON: 1 })).toBe(10);
});
test('chosen bonuses count only for a species that has some to choose', () => {
expect(finalAbilityScore(10, 'STR', 'DWARF', { STR: 2 })).toBe(10);
});
test('no species leaves score untouched', () => {
@@ -195,8 +209,7 @@ describe('computeCharacterSheet — full assembly', () => {
characterClass: 'BARD',
strScore: 10,
chaScore: 10,
halfElfBonusAbility1: 'STR',
halfElfBonusAbility2: 'INT',
speciesBonusChoices: { STR: 1, INT: 1 },
});
expect(sheet.abilities.CHA.final).toBe(12); // fixed +2
expect(sheet.abilities.STR.final).toBe(11); // chosen +1
@@ -211,4 +224,110 @@ describe('computeWeaponStats', () => {
expect(stats.damageBonus).toBe(sheet.abilities.STR.modifier);
expect(stats.damageDice).toBe('1d8');
});
test('uses the dice for the level of the copy the character holds', () => {
const sheet = computeCharacterSheet({ level: 5, strScore: 16 });
const spear = { attackAbility: 'STR', damageDice: '1d6', damageType: 'piercing', damageScaling: 1 };
expect(computeWeaponStats(spear, sheet.abilities, sheet.proficiencyBonus, true, 3).damageDice).toBe('1d10');
// The level changes the dice only, never the bonuses.
expect(computeWeaponStats(spear, sheet.abilities, sheet.proficiencyBonus, true, 3).damageBonus).toBe(sheet.abilities.STR.modifier);
});
});
describe('gear levels — the damage ladder', () => {
test('starts with single dice, then pairs, in the smallest possible steps', () => {
expect(DAMAGE_LADDER.slice(0, 13).map((r) => r.dice)).toEqual([
'1d4', '1d6', '1d8', '1d10', '1d12',
'2d6', '1d6+1d8', '2d8', '1d8+1d10', '2d10', '1d10+1d12', '2d12',
'3d8',
]);
});
test('every rung is stronger than the last by 1 average damage, or 0.5 where a die is added', () => {
for (let i = 1; i < DAMAGE_LADDER.length; i++) {
const step = DAMAGE_LADDER[i].average - DAMAGE_LADDER[i - 1].average;
expect([0.5, 1]).toContain(step);
// The stated average really is the average of the dice shown.
expect(diceAverage(DAMAGE_LADDER[i].dice)).toBe(DAMAGE_LADDER[i].average);
}
});
test('diceAverage reads the expressions DMs type, and rejects free text', () => {
expect(diceAverage('1d8')).toBe(4.5);
expect(diceAverage('d8')).toBe(4.5);
expect(diceAverage('2d6')).toBe(7);
expect(diceAverage('1d6 + 1d8')).toBe(8);
expect(diceAverage('2d4+2')).toBe(7);
expect(diceAverage('1d6-1')).toBe(2.5);
expect(diceAverage('3')).toBe(3);
expect(diceAverage('special')).toBeNull();
expect(diceAverage('')).toBeNull();
expect(diceAverage(null)).toBeNull();
});
});
describe('scaledDamageDice', () => {
const byLevel = (base, scaling, levels) => levels.map((level) => scaledDamageDice(base, level, scaling));
test('standard gear climbs one rung per level', () => {
expect(byLevel('1d4', 1, [1, 2, 3, 4, 5, 6])).toEqual(['1d4', '1d6', '1d8', '1d10', '1d12', '2d6']);
expect(byLevel('1d8', 1, [1, 2, 3])).toEqual(['1d8', '1d10', '1d12']);
});
test('powerful and legendary gear climb two and three rungs per level', () => {
expect(byLevel('1d6', 2, [1, 2, 3, 4])).toEqual(['1d6', '1d10', '2d6', '2d8']);
expect(byLevel('1d8', 3, [1, 2, 3])).toEqual(['1d8', '2d6', '1d8+1d10']);
});
test('level 1 is always the base dice exactly as written', () => {
expect(scaledDamageDice('2d4+2', 1, 1)).toBe('2d4+2');
expect(scaledDamageDice('special', 1, 3)).toBe('special');
});
test('dice that are not on the ladder start from the highest rung that does not beat them', () => {
expect(scaledDamageDice('2d4+2', 2, 1)).toBe('1d6+1d8'); // avg 7 sits on 2d6
expect(scaledDamageDice('1d4+1', 2, 1)).toBe('1d8'); // avg 3.5 sits on 1d6
expect(scaledDamageDice('1', 2, 1)).toBe('1d4'); // below the ladder
});
test('free text never scales, and levels and scaling are clamped to their ranges', () => {
expect(scaledDamageDice('special', 9, 3)).toBe('special');
expect(scaledDamageDice('1d4', 99, 1)).toBe(scaledDamageDice('1d4', 9, 1));
expect(scaledDamageDice('1d4', 9, 1)).toBe('1d8+1d10');
expect(scaledDamageDice('1d4', 0, 1)).toBe('1d4');
expect(scaledDamageDice('1d4', 2, 99)).toBe(scaledDamageDice('1d4', 2, 3));
expect(scaledDamageDice('1d4', 2, undefined)).toBe('1d6');
});
test('gear with a starting level shows its base dice there and climbs from it', () => {
// A greatsword that only exists from level 3: 2d6 at 3, then one rung per level.
expect([3, 4, 5].map((level) => scaledDamageDice('2d6', level, 1, 3))).toEqual(['2d6', '1d6+1d8', '2d8']);
expect(scaledDamageDice('2d6', 1, 1, 3)).toBe('2d6'); // never below its base
expect(scaledDamageDice('2d4+2', 3, 2, 3)).toBe('2d4+2'); // as written at its starting level
const sheet = computeCharacterSheet({ level: 5, strScore: 16 });
const greatsword = { attackAbility: 'STR', damageDice: '2d6', damageType: 'slashing', damageScaling: 1, minLevel: 3 };
expect(computeWeaponStats(greatsword, sheet.abilities, sheet.proficiencyBonus, true, 4).damageDice).toBe('1d6+1d8');
});
test('a higher level is never weaker', () => {
for (const base of ['1d4', '1d12', '2d6', '2d4+2']) {
for (const scaling of [1, 2, 3]) {
for (let level = 2; level <= 9; level++) {
expect(diceAverage(scaledDamageDice(base, level, scaling))).toBeGreaterThan(
diceAverage(scaledDamageDice(base, level - 1, scaling))
);
}
}
}
});
});
describe('canUseGear', () => {
test('a character can use gear of their own level or lower', () => {
expect(canUseGear(1, 1)).toBe(true);
expect(canUseGear(3, 1)).toBe(true);
expect(canUseGear(3, 3)).toBe(true);
expect(canUseGear(2, 3)).toBe(false);
expect(canUseGear(1, 0)).toBe(true); // cantrips
});
});
+115
View File
@@ -0,0 +1,115 @@
// 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 (and up to `until`, when it stops being
// counted). `max` gets { level, mods, profBonus }, where
// `mods` is the ability modifiers by ability. `rest` is the rest that gives
// them back: a SHORT one comes back on a long rest too.
const RESOURCES = [
// At level 20 a barbarian rages as often as they like: no counter to keep.
{ key: 'RAGE', name: 'Rage', classes: ['BARBARIAN'], max: ({ level }) => (level >= 17 ? 6 : level >= 12 ? 5 : level >= 6 ? 4 : level >= 3 ? 3 : 2), rest: 'LONG', until: 19 },
{ key: 'ZEALOUS_PRESENCE', name: 'Zealous Presence', classes: ['BARBARIAN'], subclass: 'ZEALOT', from: 10, max: () => 1, rest: 'LONG' },
// Font of Inspiration: from level 5 it comes back on a short rest.
{ key: 'BARDIC_INSPIRATION', name: 'Bardic Inspiration', classes: ['BARD'], max: ({ mods }) => atLeastOne(mods.CHA), rest: ({ level }) => (level >= 5 ? 'SHORT' : 'LONG') },
{ key: 'HEALING_HANDS', name: 'Healing Hands', classes: ['CLERIC'], max: () => 1, rest: 'SHORT' },
{ key: 'CHANNEL_DIVINITY', name: 'Channel Divinity', classes: ['CLERIC'], from: 2, max: ({ level }) => (level >= 18 ? 3 : level >= 6 ? 2 : 1), rest: 'SHORT' },
{ key: 'CHANNEL_DIVINITY', name: 'Channel Divinity', classes: ['PALADIN'], from: 3, max: () => 1, rest: 'SHORT' },
{ key: 'WARDING_FLARE', name: 'Warding Flare', classes: ['CLERIC'], subclass: 'LIGHT', from: 2, max: ({ mods }) => atLeastOne(mods.WIS), rest: 'LONG' },
{ key: 'WAR_PRIEST', name: 'War Priest', classes: ['CLERIC'], subclass: 'WAR', from: 2, max: ({ mods }) => atLeastOne(mods.WIS), rest: 'LONG' },
// An archdruid (level 20) wild shapes as often as they like.
{ key: 'WILD_SHAPE', name: 'Wild Shape', classes: ['DRUID'], from: 2, max: () => 2, rest: 'SHORT', until: 19 },
{ key: 'NATURAL_RECOVERY', name: 'Natural Recovery', classes: ['DRUID'], subclass: 'LAND', from: 2, max: () => 1, rest: 'LONG' },
{ key: 'STAR_MAP', name: 'Star Map (Guiding Bolt)', classes: ['DRUID'], subclass: 'STARS', from: 2, max: ({ profBonus }) => profBonus, rest: 'LONG' },
{ key: 'COSMIC_OMEN', name: 'Cosmic Omen', classes: ['DRUID'], subclass: 'STARS', from: 6, max: ({ profBonus }) => profBonus, rest: 'LONG' },
{ key: 'SECOND_WIND', name: 'Second Wind', classes: ['FIGHTER'], max: () => 1, rest: 'SHORT' },
{ key: 'ACTION_SURGE', name: 'Action Surge', classes: ['FIGHTER'], from: 2, max: ({ level }) => (level >= 17 ? 2 : 1), rest: 'SHORT' },
{ key: 'INDOMITABLE', name: 'Indomitable', classes: ['FIGHTER'], from: 9, max: ({ level }) => (level >= 17 ? 3 : level >= 13 ? 2 : 1), rest: 'LONG' },
{ key: 'SUPERIORITY_DICE', name: 'Superiority Dice', classes: ['FIGHTER'], subclass: 'BATTLE_MASTER', from: 3, max: ({ level }) => (level >= 15 ? 6 : level >= 7 ? 5 : 4), rest: 'SHORT' },
{ key: 'FIGHTING_SPIRIT', name: 'Fighting Spirit', classes: ['FIGHTER'], subclass: 'SAMURAI', from: 3, max: () => 3, rest: 'LONG' },
{ key: 'KI', name: 'Ki', classes: ['MONK'], from: 2, max: ({ level }) => level, rest: 'SHORT' },
{ 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: 'CLEANSING_TOUCH', name: 'Cleansing Touch', classes: ['PALADIN'], from: 14, max: ({ mods }) => atLeastOne(mods.CHA), rest: 'LONG' },
{ key: 'WILD_TALENT', name: 'Wild Talent', classes: ['SORCERER'], max: () => 1, rest: 'LONG' },
{ key: 'SORCERY_POINTS', name: 'Sorcery Points', classes: ['SORCERER'], from: 2, max: ({ level }) => level, rest: 'LONG' },
{ 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: 'SIGNATURE_SPELLS', name: 'Signature Spells', classes: ['WIZARD'], from: 20, max: () => 2, rest: 'SHORT' },
{ key: 'STROKE_OF_LUCK', name: 'Stroke of Luck', classes: ['ROGUE'], from: 20, max: () => 1, rest: 'SHORT' },
{ key: 'MYSTIC_ARCANUM', name: 'Mystic Arcanum', classes: ['WARLOCK'], from: 11, max: ({ level }) => (level >= 17 ? 4 : level >= 15 ? 3 : level >= 13 ? 2 : 1), rest: 'LONG' },
{ key: 'FLASH_OF_GENIUS', name: 'Flash of Genius', classes: ['ARTIFICER'], from: 7, max: ({ mods }) => atLeastOne(mods.INT), rest: 'LONG' },
{ key: 'ILLUSORY_SELF', name: 'Illusory Self', classes: ['WIZARD'], subclass: 'ILLUSION', from: 10, max: () => 1, rest: 'SHORT' },
];
// 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) &&
level <= (resource.until || Infinity) &&
(!resource.subclass || resource.subclass === subclass)
).map((resource) => {
const max = resource.max(context);
const used = Math.min(Math.max(Math.round(Number(tally[resource.key]) || 0), 0), max);
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'] });
});
});
+212
View File
@@ -0,0 +1,212 @@
// Combat: how an attack or a spell is resolved in a battle. An "action" is
// one attack or spell from being declared to its damage landing
// (EncounterAction): who it is aimed at, the d20 rolled against each target
// (or each target's saving throw), and the damage. Pure: no DB calls. The
// random source can be passed in for tests.
const { parseDice, leadingDice, rollExpression, rollCritical, rollDie } = require('./dice');
// What an attack roll comes to against a target. A natural 20 is a critical
// hit and a natural 1 a miss, whatever the numbers; otherwise the d20 plus the
// bonus has to reach the AC (with any area-attack penalty added).
function attackOutcome(d20, attackBonus, ac, penalty = 0) {
if (d20 === 20) return 'CRIT';
if (d20 === 1) return 'FUMBLE';
return d20 + attackBonus >= ac + penalty ? 'HIT' : 'MISS';
}
// Whether a saving throw succeeds: the d20 plus the bonus reaches the DC.
const saveSucceeds = (d20, saveBonus, dc) => d20 + saveBonus >= dc;
const lands = (outcome) => outcome === 'HIT' || outcome === 'CRIT';
// A target as the dice have it (or as the DM has overruled them), with the
// damage it takes. `outcome` and `damage` on the target are the DM's
// overrides; null leaves it to the dice.
// ATTACK: total, outcome HIT | MISS | CRIT | FUMBLE (null until a d20 is in).
// SAVE: total, outcome SAVED | FAILED (null until the save is in).
// AUTO: outcome HIT.
// `finalDamage` is null until it can be known.
function settleTarget(action, target) {
const { kind, detail } = action;
const base = action.damage ? action.damage.total : null;
const crit = action.damage && action.damage.crit ? action.damage.crit.total : null;
const noDamage = !detail.damageDice && base == null;
let total = null;
let rolled = null; // what the dice say, before any override
if (kind === 'ATTACK') {
if (target.d20 != null) {
total = target.d20 + (detail.attackBonus || 0);
rolled = attackOutcome(target.d20, detail.attackBonus || 0, target.ac ?? 10, target.penalty || 0);
}
} else if (kind === 'SAVE') {
if (target.d20 != null) {
total = target.d20 + (target.saveBonus || 0);
rolled = saveSucceeds(target.d20, target.saveBonus || 0, detail.saveDC || 10) ? 'SAVED' : 'FAILED';
}
} else {
rolled = 'HIT';
}
const outcome = target.outcome || rolled;
let finalDamage = null;
if (target.damage != null) finalDamage = target.damage;
else if (outcome === 'MISS' || outcome === 'FUMBLE') finalDamage = 0;
else if (noDamage) finalDamage = outcome ? 0 : null;
else if (outcome && base != null) {
if (outcome === 'CRIT') finalDamage = crit != null ? crit : base;
else if (outcome === 'SAVED') finalDamage = detail.halfOnSave ? Math.floor(base / 2) : 0;
else finalDamage = base;
}
return { ...target, total, rolled, outcome, overruled: Boolean(target.outcome) && target.outcome !== rolled, finalDamage };
}
// An action with every target settled, and what it is still waiting for:
// `needsRolls` (targets with no outcome yet), `needsDamage` (someone is hit
// and the damage is not known), `needsCrit` (someone is critically hit and the
// critical damage has not been rolled), and `ready` when none of those.
function settle(action) {
const targets = (action.targets || []).map((target) => settleTarget(action, target));
const needsRolls = targets.filter((t) => !t.outcome).length;
const hurts = targets.some((t) => t.damage == null && (lands(t.outcome) || t.outcome === 'FAILED' || (t.outcome === 'SAVED' && action.detail.halfOnSave)));
const hasDice = Boolean(action.detail.damageDice);
const needsDamage = hasDice && hurts && !action.damage;
const needsCrit = hasDice && Boolean(action.damage) && !action.damage.crit && !action.damage.entered && targets.some((t) => t.outcome === 'CRIT' && t.damage == null);
return { ...action, targets, needsRolls, needsDamage, needsCrit, ready: needsRolls === 0 && !needsDamage && targets.length > 0 };
}
// Rolls an action's damage: its dice plus its bonus, and, when a target is
// critically hit, the critical damage by the campaign's rule. Null for an
// action with no dice (or dice that can't be read).
function rollDamage(detail, { critical = false, critRule = 'DOUBLE_DICE', roll = rollDie } = {}) {
const dice = leadingDice(detail.damageDice || '');
const parsed = dice ? parseDice(dice) : null;
if (!parsed) return null;
const withBonus = { dice: parsed.dice, flat: parsed.flat + (detail.damageBonus || 0) };
const normal = rollExpression(withBonus, { roll });
return {
total: Math.max(0, normal.total),
dice: normal.dice,
modifier: normal.modifier,
crit: critical ? critOf(withBonus, critRule, roll) : null,
};
}
function critOf(parsed, critRule, roll) {
const crit = rollCritical(parsed, critRule, { roll });
return { total: Math.max(0, crit.total), dice: crit.dice, modifier: crit.modifier };
}
// The critical damage alone, for an action whose normal damage is already rolled.
function rollCritDamage(detail, { critRule = 'DOUBLE_DICE', roll = rollDie } = {}) {
const dice = leadingDice(detail.damageDice || '');
const parsed = dice ? parseDice(dice) : null;
if (!parsed) return null;
return critOf({ dice: parsed.dice, flat: parsed.flat + (detail.damageBonus || 0) }, critRule, roll);
}
// Which side a combatant fights on: the party (characters and friendly NPCs),
// its enemies (monsters and hostile NPCs), or neither (a neutral NPC).
// Takes { kind: 'CHARACTER' | 'MONSTER' | 'NPC', disposition? }.
function sideOf(combatant) {
if (combatant.kind === 'CHARACTER') return 'PARTY';
if (combatant.kind === 'MONSTER') return 'ENEMY';
if (combatant.disposition === 'FRIENDLY') return 'PARTY';
if (combatant.disposition === 'HOSTILE') return 'ENEMY';
return 'NEUTRAL';
}
// Who a combatant acting by itself could go for: everyone standing on the
// other side. A neutral has no enemies.
function opponentsOf(actor, combatants) {
const side = sideOf(actor);
if (side === 'NEUTRAL') return [];
return combatants.filter((c) => c.id !== actor.id && !c.fallen && sideOf(c) !== 'NEUTRAL' && sideOf(c) !== side);
}
// How likely each opponent is to be picked, relative to the others. Everyone
// starts equal; whoever the actor went for last, and whoever has hurt it,
// count for more (a creature keeps at what it is fighting), and so, a little,
// does someone easier to hit.
function targetWeights(candidates, { lastTargetId = null, hurtBy = [] } = {}) {
return candidates.map((c) => {
let weight = 2;
if (c.id === lastTargetId) weight += 3;
if (hurtBy.includes(c.id)) weight += 3;
weight += Math.max(0, Math.min(2, (16 - (c.ac ?? 12)) / 3));
return weight;
});
}
// Picks one opponent at random by those weights; null when there is nobody.
// `random` gives a number from 0 (inclusive) to 1 (exclusive).
function pickTarget(candidates, context = {}, random = Math.random) {
if (candidates.length === 0) return null;
const weights = targetWeights(candidates, context);
let at = random() * weights.reduce((sum, w) => sum + w, 0);
for (let i = 0; i < candidates.length; i++) {
at -= weights[i];
if (at < 0) return candidates[i];
}
return candidates[candidates.length - 1];
}
// The attacks a monster or NPC makes on its turn, one entry per attack made
// (a multiattack's `count` is spread out): [{ name, attackBonus, damageDice,
// damageType }]. A monster's named `attacks` when it has any, otherwise its
// single attack; an NPC's single attack. Empty for one with nothing to hit with.
function attacksOf({ monster = null, npc = null }) {
const named = monster && Array.isArray(monster.attacks) ? monster.attacks.filter((a) => a && a.name) : [];
if (named.length > 0) {
return named.flatMap((a) =>
Array.from({ length: Math.max(1, Math.min(10, Number(a.count) || 1)) }, () => ({
name: a.name,
attackBonus: Number(a.attackBonus) || 0,
damageDice: a.damageDice || null,
damageType: a.damageType || null,
}))
);
}
const stats = monster || npc;
if (!stats || (stats.attackBonus == null && !stats.damageDice)) return [];
return [{ name: 'Attack', attackBonus: stats.attackBonus || 0, damageDice: stats.damageDice || null, damageType: null }];
}
// Highest initiative first; ties keep the order combatants were added in.
// Anyone who has not rolled yet waits at the bottom.
function inTurnOrder(participants) {
const rolled = (p) => (p.initiativeRoll == null ? 0 : 1);
return [...participants].sort((a, b) => rolled(b) - rolled(a) || b.initiative - a.initiative || (a.id < b.id ? -1 : 1));
}
// Whether a combatant may change the weapon in their hand in a battle that
// is under way. There are two moments for it: as the battle begins, before
// anyone has made a move (round 1, still the first combatant's turn, nothing
// landed yet), and on their own turn once their attack has landed, before the
// turn passes. `applied` is the battle's landed actions: [{ round,
// actorParticipantId }]. `firstId` is whoever went first. `declared` is true
// while somebody's attack or spell is declared and still being resolved: that
// is a move being made.
function weaponSwapOpen({ round, activeId, firstId, participantId, applied = [], declared = false }) {
if (activeId == null) return true; // no turn has started
const opening = round === 1 && activeId === firstId && applied.length === 0 && !declared;
const afterOwnAttack = activeId === participantId && applied.some((a) => a.round === round && a.actorParticipantId === participantId);
return opening || afterOwnAttack;
}
module.exports = {
attackOutcome,
saveSucceeds,
lands,
settleTarget,
settle,
rollDamage,
rollCritDamage,
sideOf,
opponentsOf,
targetWeights,
pickTarget,
attacksOf,
inTurnOrder,
weaponSwapOpen,
};
+175
View File
@@ -0,0 +1,175 @@
const { attackOutcome, saveSucceeds, settle, rollDamage, rollCritDamage, sideOf, opponentsOf, targetWeights, pickTarget, attacksOf } = require('./combat');
const fixed = (...faces) => {
let i = 0;
return () => faces[i++ % faces.length];
};
describe('an attack roll', () => {
test('hits when the d20 plus the bonus reaches the AC', () => {
expect(attackOutcome(10, 5, 15)).toBe('HIT');
expect(attackOutcome(9, 5, 15)).toBe('MISS');
});
test('a natural 20 always hits, critically, and a natural 1 always misses', () => {
expect(attackOutcome(20, -5, 30)).toBe('CRIT');
expect(attackOutcome(1, 20, 5)).toBe('FUMBLE');
});
test('an area attack counts each AC higher', () => {
expect(attackOutcome(10, 5, 15, 2)).toBe('MISS');
expect(attackOutcome(12, 5, 15, 2)).toBe('HIT');
});
});
test('a saving throw succeeds on the DC or more', () => {
expect(saveSucceeds(12, 3, 15)).toBe(true);
expect(saveSucceeds(11, 3, 15)).toBe(false);
});
describe('settling an action', () => {
const attack = (targets, damage = null) => ({ kind: 'ATTACK', detail: { attackBonus: 5, damageDice: '1d8', damageBonus: 3 }, targets, damage });
test('waits for a d20 for each target, then for the damage', () => {
const waiting = settle(attack([{ participantId: 'a', ac: 15, d20: null }]));
expect(waiting).toMatchObject({ needsRolls: 1, ready: false });
const hit = settle(attack([{ participantId: 'a', ac: 15, d20: 12 }]));
expect(hit.targets[0]).toMatchObject({ total: 17, outcome: 'HIT', finalDamage: null });
expect(hit).toMatchObject({ needsRolls: 0, needsDamage: true, ready: false });
const done = settle(attack([{ participantId: 'a', ac: 15, d20: 12 }], { total: 7 }));
expect(done.targets[0].finalDamage).toBe(7);
expect(done.ready).toBe(true);
});
test('a miss needs no damage at all', () => {
const miss = settle(attack([{ participantId: 'a', ac: 15, d20: 4 }]));
expect(miss.targets[0]).toMatchObject({ outcome: 'MISS', finalDamage: 0 });
expect(miss).toMatchObject({ needsDamage: false, ready: true });
});
test('a critical hit takes the critical damage, once it is rolled', () => {
const crit = settle(attack([{ participantId: 'a', ac: 15, d20: 20 }], { total: 7 }));
expect(crit.needsCrit).toBe(true);
expect(settle(attack([{ participantId: 'a', ac: 15, d20: 20 }], { total: 7, crit: { total: 13 } })).targets[0].finalDamage).toBe(13);
});
test('the DM can overrule the dice, and set a target’s damage by hand', () => {
const overruled = settle(attack([{ participantId: 'a', ac: 15, d20: 4, outcome: 'HIT' }, { participantId: 'b', ac: 12, d20: 15, damage: 2 }], { total: 9 }));
expect(overruled.targets[0]).toMatchObject({ rolled: 'MISS', outcome: 'HIT', overruled: true, finalDamage: 9 });
expect(overruled.targets[1]).toMatchObject({ outcome: 'HIT', overruled: false, finalDamage: 2 });
});
test('a save spell deals full damage on a failure, half or none on a success', () => {
const spell = (halfOnSave) =>
settle({
kind: 'SAVE',
detail: { saveAbility: 'DEX', saveDC: 15, halfOnSave, damageDice: '8d6' },
targets: [{ participantId: 'a', saveBonus: 2, d20: 13 }, { participantId: 'b', saveBonus: 2, d20: 12 }, { participantId: 'c', saveBonus: 0, d20: null }],
damage: { total: 27 },
});
const fireball = spell(true);
expect(fireball.targets.map((t) => [t.outcome, t.finalDamage])).toEqual([['SAVED', 13], ['FAILED', 27], [null, null]]);
expect(fireball).toMatchObject({ needsRolls: 1, ready: false });
expect(spell(false).targets[0].finalDamage).toBe(0);
});
test('something that just lands hits everyone it is aimed at', () => {
const missile = settle({ kind: 'AUTO', detail: { damageDice: '3d4+3' }, targets: [{ participantId: 'a' }], damage: { total: 10 } });
expect(missile.targets[0]).toMatchObject({ outcome: 'HIT', finalDamage: 10 });
expect(missile.ready).toBe(true);
// With no dice at all (a utility spell) it is ready as it stands.
expect(settle({ kind: 'AUTO', detail: {}, targets: [{ participantId: 'a' }], damage: null })).toMatchObject({ ready: true });
});
});
describe('damage', () => {
test('is the dice plus the bonus', () => {
expect(rollDamage({ damageDice: '1d8', damageBonus: 3 }, { roll: fixed(5) })).toMatchObject({ total: 8, crit: null });
expect(rollDamage({ damageDice: '2d6+1 fire', damageBonus: 0 }, { roll: fixed(2, 3) }).total).toBe(6);
expect(rollDamage({ damageDice: 'a nasty bite' })).toBeNull();
});
test('a critical hit doubles the dice, never the bonus', () => {
const rolled = rollDamage({ damageDice: '1d8', damageBonus: 3 }, { critical: true, roll: fixed(5, 4, 6) });
expect(rolled.total).toBe(8);
expect(rolled.crit.total).toBe(4 + 6 + 3);
expect(rollCritDamage({ damageDice: '1d8', damageBonus: 3 }, { critRule: 'MAX_PLUS_ROLL', roll: fixed(2) }).total).toBe(8 + 2 + 3);
});
});
describe('sides', () => {
const aria = { id: 'aria', kind: 'CHARACTER', ac: 16 };
const bo = { id: 'bo', kind: 'CHARACTER', ac: 12 };
const goblin = { id: 'goblin', kind: 'MONSTER', ac: 13 };
const guard = { id: 'guard', kind: 'NPC', disposition: 'FRIENDLY', ac: 14 };
const thug = { id: 'thug', kind: 'NPC', disposition: 'HOSTILE', ac: 11 };
const baker = { id: 'baker', kind: 'NPC', disposition: 'NEUTRAL', ac: 10 };
const everyone = [aria, bo, goblin, guard, thug, baker];
test('characters and friendly NPCs against monsters and hostile NPCs', () => {
expect(everyone.map(sideOf)).toEqual(['PARTY', 'PARTY', 'ENEMY', 'PARTY', 'ENEMY', 'NEUTRAL']);
expect(opponentsOf(goblin, everyone).map((c) => c.id)).toEqual(['aria', 'bo', 'guard']);
expect(opponentsOf(guard, everyone).map((c) => c.id)).toEqual(['goblin', 'thug']);
expect(opponentsOf(baker, everyone)).toEqual([]);
});
test('the fallen are nobody’s target', () => {
expect(opponentsOf(goblin, [aria, { ...bo, fallen: true }]).map((c) => c.id)).toEqual(['aria']);
});
test('a creature favours who it fought last, who hurt it, and the easier mark', () => {
const [ariaWeight, boWeight] = targetWeights([aria, bo]);
expect(boWeight).toBeGreaterThan(ariaWeight); // lower AC
const [again] = targetWeights([aria, bo], { lastTargetId: 'aria', hurtBy: ['aria'] });
expect(again).toBeGreaterThan(boWeight);
});
test('picks by weight, and nobody when there is nobody', () => {
expect(pickTarget([aria, bo], {}, () => 0).id).toBe('aria');
expect(pickTarget([aria, bo], {}, () => 0.999).id).toBe('bo');
expect(pickTarget([], {})).toBeNull();
});
});
describe('what a creature attacks with', () => {
test('its single attack, or each of its named attacks as often as it makes it', () => {
expect(attacksOf({ monster: { attackBonus: 4, damageDice: '1d6+2', attacks: [] } })).toEqual([{ name: 'Attack', attackBonus: 4, damageDice: '1d6+2', damageType: null }]);
const owlbear = attacksOf({ monster: { attackBonus: 7, damageDice: '2d8+5', attacks: [{ name: 'Claw', attackBonus: 7, damageDice: '2d8+5', damageType: 'slashing', count: 2 }, { name: 'Beak', attackBonus: 7, damageDice: '1d10+5' }] } });
expect(owlbear.map((a) => a.name)).toEqual(['Claw', 'Claw', 'Beak']);
expect(attacksOf({ npc: { attackBonus: 3, damageDice: '1d6' } })).toHaveLength(1);
expect(attacksOf({ npc: { attackBonus: null, damageDice: null } })).toEqual([]);
});
});
describe('changing weapons in a battle', () => {
const { inTurnOrder, weaponSwapOpen } = require('./combat');
const base = { round: 1, activeId: 'a', firstId: 'a', participantId: 'b', applied: [] };
test('the turn order is by initiative, the unrolled last', () => {
const order = inTurnOrder([
{ id: '1', initiativeRoll: null, initiative: 0 },
{ id: '2', initiativeRoll: 5, initiative: 7 },
{ id: '3', initiativeRoll: 12, initiative: 12 },
]);
expect(order.map((p) => p.id)).toEqual(['3', '2', '1']);
});
test('anyone may as the battle begins, before a move is made', () => {
expect(weaponSwapOpen(base)).toBe(true);
expect(weaponSwapOpen({ ...base, activeId: null })).toBe(true);
// The first turn has passed, or something has landed: the moment is gone.
expect(weaponSwapOpen({ ...base, activeId: 'b', participantId: 'c' })).toBe(false);
expect(weaponSwapOpen({ ...base, applied: [{ round: 1, actorParticipantId: 'a' }] })).toBe(false);
expect(weaponSwapOpen({ ...base, round: 2 })).toBe(false);
expect(weaponSwapOpen({ ...base, declared: true })).toBe(false); // an attack is being resolved
});
test('after that, only on their own turn once their attack has landed', () => {
const later = { round: 2, activeId: 'b', firstId: 'a', participantId: 'b' };
expect(weaponSwapOpen({ ...later, applied: [] })).toBe(false);
expect(weaponSwapOpen({ ...later, applied: [{ round: 1, actorParticipantId: 'b' }] })).toBe(false); // last round's
expect(weaponSwapOpen({ ...later, applied: [{ round: 2, actorParticipantId: 'b' }] })).toBe(true);
// Somebody else's turn, even though they attacked earlier this round.
expect(weaponSwapOpen({ ...later, activeId: 'c', applied: [{ round: 2, actorParticipantId: 'b' }] })).toBe(false);
});
});
+575
View File
@@ -0,0 +1,575 @@
// What a player picks from in the story steps of the character builder:
// motivation, special abilities, background and appearance. A character's
// text for these is always composed from the entries below (see buildFlow.js),
// never typed freely, so the builder page fetches this file's contents rather
// than keeping its own copy.
//
// None of this feeds the calculation engine. Special abilities are written as
// table rules for the DM to apply, in plain words.
const MOTIVATIONS = [
{ key: 1, label: 'Revenge for a past wrong' },
{ key: 2, label: 'Seeking fortune or wealth' },
{ key: 3, label: 'Protecting someone they love' },
{ key: 4, label: 'Redemption for a past mistake' },
{ key: 5, label: 'Driven by curiosity or knowledge' },
{ key: 6, label: 'Duty or oath to a cause or order' },
];
// ---------------------------------------------------------------------------
// Special abilities
// ---------------------------------------------------------------------------
// Granted automatically by species.
const SPECIES_TRAITS = {
HUMAN: [{ name: 'Versatile', text: 'Once per day, reroll one skill check you just failed and keep the new result.' }],
HALF_ELF: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
],
ELF: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
{ name: 'Trance', text: 'You do not sleep. Four hours of meditation rests you as well as a full night.' },
],
GNOME: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Gnome Cunning', text: 'Advantage on Intelligence, Wisdom and Charisma saves against magic.' },
],
DWARF: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Dwarven Resilience', text: 'Advantage on saves against poison, and poison damage against you is halved.' },
],
HALFLING: [
{ name: 'Lucky', text: 'When you roll a 1 on a d20 for an attack, check or save, reroll it and use the new roll.' },
{ name: 'Brave', text: 'Advantage on saves against being frightened.' },
],
DRAGONBORN: [
{ name: 'Breath Weapon', text: 'Once per rest, exhale destructive energy: creatures in a 15-foot cone take 2d6 damage (Dexterity save for half).' },
{ name: 'Draconic Resistance', text: 'Damage of your breath weapon’s type against you is halved.' },
],
TIEFLING: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Hellish Resistance', text: 'Fire damage against you is halved.' },
],
HALF_ORC: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Relentless Endurance', text: 'Once per day, when you would drop to 0 hit points, drop to 1 instead.' },
],
ORC: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Aggressive', text: 'As a bonus action, move up to your speed toward an enemy you can see.' },
],
AASIMAR: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Celestial Resistance', text: 'Necrotic and radiant damage against you is halved.' },
{ name: 'Healing Hands', text: 'Once per long rest, touch a creature and restore hit points: roll a d4 for each point of your proficiency bonus.' },
{ name: 'Light Bearer', text: 'You know the Light cantrip (the DM grants it).' },
{ name: 'Celestial Revelation', text: 'From level 3, once per long rest, transform for a minute (bonus action), choosing: spectral wings (fly at your speed), an inner radiance (light, and radiant damage to those near you each turn) or a fearful shroud (enemies nearby make a Charisma save or are frightened). While transformed, one hit each turn deals extra damage equal to your proficiency bonus.' },
],
GOLIATH: [
{ name: 'Giant Ancestry', text: 'Choose a giant with your DM; use its gift your proficiency bonus times per long rest. Cloud: bonus-action teleport 30 ft. Fire: +1d10 fire on a hit. Frost: +1d6 cold on a hit and the target is slowed by 10 ft. Hill: knock a Large or smaller target you hit prone. Stone: reaction, reduce damage you take by 1d12 + Constitution modifier. Storm: reaction, deal 1d8 thunder to a creature that damaged you.' },
{ name: 'Large Form', text: 'From level 5, once per long rest, grow Large for 10 minutes (bonus action): advantage on Strength checks and +10 ft speed.' },
{ name: 'Powerful Build', text: 'You count as one size larger when working out what you can carry, push, drag or lift.' },
],
AARAKOCRA: [
{ name: 'Flight', text: 'You can fly at your walking speed, but not while wearing medium or heavy armor.' },
{ name: 'Talons', text: 'Your unarmed strikes deal 1d6 + Strength modifier slashing damage.' },
{ name: 'Wind Caller', text: 'From level 3, cast Gust of Wind once per long rest without a spell slot.' },
],
BUGBEAR: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
{ name: 'Long-Limbed', text: 'On your turn, your melee attacks reach 5 feet further than normal.' },
{ name: 'Powerful Build', text: 'You count as one size larger when working out what you can carry, push, drag or lift.' },
{ name: 'Sneaky', text: 'You are proficient in Stealth (add the bonus by hand) and can squeeze through spaces made for Small creatures.' },
{ name: 'Surprise Attack', text: 'Hit a creature that has not yet taken a turn in the fight: it takes an extra 2d6 damage (once per combat).' },
],
CENTAUR: [
{ name: 'Charge', text: 'Move at least 30 feet straight toward a target and hit it with a melee attack: make a Hooves attack against it as a bonus action.' },
{ name: 'Equine Build', text: 'You count as one size larger for carrying, pushing and dragging. Climbing costs 4 extra feet for each foot you climb.' },
{ name: 'Hooves', text: 'Your unarmed strikes deal 1d6 + Strength modifier bludgeoning damage.' },
{ name: 'Natural Affinity', text: 'You are proficient in one of Animal Handling, Medicine, Nature or Survival (agree it with your DM).' },
],
CHANGELING: [
{ name: 'Shapechanger', text: 'Action: change your appearance and voice (height, features, hair, skin) to look like another humanoid of your size. Your gear does not change.' },
{ name: 'Changeling Instincts', text: 'You are proficient in two of Deception, Insight, Intimidation, Performance and Persuasion (agree them with your DM).' },
],
DEEP_GNOME: [
{ name: 'Darkvision', text: 'See in dim light within 120 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Gnomish Magic Resistance', text: 'Advantage on Intelligence, Wisdom and Charisma saves against spells.' },
{ name: 'Svirfneblin Camouflage', text: 'Your proficiency bonus times per long rest, give yourself advantage on a Stealth check.' },
{ name: 'Gift of the Svirfneblin', text: 'From level 3, cast Disguise Self once per long rest; from level 5, Nondetection on yourself once per long rest.' },
],
DUERGAR: [
{ name: 'Darkvision', text: 'See in dim light within 120 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Dwarven Resilience', text: 'Advantage on saves against poison, and poison damage against you is halved.' },
{ name: 'Psionic Fortitude', text: 'Advantage on saves against being charmed or stunned.' },
{ name: 'Duergar Magic', text: 'From level 3, cast Enlarge/Reduce on yourself once per long rest; from level 5, Invisibility on yourself once per long rest.' },
],
ELADRIN: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
{ name: 'Trance', text: 'You do not sleep. Four hours of meditation rests you as well as a full night.' },
{ name: 'Keen Senses', text: 'You are proficient in Perception (add the bonus by hand).' },
{ name: 'Fey Step', text: 'Your proficiency bonus times per long rest, bonus action: teleport up to 30 feet. From level 3 it also has your season’s effect (agree it with your DM): autumn charms, winter frightens, spring lets you teleport an ally instead, summer burns those nearby.' },
],
FAIRY: [
{ name: 'Flight', text: 'You can fly at your walking speed, but not while wearing medium or heavy armor.' },
{ name: 'Fairy Magic', text: 'You know Druidcraft. From level 3 cast Faerie Fire, and from level 5 Enlarge/Reduce, each once per long rest without a slot.' },
{ name: 'Small', text: 'You are Small: heavy weapons are hard for you to wield (disadvantage).' },
],
FIRBOLG: [
{ name: 'Firbolg Magic', text: 'Cast Detect Magic and Disguise Self, each once per long rest without a slot.' },
{ name: 'Hidden Step', text: 'Your proficiency bonus times per long rest, bonus action: turn invisible until the start of your next turn or until you attack.' },
{ name: 'Powerful Build', text: 'You count as one size larger when working out what you can carry, push, drag or lift.' },
{ name: 'Speech of Beast and Leaf', text: 'Beasts and plants understand the meaning of your words, and you have advantage on Charisma checks to influence them.' },
],
AIR_GENASI: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Unending Breath', text: 'You can hold your breath indefinitely.' },
{ name: 'Lightning Resistance', text: 'Lightning damage against you is halved.' },
{ name: 'Mingle with the Wind', text: 'You know Shocking Grasp. From level 3 cast Feather Fall, and from level 5 Levitate, each once per long rest without a slot.' },
],
EARTH_GENASI: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Earth Walk', text: 'Difficult terrain made of earth or stone does not slow you.' },
{ name: 'Merge with Stone', text: 'You know Blade Ward and can cast it as a bonus action your proficiency bonus times per long rest. From level 5 cast Pass without Trace once per long rest without a slot.' },
],
FIRE_GENASI: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Fire Resistance', text: 'Fire damage against you is halved.' },
{ name: 'Reach to the Blaze', text: 'You know Produce Flame. From level 3 cast Burning Hands, and from level 5 Flame Blade, each once per long rest without a slot.' },
],
WATER_GENASI: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Acid Resistance', text: 'Acid damage against you is halved.' },
{ name: 'Amphibious', text: 'You can breathe air and water, and you have a swimming speed equal to your walking speed.' },
{ name: 'Call to the Wave', text: 'You know Acid Splash. From level 3 cast Create or Destroy Water, and from level 5 Water Walk, each once per long rest without a slot.' },
],
GITHYANKI: [
{ name: 'Astral Knowledge', text: 'After each long rest, pick one skill and one weapon or tool to be proficient in until the next (agree them with your DM).' },
{ name: 'Githyanki Psionics', text: 'You know Mage Hand (and the hand is invisible). From level 3 cast Jump, and from level 5 Misty Step, each once per long rest without a slot.' },
{ name: 'Psychic Resilience', text: 'Psychic damage against you is halved.' },
],
GITHZERAI: [
{ name: 'Mental Discipline', text: 'Advantage on saves against being charmed or frightened.' },
{ name: 'Githzerai Psionics', text: 'You know Mage Hand (and the hand is invisible). From level 3 cast Shield, and from level 5 Detect Thoughts, each once per long rest without a slot.' },
{ name: 'Psychic Resilience', text: 'Psychic damage against you is halved.' },
],
GOBLIN: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
{ name: 'Fury of the Small', text: 'Your proficiency bonus times per long rest, when you hit a creature larger than you, deal extra damage equal to your proficiency bonus.' },
{ name: 'Nimble Escape', text: 'Bonus action: Disengage or Hide.' },
{ name: 'Small', text: 'You are Small: heavy weapons are hard for you to wield (disadvantage).' },
],
HARENGON: [
{ name: 'Hare-Trigger', text: 'Add your proficiency bonus to your initiative rolls.' },
{ name: 'Leporine Senses', text: 'You are proficient in Perception (add the bonus by hand).' },
{ name: 'Lucky Footwork', text: 'Reaction, when you fail a Dexterity save: add a d4 to it, which may turn it into a success.' },
{ name: 'Rabbit Hop', text: 'Your proficiency bonus times per long rest, bonus action: jump 5 × your proficiency bonus feet without provoking opportunity attacks.' },
],
HOBGOBLIN: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
{ name: 'Fey Gift', text: 'Your proficiency bonus times per long rest, take the Help action as a bonus action. From level 3 it also gives temporary HP, speed, or makes the target’s next attack against you harder (agree which with your DM).' },
{ name: 'Fortune from the Many', text: 'Once per long rest, when you miss an attack or fail a check or save, add +1 for each ally you can see within 30 feet (up to +3).' },
],
KENKU: [
{ name: 'Expert Duplication', text: 'Advantage on checks to copy handwriting or craftwork.' },
{ name: 'Kenku Recall', text: 'You are proficient in two skills of your choice. Your proficiency bonus times per long rest, give yourself advantage on a check with a skill you are proficient in.' },
{ name: 'Mimicry', text: 'You can copy any sound or voice you have heard; telling it apart takes an Insight check against yours.' },
],
KOBOLD: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Draconic Cry', text: 'Your proficiency bonus times per long rest, bonus action: until the start of your next turn, you and your allies have advantage on attacks against enemies within 10 feet of you.' },
{ name: 'Kobold Legacy', text: 'Choose one with your DM: Craftiness (proficiency in Arcana, Investigation, Medicine, Sleight of Hand or Survival), Defiance (advantage on saves against being frightened), or Draconic Sorcery (one sorcerer cantrip).' },
{ name: 'Small', text: 'You are Small: heavy weapons are hard for you to wield (disadvantage).' },
],
LIZARDFOLK: [
{ name: 'Bite', text: 'Your unarmed strikes can be bites: 1d6 + Strength modifier piercing damage.' },
{ name: 'Hold Breath', text: 'You can hold your breath for up to 15 minutes, and you swim at your walking speed.' },
{ name: 'Hungry Jaws', text: 'Your proficiency bonus times per long rest, bonus action: a Bite attack that, on a hit, gives you temporary HP equal to your proficiency bonus.' },
{ name: 'Natural Armor', text: 'Without armor your AC is 13 + your Dexterity modifier (ask your DM to set your armor bonus). A shield still adds.' },
{ name: 'Nature’s Intuition', text: 'You are proficient in two of Animal Handling, Medicine, Nature, Perception, Stealth and Survival (agree them with your DM; add the bonus by hand).' },
],
MINOTAUR: [
{ name: 'Horns', text: 'Your unarmed strikes can gore: 1d6 + Strength modifier piercing damage.' },
{ name: 'Goring Rush', text: 'Right after you Dash and move at least 20 feet, make one Horns attack as a bonus action.' },
{ name: 'Hammering Horns', text: 'After you hit with a melee attack as part of the Attack action, shove the target up to 10 feet away as a bonus action (Strength save to resist).' },
{ name: 'Labyrinthine Recall', text: 'You always know which way is north, and you have advantage on checks to navigate or track.' },
],
SEA_ELF: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
{ name: 'Trance', text: 'You do not sleep. Four hours of meditation rests you as well as a full night.' },
{ name: 'Child of the Sea', text: 'You breathe air and water, swim at your walking speed, and cold damage against you is halved.' },
{ name: 'Friend of the Sea', text: 'You can share simple ideas with beasts that breathe water.' },
],
SHADAR_KAI: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Fey Ancestry', text: 'Advantage on saves against being charmed, and magic cannot put you to sleep.' },
{ name: 'Trance', text: 'You do not sleep. Four hours of meditation rests you as well as a full night.' },
{ name: 'Necrotic Resistance', text: 'Necrotic damage against you is halved.' },
{ name: 'Blessing of the Raven Queen', text: 'Your proficiency bonus times per long rest, bonus action: teleport up to 30 feet. From level 3 you also halve all damage against you until the start of your next turn.' },
],
SHIFTER: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Bestial Instincts', text: 'You are proficient in one of Acrobatics, Athletics, Intimidation or Survival (agree it with your DM).' },
{ name: 'Shifting', text: 'Your proficiency bonus times per long rest, bonus action: shift for one minute and gain 2 × your proficiency bonus temporary HP, plus your kind’s benefit (agree it with your DM): Beasthide +1 AC, Longtooth a bonus-action bite, Swiftstride +10 ft speed, Wildhunt advantage on Wisdom checks.' },
],
TABAXI: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Cat’s Claws', text: 'Your unarmed strikes deal 1d6 + Strength modifier slashing damage, and you climb at your walking speed.' },
{ name: 'Cat’s Talent', text: 'You are proficient in Perception and Stealth (add the bonus by hand).' },
{ name: 'Feline Agility', text: 'On your turn, double your speed until the end of the turn. You can do it again after a turn in which you do not move.' },
],
TORTLE: [
{ name: 'Claws', text: 'Your unarmed strikes deal 1d6 + Strength modifier slashing damage.' },
{ name: 'Hold Breath', text: 'You can hold your breath for up to an hour.' },
{ name: 'Natural Armor', text: 'Your shell gives you AC 17, whatever your Dexterity; you cannot wear armor, but a shield still adds (ask your DM to set your armor bonus).' },
{ name: 'Shell Defense', text: 'Action: withdraw into your shell: +4 AC and advantage on Strength and Constitution saves, but speed 0, disadvantage on Dexterity saves and no reactions, until you come out (bonus action).' },
{ name: 'Nature’s Intuition', text: 'You are proficient in two of Animal Handling, Medicine, Nature, Perception, Stealth and Survival (agree them with your DM; add the bonus by hand).' },
],
TRITON: [
{ name: 'Amphibious', text: 'You can breathe air and water, and you have a swimming speed equal to your walking speed.' },
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Control Air and Water', text: 'Cast Fog Cloud; from level 3 Gust of Wind, and from level 5 Water Walk, each once per long rest without a slot.' },
{ name: 'Emissary of the Sea', text: 'You can share simple ideas with beasts, elementals and monstrosities that breathe water.' },
{ name: 'Guardian of the Depths', text: 'Cold damage against you is halved.' },
],
YUAN_TI: [
{ name: 'Darkvision', text: 'See in dim light within 60 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Magic Resistance', text: 'Advantage on saves against spells.' },
{ name: 'Poison Resilience', text: 'Advantage on saves against being poisoned, and poison damage against you is halved.' },
{ name: 'Serpentine Spellcasting', text: 'You know Poison Spray, and you can cast Animal Friendship on snakes at will. From level 3 cast Suggestion once per long rest without a slot.' },
],
OWLIN: [
{ name: 'Darkvision', text: 'See in dim light within 120 feet as if it were bright, and in darkness as if it were dim.' },
{ name: 'Flight', text: 'You can fly at your walking speed, but not while wearing medium or heavy armor.' },
{ name: 'Silent Feathers', text: 'You are proficient in Stealth (add the bonus by hand).' },
],
SATYR: [
{ name: 'Ram', text: 'Your unarmed strikes can headbutt: 1d6 + Strength modifier bludgeoning damage.' },
{ name: 'Magic Resistance', text: 'Advantage on saves against spells.' },
{ name: 'Mirthful Leaps', text: 'Whenever you jump, roll a d8 and add it to the distance in feet.' },
{ name: 'Reveler', text: 'You are proficient in Performance and Persuasion (add the bonus by hand).' },
],
};
// Granted automatically by class, at level 1.
const CLASS_FEATURES = {
FIGHTER: [
{ name: 'Second Wind', text: 'Once per rest, as a bonus action, recover 1d10 + your level hit points.' },
{ name: 'Fighting Style', text: 'Choose one with your DM: +2 to ranged attacks, +1 AC in armor, or +2 damage with a one-handed weapon.' },
],
BARBARIAN: [
{ name: 'Rage', text: 'Twice per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved.' },
{ name: 'Unarmored Defense', text: 'Without armor, add your Constitution modifier to your AC (ask your DM to set your armor bonus).' },
],
ROGUE: [
{ name: 'Sneak Attack', text: 'Once per turn, deal an extra 1d6 damage to a target you have advantage against, or that has one of your allies next to it.' },
{ name: 'Thieves’ Cant', text: 'You know the secret slang and signs of the criminal underworld.' },
],
RANGER: [
{ name: 'Favored Enemy', text: 'Choose a kind of creature with your DM. Advantage on checks to track them and to recall what you know about them.' },
{ name: 'Natural Explorer', text: 'In the wild you cannot get lost, you find twice as much food, and difficult terrain does not slow your group.' },
],
WIZARD: [
{ name: 'Spellbook', text: 'Your spells are written in a book. You can copy new spells into it when you find them.' },
{ name: 'Arcane Recovery', text: 'Once per day, after a short rest, regain some of your spent magic.' },
],
SORCERER: [
{ name: 'Innate Magic', text: 'Your magic comes from within: you need no spellbook and no prayers.' },
{ name: 'Wild Talent', text: 'Once per day, reroll the damage dice of one spell and keep the better result.' },
],
CLERIC: [
{ name: 'Divine Domain', text: 'Choose with your DM what your god stands for (life, light, war, knowledge…). It shapes the favours you can ask.' },
{ name: 'Healing Hands', text: 'Once per rest, touch a creature to restore 1d8 + your Wisdom modifier hit points.' },
],
PALADIN: [
{ name: 'Divine Sense', text: 'A few times per day, sense any celestial, fiend or undead within 60 feet.' },
{ name: 'Lay on Hands', text: 'A pool of healing equal to 5 × your level per day, spent by touch however you like.' },
],
BARD: [
{ name: 'Bardic Inspiration', text: 'A few times per day, as a bonus action, give an ally a d6 to add to one attack, check or save.' },
{ name: 'Jack of Many Tales', text: 'You have heard a story about nearly everything: ask your DM for one useful rumour per session.' },
],
WARLOCK: [
{ name: 'Otherworldly Patron', text: 'Choose with your DM who granted your power, and what they want in return.' },
{ name: 'Pact Magic', text: 'Your magic returns after a short rest, not only a long one.' },
],
DRUID: [
{ name: 'Druidic', text: 'You know the secret language of druids and can leave hidden messages in it.' },
{ name: 'Nature’s Voice', text: 'You can calm and make simple requests of ordinary animals.' },
],
MONK: [
{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d4 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.' },
{ name: 'Unarmored Defense', text: 'Without armor, add your Wisdom modifier to your AC (ask your DM to set your armor bonus).' },
],
ARTIFICER: [
{ name: 'Magical Tinkering', text: 'Touch a tiny object to give it a small magic: it shines, plays a recorded message, gives off a smell or sound, or shows a picture. You can keep as many going as your Intelligence modifier.' },
{ name: 'Spellcasting', text: 'You cast artificer spells through tools: thieves’ tools, artisan’s tools or an infused item serve as your focus.' },
],
};
// The player picks TALENT_PICKS of these.
const TALENT_PICKS = 2;
const TALENTS = [
{ key: 'ALERT', name: 'Alert', text: 'You cannot be surprised while you are awake.' },
{ key: 'KEEN_EYE', name: 'Keen Eye', text: 'Advantage on checks to spot hidden doors, traps and things out of place.' },
{ key: 'SILVER_TONGUE', name: 'Silver Tongue', text: 'Once per day, reroll a Persuasion or Deception check and keep the better result.' },
{ key: 'LIGHT_SLEEPER', name: 'Light Sleeper', text: 'You wake at the slightest sound, and keeping watch does not tire you.' },
{ key: 'IRON_STOMACH', name: 'Iron Stomach', text: 'Advantage on saves against spoiled food, strong drink and swallowed poisons.' },
{ key: 'ANIMAL_FRIEND', name: 'Animal Friend', text: 'Ordinary animals are calm around you unless provoked.' },
{ key: 'QUICK_HANDS', name: 'Quick Hands', text: 'Once per turn, draw, stow or pick up an object without using your action.' },
{ key: 'SURE_FOOTED', name: 'Sure-Footed', text: 'Advantage on checks to keep your balance or avoid being knocked down.' },
{ key: 'LINGUIST', name: 'Linguist', text: 'You speak two extra languages of your choice, agreed with your DM.' },
{ key: 'FIELD_MEDIC', name: 'Field Medic', text: 'Steadying a dying creature is automatic for you; no roll needed.' },
{ key: 'STRONG_BACK', name: 'Strong Back', text: 'You can carry, lift and drag twice as much as your size suggests.' },
{ key: 'GOOD_MEMORY', name: 'Good Memory', text: 'You recall exactly anything you have seen or heard in the last month.' },
{ key: 'STREETWISE', name: 'Streetwise', text: 'In any town you can find a place to sleep, a rumour, and someone who buys things without asking questions.' },
{ key: 'TOUGH_AS_NAILS', name: 'Tough as Nails', text: 'Once per day, shrug off a blow: reduce one instance of damage by 1d6.' },
];
// ---------------------------------------------------------------------------
// Background and appearance — groups of options. `min`/`max` say how many a
// player picks in each group.
// ---------------------------------------------------------------------------
const BACKGROUND_GROUPS = [
{
key: 'upbringing',
label: 'Upbringing',
prompt: 'What were you before you became an adventurer?',
min: 1,
max: 1,
options: [
{ key: 'SOLDIER', label: 'Soldier', text: 'Served in an army or militia, and still thinks in terms of orders and watches.' },
{ key: 'ACOLYTE', label: 'Acolyte', text: 'Raised in a temple, tending shrines and copying scripture.' },
{ key: 'CRIMINAL', label: 'Criminal', text: 'Made a living outside the law, and knows who to ask for what.' },
{ key: 'NOBLE', label: 'Noble', text: 'Born to wealth and a family name that opens doors — and makes enemies.' },
{ key: 'SCHOLAR', label: 'Scholar', text: 'Spent years among books, chasing answers to old questions.' },
{ key: 'FARMER', label: 'Farmer', text: 'Worked the land from dawn to dusk, and knows the value of a good harvest.' },
{ key: 'SAILOR', label: 'Sailor', text: 'Grew up on deck, at home with rope, salt and bad weather.' },
{ key: 'ARTISAN', label: 'Guild artisan', text: 'Learned a craft in a workshop and takes pride in good work.' },
{ key: 'ENTERTAINER', label: 'Entertainer', text: 'Sang, juggled or acted for coin in taverns and market squares.' },
{ key: 'OUTLANDER', label: 'Outlander', text: 'Raised far from towns, among hunters, herders or nomads.' },
{ key: 'URCHIN', label: 'Urchin', text: 'Grew up alone on city streets, surviving on wits and quick feet.' },
{ key: 'HERMIT', label: 'Hermit', text: 'Lived apart from the world for years, with only thoughts for company.' },
{ key: 'MERCHANT', label: 'Merchant', text: 'Travelled the trade roads buying low and selling high.' },
{ key: 'HEALER', label: 'Village healer', text: 'Set bones, brewed remedies and sat with the sick.' },
],
},
{
key: 'home',
label: 'Home',
prompt: 'Where did you grow up?',
min: 1,
max: 1,
options: [
{ key: 'CITY', label: 'A crowded city', text: 'Noise, alleys and a thousand strangers.' },
{ key: 'VILLAGE', label: 'A small village', text: 'Everyone knew everyone, and nothing stayed secret.' },
{ key: 'PORT', label: 'A harbour town', text: 'Ships, gulls and news from far away.' },
{ key: 'MOUNTAIN', label: 'A mountain hold', text: 'Thin air, stone halls and long winters.' },
{ key: 'FOREST', label: 'Deep in the forest', text: 'Under old trees, far from any road.' },
{ key: 'FRONTIER', label: 'A frontier fort', text: 'The last wall before the wild.' },
{ key: 'ROAD', label: 'On the road', text: 'Never in one place long enough to call it home.' },
{ key: 'MONASTERY', label: 'A remote monastery', text: 'Bells, silence and routine.' },
],
},
{
key: 'turningPoint',
label: 'Turning point',
prompt: 'What set you on the road?',
min: 1,
max: 1,
options: [
{ key: 'LOSS', label: 'Lost someone dear', text: 'And have not been the same since.' },
{ key: 'BETRAYED', label: 'Was betrayed', text: 'By someone they trusted completely.' },
{ key: 'DISASTER', label: 'Survived a disaster', text: 'Fire, flood, plague or war — and walked away when others did not.' },
{ key: 'SECRET', label: 'Found a secret', text: 'Something they were never meant to see.' },
{ key: 'DEBT', label: 'Owes a debt', text: 'One that cannot be repaid by staying home.' },
{ key: 'EXILED', label: 'Was cast out', text: 'Rightly or wrongly, there is no going back.' },
{ key: 'RESCUED', label: 'Was saved by a stranger', text: 'And means to pass the favour on.' },
{ key: 'CALLING', label: 'Heard a calling', text: 'A dream, a sign or a voice that would not let them rest.' },
{ key: 'BOREDOM', label: 'Could not stand the quiet', text: 'An ordinary life was never going to be enough.' },
],
},
{
key: 'personality',
label: 'Personality',
prompt: 'How do others describe you? Pick one or two.',
min: 1,
max: 2,
options: [
{ key: 'BRAVE', label: 'Brave' },
{ key: 'CAUTIOUS', label: 'Cautious' },
{ key: 'CURIOUS', label: 'Curious' },
{ key: 'KIND', label: 'Kind' },
{ key: 'HOT_HEADED', label: 'Hot-headed' },
{ key: 'SARCASTIC', label: 'Sarcastic' },
{ key: 'LOYAL', label: 'Loyal' },
{ key: 'HONEST', label: 'Honest to a fault' },
{ key: 'CHEERFUL', label: 'Cheerful' },
{ key: 'QUIET', label: 'Quiet' },
{ key: 'PROUD', label: 'Proud' },
{ key: 'SUPERSTITIOUS', label: 'Superstitious' },
{ key: 'GENEROUS', label: 'Generous' },
{ key: 'STUBBORN', label: 'Stubborn' },
],
},
{
key: 'flaw',
label: 'Flaw',
prompt: 'What gets you into trouble?',
min: 1,
max: 1,
options: [
{ key: 'GREEDY', label: 'Greedy', text: 'Cannot walk past treasure.' },
{ key: 'RECKLESS', label: 'Reckless', text: 'Acts first and thinks later.' },
{ key: 'VENGEFUL', label: 'Holds grudges', text: 'Never forgets a slight.' },
{ key: 'GULLIBLE', label: 'Too trusting', text: 'Believes a good story.' },
{ key: 'COWARDLY', label: 'Loses their nerve', text: 'When it matters most.' },
{ key: 'BOASTFUL', label: 'Boastful', text: 'Promises more than they can deliver.' },
{ key: 'SECRETIVE', label: 'Secretive', text: 'Keeps things even from friends.' },
{ key: 'SOFT_HEARTED', label: 'Soft-hearted', text: 'Cannot refuse someone in need.' },
{ key: 'TEMPER', label: 'Short temper', text: 'Easily provoked.' },
],
},
];
const APPEARANCE_GROUPS = [
{
key: 'build',
label: 'Build',
prompt: 'What shape are you?',
min: 1,
max: 1,
options: [
{ key: 'SLIGHT', label: 'Slight' },
{ key: 'LEAN', label: 'Lean' },
{ key: 'AVERAGE', label: 'Average' },
{ key: 'STOCKY', label: 'Stocky' },
{ key: 'MUSCULAR', label: 'Muscular' },
{ key: 'HEAVYSET', label: 'Heavyset' },
],
},
{
key: 'height',
label: 'Height',
prompt: 'For your species, are you…',
min: 1,
max: 1,
options: [
{ key: 'SHORT', label: 'Short' },
{ key: 'AVERAGE', label: 'Average height' },
{ key: 'TALL', label: 'Tall' },
{ key: 'TOWERING', label: 'Towering' },
],
},
{
key: 'hair',
label: 'Hair',
prompt: 'Hair (or what you have instead).',
min: 1,
max: 1,
options: [
{ key: 'BLACK', label: 'Black' },
{ key: 'BROWN', label: 'Brown' },
{ key: 'BLONDE', label: 'Blonde' },
{ key: 'RED', label: 'Red' },
{ key: 'GREY', label: 'Grey' },
{ key: 'WHITE', label: 'White' },
{ key: 'UNUSUAL', label: 'An unusual colour' },
{ key: 'BALD', label: 'Bald' },
{ key: 'NONE', label: 'None — scales, horns or crest' },
],
},
{
key: 'eyes',
label: 'Eyes',
prompt: 'Eye colour.',
min: 1,
max: 1,
options: [
{ key: 'BROWN', label: 'Brown' },
{ key: 'BLUE', label: 'Blue' },
{ key: 'GREEN', label: 'Green' },
{ key: 'GREY', label: 'Grey' },
{ key: 'AMBER', label: 'Amber' },
{ key: 'BLACK', label: 'Black' },
{ key: 'VIOLET', label: 'Violet' },
{ key: 'MISMATCHED', label: 'Mismatched' },
],
},
{
key: 'complexion',
label: 'Skin',
prompt: 'Skin or scales.',
min: 1,
max: 1,
options: [
{ key: 'PALE', label: 'Pale' },
{ key: 'FAIR', label: 'Fair' },
{ key: 'TANNED', label: 'Tanned' },
{ key: 'BROWN', label: 'Brown' },
{ key: 'DARK', label: 'Dark' },
{ key: 'WEATHERED', label: 'Weathered' },
{ key: 'ASHEN', label: 'Ashen grey' },
{ key: 'GREEN', label: 'Green-tinged' },
{ key: 'RED', label: 'Deep red' },
{ key: 'SCALED', label: 'Scaled' },
],
},
{
key: 'features',
label: 'Distinguishing features',
prompt: 'What would a stranger remember? Pick up to two, or none.',
min: 0,
max: 2,
options: [
{ key: 'SCAR', label: 'A scar across the face' },
{ key: 'TATTOO', label: 'A tattoo' },
{ key: 'FRECKLES', label: 'Freckles' },
{ key: 'BROKEN_NOSE', label: 'A broken nose' },
{ key: 'EYEPATCH', label: 'An eyepatch' },
{ key: 'BEARD', label: 'A braided beard' },
{ key: 'PIERCINGS', label: 'Piercings' },
{ key: 'BIRTHMARK', label: 'A birthmark' },
{ key: 'BURN', label: 'An old burn' },
{ key: 'MISSING_FINGER', label: 'A missing finger' },
{ key: 'LIMP', label: 'A slight limp' },
{ key: 'BIG_GRIN', label: 'An easy grin' },
],
},
{
key: 'style',
label: 'Usually wears',
prompt: 'What are you usually seen in?',
min: 1,
max: 1,
options: [
{ key: 'LEATHERS', label: 'Travel-worn leathers' },
{ key: 'ARMOR', label: 'Well-kept armor' },
{ key: 'CLOAK', label: 'A hooded cloak' },
{ key: 'FINE', label: 'Fine clothes' },
{ key: 'PATCHED', label: 'Patched and mended clothes' },
{ key: 'ROBES', label: 'Long robes' },
{ key: 'FURS', label: 'Furs and hides' },
{ key: 'BRIGHT', label: 'Bright colours' },
{ key: 'PLAIN', label: 'Plain, practical clothes' },
],
},
];
module.exports = {
MOTIVATIONS,
SPECIES_TRAITS,
CLASS_FEATURES,
TALENT_PICKS,
TALENTS,
BACKGROUND_GROUPS,
APPEARANCE_GROUPS,
};
+74 -12
View File
@@ -1,19 +1,81 @@
// Generated from /seed-data/classes.json — keep in sync by hand if that file changes.
// See docs/GAME_RULES_REFERENCE.md "Class Quick Facts".
// skillChoices is the SRD "choose N from this list" skill-proficiency rule per
// class, used by buildFlow.js's autoSelectSkills() to auto-generate skill
// proficiencies during the character builder instead of a manual checkbox pick.
const ALL_SKILLS = [
'ACROBATICS', 'ANIMAL_HANDLING', 'ARCANA', 'ATHLETICS', 'DECEPTION', 'HISTORY',
'INSIGHT', 'INTIMIDATION', 'INVESTIGATION', 'MEDICINE', 'NATURE', 'PERCEPTION',
'PERFORMANCE', 'PERSUASION', 'RELIGION', 'SLEIGHT_OF_HAND', 'STEALTH', 'SURVIVAL',
];
const CLASSES = {
FIGHTER: { key: 'FIGHTER', name: 'Fighter', hitDie: 10, primaryAbility: 'Str/Dex', saveProficiencies: ['STR', 'CON'], spellcastingAbility: null },
BARBARIAN: { key: 'BARBARIAN', name: 'Barbarian', hitDie: 12, primaryAbility: 'Str', saveProficiencies: ['STR', 'CON'], spellcastingAbility: null },
ROGUE: { key: 'ROGUE', name: 'Rogue', hitDie: 8, primaryAbility: 'Dex', saveProficiencies: ['DEX', 'INT'], spellcastingAbility: null },
RANGER: { key: 'RANGER', name: 'Ranger', hitDie: 10, primaryAbility: 'Dex/Wis', saveProficiencies: ['STR', 'DEX'], spellcastingAbility: 'WIS' },
WIZARD: { key: 'WIZARD', name: 'Wizard', hitDie: 6, primaryAbility: 'Int', saveProficiencies: ['INT', 'WIS'], spellcastingAbility: 'INT' },
SORCERER: { key: 'SORCERER', name: 'Sorcerer', hitDie: 6, primaryAbility: 'Cha', saveProficiencies: ['CON', 'CHA'], spellcastingAbility: 'CHA' },
CLERIC: { key: 'CLERIC', name: 'Cleric', hitDie: 8, primaryAbility: 'Wis', saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'WIS' },
PALADIN: { key: 'PALADIN', name: 'Paladin', hitDie: 10, primaryAbility: 'Str/Cha', saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'CHA' },
BARD: { key: 'BARD', name: 'Bard', hitDie: 8, primaryAbility: 'Cha', saveProficiencies: ['DEX', 'CHA'], spellcastingAbility: 'CHA' },
WARLOCK: { key: 'WARLOCK', name: 'Warlock', hitDie: 8, primaryAbility: 'Cha', saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'CHA' },
DRUID: { key: 'DRUID', name: 'Druid', hitDie: 8, primaryAbility: 'Wis', saveProficiencies: ['INT', 'WIS'], spellcastingAbility: 'WIS' },
MONK: { key: 'MONK', name: 'Monk', hitDie: 8, primaryAbility: 'Dex/Wis', saveProficiencies: ['STR', 'DEX'], spellcastingAbility: null },
FIGHTER: {
key: 'FIGHTER', name: 'Fighter', hitDie: 10, primaryAbility: 'Str/Dex',
saveProficiencies: ['STR', 'CON'], spellcastingAbility: null,
skillChoices: { count: 2, options: ['ACROBATICS', 'ANIMAL_HANDLING', 'ATHLETICS', 'HISTORY', 'INSIGHT', 'INTIMIDATION', 'PERCEPTION', 'SURVIVAL'] },
},
BARBARIAN: {
key: 'BARBARIAN', name: 'Barbarian', hitDie: 12, primaryAbility: 'Str',
saveProficiencies: ['STR', 'CON'], spellcastingAbility: null,
skillChoices: { count: 2, options: ['ANIMAL_HANDLING', 'ATHLETICS', 'INTIMIDATION', 'NATURE', 'PERCEPTION', 'SURVIVAL'] },
},
ROGUE: {
key: 'ROGUE', name: 'Rogue', hitDie: 8, primaryAbility: 'Dex',
saveProficiencies: ['DEX', 'INT'], spellcastingAbility: null,
skillChoices: { count: 4, options: ['ACROBATICS', 'ATHLETICS', 'DECEPTION', 'INSIGHT', 'INTIMIDATION', 'INVESTIGATION', 'PERCEPTION', 'PERFORMANCE', 'PERSUASION', 'SLEIGHT_OF_HAND', 'STEALTH'] },
},
RANGER: {
key: 'RANGER', name: 'Ranger', hitDie: 10, primaryAbility: 'Dex/Wis',
saveProficiencies: ['STR', 'DEX'], spellcastingAbility: 'WIS',
skillChoices: { count: 3, options: ['ANIMAL_HANDLING', 'ATHLETICS', 'INSIGHT', 'INVESTIGATION', 'NATURE', 'PERCEPTION', 'STEALTH', 'SURVIVAL'] },
},
WIZARD: {
key: 'WIZARD', name: 'Wizard', hitDie: 6, primaryAbility: 'Int',
saveProficiencies: ['INT', 'WIS'], spellcastingAbility: 'INT',
skillChoices: { count: 2, options: ['ARCANA', 'HISTORY', 'INSIGHT', 'INVESTIGATION', 'MEDICINE', 'RELIGION'] },
},
SORCERER: {
key: 'SORCERER', name: 'Sorcerer', hitDie: 6, primaryAbility: 'Cha',
saveProficiencies: ['CON', 'CHA'], spellcastingAbility: 'CHA',
skillChoices: { count: 2, options: ['ARCANA', 'DECEPTION', 'INSIGHT', 'INTIMIDATION', 'PERSUASION', 'RELIGION'] },
},
CLERIC: {
key: 'CLERIC', name: 'Cleric', hitDie: 8, primaryAbility: 'Wis',
saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'WIS',
skillChoices: { count: 2, options: ['HISTORY', 'INSIGHT', 'MEDICINE', 'PERSUASION', 'RELIGION'] },
},
PALADIN: {
key: 'PALADIN', name: 'Paladin', hitDie: 10, primaryAbility: 'Str/Cha',
saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'CHA',
skillChoices: { count: 2, options: ['ATHLETICS', 'INSIGHT', 'INTIMIDATION', 'MEDICINE', 'PERSUASION', 'RELIGION'] },
},
BARD: {
key: 'BARD', name: 'Bard', hitDie: 8, primaryAbility: 'Cha',
saveProficiencies: ['DEX', 'CHA'], spellcastingAbility: 'CHA',
skillChoices: { count: 3, options: ALL_SKILLS },
},
WARLOCK: {
key: 'WARLOCK', name: 'Warlock', hitDie: 8, primaryAbility: 'Cha',
saveProficiencies: ['WIS', 'CHA'], spellcastingAbility: 'CHA',
skillChoices: { count: 2, options: ['ARCANA', 'DECEPTION', 'HISTORY', 'INTIMIDATION', 'INVESTIGATION', 'NATURE', 'RELIGION'] },
},
DRUID: {
key: 'DRUID', name: 'Druid', hitDie: 8, primaryAbility: 'Wis',
saveProficiencies: ['INT', 'WIS'], spellcastingAbility: 'WIS',
skillChoices: { count: 2, options: ['ARCANA', 'ANIMAL_HANDLING', 'INSIGHT', 'MEDICINE', 'NATURE', 'PERCEPTION', 'RELIGION', 'SURVIVAL'] },
},
MONK: {
key: 'MONK', name: 'Monk', hitDie: 8, primaryAbility: 'Dex/Wis',
saveProficiencies: ['STR', 'DEX'], spellcastingAbility: null,
skillChoices: { count: 2, options: ['ACROBATICS', 'ATHLETICS', 'HISTORY', 'INSIGHT', 'RELIGION', 'STEALTH'] },
},
ARTIFICER: {
key: 'ARTIFICER', name: 'Artificer', hitDie: 8, primaryAbility: 'Int',
saveProficiencies: ['CON', 'INT'], spellcastingAbility: 'INT',
skillChoices: { count: 2, options: ['ARCANA', 'HISTORY', 'INVESTIGATION', 'MEDICINE', 'NATURE', 'PERCEPTION', 'SLEIGHT_OF_HAND'] },
},
};
module.exports = { CLASSES };
+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 };
+392
View File
@@ -0,0 +1,392 @@
// What each class gains at each level after the first: the "Levelling-Up
// Checklist" tables of the Hollowcrown book (Rules Cheat Sheet) for levels 2
// to 10, carried on to level 20 in the same spirit. These are the features every member of the class gets; what a subclass
// adds is in subclasses.js. Level 1 features are in characterOptions.js and
// are written by the builder.
//
// 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)],
11: [{ name: 'Relentless Rage', text: 'While raging, when you drop to 0 HP but are not killed outright, make a DC 10 Constitution save to drop to 1 HP instead. The DC rises by 5 each time, back to 10 after a rest.' }],
12: [BOON, rage('Five times', 3)],
13: [{ name: 'Brutal Critical', text: 'Roll two extra weapon damage dice on a critical hit.', upgrade: true }],
15: [{ name: 'Persistent Rage', text: 'Your rage only ends early if you fall unconscious or choose to end it.' }],
16: [BOON, rage('Five times', 4)],
17: [{ name: 'Brutal Critical', text: 'Roll three extra weapon damage dice on a critical hit.', upgrade: true }, rage('Six times', 4)],
18: [{ name: 'Indomitable Might', text: 'If a Strength check comes up lower than your Strength score, use the score instead.' }],
19: [BOON],
20: [
{ name: 'Primal Champion', text: 'Your Strength and Constitution each count 4 higher (to 24 at most): add +2 to those modifiers by hand.' },
{ name: 'Rage', text: 'As often as you like, for one minute: +4 melee damage, advantage on Strength checks and saves, and physical damage against you is halved.', upgrade: true },
],
},
BARD: {
2: [
{ name: 'Jack of All Trades', text: 'Add half your proficiency bonus (round down) to any check you are not proficient in.' },
{ name: '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).' },
],
12: [BOON],
13: [{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d10.', upgrade: true }],
14: [{ name: 'Magical Secrets', text: 'Learn four spells from any class in all (the DM grants two more within the unlock schedule).', upgrade: true }],
15: [bardicInspiration('d12')],
16: [BOON],
17: [{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d12.', upgrade: true }],
18: [{ name: 'Magical Secrets', text: 'Learn six spells from any class in all (the DM grants two more within the unlock schedule).', upgrade: true }],
19: [BOON],
20: [{ name: 'Superior Inspiration', text: 'When you roll initiative with no Bardic Inspiration left, you get one back.' }],
},
CLERIC: {
2: [
{ 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).' }],
11: [{ name: 'Destroy Undead', text: 'Undead of CR 2 or lower that fail against your Turn Undead are destroyed.', upgrade: true }],
12: [BOON],
14: [{ name: 'Destroy Undead', text: 'Undead of CR 3 or lower that fail against your Turn Undead are destroyed.', upgrade: true }],
16: [BOON],
17: [{ name: 'Destroy Undead', text: 'Undead of CR 4 or lower that fail against your Turn Undead are destroyed.', upgrade: true }],
18: [{ name: 'Channel Divinity', text: 'Three times per short rest, use Turn Undead or your domain’s Channel Divinity.', upgrade: true }],
19: [BOON],
20: [{ name: 'Divine Intervention', text: 'Once per week, call on your god and it answers: no roll needed (the DM decides how it helps).', upgrade: true }],
},
DRUID: {
2: [
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)')],
12: [BOON],
16: [BOON],
18: [
{ name: 'Timeless Body', text: 'You age one year for every ten that pass.' },
{ name: 'Beast Spells', text: 'You can cast spells while in Wild Shape (anything that needs no costly material).' },
],
19: [BOON],
20: [{ name: 'Archdruid', text: 'Wild Shape as often as you like, and cast druid spells without words, gestures or simple materials.' }],
},
FIGHTER: {
2: [{ name: 'Action Surge', text: 'Once per short rest, take one extra action on your turn.' }],
4: [BOON],
5: [EXTRA_ATTACK],
6: [BOON],
8: [BOON],
9: [{ name: 'Indomitable', text: 'Once per long rest, reroll a failed saving throw.' }],
11: [{ name: 'Extra Attack', text: 'Attack three times when you take the Attack action.', upgrade: true }],
12: [BOON],
13: [{ name: 'Indomitable', text: 'Twice per long rest, reroll a failed saving throw.', upgrade: true }],
14: [BOON],
16: [BOON],
17: [
{ name: 'Action Surge', text: 'Twice per short rest (but once per turn), take one extra action on your turn.', upgrade: true },
{ name: 'Indomitable', text: 'Three times per long rest, reroll a failed saving throw.', upgrade: true },
],
19: [BOON],
20: [{ name: 'Extra Attack', text: 'Attack four times when you take the Attack action.', upgrade: true }],
},
MONK: {
2: [
{ 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 },
],
11: [{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d8 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.', upgrade: true }],
12: [BOON],
13: [{ name: 'Tongue of the Sun and Moon', text: 'You understand every spoken language, and anyone who speaks one understands you.' }],
14: [
{ name: 'Diamond Soul', text: 'You are proficient in every saving throw (add the bonus by hand); 1 ki rerolls a failed save.' },
{ name: 'Unarmored Movement', text: '+25 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
],
15: [{ name: 'Timeless Body', text: 'You need no food or water and suffer none of the frailty of old age.' }],
16: [BOON],
17: [{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d10 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.', upgrade: true }],
18: [
{ name: 'Empty Body', text: '4 ki: invisible for one minute, and all damage but force against you is halved. 8 ki: cast Astral Projection on yourself.' },
{ name: 'Unarmored Movement', text: '+30 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
],
19: [BOON],
20: [{ name: 'Perfect Self', text: 'When you roll initiative with no ki left, regain 4 ki.' }],
},
PALADIN: {
2: [
{ 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.' }],
11: [{ name: 'Improved Divine Smite', text: 'Every melee weapon hit deals an extra 1d8 radiant damage.' }],
12: [BOON],
14: [{ name: 'Cleansing Touch', text: 'A few times per day (your Charisma modifier, at least once), action: end one spell on yourself or a willing creature you touch.' }],
16: [BOON],
18: [
{ name: 'Aura of Protection', text: 'You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious.', upgrade: true },
{ name: 'Aura of Courage', text: 'You and allies within 30 ft can’t be frightened.', upgrade: true },
],
19: [BOON],
},
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.' },
],
12: [BOON],
14: [
{ name: 'Vanish', text: 'Hide as a bonus action, and you can’t be tracked by non-magical means unless you choose to leave a trail.' },
{ name: 'Favored Enemy', text: 'Choose a third favored enemy with your DM.', upgrade: true },
],
16: [BOON],
18: [{ name: 'Feral Senses', text: 'Within 30 ft, invisibility gives no advantage against you, and you know where invisible creatures are.' }],
19: [BOON],
20: [{ name: 'Foe Slayer', text: 'Once per turn, add your Wisdom modifier to the attack or damage roll against a favored enemy.' }],
},
ROGUE: {
2: [{ name: 'Cunning Action', text: 'Bonus action: Dash, Disengage or Hide.' }],
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],
11: [sneakAttack('6d6'), { name: 'Reliable Talent', text: 'On a check with a skill you are proficient in, treat a d20 roll of 9 or lower as a 10.' }],
12: [BOON],
13: [sneakAttack('7d6')],
14: [{ name: 'Blindsense', text: 'If you can hear, you know where any hidden or invisible creature within 10 ft is.' }],
15: [sneakAttack('8d6'), { name: 'Slippery Mind', text: 'You are proficient in Wisdom saves (add the bonus by hand).' }],
16: [BOON],
17: [sneakAttack('9d6')],
18: [{ name: 'Elusive', text: 'No attack against you has advantage while you are not incapacitated.' }],
19: [BOON, sneakAttack('10d6')],
20: [{ name: 'Stroke of Luck', text: 'Once per short rest, turn a missed attack into a hit, or a failed check into a 20.' }],
},
SORCERER: {
2: [{ name: 'Font of Magic', text: 'Sorcery points equal to your level; they come back on a long rest. Bonus action: turn points into a spell slot (2 points = 1st, 3 = 2nd, 5 = 3rd, 6 = 4th, 7 = 5th), or a spell slot into points equal to its level.' }],
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.' }],
12: [BOON],
16: [BOON],
17: [{ name: 'Metamagic', text: 'Choose a fourth option with your DM.' }],
19: [BOON],
20: [{ name: 'Sorcerous Restoration', text: 'Regain 4 sorcery points after each short rest.' }],
},
WARLOCK: {
2: [{ name: 'Eldritch Invocations', text: `Choose two with your DM: ${INVOCATIONS}.` }],
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')],
11: [{ name: 'Mystic Arcanum', text: 'Choose a 6th-level warlock spell with your DM: cast it once per long rest without a slot. One more spell is added at 13 (7th), 15 (8th) and 17 (9th).' }],
12: [BOON, invocations('six')],
13: [{ name: 'Mystic Arcanum', text: 'Choose a 7th-level spell too: each arcanum spell is cast once per long rest without a slot.', upgrade: true }],
15: [{ name: 'Mystic Arcanum', text: 'Choose an 8th-level spell too: each arcanum spell is cast once per long rest without a slot.', upgrade: true }, invocations('seven')],
16: [BOON],
17: [{ name: 'Mystic Arcanum', text: 'Choose a 9th-level spell too: each arcanum spell (6th to 9th) is cast once per long rest without a slot.', upgrade: true }],
18: [invocations('eight')],
19: [BOON],
20: [{ name: 'Eldritch Master', text: 'Once per long rest, spend a minute pleading with your patron to regain all your pact slots.' }],
},
WIZARD: {
4: [BOON],
8: [BOON],
12: [BOON],
16: [BOON],
18: [{ name: 'Spell Mastery', text: 'Choose a 1st- and a 2nd-level spell in your book with your DM: cast them at their lowest level without a slot.' }],
19: [BOON],
20: [{ name: 'Signature Spells', text: 'Choose two 3rd-level spells with your DM: each can be cast once per short rest without a slot.' }],
},
ARTIFICER: {
2: [{ name: 'Infuse Item', text: 'After a long rest, infuse up to two items with magic (you know four infusions; choose them with your DM): an enhanced weapon (+1 to hit and damage), enhanced armor (+1 AC), a bag of holding, a glowing focus (+1 spell attacks), and more.' }],
3: [{ name: 'The Right Tool for the Job', text: 'With an hour’s work and thieves’ or artisan’s tools, make any set of artisan’s tools you need.' }],
4: [BOON],
6: [
{ name: 'Tool Expertise', text: 'Double your proficiency bonus on any check that uses a tool (add it by hand).' },
{ name: 'Infuse Item', text: 'You know six infusions and keep up to three infused items at once.', upgrade: true },
],
7: [{ name: 'Flash of Genius', text: 'A few times per long rest (your Intelligence modifier, at least once), reaction: add your Intelligence modifier to a check or save made by you or a creature within 30 ft.' }],
8: [BOON],
10: [
{ name: 'Magic Item Adept', text: 'Attune to four magic items, and craft common or uncommon ones in a quarter of the time for half the gold.' },
{ name: 'Infuse Item', text: 'You know eight infusions and keep up to four infused items at once.', upgrade: true },
],
11: [{ name: 'Spell-Storing Item', text: 'After a long rest, store a 1st- or 2nd-level artificer spell in an item. Whoever holds it can cast it from the item, twice your Intelligence modifier times.' }],
12: [BOON],
14: [
{ name: 'Magic Item Savant', text: 'Attune to five magic items, and ignore class, species, spell and level requirements on them.' },
{ name: 'Infuse Item', text: 'You know ten infusions and keep up to five infused items at once.', upgrade: true },
],
16: [BOON],
18: [
{ name: 'Magic Item Master', text: 'Attune to six magic items.' },
{ name: 'Infuse Item', text: 'You know twelve infusions and keep up to six infused items at once.', upgrade: true },
],
19: [BOON],
20: [{ name: 'Soul of Artifice', text: '+1 to every saving throw for each magic item you are attuned to. When you drop to 0 HP, end one of your infusions to drop to 1 instead.' }],
},
};
// The class tables above go this far; so does a character.
const MAX_FEATURE_LEVEL = 20;
// From level 5 a damaging cantrip rolls twice the dice the site shows. Written
// onto the sheet of every class that casts, so nobody forgets.
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.` },
// Past level 10 a class can have more Boons than the four above allow.
{ key: 'RESOLVE', name: 'Resolve', max: 2, per: 1, text: (amount) => `+${amount} to every saving throw (add it to the numbers the site shows).` },
];
module.exports = { LEVEL_FEATURES, MAX_FEATURE_LEVEL, CANTRIP_LEVEL, CANTRIP_FEATURE, BOONS };
+9
View File
@@ -0,0 +1,9 @@
// 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, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY };
+47
View File
@@ -0,0 +1,47 @@
// Default monster library — a starter bestiary spanning CR 0 to CR 5,
// offered to any campaign so a DM isn't hand-typing common enemies from
// scratch. Shape matches the `Monster` Prisma model minus `id`/`campaignId`,
// plus a stable `key` for "add to campaign" lookups.
const MONSTER_LIBRARY = [
{ key: 'commoner', name: 'Commoner', challengeRating: '0', ac: 10, maxHp: 4, speed: 30, strScore: 10, dexScore: 10, conScore: 10, intScore: 10, wisScore: 10, chaScore: 10, attackBonus: 2, damageDice: '1d4', specialTrait: null, notes: 'An ordinary villager, armed with a club.' },
{ key: 'giant-rat', name: 'Giant Rat', challengeRating: '1/8', ac: 12, maxHp: 7, speed: 30, strScore: 7, dexScore: 15, conScore: 11, intScore: 2, wisScore: 10, chaScore: 4, attackBonus: 4, damageDice: '1d4+2', specialTrait: 'Pack Tactics', notes: 'Bite, piercing damage.' },
{ key: 'kobold', name: 'Kobold', challengeRating: '1/8', ac: 12, maxHp: 5, speed: 30, strScore: 7, dexScore: 15, conScore: 9, intScore: 8, wisScore: 7, chaScore: 8, attackBonus: 4, damageDice: '1d4+2', specialTrait: 'Pack Tactics, Sunlight Sensitivity', notes: 'Dagger, piercing damage.' },
{ key: 'bandit', name: 'Bandit', challengeRating: '1/8', ac: 12, maxHp: 11, speed: 30, strScore: 11, dexScore: 12, conScore: 12, intScore: 10, wisScore: 10, chaScore: 10, attackBonus: 3, damageDice: '1d6+1', specialTrait: null, notes: 'Scimitar, slashing damage.' },
{ key: 'cultist', name: 'Cultist', challengeRating: '1/8', ac: 12, maxHp: 9, speed: 30, strScore: 11, dexScore: 12, conScore: 10, intScore: 10, wisScore: 11, chaScore: 10, attackBonus: 3, damageDice: '1d6+1', specialTrait: 'Dark Devotion', notes: 'Scimitar, slashing damage.' },
{ key: 'stirge', name: 'Stirge', challengeRating: '1/8', ac: 12, maxHp: 2, speed: 10, strScore: 4, dexScore: 16, conScore: 11, intScore: 2, wisScore: 8, chaScore: 6, attackBonus: 5, damageDice: '1d4', specialTrait: 'Blood Drain', notes: 'Proboscis; attaches and drains hp on a hit.' },
{ key: 'goblin', name: 'Goblin', challengeRating: '1/4', ac: 15, maxHp: 7, speed: 30, strScore: 8, dexScore: 14, conScore: 10, intScore: 10, wisScore: 8, chaScore: 8, attackBonus: 4, damageDice: '1d6+2', specialTrait: 'Nimble Escape', notes: 'Scimitar, slashing damage.' },
{ key: 'skeleton', name: 'Skeleton', challengeRating: '1/4', ac: 13, maxHp: 13, speed: 30, strScore: 10, dexScore: 14, conScore: 15, intScore: 6, wisScore: 8, chaScore: 5, attackBonus: 4, damageDice: '1d6+2', specialTrait: 'Undead — vulnerable to bludgeoning', notes: 'Shortsword, piercing damage.' },
{ key: 'zombie', name: 'Zombie', challengeRating: '1/4', ac: 8, maxHp: 22, speed: 20, strScore: 13, dexScore: 6, conScore: 16, intScore: 3, wisScore: 6, chaScore: 5, attackBonus: 3, damageDice: '1d6+1', specialTrait: 'Undead Fortitude', notes: 'Slam, bludgeoning damage.' },
{ key: 'wolf', name: 'Wolf', challengeRating: '1/4', ac: 13, maxHp: 11, speed: 40, strScore: 12, dexScore: 15, conScore: 12, intScore: 3, wisScore: 12, chaScore: 6, attackBonus: 4, damageDice: '2d4+2', specialTrait: 'Pack Tactics', notes: 'Bite; can knock prone on a hit.' },
{ key: 'giant-wolf-spider', name: 'Giant Wolf Spider', challengeRating: '1/4', ac: 13, maxHp: 11, speed: 40, strScore: 12, dexScore: 16, conScore: 13, intScore: 3, wisScore: 12, chaScore: 4, attackBonus: 3, damageDice: '1d6+1', specialTrait: 'Poison bite', notes: 'Spider climb; bite deals poison damage.' },
{ key: 'scout', name: 'Scout', challengeRating: '1/2', ac: 13, maxHp: 16, speed: 30, strScore: 11, dexScore: 14, conScore: 12, intScore: 11, wisScore: 13, chaScore: 11, attackBonus: 4, damageDice: '1d6+2', specialTrait: 'Keen Hearing and Sight', notes: 'Shortsword or longbow.' },
{ key: 'orc', name: 'Orc', challengeRating: '1/2', ac: 13, maxHp: 15, speed: 30, strScore: 16, dexScore: 12, conScore: 16, intScore: 7, wisScore: 11, chaScore: 10, attackBonus: 5, damageDice: '1d12+3', specialTrait: 'Aggressive', notes: 'Greataxe, slashing damage.' },
{ key: 'hobgoblin', name: 'Hobgoblin', challengeRating: '1/2', ac: 18, maxHp: 11, speed: 30, strScore: 13, dexScore: 12, conScore: 12, intScore: 10, wisScore: 10, chaScore: 9, attackBonus: 3, damageDice: '1d8+1', specialTrait: 'Martial Advantage', notes: 'Longsword, slashing damage.' },
{ key: 'thug', name: 'Thug', challengeRating: '1/2', ac: 11, maxHp: 32, speed: 30, strScore: 15, dexScore: 11, conScore: 14, intScore: 10, wisScore: 10, chaScore: 11, attackBonus: 4, damageDice: '1d6+2', specialTrait: 'Pack Tactics', notes: 'Mace, bludgeoning damage.' },
{ key: 'gnoll', name: 'Gnoll', challengeRating: '1/2', ac: 15, maxHp: 22, speed: 30, strScore: 14, dexScore: 12, conScore: 11, intScore: 6, wisScore: 10, chaScore: 7, attackBonus: 3, damageDice: '1d8+1', specialTrait: 'Rampage', notes: 'Spear, piercing damage.' },
{ key: 'lizardfolk', name: 'Lizardfolk', challengeRating: '1/2', ac: 15, maxHp: 22, speed: 30, strScore: 15, dexScore: 10, conScore: 13, intScore: 7, wisScore: 12, chaScore: 7, attackBonus: 3, damageDice: '1d6+1', specialTrait: 'Hold Breath', notes: 'Club or spear, piercing damage.' },
{ key: 'black-bear', name: 'Black Bear', challengeRating: '1/2', ac: 11, maxHp: 19, speed: 40, strScore: 15, dexScore: 10, conScore: 14, intScore: 2, wisScore: 12, chaScore: 7, attackBonus: 3, damageDice: '1d6+2', specialTrait: 'Keen Smell', notes: 'Bite and claw attacks.' },
{ key: 'giant-spider', name: 'Giant Spider', challengeRating: '1', ac: 14, maxHp: 26, speed: 30, strScore: 14, dexScore: 16, conScore: 12, intScore: 2, wisScore: 11, chaScore: 4, attackBonus: 5, damageDice: '1d8+3', specialTrait: 'Web, poison bite', notes: 'Bite deals piercing plus poison damage.' },
{ key: 'ghoul', name: 'Ghoul', challengeRating: '1', ac: 12, maxHp: 22, speed: 30, strScore: 13, dexScore: 15, conScore: 10, intScore: 7, wisScore: 10, chaScore: 6, attackBonus: 2, damageDice: '2d6+2', specialTrait: 'Claws paralyze on a hit', notes: 'Undead.' },
{ key: 'dire-wolf', name: 'Dire Wolf', challengeRating: '1', ac: 14, maxHp: 37, speed: 50, strScore: 17, dexScore: 15, conScore: 15, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 5, damageDice: '2d6+3', specialTrait: 'Pack Tactics', notes: 'Bite; can knock prone on a hit.' },
{ key: 'brown-bear', name: 'Brown Bear', challengeRating: '1', ac: 11, maxHp: 34, speed: 40, strScore: 19, dexScore: 10, conScore: 16, intScore: 2, wisScore: 13, chaScore: 7, attackBonus: 5, damageDice: '1d8+5', specialTrait: 'Keen Smell', notes: 'Multiattack: bite and claws.' },
{ key: 'harpy', name: 'Harpy', challengeRating: '1', ac: 11, maxHp: 38, speed: 20, strScore: 12, dexScore: 13, conScore: 12, intScore: 7, wisScore: 10, chaScore: 13, attackBonus: 4, damageDice: '2d4+2', specialTrait: 'Luring Song', notes: 'Flying speed 40 ft; claws deal slashing damage.' },
{ key: 'ochre-jelly', name: 'Ochre Jelly', challengeRating: '2', ac: 8, maxHp: 45, speed: 10, strScore: 15, dexScore: 6, conScore: 14, intScore: 2, wisScore: 6, chaScore: 1, attackBonus: 5, damageDice: '2d6+3', specialTrait: 'Amorphous, splits when damaged by slashing', notes: 'Pseudopod, bludgeoning plus acid damage.' },
{ key: 'ogre', name: 'Ogre', challengeRating: '2', ac: 11, maxHp: 59, speed: 40, strScore: 19, dexScore: 8, conScore: 16, intScore: 5, wisScore: 7, chaScore: 7, attackBonus: 6, damageDice: '2d8+4', specialTrait: null, notes: 'Greatclub, bludgeoning damage.' },
{ key: 'griffon', name: 'Griffon', challengeRating: '2', ac: 12, maxHp: 59, speed: 30, strScore: 18, dexScore: 15, conScore: 16, intScore: 2, wisScore: 13, chaScore: 8, attackBonus: 6, damageDice: '2d6+4', specialTrait: 'Keen Sight', notes: 'Flying speed 80 ft; multiattack beak and claws.' },
{ key: 'ankheg', name: 'Ankheg', challengeRating: '2', ac: 14, maxHp: 39, speed: 30, strScore: 17, dexScore: 11, conScore: 13, intScore: 1, wisScore: 13, chaScore: 6, attackBonus: 6, damageDice: '2d6+4', specialTrait: 'Burrow speed 10 ft; can spit acid', notes: 'Bite, piercing plus acid damage.' },
{ key: 'manticore', name: 'Manticore', challengeRating: '3', ac: 14, maxHp: 68, speed: 30, strScore: 17, dexScore: 16, conScore: 17, intScore: 7, wisScore: 12, chaScore: 8, attackBonus: 5, damageDice: '1d8+3', specialTrait: 'Tail spikes (ranged)', notes: 'Flying speed 50 ft; multiattack claws or tail spikes.' },
{ key: 'basilisk', name: 'Basilisk', challengeRating: '3', ac: 15, maxHp: 52, speed: 20, strScore: 16, dexScore: 8, conScore: 15, intScore: 2, wisScore: 8, chaScore: 7, attackBonus: 5, damageDice: '2d6+3', specialTrait: 'Petrifying Gaze', notes: 'Bite, piercing damage.' },
{ key: 'owlbear', name: 'Owlbear', challengeRating: '3', ac: 13, maxHp: 59, speed: 40, strScore: 20, dexScore: 12, conScore: 17, intScore: 3, wisScore: 12, chaScore: 7, attackBonus: 7, damageDice: '1d10+5', specialTrait: 'Keen Sight and Smell', notes: 'Multiattack: beak and claws.' },
{ key: 'wight', name: 'Wight', challengeRating: '3', ac: 14, maxHp: 45, speed: 30, strScore: 15, dexScore: 14, conScore: 16, intScore: 10, wisScore: 13, chaScore: 15, attackBonus: 4, damageDice: '1d8+2', specialTrait: 'Life Drain, Sunlight Sensitivity', notes: 'Longsword or life-draining touch.' },
{ key: 'werewolf', name: 'Werewolf', challengeRating: '3', ac: 12, maxHp: 58, speed: 30, strScore: 15, dexScore: 13, conScore: 14, intScore: 10, wisScore: 11, chaScore: 10, attackBonus: 4, damageDice: '1d8+2', specialTrait: 'Shapechanger, regeneration', notes: 'Bite (hybrid/wolf form) or multiattack claws + bite.' },
{ key: 'doppelganger', name: 'Doppelganger', challengeRating: '3', ac: 14, maxHp: 52, speed: 30, strScore: 11, dexScore: 18, conScore: 14, intScore: 11, wisScore: 12, chaScore: 14, attackBonus: 4, damageDice: '2d4+2', specialTrait: 'Shapechanger, Ambusher', notes: 'Multiattack slam.' },
{ key: 'displacer-beast', name: 'Displacer Beast', challengeRating: '3', ac: 13, maxHp: 85, speed: 40, strScore: 18, dexScore: 15, conScore: 16, intScore: 6, wisScore: 12, chaScore: 8, attackBonus: 7, damageDice: '2d6+4', specialTrait: 'Displacement (attacks vs. it have disadvantage)', notes: 'Multiattack: two tentacles.' },
{ key: 'ettin', name: 'Ettin', challengeRating: '4', ac: 12, maxHp: 85, speed: 40, strScore: 21, dexScore: 8, conScore: 17, intScore: 6, wisScore: 10, chaScore: 8, attackBonus: 7, damageDice: '2d8+4', specialTrait: 'Two heads — advantage on Perception, can’t be surprised easily', notes: 'Multiattack: battleaxe and morningstar.' },
{ key: 'hill-giant', name: 'Hill Giant', challengeRating: '5', ac: 13, maxHp: 105, speed: 40, strScore: 21, dexScore: 8, conScore: 19, intScore: 5, wisScore: 9, chaScore: 6, attackBonus: 8, damageDice: '3d8+5', specialTrait: null, notes: 'Greatclub, bludgeoning damage.' },
{ key: 'troll', name: 'Troll', challengeRating: '5', ac: 15, maxHp: 84, speed: 30, strScore: 18, dexScore: 13, conScore: 20, intScore: 7, wisScore: 9, chaScore: 7, attackBonus: 7, damageDice: '2d6+4', specialTrait: 'Regeneration (vulnerable to fire/acid)', notes: 'Multiattack: bite and two claws.' },
{ key: 'wraith', name: 'Wraith', challengeRating: '5', ac: 13, maxHp: 67, speed: 0, strScore: 6, dexScore: 16, conScore: 16, intScore: 12, wisScore: 14, chaScore: 15, attackBonus: 6, damageDice: '4d8+4', specialTrait: 'Incorporeal, life drain, flying 60 ft', notes: 'Undead; life drain touch attack.' },
];
module.exports = { MONSTER_LIBRARY };
+116
View File
@@ -0,0 +1,116 @@
// Suggested multiclass routes: well-known ways to combine classes, for a
// player choosing where their next level goes. Each route starts from a class
// (`from`) and lists its `steps` in order: take `class` up to `toLevel` (that
// class's own level). `subclass` is the subclass the route assumes, if any,
// and `why` what it buys. The requirements are checked against the character
// when shown (game/multiclass.js); none of this is enforced.
const MULTICLASS_PATHS = [
{
key: 'HEXADIN',
name: 'Hexadin',
from: 'PALADIN',
steps: [{ class: 'PALADIN', toLevel: 2 }, { class: 'WARLOCK', toLevel: 1, subclass: 'HEXBLADE' }, { class: 'PALADIN', toLevel: 18 }],
why: 'Hexblade lets you swing with Charisma, so one ability powers attacks, smites and auras.',
},
{
key: 'SORCADIN',
name: 'Sorcadin',
from: 'PALADIN',
steps: [{ class: 'PALADIN', toLevel: 6 }, { class: 'SORCERER', toLevel: 14 }],
why: 'Aura of Protection, then a deep pool of spell slots to pour into Divine Smite.',
},
{
key: 'SORLOCK',
name: 'Sorlock',
from: 'SORCERER',
steps: [{ class: 'SORCERER', toLevel: 2 }, { class: 'WARLOCK', toLevel: 2 }, { class: 'SORCERER', toLevel: 18 }],
why: 'Agonizing Eldritch Blast, quickened with Metamagic, and pact slots that come back on a short rest.',
},
{
key: 'GLOOM_ASSASSIN',
name: 'Gloom Assassin',
from: 'RANGER',
steps: [{ class: 'RANGER', toLevel: 5, subclass: 'GLOOM_STALKER' }, { class: 'ROGUE', toLevel: 3, subclass: 'ASSASSIN' }, { class: 'RANGER', toLevel: 12 }],
why: 'An enormous first round: extra attacks, advantage and automatic critical hits on the surprised.',
},
{
key: 'FIGHTER_DIP',
name: 'Fighter dip for anyone who swings',
from: null,
steps: [{ class: 'FIGHTER', toLevel: 2 }],
why: 'Two levels give a fighting style, Second Wind and Action Surge: one huge turn per rest.',
},
{
key: 'CLERIC_DIP',
name: 'Cleric dip for armor and healing',
from: 'WIZARD',
steps: [{ class: 'CLERIC', toLevel: 1 }, { class: 'WIZARD', toLevel: 19 }],
why: 'Armor and shields, healing words and a domain’s gifts on a wizard, for one level.',
},
{
key: 'BLADESINGER_FIGHTER',
name: 'Fighting wizard',
from: 'FIGHTER',
steps: [{ class: 'FIGHTER', toLevel: 2 }, { class: 'WIZARD', toLevel: 18, subclass: 'BLADESINGING' }],
why: 'Starts in armor with Action Surge, then grows into a Bladesinger with real spells.',
},
{
key: 'ROGUE_FIGHTER',
name: 'Skirmisher',
from: 'ROGUE',
steps: [{ class: 'ROGUE', toLevel: 1 }, { class: 'FIGHTER', toLevel: 2 }, { class: 'ROGUE', toLevel: 17 }],
why: 'Action Surge for a second Sneak Attack chance in a turn, and better armor.',
},
{
key: 'MONK_CLERIC',
name: 'Wisdom warrior',
from: 'MONK',
steps: [{ class: 'MONK', toLevel: 5 }, { class: 'CLERIC', toLevel: 2 }, { class: 'MONK', toLevel: 13 }],
why: 'Stunning Strike and Extra Attack first, then Wisdom-powered healing and Channel Divinity.',
},
{
key: 'BARBARIAN_FIGHTER',
name: 'Raging champion',
from: 'BARBARIAN',
steps: [{ class: 'BARBARIAN', toLevel: 5 }, { class: 'FIGHTER', toLevel: 3, subclass: 'CHAMPION' }, { class: 'BARBARIAN', toLevel: 12 }],
why: 'Reckless Attack with critical hits on 19–20: advantage makes those crits come often.',
},
{
key: 'DRUID_CLERIC',
name: 'Nature priest',
from: 'DRUID',
steps: [{ class: 'CLERIC', toLevel: 1, subclass: 'LIFE' }, { class: 'DRUID', toLevel: 19 }],
why: 'One Life Domain level makes every druid healing spell heal more, and adds heavy armor.',
},
{
key: 'BARD_WARLOCK',
name: 'Eldritch bard',
from: 'BARD',
steps: [{ class: 'BARD', toLevel: 6 }, { class: 'WARLOCK', toLevel: 2 }, { class: 'BARD', toLevel: 12 }],
why: 'Eldritch Blast with Agonizing Blast as a dependable attack for a support caster.',
},
{
key: 'ARTIFICER_WIZARD',
name: 'Arcane engineer',
from: 'ARTIFICER',
steps: [{ class: 'ARTIFICER', toLevel: 2 }, { class: 'WIZARD', toLevel: 18 }],
why: 'Infusions and a tool focus early, then a wizard’s full spell list, all on Intelligence.',
},
{
key: 'RANGER_DRUID',
name: 'Wild warden',
from: 'RANGER',
steps: [{ class: 'RANGER', toLevel: 5 }, { class: 'DRUID', toLevel: 2 }, { class: 'RANGER', toLevel: 13 }],
why: 'Extra Attack and a ranger’s toolkit, plus Wild Shape for scouting and a second health bar.',
},
{
key: 'WARLOCK_PALADIN',
name: 'Blade pact knight',
from: 'WARLOCK',
steps: [{ class: 'WARLOCK', toLevel: 3, subclass: 'HEXBLADE' }, { class: 'PALADIN', toLevel: 2 }, { class: 'WARLOCK', toLevel: 15 }],
why: 'A pact weapon swung with Charisma, and short-rest slots to burn on Divine Smite.',
},
];
module.exports = { MULTICLASS_PATHS };
+70 -17
View File
@@ -1,23 +1,76 @@
// Generated from /seed-data/species.json — keep in sync by hand if that file changes.
// See docs/GAME_RULES_REFERENCE.md "Species Ability Score Increases & Speed".
//
// `abilityBonuses` are fixed. `flexible` is a choice the player makes in the
// builder on top of them: one of the `patterns` (+2 and +1, or +1 three
// times...), each to a different ability, never one in `exclude`. The choice
// is kept on the character as speciesBonusChoices, e.g. { STR: 2, WIS: 1 }.
const SPECIES = {
HUMAN: { key: 'HUMAN', name: 'Human', abilityBonuses: { STR: 1, DEX: 1, CON: 1, INT: 1, WIS: 1, CHA: 1 }, speed: 30, notes: null },
HALF_ELF: {
key: 'HALF_ELF',
name: 'Half-Elf',
abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 2 },
speed: 30,
notes: "Fixed +2 CHA plus a flexible +1/+1 to two other abilities of the player's choice. The builder UI must prompt for this choice; do not hardcode a default.",
},
ELF: { key: 'ELF', name: 'Elf', abilityBonuses: { STR: 0, DEX: 2, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, notes: null },
GNOME: { key: 'GNOME', name: 'Gnome', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 2, WIS: 0, CHA: 0 }, speed: 25, notes: null },
DWARF: { key: 'DWARF', name: 'Dwarf', abilityBonuses: { STR: 0, DEX: 0, CON: 2, INT: 0, WIS: 0, CHA: 0 }, speed: 25, notes: null },
HALFLING: { key: 'HALFLING', name: 'Halfling', abilityBonuses: { STR: 0, DEX: 2, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 25, notes: null },
DRAGONBORN: { key: 'DRAGONBORN', name: 'Dragonborn', abilityBonuses: { STR: 2, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 1 }, speed: 30, notes: null },
TIEFLING: { key: 'TIEFLING', name: 'Tiefling', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 1, WIS: 0, CHA: 2 }, speed: 30, notes: null },
HALF_ORC: { key: 'HALF_ORC', name: 'Half-Orc', abilityBonuses: { STR: 2, DEX: 0, CON: 1, INT: 0, WIS: 0, CHA: 0 }, speed: 30, notes: null },
ORC: { key: 'ORC', name: 'Orc', abilityBonuses: { STR: 2, DEX: 0, CON: 1, INT: 0, WIS: 0, CHA: 0 }, speed: 30, notes: null },
HUMAN: { key: 'HUMAN', name: 'Human', abilityBonuses: { STR: 1, DEX: 1, CON: 1, INT: 1, WIS: 1, CHA: 1 }, speed: 30 },
HALF_ELF: { key: 'HALF_ELF', name: 'Half-Elf', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 2 }, speed: 30, flexible: { patterns: [[1, 1]], exclude: ['CHA'] } },
ELF: { key: 'ELF', name: 'Elf', abilityBonuses: { STR: 0, DEX: 2, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30 },
GNOME: { key: 'GNOME', name: 'Gnome', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 2, WIS: 0, CHA: 0 }, speed: 25 },
DWARF: { key: 'DWARF', name: 'Dwarf', abilityBonuses: { STR: 0, DEX: 0, CON: 2, INT: 0, WIS: 0, CHA: 0 }, speed: 25 },
HALFLING: { key: 'HALFLING', name: 'Halfling', abilityBonuses: { STR: 0, DEX: 2, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 25 },
DRAGONBORN: { key: 'DRAGONBORN', name: 'Dragonborn', abilityBonuses: { STR: 2, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 1 }, speed: 30 },
TIEFLING: { key: 'TIEFLING', name: 'Tiefling', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 1, WIS: 0, CHA: 2 }, speed: 30 },
HALF_ORC: { key: 'HALF_ORC', name: 'Half-Orc', abilityBonuses: { STR: 2, DEX: 0, CON: 1, INT: 0, WIS: 0, CHA: 0 }, speed: 30 },
ORC: { key: 'ORC', name: 'Orc', abilityBonuses: { STR: 2, DEX: 0, CON: 1, INT: 0, WIS: 0, CHA: 0 }, speed: 30 },
AASIMAR: { key: 'AASIMAR', name: 'Aasimar', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
GOLIATH: { key: 'GOLIATH', name: 'Goliath', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 35, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
AARAKOCRA: { key: 'AARAKOCRA', name: 'Aarakocra', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
BUGBEAR: { key: 'BUGBEAR', name: 'Bugbear', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
CENTAUR: { key: 'CENTAUR', name: 'Centaur', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 40, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
CHANGELING: { key: 'CHANGELING', name: 'Changeling', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
DEEP_GNOME: { key: 'DEEP_GNOME', name: 'Deep Gnome', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
DUERGAR: { key: 'DUERGAR', name: 'Duergar', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
ELADRIN: { key: 'ELADRIN', name: 'Eladrin', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
FAIRY: { key: 'FAIRY', name: 'Fairy', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
FIRBOLG: { key: 'FIRBOLG', name: 'Firbolg', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
AIR_GENASI: { key: 'AIR_GENASI', name: 'Air Genasi', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 35, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
EARTH_GENASI: { key: 'EARTH_GENASI', name: 'Earth Genasi', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
FIRE_GENASI: { key: 'FIRE_GENASI', name: 'Fire Genasi', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
WATER_GENASI: { key: 'WATER_GENASI', name: 'Water Genasi', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
GITHYANKI: { key: 'GITHYANKI', name: 'Githyanki', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
GITHZERAI: { key: 'GITHZERAI', name: 'Githzerai', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
GOBLIN: { key: 'GOBLIN', name: 'Goblin', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
HARENGON: { key: 'HARENGON', name: 'Harengon', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
HOBGOBLIN: { key: 'HOBGOBLIN', name: 'Hobgoblin', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
KENKU: { key: 'KENKU', name: 'Kenku', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
KOBOLD: { key: 'KOBOLD', name: 'Kobold', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
LIZARDFOLK: { key: 'LIZARDFOLK', name: 'Lizardfolk', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
MINOTAUR: { key: 'MINOTAUR', name: 'Minotaur', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
SEA_ELF: { key: 'SEA_ELF', name: 'Sea Elf', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
SHADAR_KAI: { key: 'SHADAR_KAI', name: 'Shadar-kai', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
SHIFTER: { key: 'SHIFTER', name: 'Shifter', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
TABAXI: { key: 'TABAXI', name: 'Tabaxi', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
TORTLE: { key: 'TORTLE', name: 'Tortle', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
TRITON: { key: 'TRITON', name: 'Triton', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
YUAN_TI: { key: 'YUAN_TI', name: 'Yuan-ti', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
OWLIN: { key: 'OWLIN', name: 'Owlin', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 30, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
SATYR: { key: 'SATYR', name: 'Satyr', abilityBonuses: { STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 }, speed: 35, flexible: { patterns: [[2, 1], [1, 1, 1]] } },
};
module.exports = { SPECIES };
const ABILITIES = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
// What is wrong with a player's flexible bonuses for a species, or null when
// they are fine. A species without a choice to make takes none.
function speciesBonusProblem(speciesKey, choices) {
const species = SPECIES[speciesKey];
if (!species) return 'That is not a species.';
const entries = Object.entries(choices || {}).filter(([, amount]) => amount);
if (!species.flexible) return entries.length ? `${species.name} has no bonuses to choose.` : null;
if (entries.some(([ability]) => !ABILITIES.includes(ability))) return 'Bonuses go to the six abilities.';
const excluded = species.flexible.exclude || [];
if (entries.some(([ability]) => excluded.includes(ability))) return `${species.name} cannot put a chosen bonus on ${excluded.join(' or ')}.`;
const amounts = entries.map(([, amount]) => amount).sort((a, b) => b - a).join(',');
const fits = species.flexible.patterns.some((pattern) => [...pattern].sort((a, b) => b - a).join(',') === amounts);
if (!fits) {
const options = species.flexible.patterns.map((pattern) => pattern.map((n) => `+${n}`).join('/')).join(' or ');
return `${species.name} chooses ${options}, each to a different ability.`;
}
return null;
}
module.exports = { SPECIES, speciesBonusProblem };
+158
View File
@@ -0,0 +1,158 @@
// 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],
];
// Artificer: a half caster that starts at level 1, rounding up.
const ARTIFICER_SLOTS = [
[2],
[2],
[3],
[3],
[4, 2],
[4, 2],
[4, 3],
[4, 3],
[4, 3, 2],
[4, 3, 2],
[4, 3, 3],
[4, 3, 3],
[4, 3, 3, 1],
[4, 3, 3, 1],
[4, 3, 3, 2],
[4, 3, 3, 2],
[4, 3, 3, 3, 1],
[4, 3, 3, 3, 1],
[4, 3, 3, 3, 2],
[4, 3, 3, 3, 2],
];
// Eldritch Knight, Arcane Trickster: a third of a full caster's pace, from
// level 3 (the subclass is what casts, not the class).
const THIRD_CASTER_SLOTS = [
[],
[],
[2],
[3],
[3],
[3],
[4, 2],
[4, 2],
[4, 2],
[4, 3],
[4, 3],
[4, 3],
[4, 3, 2],
[4, 3, 2],
[4, 3, 2],
[4, 3, 3],
[4, 3, 3],
[4, 3, 3],
[4, 3, 3, 1],
[4, 3, 3, 1],
];
// Warlock: a few slots, all of one slot level. [how many, which level].
const PACT_SLOTS = [
[1, 1],
[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',
ARTIFICER: 'HALF',
WARLOCK: 'PACT',
};
// A class whose slots follow its own table rather than its kind's.
const CLASS_SLOT_TABLES = { ARTIFICER: ARTIFICER_SLOTS };
// Subclasses that cast in a class that otherwise does not, with Intelligence.
const SUBCLASS_CASTER_KIND = { ELDRITCH_KNIGHT: 'THIRD', ARCANE_TRICKSTER: 'THIRD' };
const MAX_SLOT_LEVEL = 9;
module.exports = {
FULL_CASTER_SLOTS,
HALF_CASTER_SLOTS,
ARTIFICER_SLOTS,
THIRD_CASTER_SLOTS,
PACT_SLOTS,
CASTER_KIND,
CLASS_SLOT_TABLES,
SUBCLASS_CASTER_KIND,
MAX_SLOT_LEVEL,
};
+215
View File
@@ -0,0 +1,215 @@
// Default spell library — a broad SRD-style spell list (cantrips through 5th
// level) spanning every spellcasting class, offered to any campaign so a DM
// isn't hand-typing spells from scratch. Shape matches the `Spell` Prisma
// model minus `id`/`campaignId`, plus a stable `key` for "add to campaign"
// lookups and a `classes` tag array the library picker can filter by.
//
// `level` is the classic spell level, which in this app is also the character
// level needed to use the spell. `damageDice` is a default for spells that
// deal damage — the dice at the spell's own level, per the SRD (per ray for
// Scorching Ray, total for Magic Missile's three darts). Spells that only
// heal, protect or control have none. A DM can change either after adding.
const SPELL_LIBRARY = [
// ---- Cantrips (level 0) ----
{ key: 'acid-splash', name: 'Acid Splash', level: 0, school: 'Conjuration', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'Hurl a bubble of acid at one or two creatures within 5 feet of each other.', damageDice: '1d6', classes: ['SORCERER', 'WIZARD'] },
{ key: 'blade-ward', name: 'Blade Ward', level: 0, school: 'Abjuration', castingTime: '1 action', range: 'Self', duration: '1 round', description: 'Resist bludgeoning, piercing, and slashing damage until your next turn.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'chill-touch', name: 'Chill Touch', level: 0, school: 'Necromancy', castingTime: '1 action', range: '120 feet', duration: '1 round', description: 'A ghostly hand deals necrotic damage and prevents healing.', damageDice: '1d8', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'dancing-lights', name: 'Dancing Lights', level: 0, school: 'Illusion', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Create up to four torch-sized lights that you can move.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'druidcraft', name: 'Druidcraft', level: 0, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: 'Instantaneous', description: 'A small, harmless nature-flavored effect: predict weather, sprout a seedling, and the like.', classes: ['DRUID'] },
{ key: 'eldritch-blast', name: 'Eldritch Blast', level: 0, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'A beam of crackling energy strikes one creature for force damage.', damageDice: '1d10', classes: ['WARLOCK'] },
{ key: 'fire-bolt', name: 'Fire Bolt', level: 0, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'Hurl a mote of fire that deals fire damage on a hit.', damageDice: '1d10', classes: ['SORCERER', 'WIZARD'] },
{ key: 'friends', name: 'Friends', level: 0, school: 'Enchantment', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 1 minute', description: 'Advantage on Charisma checks against one creature for the duration.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'guidance', name: 'Guidance', level: 0, school: 'Divination', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 minute', description: 'Add 1d4 to one ability check of your choice.', classes: ['CLERIC', 'DRUID'] },
{ key: 'light', name: 'Light', level: 0, school: 'Evocation', castingTime: '1 action', range: 'Touch', duration: '1 hour', description: 'An object sheds bright light in a 20-foot radius.', classes: ['BARD', 'CLERIC', 'SORCERER', 'WIZARD'] },
{ key: 'mage-hand', name: 'Mage Hand', level: 0, school: 'Conjuration', castingTime: '1 action', range: '30 feet', duration: '1 minute', description: 'A spectral hand manipulates objects, opens doors, and carries small items.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'mending', name: 'Mending', level: 0, school: 'Transmutation', castingTime: '1 minute', range: 'Touch', duration: 'Instantaneous', description: 'Repairs a single break or tear in an object.', classes: ['BARD', 'CLERIC', 'DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'message', name: 'Message', level: 0, school: 'Transmutation', castingTime: '1 action', range: '120 feet', duration: '1 round', description: 'Whisper a message to a target that only they can hear, with a whispered reply.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'minor-illusion', name: 'Minor Illusion', level: 0, school: 'Illusion', castingTime: '1 action', range: '30 feet', duration: '1 minute', description: 'Create a sound or an image of an object for the duration.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'poison-spray', name: 'Poison Spray', level: 0, school: 'Conjuration', castingTime: '1 action', range: '10 feet', duration: 'Instantaneous', description: 'Project a puff of noxious gas that deals poison damage.', damageDice: '1d12', classes: ['DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'prestidigitation', name: 'Prestidigitation', level: 0, school: 'Transmutation', castingTime: '1 action', range: '10 feet', duration: 'Up to 1 hour', description: 'A minor magical trick — light a candle, clean an object, chill or warm food, and the like.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'produce-flame', name: 'Produce Flame', level: 0, school: 'Conjuration', castingTime: '1 action', range: 'Self', duration: '10 minutes', description: 'A flickering flame in your hand sheds light and can be hurled for fire damage.', damageDice: '1d8', classes: ['DRUID'] },
{ key: 'ray-of-frost', name: 'Ray of Frost', level: 0, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A ray of freezing energy deals cold damage and slows the target.', damageDice: '1d8', classes: ['SORCERER', 'WIZARD'] },
{ key: 'resistance', name: 'Resistance', level: 0, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 minute', description: 'The target adds 1d4 to one saving throw of its choice.', classes: ['CLERIC', 'DRUID'] },
{ key: 'sacred-flame', name: 'Sacred Flame', level: 0, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'Radiant flame descends on a creature that must succeed a Dexterity save or take damage.', damageDice: '1d8', classes: ['CLERIC'] },
{ key: 'shillelagh', name: 'Shillelagh', level: 0, school: 'Transmutation', castingTime: '1 bonus action', range: 'Touch', duration: '1 minute', description: 'Imbue a club or quarterstaff with magic, using Wisdom for its attack and damage.', classes: ['DRUID'] },
{ key: 'shocking-grasp', name: 'Shocking Grasp', level: 0, school: 'Evocation', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Lightning springs from your hand, dealing damage and preventing reactions.', damageDice: '1d8', classes: ['SORCERER', 'WIZARD'] },
{ key: 'spare-the-dying', name: 'Spare the Dying', level: 0, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature you touch that has 0 hit points becomes stable.', classes: ['CLERIC'] },
{ key: 'thorn-whip', name: 'Thorn Whip', level: 0, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: 'Instantaneous', description: 'A vine-like whip deals damage and drags the target closer.', damageDice: '1d6', classes: ['DRUID'] },
{ key: 'true-strike', name: 'True Strike', level: 0, school: 'Divination', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 round', description: 'Gain advantage on your next attack roll against the target.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'vicious-mockery', name: 'Vicious Mockery', level: 0, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A string of insults deals psychic damage and imposes disadvantage on the target’s next attack.', damageDice: '1d4', classes: ['BARD'] },
// ---- Level 1 ----
{ key: 'alarm', name: 'Alarm', level: 1, school: 'Abjuration', castingTime: '1 minute', range: '30 feet', duration: '8 hours', description: 'Set an audible or mental alarm against intruders.', classes: ['RANGER', 'WIZARD'] },
{ key: 'animal-friendship', name: 'Animal Friendship', level: 1, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: '24 hours', description: 'Convince a beast that you mean it no harm.', classes: ['BARD', 'DRUID', 'RANGER'] },
{ key: 'bane', name: 'Bane', level: 1, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'Up to three creatures subtract 1d4 from attack rolls and saves.', classes: ['BARD', 'CLERIC'] },
{ key: 'bless', name: 'Bless', level: 1, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'Up to three creatures add 1d4 to attack rolls and saves.', classes: ['CLERIC', 'PALADIN'] },
{ key: 'burning-hands', name: 'Burning Hands', level: 1, school: 'Evocation', castingTime: '1 action', range: 'Self (15-foot cone)', duration: 'Instantaneous', description: 'A thin sheet of flame sweeps out from your hands.', damageDice: '3d6', classes: ['SORCERER', 'WIZARD'] },
{ key: 'charm-person', name: 'Charm Person', level: 1, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: '1 hour', description: 'A humanoid you can see regards you as a friendly acquaintance.', classes: ['BARD', 'DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'color-spray', name: 'Color Spray', level: 1, school: 'Illusion', castingTime: '1 action', range: 'Self (15-foot cone)', duration: '1 round', description: 'A dazzling array of flashing colors can blind weak creatures.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'command', name: 'Command', level: 1, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: '1 round', description: 'Speak a one-word command a creature must obey.', classes: ['CLERIC', 'PALADIN'] },
{ key: 'comprehend-languages', name: 'Comprehend Languages', level: 1, school: 'Divination', castingTime: '1 action', range: 'Self', duration: '1 hour', description: 'Understand any spoken or written language.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'cure-wounds', name: 'Cure Wounds', level: 1, school: 'Evocation', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'A creature you touch regains hit points.', classes: ['BARD', 'CLERIC', 'DRUID', 'PALADIN', 'RANGER'] },
{ key: 'detect-magic', name: 'Detect Magic', level: 1, school: 'Divination', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 10 minutes', description: 'Sense the presence of magic within 30 feet.', classes: ['BARD', 'CLERIC', 'DRUID', 'PALADIN', 'RANGER', 'SORCERER', 'WIZARD'] },
{ key: 'disguise-self', name: 'Disguise Self', level: 1, school: 'Illusion', castingTime: '1 action', range: 'Self', duration: '1 hour', description: 'Change your appearance, including clothing and gear.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'entangle', name: 'Entangle', level: 1, school: 'Conjuration', castingTime: '1 action', range: '90 feet', duration: 'Concentration, up to 1 minute', description: 'Grasping weeds and vines sprout in a 20-foot square.', classes: ['DRUID', 'RANGER'] },
{ key: 'faerie-fire', name: 'Faerie Fire', level: 1, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'Objects in a 20-foot cube are outlined in light, granting attackers advantage.', classes: ['DRUID'] },
{ key: 'feather-fall', name: 'Feather Fall', level: 1, school: 'Transmutation', castingTime: '1 reaction', range: '60 feet', duration: '1 minute', description: 'Falling creatures slow to a gentle descent.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'fog-cloud', name: 'Fog Cloud', level: 1, school: 'Conjuration', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 hour', description: 'A 20-foot-radius sphere of fog heavily obscures the area.', classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
{ key: 'guiding-bolt', name: 'Guiding Bolt', level: 1, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: '1 round', description: 'A flash of light streaks to a target, dealing radiant damage.', damageDice: '4d6', classes: ['CLERIC'] },
{ key: 'healing-word', name: 'Healing Word', level: 1, school: 'Evocation', castingTime: '1 bonus action', range: '60 feet', duration: 'Instantaneous', description: 'A creature you can see regains hit points.', classes: ['BARD', 'CLERIC', 'DRUID'] },
{ key: 'hunters-mark', name: "Hunter's Mark", level: 1, school: 'Divination', castingTime: '1 bonus action', range: '90 feet', duration: 'Concentration, up to 1 hour', description: 'Deal extra damage to a marked target and track it magically.', damageDice: '1d6', classes: ['RANGER'] },
{ key: 'identify', name: 'Identify', level: 1, school: 'Divination', castingTime: '1 minute', range: 'Touch', duration: 'Instantaneous', description: 'Learn the properties of a magic item or the nature of a spell affecting a creature.', classes: ['BARD', 'WIZARD'] },
{ key: 'jump', name: 'Jump', level: 1, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: '1 minute', description: "A creature's jump distance triples.", classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
{ key: 'longstrider', name: 'Longstrider', level: 1, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: '1 hour', description: "A creature's speed increases by 10 feet.", classes: ['BARD', 'DRUID', 'RANGER', 'WIZARD'] },
{ key: 'mage-armor', name: 'Mage Armor', level: 1, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: '8 hours', description: "A protective magical force gives the target a base AC of 13 + Dex modifier.", classes: ['SORCERER', 'WIZARD'] },
{ key: 'magic-missile', name: 'Magic Missile', level: 1, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'Three darts of magical force each strike a target unerringly.', damageDice: '3d4+3', classes: ['SORCERER', 'WIZARD'] },
{ key: 'protection-from-evil-and-good', name: 'Protection from Evil and Good', level: 1, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 10 minutes', description: 'Protect a creature from aberrations, celestials, elementals, fey, fiends, and undead.', classes: ['CLERIC', 'PALADIN', 'WARLOCK', 'WIZARD'] },
{ key: 'shield', name: 'Shield', level: 1, school: 'Abjuration', castingTime: '1 reaction', range: 'Self', duration: '1 round', description: 'An invisible barrier grants +5 AC until the start of your next turn.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'sleep', name: 'Sleep', level: 1, school: 'Enchantment', castingTime: '1 action', range: '90 feet', duration: '1 minute', description: 'Creatures in a 20-foot radius fall unconscious, weakest hit points first.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'speak-with-animals', name: 'Speak with Animals', level: 1, school: 'Divination', castingTime: '1 action', range: 'Self', duration: '10 minutes', description: 'Comprehend and verbally communicate with beasts.', classes: ['BARD', 'DRUID', 'RANGER'] },
{ key: 'thunderwave', name: 'Thunderwave', level: 1, school: 'Evocation', castingTime: '1 action', range: 'Self (15-foot cube)', duration: 'Instantaneous', description: 'A wave of thunderous force sweeps out, damaging and pushing creatures.', damageDice: '2d8', classes: ['BARD', 'DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'witch-bolt', name: 'Witch Bolt', level: 1, school: 'Evocation', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'A beam of lightning forms a sustained arc that damages the target each turn.', damageDice: '1d12', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
// ---- Level 2 ----
{ key: 'aid', name: 'Aid', level: 2, school: 'Abjuration', castingTime: '1 action', range: '30 feet', duration: '8 hours', description: 'Bolster allies with additional hit points.', classes: ['CLERIC', 'PALADIN'] },
{ key: 'alter-self', name: 'Alter Self', level: 2, school: 'Transmutation', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 1 hour', description: 'Assume a variety of minor physical changes.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'animal-messenger', name: 'Animal Messenger', level: 2, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: '24 hours', description: 'Send a tiny animal to deliver a short message.', classes: ['BARD', 'DRUID', 'RANGER'] },
{ key: 'blindness-deafness', name: 'Blindness/Deafness', level: 2, school: 'Necromancy', castingTime: '1 action', range: '30 feet', duration: '1 minute', description: 'Blind or deafen a creature that fails a Constitution save.', classes: ['BARD', 'CLERIC', 'SORCERER', 'WIZARD'] },
{ key: 'blur', name: 'Blur', level: 2, school: 'Illusion', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 1 minute', description: 'Your outline blurs, giving attackers disadvantage.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'darkvision', name: 'Darkvision', level: 2, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: '8 hours', description: 'Grant a creature darkvision out to 60 feet.', classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
{ key: 'enhance-ability', name: 'Enhance Ability', level: 2, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'Grant advantage on checks with one chosen ability.', classes: ['BARD', 'CLERIC', 'DRUID', 'SORCERER'] },
{ key: 'enlarge-reduce', name: 'Enlarge/Reduce', level: 2, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'A creature or object doubles or halves in size.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'flaming-sphere', name: 'Flaming Sphere', level: 2, school: 'Conjuration', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'A 5-foot-diameter sphere of fire rolls where you direct it.', damageDice: '2d6', classes: ['DRUID', 'WIZARD'] },
{ key: 'gust-of-wind', name: 'Gust of Wind', level: 2, school: 'Evocation', castingTime: '1 action', range: 'Self (60-foot line)', duration: 'Concentration, up to 1 minute', description: 'A line of strong wind pushes creatures and extinguishes flames.', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'hold-person', name: 'Hold Person', level: 2, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'Paralyze a humanoid that fails a Wisdom save.', classes: ['BARD', 'CLERIC', 'DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'invisibility', name: 'Invisibility', level: 2, school: 'Illusion', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'A creature you touch becomes invisible.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'lesser-restoration', name: 'Lesser Restoration', level: 2, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'End one disease or condition afflicting the target.', classes: ['BARD', 'CLERIC', 'DRUID', 'PALADIN', 'RANGER'] },
{ key: 'levitate', name: 'Levitate', level: 2, school: 'Transmutation', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 10 minutes', description: 'A creature or object rises vertically and can be moved.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'mirror-image', name: 'Mirror Image', level: 2, school: 'Illusion', castingTime: '1 action', range: 'Self', duration: '1 minute', description: 'Three illusory duplicates of yourself spring into being.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'misty-step', name: 'Misty Step', level: 2, school: 'Conjuration', castingTime: '1 bonus action', range: 'Self', duration: 'Instantaneous', description: 'Teleport up to 30 feet in a burst of silvery mist.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'moonbeam', name: 'Moonbeam', level: 2, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'A silvery beam of pale light shines down in a 5-foot-radius cylinder.', damageDice: '2d10', classes: ['DRUID'] },
{ key: 'pass-without-trace', name: 'Pass without Trace', level: 2, school: 'Abjuration', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 1 hour', description: 'The party becomes exceptionally difficult to track.', classes: ['DRUID', 'RANGER'] },
{ key: 'scorching-ray', name: 'Scorching Ray', level: 2, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'Three rays of fire streak toward targets within range.', damageDice: '2d6', classes: ['SORCERER', 'WIZARD'] },
{ key: 'shatter', name: 'Shatter', level: 2, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A sudden loud ringing noise deals thunder damage in a 10-foot-radius sphere.', damageDice: '3d8', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'silence', name: 'Silence', level: 2, school: 'Illusion', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'No sound can be created within or pass through a 20-foot-radius sphere.', classes: ['BARD', 'CLERIC', 'RANGER'] },
{ key: 'spider-climb', name: 'Spider Climb', level: 2, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'A creature gains the ability to move on vertical surfaces and ceilings.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'spiritual-weapon', name: 'Spiritual Weapon', level: 2, school: 'Evocation', castingTime: '1 bonus action', range: '60 feet', duration: '1 minute', description: 'A spectral weapon attacks a target and can be moved each turn.', damageDice: '1d8', classes: ['CLERIC'] },
{ key: 'suggestion', name: 'Suggestion', level: 2, school: 'Enchantment', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 8 hours', description: 'Plant a reasonable-sounding suggestion in a creature’s mind.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'web', name: 'Web', level: 2, school: 'Conjuration', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 hour', description: 'Thick, sticky webs fill a 20-foot cube.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'zone-of-truth', name: 'Zone of Truth', level: 2, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: '10 minutes', description: 'Creatures in a 15-foot-radius sphere can’t speak a deliberate lie.', classes: ['BARD', 'CLERIC', 'PALADIN'] },
// ---- Level 3 ----
{ key: 'animate-dead', name: 'Animate Dead', level: 3, school: 'Necromancy', castingTime: '1 minute', range: '10 feet', duration: 'Instantaneous', description: 'Raise a skeleton or zombie from a corpse to fight for you.', classes: ['CLERIC', 'WIZARD'] },
{ key: 'beacon-of-hope', name: 'Beacon of Hope', level: 3, school: 'Abjuration', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'Bolster creatures against despair, granting advantage on saves and max healing.', classes: ['CLERIC'] },
{ key: 'call-lightning', name: 'Call Lightning', level: 3, school: 'Conjuration', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A storm cloud forms and you can call down bolts of lightning.', damageDice: '3d10', classes: ['DRUID'] },
{ key: 'counterspell', name: 'Counterspell', level: 3, school: 'Abjuration', castingTime: '1 reaction', range: '60 feet', duration: 'Instantaneous', description: 'Interrupt a creature casting a spell.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'daylight', name: 'Daylight', level: 3, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: '1 hour', description: 'A 60-foot-radius sphere of bright light spreads out.', classes: ['CLERIC', 'DRUID', 'PALADIN', 'RANGER', 'SORCERER'] },
{ key: 'dispel-magic', name: 'Dispel Magic', level: 3, school: 'Abjuration', castingTime: '1 action', range: '120 feet', duration: 'Instantaneous', description: 'End one active spell affecting a creature, object, or effect.', classes: ['BARD', 'CLERIC', 'DRUID', 'PALADIN', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'fear', name: 'Fear', level: 3, school: 'Illusion', castingTime: '1 action', range: 'Self (30-foot cone)', duration: 'Concentration, up to 1 minute', description: 'Creatures in the cone become frightened and drop what they’re holding.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'fireball', name: 'Fireball', level: 3, school: 'Evocation', castingTime: '1 action', range: '150 feet', duration: 'Instantaneous', description: 'A bright streak explodes into flame, dealing fire damage in a 20-foot radius.', damageDice: '8d6', classes: ['SORCERER', 'WIZARD'] },
{ key: 'fly', name: 'Fly', level: 3, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 10 minutes', description: 'The target gains a flying speed of 60 feet.', classes: ['SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'haste', name: 'Haste', level: 3, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: 'Concentration, up to 1 minute', description: 'Double the target’s speed and grant an extra action.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'lightning-bolt', name: 'Lightning Bolt', level: 3, school: 'Evocation', castingTime: '1 action', range: 'Self (100-foot line)', duration: 'Instantaneous', description: 'A stroke of lightning blasts out in a line, dealing lightning damage.', damageDice: '8d6', classes: ['SORCERER', 'WIZARD'] },
{ key: 'mass-healing-word', name: 'Mass Healing Word', level: 3, school: 'Evocation', castingTime: '1 bonus action', range: '60 feet', duration: 'Instantaneous', description: 'Up to six creatures you can see regain hit points.', classes: ['CLERIC'] },
{ 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.', 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'] },
{ key: 'stinking-cloud', name: 'Stinking Cloud', level: 3, school: 'Conjuration', castingTime: '1 action', range: '90 feet', duration: 'Concentration, up to 1 minute', description: 'A 20-foot-radius sphere of yellow, nauseating gas spreads out.', classes: ['SORCERER', 'WIZARD'] },
{ key: 'water-breathing', name: 'Water Breathing', level: 3, school: 'Transmutation', castingTime: '1 action', range: '30 feet', duration: '24 hours', description: 'Up to ten creatures can breathe underwater.', classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
// ---- Level 4 ----
{ key: 'banishment', name: 'Banishment', level: 4, school: 'Abjuration', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'Send a creature to a harmless demiplane.', classes: ['CLERIC', 'PALADIN', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'blight', name: 'Blight', level: 4, school: 'Necromancy', castingTime: '1 action', range: '30 feet', duration: 'Instantaneous', description: 'Necromantic energy withers a creature, dealing necrotic damage.', damageDice: '8d8', classes: ['DRUID', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'confusion', name: 'Confusion', level: 4, school: 'Enchantment', castingTime: '1 action', range: '90 feet', duration: 'Concentration, up to 1 minute', description: 'Creatures in a 10-foot-radius sphere act randomly.', classes: ['BARD', 'DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'dimension-door', name: 'Dimension Door', level: 4, school: 'Conjuration', castingTime: '1 action', range: '500 feet', duration: 'Instantaneous', description: 'Teleport yourself (and possibly a companion) to a spot you can visualize.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ key: 'fire-shield', name: 'Fire Shield', level: 4, school: 'Evocation', castingTime: '1 action', range: 'Self', duration: '10 minutes', description: 'Wreathe yourself in flame that damages attackers and grants resistance.', damageDice: '2d8', classes: ['SORCERER', 'WIZARD'] },
{ key: 'freedom-of-movement', name: 'Freedom of Movement', level: 4, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: '1 hour', description: 'The target’s movement is unaffected by difficult terrain and restraints.', classes: ['BARD', 'CLERIC', 'DRUID', 'RANGER'] },
{ key: 'greater-invisibility', name: 'Greater Invisibility', level: 4, school: 'Illusion', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 minute', description: 'The target becomes invisible even while attacking.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'guardian-of-faith', name: 'Guardian of Faith', level: 4, school: 'Conjuration', castingTime: '1 action', range: '30 feet', duration: '8 hours', description: 'A Large spectral guardian appears and attacks enemies near it.', damageDice: '20', classes: ['CLERIC'] },
{ key: 'ice-storm', name: 'Ice Storm', level: 4, school: 'Evocation', castingTime: '1 action', range: '300 feet', duration: 'Instantaneous', description: 'Hail hammers a 20-foot-radius cylinder, dealing bludgeoning and cold damage.', damageDice: '2d8+4d6', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'polymorph', name: 'Polymorph', level: 4, school: 'Transmutation', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 hour', description: 'Transform a creature into a new form.', classes: ['BARD', 'DRUID', 'SORCERER', 'WIZARD'] },
{ key: 'stone-shape', name: 'Stone Shape', level: 4, school: 'Transmutation', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Reshape a chunk of stone into a form you desire.', classes: ['CLERIC', 'DRUID', 'WIZARD'] },
{ key: 'stoneskin', name: 'Stoneskin', level: 4, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Concentration, up to 1 hour', description: 'The target gains resistance to nonmagical bludgeoning, piercing, and slashing damage.', classes: ['DRUID', 'RANGER', 'SORCERER', 'WIZARD'] },
{ key: 'wall-of-fire', name: 'Wall of Fire', level: 4, school: 'Evocation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Conjure a wall of fire that damages creatures passing through it.', damageDice: '5d8', classes: ['DRUID', 'SORCERER', 'WIZARD'] },
// ---- Level 5 ----
{ key: 'animate-objects', name: 'Animate Objects', level: 5, school: 'Transmutation', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 1 minute', description: 'Up to ten nonmagical objects animate and fight for you.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'cloudkill', name: 'Cloudkill', level: 5, school: 'Conjuration', castingTime: '1 action', range: '120 feet', duration: 'Concentration, up to 10 minutes', description: 'A 20-foot-radius sphere of poisonous, yellow-green fog billows out.', damageDice: '5d8', classes: ['SORCERER', 'WIZARD'] },
{ key: 'cone-of-cold', name: 'Cone of Cold', level: 5, school: 'Evocation', castingTime: '1 action', range: 'Self (60-foot cone)', duration: 'Instantaneous', description: 'A blast of cold air deals cold damage to everything in the cone.', damageDice: '8d8', classes: ['SORCERER', 'WIZARD'] },
{ key: 'contagion', name: 'Contagion', level: 5, school: 'Necromancy', castingTime: '1 action', range: 'Touch', duration: '7 days', description: 'Infect a creature with a magical disease.', classes: ['CLERIC', 'DRUID'] },
{ key: 'dispel-evil-and-good', name: 'Dispel Evil and Good', level: 5, school: 'Abjuration', castingTime: '1 action', range: 'Self', duration: 'Concentration, up to 1 minute', description: 'Protection and the ability to banish otherworldly creatures.', classes: ['CLERIC', 'PALADIN'] },
{ key: 'dominate-person', name: 'Dominate Person', level: 5, school: 'Enchantment', castingTime: '1 action', range: '60 feet', duration: 'Concentration, up to 1 minute', description: 'Attempt to beguile a humanoid and control its actions.', classes: ['BARD', 'SORCERER', 'WIZARD'] },
{ key: 'flame-strike', name: 'Flame Strike', level: 5, school: 'Evocation', castingTime: '1 action', range: '60 feet', duration: 'Instantaneous', description: 'A column of divine fire roars down, dealing fire and radiant damage.', damageDice: '8d6', classes: ['CLERIC'] },
{ key: 'greater-restoration', name: 'Greater Restoration', level: 5, school: 'Abjuration', castingTime: '1 action', range: 'Touch', duration: 'Instantaneous', description: 'Undo a debilitating effect: reduced ability score, exhaustion, or curse.', classes: ['BARD', 'CLERIC', 'DRUID'] },
{ key: 'hold-monster', name: 'Hold Monster', level: 5, school: 'Enchantment', castingTime: '1 action', range: '90 feet', duration: 'Concentration, up to 1 minute', description: 'Paralyze one creature that fails a Wisdom save.', classes: ['BARD', 'SORCERER', 'WARLOCK', 'WIZARD'] },
{ 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.', 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'] },
];
// How each spell lands in a battle (game/combat.js): a spell attack against
// AC, or a saving throw by each target, [ability, half damage on a success].
// A spell in neither list simply takes effect. Written onto the entries
// below, as `attackRoll`, `saveAbility` and `halfOnSave`.
const SPELL_ATTACKS = ['chill-touch', 'eldritch-blast', 'fire-bolt', 'produce-flame', 'ray-of-frost', 'shocking-grasp', 'thorn-whip', 'guiding-bolt', 'witch-bolt', 'scorching-ray', 'spiritual-weapon', 'contagion'];
const SPELL_SAVES = {
'acid-splash': ['DEX', false],
'poison-spray': ['CON', false],
'sacred-flame': ['DEX', false],
'vicious-mockery': ['WIS', false],
'burning-hands': ['DEX', true],
'thunderwave': ['CON', true],
'bane': ['CHA', false],
'charm-person': ['WIS', false],
'command': ['WIS', false],
'entangle': ['STR', false],
'faerie-fire': ['DEX', false],
'animal-friendship': ['WIS', false],
'blindness-deafness': ['CON', false],
'hold-person': ['WIS', false],
'flaming-sphere': ['DEX', true],
'moonbeam': ['CON', true],
'shatter': ['CON', true],
'suggestion': ['WIS', false],
'web': ['DEX', false],
'zone-of-truth': ['CHA', false],
'levitate': ['CON', false],
'gust-of-wind': ['STR', false],
'call-lightning': ['DEX', true],
'fear': ['WIS', false],
'fireball': ['DEX', true],
'lightning-bolt': ['DEX', true],
'sleet-storm': ['DEX', false],
'slow': ['WIS', false],
'spirit-guardians': ['WIS', true],
'stinking-cloud': ['CON', false],
'banishment': ['CHA', false],
'blight': ['CON', true],
'confusion': ['WIS', false],
'ice-storm': ['DEX', true],
'polymorph': ['WIS', false],
'wall-of-fire': ['DEX', true],
'guardian-of-faith': ['DEX', true],
'cloudkill': ['CON', true],
'cone-of-cold': ['CON', true],
'dominate-person': ['WIS', false],
'flame-strike': ['DEX', true],
'hold-monster': ['WIS', false],
'insect-plague': ['CON', true],
};
for (const spell of SPELL_LIBRARY) {
spell.attackRoll = SPELL_ATTACKS.includes(spell.key);
const save = SPELL_SAVES[spell.key];
spell.saveAbility = save ? save[0] : null;
spell.halfOnSave = save ? save[1] : false;
}
module.exports = { SPELL_LIBRARY };
@@ -0,0 +1,70 @@
// Artificer subclasses: the specialists. The shape is described in index.js.
const ARTIFICER = [
{
key: 'ALCHEMIST',
name: 'Alchemist',
source: 'Eberron: Rising from the Last War',
edition: '2014',
summary: 'Brews elixirs and bolsters healing and acid with chemistry.',
features: {
3: [
{ name: 'Experimental Elixir', text: 'Alchemist. After a long rest, brew an elixir (roll a d6: healing, swiftness, resilience, boldness, flight or transformation); spend a spell slot to brew more and choose.' },
{ name: 'Tool Proficiency', text: 'You are proficient with alchemist’s supplies.' },
],
5: [{ name: 'Alchemical Savant', text: 'Add your Intelligence modifier to one roll of acid, fire, necrotic or poison damage, or of healing, from your spells.' }],
9: [{ name: 'Restorative Reagents', text: 'Your elixirs give 2d6 + Intelligence modifier Temp HP, and a few times per long rest you cast Lesser Restoration without a slot.' }],
15: [{ name: 'Chemical Mastery', text: 'Acid and poison damage against you is halved, and you are immune to being poisoned. Once per long rest each, cast Greater Restoration and Heal without a slot.' }],
},
},
{
key: 'ARMORER',
name: 'Armorer',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Wears a suit of arcane power armor: guardian or infiltrator.',
features: {
3: [
{ name: 'Arcane Armor', text: 'Armorer. Your armor becomes your focus, covers your whole body, and you are proficient with heavy armor.' },
{ name: 'Armor Model', text: 'Choose with your DM, changeable after a rest. Guardian: thunder gauntlets (1d8 thunder; the target has disadvantage against others) and a defensive field (bonus action, Temp HP equal to your level). Infiltrator: a lightning launcher (1d6 lightning, +1d6 once per turn), +5 ft speed and advantage on Stealth.' },
],
5: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
9: [{ name: 'Armor Modifications', text: 'Your armor counts as four items for your infusions (helmet, chest, boots, weapon), and you can keep two more infused items.' }],
15: [{ name: 'Perfected Armor', text: 'Guardian: reaction, pull a Huge or smaller creature 30 ft and attack it. Infiltrator: lightning hits make the target glow and give advantage on the next attack against it, plus 1d6 lightning.' }],
},
},
{
key: 'ARTILLERIST',
name: 'Artillerist',
source: 'Eberron: Rising from the Last War',
edition: '2014',
summary: 'Builds an eldritch cannon that blasts, burns or shields.',
features: {
3: [
{ name: 'Eldritch Cannon', text: 'Artillerist. Once per long rest (or by spending a spell slot), action: make a Small or Tiny cannon (the DM adds it). Bonus action: it fires a Flamethrower (2d8 fire cone), a Force Ballista (2d8 force and a push) or a Protector (1d8 + Intelligence modifier Temp HP to allies nearby).' },
{ name: 'Tool Proficiency', text: 'You are proficient with woodcarver’s tools.' },
],
5: [{ name: 'Arcane Firearm', text: 'Carve a wand, staff or rod into an arcane firearm: your artificer spells cast through it add 1d8 to one damage roll.' }],
9: [{ name: 'Explosive Cannon', text: 'Your cannon deals 1d8 more, and you can detonate it (3d8 force to creatures within 20 ft, Dexterity save for half).' }],
15: [{ name: 'Fortified Position', text: 'You can have two cannons at once, and you and allies within 10 ft of a cannon have half cover.' }],
},
},
{
key: 'BATTLE_SMITH',
name: 'Battle Smith',
source: 'Eberron: Rising from the Last War',
edition: '2014',
summary: 'Fights with a steel defender at your side and a magic weapon in hand.',
features: {
3: [
{ name: 'Battle Ready', text: 'Battle Smith. Proficient with martial weapons, and you can use Intelligence for attack and damage with magic weapons.' },
{ name: 'Steel Defender', text: 'A mechanical companion fights beside you (the DM adds it): it acts on your turn, can repair itself, and uses its reaction to give an attacker disadvantage against someone near it. Bonus action: command it to attack.' },
],
5: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
9: [{ name: 'Arcane Jolt', text: 'A few times per long rest (Intelligence modifier), when you or your defender hit: +2d6 force, or heal a creature within 30 ft 2d6.' }],
15: [{ name: 'Improved Defender', text: 'Arcane Jolt deals or heals 4d6; your defender gains +2 AC, and when it deflects an attack the attacker takes 1d4 + Intelligence modifier force.' }],
},
},
];
module.exports = ARTIFICER;
@@ -0,0 +1,165 @@
// Barbarian subclasses: the primal paths. The shape is described in index.js.
const BARBARIAN = [
{
key: 'BERSERKER',
name: 'Path of the Berserker',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Rage as pure fury: extra attacks now, exhaustion after.',
features: {
3: [{ name: 'Frenzy', text: 'Path of the Berserker. While raging you may make one extra melee attack as a bonus action each turn; when the rage ends, gain one level of exhaustion.' }],
6: [{ name: 'Mindless Rage', text: 'You can’t be charmed or frightened while raging.' }],
10: [{ name: 'Intimidating Presence', text: 'Action: one creature within 30 ft makes a Wisdom save (DC 8 + proficiency bonus + Charisma modifier) or is frightened of you until the end of your next turn.' }],
14: [{ name: 'Retaliation', text: 'Reaction: when a creature within 5 ft damages you, make a melee weapon attack against it.' }],
},
},
{
key: 'TOTEM_WARRIOR',
name: 'Path of the Totem Warrior',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A spirit animal walks with you and lends its strength.',
features: {
3: [
{ name: 'Spirit Seeker', text: 'Path of the Totem Warrior. You can speak with beasts and sense what they sense, as a ritual with your DM.' },
{ name: 'Totem Spirit', text: 'Choose one with your DM. Bear: while raging, all damage but psychic is halved. Eagle: while raging, opportunity attacks against you have disadvantage and you can Dash as a bonus action. Wolf: while raging, allies have advantage on melee attacks against enemies next to you.' },
],
6: [{ name: 'Aspect of the Beast', text: 'Choose one with your DM. Bear: carry twice as much, advantage on Strength checks to push, pull or lift. Eagle: see clearly up to a mile, no penalty in dim light. Wolf: track at a fast pace and move stealthily at a normal one.' }],
10: [{ name: 'Spirit Walker', text: 'As a ritual, ask the spirits of a place for what they know (the DM answers as a nature spirit would).' }],
14: [{ name: 'Totemic Attunement', text: 'Choose one with your DM. Bear: while raging, enemies next to you have disadvantage on attacks against anyone else. Eagle: while raging, fly at your speed for the turn. Wolf: while raging, a melee hit can knock a Large or smaller creature prone as a bonus action.' }],
},
},
{
key: 'ZEALOT',
name: 'Path of the Zealot',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A god’s fury in your blows, and death has trouble keeping you.',
features: {
3: [
{ name: 'Divine Fury', text: 'Path of the Zealot. While raging, the first creature you hit each turn takes an extra 1d6 + half your level radiant or necrotic damage.' },
{ name: 'Warrior of the Gods', text: 'Spells that bring you back from the dead cost nothing to cast on you.' },
],
6: [{ name: 'Fanatical Focus', text: 'Once per rage, reroll a saving throw you just failed.' }],
10: [{ name: 'Zealous Presence', text: 'Once per long rest, bonus action: up to ten allies within 60 ft have advantage on attacks and saves until your next turn.' }],
14: [{ name: 'Rage Beyond Death', text: 'While raging, dropping to 0 HP does not knock you out. You still make death saves; you die only when the rage ends with you at 0 HP.' }],
},
},
{
key: 'ANCESTRAL_GUARDIAN',
name: 'Path of the Ancestral Guardian',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Ancestor spirits shield your friends from whatever you hit.',
features: {
3: [{ name: 'Ancestral Protectors', text: 'Path of the Ancestral Guardian. While raging, the first creature you hit each turn has disadvantage on attacks against anyone but you, and anyone else it hits takes only half damage, until your next turn.' }],
6: [{ name: 'Spirit Shield', text: 'While raging, reaction: when a creature within 30 ft takes damage, reduce it by 2d6 (3d6 from level 10, 4d6 from level 14).' }],
10: [{ name: 'Consult the Spirits', text: 'Once per short rest, cast Augury or Clairvoyance without a slot, the spirits doing the seeing.' }],
14: [{ name: 'Vengeful Ancestors', text: 'When Spirit Shield reduces damage, the attacker takes force damage equal to the amount reduced.' }],
},
},
{
key: 'BATTLERAGER',
name: 'Path of the Battlerager',
source: 'Sword Coast Adventurer’s Guide',
edition: '2014',
summary: 'Spiked armor and a reckless charge into the middle of everything.',
features: {
3: [{ name: 'Battlerager Armor', text: 'Path of the Battlerager. In spiked armor while raging: bonus action, a spike attack (1d4 + Strength piercing); and when you grapple a creature it takes 3 piercing damage.' }],
6: [{ name: 'Reckless Abandon', text: 'When you attack recklessly while raging, gain Temp HP equal to your Constitution modifier.' }],
10: [{ name: 'Battlerager Charge', text: 'While raging, you can Dash as a bonus action.' }],
14: [{ name: 'Spiked Retribution', text: 'While raging in spiked armor, a creature within 5 ft that hits you with a melee attack takes 3 piercing damage.' }],
},
},
{
key: 'BEAST',
name: 'Path of the Beast',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Your rage brings out claws, a bite or a lashing tail.',
features: {
3: [{ name: 'Form of the Beast', text: 'Path of the Beast. Each time you rage, choose a natural weapon: Bite (1d8; once per turn heal your proficiency bonus when below half HP), Claws (1d6 each; one extra claw attack when you attack with one) or Tail (1d8, 10 ft reach; reaction +1d8 to your AC against a hit).' }],
6: [{ name: 'Bestial Soul', text: 'Your natural weapons count as magical. After a long rest choose: swim speed and water breathing, climb speed, or extra jump distance.' }],
10: [{ name: 'Infectious Fury', text: 'A few times per long rest (proficiency bonus), when your natural weapon hits: the target makes a Wisdom save or uses its reaction to attack someone else, or takes 2d12 psychic damage.' }],
14: [{ name: 'Call the Hunt', text: 'A few times per long rest (proficiency bonus), when you rage: up to your Constitution modifier allies gain 1d6 extra damage once per turn, and you gain 5 Temp HP for each.' }],
},
},
{
key: 'GIANT',
name: 'Path of the Giant',
source: 'Bigby Presents: Glory of the Giants',
edition: '2014',
summary: 'Grow huge in your rage, and hurl weapons and foes alike.',
features: {
3: [
{ name: 'Giant’s Power', text: 'Path of the Giant. You speak Giant and know Druidcraft or Thaumaturgy.' },
{ name: 'Giant’s Havoc', text: 'While raging your reach grows by 5 ft, and you can grow Large if there is room.' },
],
6: [{ name: 'Elemental Cleaver', text: 'While raging, your weapon carries acid, cold, fire, lightning or thunder: +1d6 of that damage, and it can be thrown 20/60 ft and flies back.' }],
10: [{ name: 'Mighty Impel', text: 'Bonus action while raging: throw a Medium or smaller creature within reach up to 30 ft (Strength save if unwilling); it lands prone and takes 1d8 bludgeoning.' }],
14: [{ name: 'Demiurgic Colossus', text: 'Your rage reach grows by 10 ft, you can grow Large or Huge, Mighty Impel throws Large creatures, and Elemental Cleaver deals 2d6.' }],
},
},
{
key: 'STORM_HERALD',
name: 'Path of the Storm Herald',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A storm of desert, sea or tundra rages around you.',
features: {
3: [{ name: 'Storm Aura', text: 'Path of the Storm Herald. While raging, a 10 ft aura (choose with your DM). Desert: each turn, others in it take fire damage (2 at level 3, rising). Sea: each turn, one creature in it takes 1d6 lightning (Dexterity save for half). Tundra: each turn, you and allies in it gain Temp HP (2 at level 3, rising).' }],
6: [{ name: 'Storm Soul', text: 'Desert: fire resistance. Sea: lightning resistance, breathe underwater, swim speed. Tundra: cold resistance, and you can freeze a little water.' }],
10: [{ name: 'Shielding Storm', text: 'Allies in your Storm Aura gain the resistance your Storm Soul gives.' }],
14: [{ name: 'Raging Storm', text: 'Desert: reaction, a creature in your aura that hits you takes fire damage of half your level. Sea: reaction, a creature you hit makes a Strength save or is knocked prone. Tundra: each turn, one creature in your aura makes a Strength save or has speed 0.' }],
},
},
{
key: 'WILD_MAGIC_BARBARIAN',
name: 'Path of Wild Magic',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Raw magic erupts every time you rage.',
features: {
3: [
{ name: 'Magic Awareness', text: 'Path of Wild Magic. A few times per long rest (proficiency bonus), action: sense spells and magic items within 60 ft.' },
{ name: 'Wild Surge', text: 'When you rage, roll a d8 for a wild effect (the DM reads it): necrotic tendrils, a teleport, an exploding spirit, a magic weapon, a retaliating aura, a light that blinds, flowers and vines, or a bolt of light.' },
],
6: [{ name: 'Bolstering Magic', text: 'A few times per long rest (proficiency bonus), action: a creature you touch adds a d3 to attacks and checks for 10 minutes, or regains a spent spell slot of up to a d3’s level.' }],
10: [{ name: 'Unstable Backlash', text: 'While raging, reaction when you take damage or fail a save: roll a new Wild Surge in place of the current one.' }],
14: [{ name: 'Controlled Surge', text: 'When you roll a Wild Surge, roll twice and choose (any doubles, choose any effect).' }],
},
},
{
key: 'WILD_HEART',
name: 'Path of the Wild Heart',
source: 'Player’s Handbook (2024)',
edition: '2024',
replaces: 'TOTEM_WARRIOR',
summary: 'The 2024 totem path: a beast’s spirit rages with you.',
features: {
3: [
{ name: 'Animal Speaker', text: 'Path of the Wild Heart. Cast Beast Sense and Speak with Animals as rituals.' },
{ name: 'Rage of the Wilds', text: 'Each time you rage, choose. Bear: all damage but force, necrotic, psychic and radiant is halved. Eagle: Dash and Disengage as one bonus action when you rage and on later turns. Wolf: allies have advantage on attacks against enemies next to you.' },
],
6: [{ name: 'Aspect of the Wilds', text: 'After each long rest choose: Owl (darkvision 60 ft, or 60 ft more), Panther (climb speed), or Salmon (swim speed).' }],
10: [{ name: 'Nature Speaker', text: 'Cast Commune with Nature as a ritual.' }],
14: [{ name: 'Power of the Wilds', text: 'Each time you rage, choose. Falcon: fly at your speed. Lion: enemies next to you have disadvantage on attacks against anyone else. Ram: a melee hit can knock a Large or smaller creature prone.' }],
},
},
{
key: 'WORLD_TREE',
name: 'Path of the World Tree',
source: 'Player’s Handbook (2024)',
edition: '2024',
summary: 'The cosmic tree’s roots and branches move with you and your allies.',
features: {
3: [{ name: 'Vitality of the Tree', text: 'Path of the World Tree. When you rage, gain Temp HP equal to your level. While raging, at the start of each turn give an ally within 10 ft Temp HP: d6s equal to your rage damage bonus.' }],
6: [{ name: 'Branches of the Tree', text: 'While raging, reaction: a creature within 30 ft makes a Strength save or is teleported next to you, and its speed is 0 until the turn ends.' }],
10: [{ name: 'Battering Roots', text: 'Your reach with Heavy or Versatile melee weapons grows by 10 ft, and a hit can push the target 15 ft or knock it prone.' }],
14: [{ name: 'Travel Along the Tree', text: 'When you rage, and as a bonus action while raging, teleport up to 60 ft. Once per rage, teleport up to 150 ft and take up to six willing creatures within 10 ft with you.' }],
},
},
];
module.exports = BARBARIAN;
+147
View File
@@ -0,0 +1,147 @@
// Bard subclasses: the colleges. The shape is described in index.js.
const BARD = [
{
key: 'LORE',
name: 'College of Lore',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Knows a bit of everything, and a word to trip anyone up.',
features: {
3: [
{ name: 'Cutting Words', text: 'College of Lore. Reaction: spend a Bardic Inspiration to subtract the die from an enemy’s attack, check or damage roll.' },
{ name: 'Bonus Proficiencies', text: 'Treat three extra skills of your choice as proficient (add your proficiency bonus by hand).' },
],
6: [{ name: 'Additional Magical Secrets', text: 'Learn two spells from any class list (the DM grants them within the unlock schedule).' }],
14: [{ name: 'Peerless Skill', text: 'When you make an ability check, spend a Bardic Inspiration and add the die to it.' }],
},
},
{
key: 'VALOR',
name: 'College of Valor',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A skald in the front line, inspiring by example.',
features: {
3: [
{ name: 'Combat Inspiration', text: 'College of Valor. A creature with your Bardic Inspiration may add the die to a weapon damage roll, or to its AC against one attack as a reaction.' },
{ name: 'Battle Training', text: 'You fight in medium armor and with a shield (the DM raises Extra AC to match what you wear).' },
],
6: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
14: [{ name: 'Battle Magic', text: 'When you cast a bard spell with your action, make one weapon attack as a bonus action.' }],
},
},
{
key: 'SWORDS',
name: 'College of Swords',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A blade dancer whose performance is the fight itself.',
features: {
3: [
{ name: 'Fighting Style', text: 'College of Swords. Choose one with your DM: +2 damage with a one-handed weapon and nothing in the other hand, or add your ability modifier to the damage of a second weapon (added by hand).' },
{ name: 'Blade Flourish', text: 'When you attack on your turn, +10 ft speed that turn. On a hit, spend a Bardic Inspiration and add the die to the damage, and choose: add it to your AC until your next turn, hit a second creature next to you for the die’s roll, or push the target 5 ft + the roll and follow it.' },
],
6: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
14: [{ name: 'Master’s Flourish', text: 'Use a d6 for a Blade Flourish instead of spending a Bardic Inspiration.' }],
},
},
{
key: 'CREATION',
name: 'College of Creation',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Sings things into being, and makes objects dance.',
features: {
3: [
{ name: 'Mote of Potential', text: 'College of Creation. Your Bardic Inspiration comes with a mote: on an ability check roll the die twice and keep either; on an attack, a thunderclap hits creatures near the target for the die’s roll; on a save, gain Temp HP of the roll + your Charisma modifier.' },
{ name: 'Performance of Creation', text: 'Once per long rest (or by spending a 2nd-level slot), action: create a non-magical object worth up to 20 × your level gold, for a few hours.' },
],
6: [{ name: 'Animating Performance', text: 'Once per long rest (or by spending a 3rd-level slot), action: animate a Large or smaller object as a Dancing Item that fights for you for an hour (the DM adds it as a monster).' }],
14: [{ name: 'Creative Crescendo', text: 'Performance of Creation makes several objects at once (your Charisma modifier), and with no limit on their value.' }],
},
},
{
key: 'DANCE',
name: 'College of Dance',
source: 'Player’s Handbook (2024)',
edition: '2024',
summary: 'A whirling dancer who fights unarmored and moves the whole party.',
features: {
3: [
{ name: 'Dazzling Footwork', text: 'College of Dance. Without armor or a shield: AC is 10 + Dexterity + Charisma (ask your DM to set it), advantage on Performance for dancing, unarmed strikes use Dexterity and deal your Bardic Inspiration die + Dexterity bludgeoning, and when you give Bardic Inspiration you may make an unarmed strike as part of it.' },
],
6: [
{ name: 'Inspiring Movement', text: 'Reaction when an enemy ends its turn within 5 ft: spend a Bardic Inspiration to move half your speed, and one ally within 30 ft can do the same, without provoking.' },
{ name: 'Tandem Footwork', text: 'When you roll initiative, spend a Bardic Inspiration: you and allies within 30 ft add the die to initiative.' },
],
14: [{ name: 'Leading Evasion', text: 'On a Dexterity save for half damage, take none on a success and half on a failure; allies within 5 ft making the same save get the same.' }],
},
},
{
key: 'ELOQUENCE',
name: 'College of Eloquence',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'The master orator: words that never fall flat.',
features: {
3: [
{ name: 'Silver Tongue', text: 'College of Eloquence. Treat a Persuasion or Deception roll of 9 or lower as a 10.' },
{ name: 'Unsettling Words', text: 'Bonus action: spend a Bardic Inspiration; a creature within 60 ft subtracts the die from its next save.' },
],
6: [
{ name: 'Unfailing Inspiration', text: 'A creature that adds your Bardic Inspiration and still fails keeps the die.' },
{ name: 'Universal Speech', text: 'Once per long rest, up to your Charisma modifier creatures understand you for an hour, whatever language you speak.' },
],
14: [{ name: 'Infectious Inspiration', text: 'A few times per long rest (Charisma modifier), reaction when a creature succeeds with your Bardic Inspiration: give a free Bardic Inspiration to another creature.' }],
},
},
{
key: 'GLAMOUR',
name: 'College of Glamour',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Fey beauty that charms crowds and commands attention.',
features: {
3: [
{ name: 'Mantle of Inspiration', text: 'College of Glamour. Bonus action, spend a Bardic Inspiration: up to your Charisma modifier creatures within 60 ft gain Temp HP (5, rising with level) and can move their speed without provoking.' },
{ name: 'Enthralling Performance', text: 'After performing for a minute, up to your Charisma modifier listeners make a Wisdom save or are charmed by you for an hour.' },
],
6: [{ name: 'Mantle of Majesty', text: 'Once per long rest, bonus action: for a minute, cast Command as a bonus action each turn without a slot; a creature you charmed fails it automatically.' }],
14: [{ name: 'Unbreakable Majesty', text: 'Once per short rest, bonus action for a minute: a creature that attacks you makes a Charisma save or must pick another target.' }],
},
},
{
key: 'SPIRITS',
name: 'College of Spirits',
source: 'Van Richten’s Guide to Ravenloft',
edition: '2014',
summary: 'A storyteller who channels the tales of the dead.',
features: {
3: [
{ name: 'Guiding Whispers', text: 'College of Spirits. You know Guidance, and it reaches 60 ft.' },
{ name: 'Spiritual Focus', text: 'A candle, crystal ball or skull serves as your focus; from level 6, add a d6 to the damage or healing of your bard spells through it.' },
{ name: 'Tales from Beyond', text: 'Bonus action, spend a Bardic Inspiration: roll it to learn a tale (the DM reads the effect). Bonus action later: tell it to a creature within 30 ft for its effect.' },
],
6: [{ name: 'Spirit Session', text: 'With an hour-long ritual, learn a divination or necromancy spell from any class for the day.' }],
14: [{ name: 'Mystical Connection', text: 'When you roll a tale, roll twice and choose.' }],
},
},
{
key: 'WHISPERS',
name: 'College of Whispers',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Secrets, fear and poison words: a bard who frightens.',
features: {
3: [
{ name: 'Psychic Blades', text: 'College of Whispers. When you hit with a weapon, spend a Bardic Inspiration: +2d6 psychic damage (3d6 at level 5, 5d6 at 10, 8d6 at 15), once per turn.' },
{ name: 'Words of Terror', text: 'Once per short rest, after a minute alone talking with a creature: Wisdom save or frightened of you or someone you name for an hour.' },
],
6: [{ name: 'Mantle of Whispers', text: 'Once per short rest, reaction when a humanoid dies within 30 ft: take its shadow and wear its appearance for an hour, knowing what it knew of its life.' }],
14: [{ name: 'Shadow Lore', text: 'Once per long rest, action: whisper to a creature; it makes a Wisdom save or is charmed by you for 8 hours, believing you know its darkest secret.' }],
},
},
];
module.exports = BARD;
+232
View File
@@ -0,0 +1,232 @@
// Cleric subclasses: the divine domains. The shape is described in index.js.
// A domain’s first-level gifts arrive with the choice, at level 2.
const CLERIC = [
{
key: 'LIFE',
name: 'Life Domain',
source: 'Player’s Handbook',
edition: '2014',
summary: 'The healer: more from every cure, and healing in a burst.',
features: {
2: [{ name: 'Preserve Life', text: 'Channel Divinity. Action: share out healing of 5 × your level among creatures within 30 ft (up to half their max HP each).' }],
6: [{ name: 'Blessed Healer', text: 'When you heal someone else with a spell, you heal yourself 2 + the spell’s level.' }],
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 radiant damage.' }],
14: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +2d8 radiant damage.', upgrade: true }],
17: [{ name: 'Supreme Healing', text: 'Your healing spells heal the most their dice can roll, instead of rolling.' }],
},
},
{
key: 'LIGHT',
name: 'Light Domain',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Fire and radiance: blinds attackers and burns the dark away.',
features: {
2: [
{ name: 'Warding Flare', text: 'Light Domain. Reaction, a few times per day (your Wisdom modifier, at least once): a creature within 30 ft that attacks you has disadvantage on that attack.' },
{ name: 'Radiance of the Dawn', text: 'Channel Divinity. Action: magical darkness within 30 ft is dispelled, and each enemy there takes 2d10 + your level radiant damage (Constitution save for half).' },
],
6: [{ name: 'Improved Flare', text: 'Use Warding Flare when a creature attacks someone else within 30 ft of you.' }],
8: [{ name: 'Potent Spellcasting', text: 'Add your Wisdom modifier to the damage of your cleric cantrips.' }],
17: [{ name: 'Corona of Light', text: 'Action: shine like the sun for a minute (60 ft bright light); enemies in it have disadvantage on saves against fire and radiant spells.' }],
},
},
{
key: 'WAR',
name: 'War Domain',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A soldier of the faith: extra strikes and blows that land.',
features: {
2: [
{ name: 'War Priest', text: 'War Domain. A few times per day (your Wisdom modifier, at least once), after you take the Attack action, make one weapon attack as a bonus action.' },
{ name: 'Guided Strike', text: 'Channel Divinity. When you make an attack roll, add +10 to it after seeing the roll.' },
],
6: [{ name: 'War God’s Blessing', text: 'Channel Divinity. Reaction: give +10 to an attack roll made by a creature within 30 ft.' }],
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 damage of the weapon’s type.' }],
14: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +2d8 damage of the weapon’s type.', upgrade: true }],
17: [{ name: 'Avatar of Battle', text: 'Bludgeoning, piercing and slashing damage from non-magical attacks against you is halved.' }],
},
},
{
key: 'ARCANA',
name: 'Arcana Domain',
source: 'Sword Coast Adventurer’s Guide',
edition: '2014',
summary: 'A scholar of the god of magic, at home with wizard spells.',
features: {
2: [
{ name: 'Arcane Initiate', text: 'Arcana Domain. Treat Arcana as proficient (add the bonus by hand) and learn two wizard cantrips (the DM grants them).' },
{ name: 'Arcane Abjuration', text: 'Channel Divinity. Action: a celestial, elemental, fey or fiend within 30 ft makes a Wisdom save or is turned for a minute; from level 5, weak ones are banished.' },
],
6: [{ name: 'Spell Breaker', text: 'When you heal an ally with a spell, also end one spell on them of a level no higher than the slot you used.' }],
8: [{ name: 'Potent Spellcasting', text: 'Add your Wisdom modifier to the damage of your cleric cantrips.' }],
17: [{ name: 'Arcane Mastery', text: 'Add one wizard spell of each of 6th, 7th, 8th and 9th level to your domain spells (the DM grants them).' }],
},
},
{
key: 'DEATH',
name: 'Death Domain',
source: 'Dungeon Master’s Guide',
edition: '2014',
summary: 'A priest of endings: necrotic magic that ignores resistance.',
features: {
2: [
{ name: 'Reaper', text: 'Death Domain. Learn a necromancy cantrip; when you cast one that targets one creature, you may target two within 5 ft of each other.' },
{ name: 'Touch of Death', text: 'Channel Divinity. When you hit with a melee attack, deal an extra 5 + 2 × your level necrotic damage.' },
],
6: [{ name: 'Inescapable Destruction', text: 'Your necrotic damage ignores resistance to necrotic.' }],
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 necrotic damage (2d8 from level 14).' }],
17: [{ name: 'Improved Reaper', text: 'When you cast a necromancy spell of 1st to 5th level that targets one creature, you may target a second within 5 ft of the first.' }],
},
},
{
key: 'FORGE',
name: 'Forge Domain',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A smith-priest in heavy armor, blessing weapons and walking through fire.',
features: {
2: [
{ name: 'Blessing of the Forge', text: 'Forge Domain. After a long rest, touch a weapon or suit of armor: it gets +1 to attack and damage, or +1 AC, until your next long rest.' },
{ name: 'Artisan’s Blessing', text: 'Channel Divinity. An hour’s ritual creates a metal object worth up to 100 gold.' },
],
6: [{ name: 'Soul of the Forge', text: 'Fire damage against you is halved, and you gain +1 AC in heavy armor (the DM raises Extra AC by 1).' }],
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 fire damage (2d8 from level 14).' }],
17: [{ name: 'Saint of Forge and Fire', text: 'Immune to fire, and in heavy armor non-magical weapon damage against you is halved.' }],
},
},
{
key: 'GRAVE',
name: 'Grave Domain',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Guards the line between life and death, and pulls friends back from it.',
features: {
2: [
{ name: 'Circle of Mortality', text: 'Grave Domain. Healing spells on a creature at 0 HP heal their maximum. You know Spare the Dying, at 30 ft, as a bonus action.' },
{ name: 'Eyes of the Grave', text: 'A few times per long rest (Wisdom modifier), action: sense undead within 60 ft.' },
{ name: 'Path to the Grave', text: 'Channel Divinity. Action: curse a creature within 30 ft; the next attack to hit it deals double damage.' },
],
6: [{ name: 'Sentinel at Death’s Door', text: 'A few times per long rest (Wisdom modifier), reaction: turn a critical hit on a creature within 30 ft into a normal hit.' }],
8: [{ name: 'Potent Spellcasting', text: 'Add your Wisdom modifier to the damage of your cleric cantrips.' }],
17: [{ name: 'Keeper of Souls', text: 'Once per round, when an enemy dies within 60 ft, you or an ally within 60 ft regains HP equal to its number of Hit Dice.' }],
},
},
{
key: 'KNOWLEDGE',
name: 'Knowledge Domain',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A seeker of secrets who reads minds and past events.',
features: {
2: [
{ name: 'Blessings of Knowledge', text: 'Knowledge Domain. Two extra languages, and expertise in two of Arcana, History, Nature or Religion (add double proficiency by hand).' },
{ name: 'Knowledge of the Ages', text: 'Channel Divinity. For 10 minutes, be proficient with a skill or tool of your choice.' },
],
6: [{ name: 'Read Thoughts', text: 'Channel Divinity. Read the surface thoughts of a creature within 60 ft (Wisdom save); you may then cast Suggestion on it without a slot.' }],
8: [{ name: 'Potent Spellcasting', text: 'Add your Wisdom modifier to the damage of your cleric cantrips.' }],
17: [{ name: 'Visions of the Past', text: 'After a minute of meditation, see recent events around an object you hold or the place you are in.' }],
},
},
{
key: 'NATURE',
name: 'Nature Domain',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A green priest who charms beasts and commands plants.',
features: {
2: [
{ name: 'Acolyte of Nature', text: 'Nature Domain. Learn a druid cantrip, and treat one of Animal Handling, Nature or Survival as proficient.' },
{ name: 'Charm Animals and Plants', text: 'Channel Divinity. Action: beasts and plant creatures within 30 ft make a Wisdom save or are charmed by you for a minute.' },
],
6: [{ name: 'Dampen Elements', text: 'Reaction: give yourself or a creature within 30 ft resistance to one instance of acid, cold, fire, lightning or thunder damage.' }],
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 cold, fire or lightning damage (2d8 from level 14).' }],
17: [{ name: 'Master of Nature', text: 'Bonus action: command the creatures you have charmed with Charm Animals and Plants.' }],
},
},
{
key: 'ORDER',
name: 'Order Domain',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'A herald of law whose spells make allies strike.',
features: {
2: [
{ name: 'Voice of Authority', text: 'Order Domain. When you cast a spell of 1st level or higher on an ally, they may use their reaction to make one weapon attack.' },
{ name: 'Order’s Demand', text: 'Channel Divinity. Action: creatures of your choice within 30 ft make a Wisdom save or are charmed until your next turn and drop what they hold.' },
],
6: [{ name: 'Embodiment of the Law', text: 'A few times per long rest (Wisdom modifier), cast an enchantment spell of 1st level or higher as a bonus action.' }],
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 psychic damage (2d8 from level 14).' }],
17: [{ name: 'Order’s Wrath', text: 'Once per turn, a creature you hit with Divine Strike is cursed: the next ally to hit it before your next turn deals +2d8 psychic.' }],
},
},
{
key: 'PEACE',
name: 'Peace Domain',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Binds friends together so they protect one another.',
features: {
2: [
{ name: 'Emboldening Bond', text: 'Peace Domain. A few times per long rest (proficiency bonus), bond up to your proficiency bonus creatures for 10 minutes: once per turn each may add a d4 to an attack, check or save while within 30 ft of another.' },
{ name: 'Balm of Peace', text: 'Channel Divinity. Action: move up to your speed without provoking; each creature you pass within 5 ft of heals 2d6 + your Wisdom modifier.' },
],
6: [{ name: 'Protective Bond', text: 'When a bonded creature is about to take damage, another within 30 ft can teleport next to it and take the damage instead.' }],
8: [{ name: 'Potent Spellcasting', text: 'Add your Wisdom modifier to the damage of your cleric cantrips.' }],
17: [{ name: 'Expansive Bond', text: 'Your bonds reach 60 ft, and a creature taking damage for another resists it.' }],
},
},
{
key: 'TEMPEST',
name: 'Tempest Domain',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Thunder and lightning at the god’s command.',
features: {
2: [
{ name: 'Wrath of the Storm', text: 'Tempest Domain. A few times per long rest (Wisdom modifier), reaction when a creature within 5 ft hits you: it takes 2d8 lightning or thunder (Dexterity save for half).' },
{ name: 'Destructive Wrath', text: 'Channel Divinity. When you roll lightning or thunder damage, deal the maximum instead.' },
],
6: [{ name: 'Thunderbolt Strike', text: 'When you deal lightning damage to a Large or smaller creature, push it up to 10 ft away.' }],
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 thunder damage (2d8 from level 14).' }],
17: [{ name: 'Stormborn', text: 'You can fly at your walking speed whenever you are outdoors and not underground.' }],
},
},
{
key: 'TRICKERY',
name: 'Trickery Domain',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A god of mischief’s agent: illusions, duplicates and blessings of stealth.',
features: {
2: [
{ name: 'Blessing of the Trickster', text: 'Trickery Domain. Action: give a creature advantage on Stealth checks for an hour.' },
{ name: 'Invoke Duplicity', text: 'Channel Divinity. Create an illusory double of yourself within 30 ft for a minute; you can cast spells as if from its space, and have advantage on attacks against creatures next to it.' },
],
6: [{ name: 'Cloak of Shadows', text: 'Channel Divinity. Action: turn invisible until the end of your next turn.' }],
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 poison damage (2d8 from level 14).' }],
17: [{ name: 'Improved Duplicity', text: 'Invoke Duplicity makes up to four doubles, and you can move them all at once.' }],
},
},
{
key: 'TWILIGHT',
name: 'Twilight Domain',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'A guardian of the night who keeps fear away and friends topped up.',
features: {
2: [
{ name: 'Eyes of Night', text: 'Twilight Domain. You see in darkness to 300 ft, and can share it with allies for an hour.' },
{ name: 'Vigilant Blessing', text: 'Action: a creature has advantage on its next initiative roll.' },
{ name: 'Twilight Sanctuary', text: 'Channel Divinity. Action: a 30 ft sphere of dim light around you for a minute; each turn, a creature in it may gain 1d6 + your level Temp HP or end being charmed or frightened.' },
],
6: [{ name: 'Steps of Night', text: 'A few times per long rest (proficiency bonus), bonus action in dim light or darkness: fly at your speed for a minute.' }],
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 radiant damage (2d8 from level 14).' }],
17: [{ name: 'Twilight Shroud', text: 'You and allies in your Twilight Sanctuary have half cover.' }],
},
},
];
module.exports = CLERIC;
+125
View File
@@ -0,0 +1,125 @@
// Druid subclasses: the circles. The shape is described in index.js.
const DRUID = [
{
key: 'LAND',
name: 'Circle of the Land',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A keeper of old magic, at home in the wild places.',
features: {
2: [
{ name: 'Natural Recovery', text: 'Once per day on a short rest, recover spent spell slots totalling up to half your level (rounded up).' },
{ name: 'Bonus Cantrip', text: 'Learn one extra cantrip (the DM grants it).' },
],
6: [{ name: 'Land’s Stride', text: 'Ordinary difficult terrain and plants do not slow you; advantage on saves against magical plants.' }],
10: [{ name: 'Nature’s Ward', text: 'Immune to poison and disease; you can’t be charmed or frightened by fey or elementals.' }],
14: [{ name: 'Nature’s Sanctuary', text: 'Beasts and plant creatures must make a Wisdom save to attack you; on a failure they pick another target.' }],
},
},
{
key: 'MOON',
name: 'Circle of the Moon',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A shapeshifter who fights as the beast.',
features: {
2: [
{ name: 'Combat Wild Shape', text: 'Circle of the Moon. Use Wild Shape as a bonus action. While in beast form, bonus action: spend a spell slot to heal 1d8 per slot level.' },
{ name: 'Circle Forms', text: 'Your Wild Shape can be a beast of up to CR 1; from level 6, up to CR equal to a third of your level (rounded down).' },
],
6: [{ name: 'Primal Strike', text: 'Your attacks in beast form count as magical.' }],
10: [{ name: 'Elemental Wild Shape', text: 'Spend both uses of Wild Shape at once to become an air, earth, fire or water elemental.' }],
14: [{ name: 'Thousand Forms', text: 'Cast Alter Self at will.' }],
},
},
{
key: 'STARS',
name: 'Circle of Stars',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Reads the night sky and wears its constellations.',
features: {
2: [
{ name: 'Star Map', text: 'Circle of Stars. You know the Guidance cantrip, and cast Guiding Bolt without a slot a number of times per day equal to your proficiency bonus (the DM grants both).' },
{ name: 'Starry Form', text: 'Bonus action: spend a Wild Shape use to take a starry form for 10 minutes. Choose Archer (bonus action: ranged spell attack, 60 ft, 1d8 + Wisdom modifier radiant), Chalice (when you cast a healing spell, another creature within 30 ft heals 1d8 + Wisdom modifier) or Dragon (treat a roll of 9 or lower as 10 on Intelligence and Wisdom checks and on concentration saves).' },
],
6: [{ name: 'Cosmic Omen', text: 'After a long rest roll a die. Even (Weal): reaction, add 1d6 to a creature’s attack, check or save. Odd (Woe): reaction, subtract 1d6. Usable a number of times per day equal to your proficiency bonus, on creatures within 30 ft.' }],
10: [{ name: 'Twinkling Constellations', text: 'Archer and Chalice use 2d8. In Dragon form you can fly 20 ft. At the start of each turn in Starry Form you may change constellation.' }],
14: [{ name: 'Full of Stars', text: 'In Starry Form, bludgeoning, piercing and slashing damage against you is halved.' }],
},
},
{
key: 'DREAMS',
name: 'Circle of Dreams',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Fey-touched healer who opens safe paths through the night.',
features: {
2: [{ name: 'Balm of the Summer Court', text: 'Circle of Dreams. A pool of d6s equal to your level per long rest. Bonus action: spend up to half your level of them to heal a creature within 120 ft, who also gains 1 Temp HP per die.' }],
6: [{ name: 'Hearth of Moonlight and Shadow', text: 'During a rest, a 30 ft sphere around you gives +5 to Stealth and Perception checks and hides your camp’s light.' }],
10: [{ name: 'Hidden Paths', text: 'A few times per long rest (Wisdom modifier), bonus action: teleport 60 ft, or action: teleport a willing creature you touch 30 ft.' }],
14: [{ name: 'Walker in Dreams', text: 'Once per long rest, right after a short rest: cast Dream, Scrying or Teleportation Circle without a slot.' }],
},
},
{
key: 'SEA',
name: 'Circle of the Sea',
source: 'Player’s Handbook (2024)',
edition: '2024',
summary: 'Wears the ocean’s fury as a stinging, storm-wrapped aura.',
features: {
2: [{ name: 'Wrath of the Sea', text: 'Circle of the Sea. Bonus action, spend a Wild Shape use: a 5 ft emanation of ocean spray for 10 minutes. Each turn, one creature in it takes cold damage (d6s equal to your Wisdom modifier, Constitution save for half) and may be pushed 15 ft.' }],
6: [{ name: 'Aquatic Affinity', text: 'Your Wrath of the Sea grows to 10 ft, and you gain a swim speed equal to your walking speed.' }],
10: [{ name: 'Stormborn', text: 'While Wrath of the Sea is active you fly at your speed and resist cold, lightning and thunder damage.' }],
14: [{ name: 'Oceanic Gift', text: 'You can put your Wrath of the Sea around an ally instead, or around both of you by spending two uses.' }],
},
},
{
key: 'SHEPHERD',
name: 'Circle of the Shepherd',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A summoner whose spirits and beasts are stronger than anyone’s.',
features: {
2: [
{ name: 'Speech of the Woods', text: 'Circle of the Shepherd. You speak Sylvan and can cast Speak with Animals at will.' },
{ name: 'Spirit Totem', text: 'Once per short rest, bonus action: a spirit aura of 30 ft for a minute. Bear: allies gain 5 + your level Temp HP and advantage on Strength checks. Hawk: reaction, give an ally advantage on an attack. Unicorn: advantage on checks to detect creatures, and your healing spells heal everyone in it your level more.' },
],
6: [{ name: 'Mighty Summoner', text: 'Beasts and fey you summon have 2 extra HP per Hit Die, and their attacks count as magical.' }],
10: [{ name: 'Guardian Spirit', text: 'Creatures you summon that end a turn in your Spirit Totem heal half your level.' }],
14: [{ name: 'Faithful Summons', text: 'Once per long rest, when you drop to 0 HP or are incapacitated: four CR 2 beasts appear to protect you (as Conjure Animals).' }],
},
},
{
key: 'SPORES',
name: 'Circle of Spores',
source: 'Guildmasters’ Guide to Ravnica',
edition: '2014',
summary: 'Rot and fungus: poison the living and raise the fallen.',
features: {
2: [
{ name: 'Halo of Spores', text: 'Circle of Spores. Reaction: a creature within 10 ft takes 1d4 necrotic (1d6 at level 6, 1d8 at 10, 1d10 at 14; Constitution save negates).' },
{ name: 'Symbiotic Entity', text: 'Action, spend a Wild Shape use: gain 4 × your level Temp HP for 10 minutes; while they last your Halo of Spores dice double and your melee hits deal +1d6 necrotic.' },
],
6: [{ name: 'Fungal Infestation', text: 'A few times per long rest (Wisdom modifier), reaction when a beast or humanoid dies within 10 ft: it rises as a 1 HP zombie under your command for an hour.' }],
10: [{ name: 'Spreading Spores', text: 'Bonus action while Symbiotic Entity is active: hurl your spores into a 10 ft cube up to 30 ft away, which deals your Halo damage to anyone who enters or starts there.' }],
14: [{ name: 'Fungal Body', text: 'You can’t be blinded, deafened, frightened or poisoned, and critical hits against you are normal hits unless you are incapacitated.' }],
},
},
{
key: 'WILDFIRE',
name: 'Circle of Wildfire',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'A fire spirit companion that burns and renews in equal measure.',
features: {
2: [{ name: 'Summon Wildfire Spirit', text: 'Circle of Wildfire. Action, spend a Wild Shape use: a wildfire spirit appears for an hour, dealing 2d6 fire to creatures within 10 ft of it as it arrives (the DM adds it as a monster). It can teleport allies and fight beside you.' }],
6: [{ name: 'Enhanced Bond', text: 'While your spirit is summoned, your fire spells and healing spells add +1d8, and spells can come from the spirit’s position.' }],
10: [{ name: 'Cauterizing Flames', text: 'A few times per long rest (proficiency bonus), when a creature dies within 30 ft of you or your spirit: a flame appears there; anyone entering it can be healed or burned 2d10 + your Wisdom modifier, your choice.' }],
14: [{ name: 'Blazing Revival', text: 'Once per long rest, when you drop to 0 HP with your spirit within 120 ft: it vanishes and you regain half your HP and stand up.' }],
},
},
];
module.exports = DRUID;
+183
View File
@@ -0,0 +1,183 @@
// Fighter subclasses: the martial archetypes. The shape is described in index.js.
const FIGHTER = [
{
key: 'CHAMPION',
name: 'Champion',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Simple and deadly: more critical hits, more athletic.',
features: {
3: [{ name: 'Improved Critical', text: 'Your weapon attacks score a critical hit on a roll of 19 or 20.' }],
7: [{ name: 'Remarkable Athlete', text: 'Add half your proficiency bonus to Strength, Dexterity and Constitution checks you are not proficient in; long jump + Strength modifier feet.' }],
10: [{ name: 'Additional Fighting Style', text: 'Choose a second option from your Fighting Style list. (If it is +1 AC in armor, the DM raises Extra AC by 1.)' }],
15: [{ name: 'Superior Critical', text: 'Your weapon attacks score a critical hit on a roll of 18 to 20.' }],
18: [{ name: 'Survivor', text: 'At the start of each turn, if you are below half HP but above 0, regain 5 + your Constitution modifier HP.' }],
},
},
{
key: 'BATTLE_MASTER',
name: 'Battle Master',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A tactician who turns each swing into a manoeuvre.',
features: {
3: [
{ name: 'Combat Superiority', text: 'Battle Master. Four superiority dice (d8), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier.' },
{ name: 'Manoeuvres', text: 'Choose three with your DM: Trip Attack (add the die to damage; Strength save or prone), Disarming Attack (add the die; Strength save or it drops what it holds), Pushing Attack (add the die; Strength save or pushed 15 ft), Precision Attack (add the die to an attack roll), Riposte (reaction when a creature misses you: attack it and add the die), Parry (reaction: reduce melee damage by the die + Dexterity modifier), Rally (bonus action: an ally gains the die + Charisma modifier Temp HP), Menacing Attack (add the die; Wisdom save or frightened until your next turn).' },
],
7: [
{ name: 'Know Your Enemy', text: 'After a minute watching a creature, the DM tells you whether it is your equal, better or worse in two of: Strength, Dexterity, Constitution, AC, current HP, level.' },
{ name: 'Combat Superiority', text: 'Battle Master. Five superiority dice (d8), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier.', upgrade: true },
{ name: 'Manoeuvres', text: 'Choose two more with your DM (five in all) from the list on your level 3 line.' },
],
10: [{ name: 'Combat Superiority', text: 'Battle Master. Five superiority dice (d10), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier. Choose two more manoeuvres with your DM (seven in all).', upgrade: true }],
15: [
{ name: 'Relentless', text: 'When you roll initiative with no superiority dice left, regain one.' },
{ name: 'Combat Superiority', text: 'Battle Master. Six superiority dice (d10), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier. Choose two more manoeuvres with your DM (nine in all).', upgrade: true },
],
18: [{ name: 'Combat Superiority', text: 'Battle Master. Six superiority dice (d12), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier. Nine manoeuvres in all.', upgrade: true }],
},
},
{
key: 'SAMURAI',
name: 'Samurai',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Unbreakable resolve: strikes with advantage and shrugs off blows.',
features: {
3: [
{ name: 'Fighting Spirit', text: 'Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 5 Temp HP.' },
{ name: 'Bonus Proficiency', text: 'Treat one of History, Insight, Performance or Persuasion as proficient (add your proficiency bonus by hand).' },
],
7: [{ name: 'Elegant Courtier', text: 'Add your Wisdom modifier to Persuasion checks, and treat Wisdom saves as proficient (both added by hand).' }],
10: [
{ name: 'Tireless Spirit', text: 'If you roll initiative with no uses of Fighting Spirit left, you regain one.' },
{ name: 'Fighting Spirit', text: 'Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 10 Temp HP.', upgrade: true },
],
15: [
{ name: 'Rapid Strike', text: 'Once per turn, when you have advantage on an attack, give it up to make one extra attack.' },
{ name: 'Fighting Spirit', text: 'Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 15 Temp HP.', upgrade: true },
],
18: [{ name: 'Strength Before Death', text: 'Once per long rest, when you drop to 0 HP: take a full extra turn right away before you fall.' }],
},
},
{
key: 'ARCANE_ARCHER',
name: 'Arcane Archer',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Elven archery that wraps each arrow in a spell.',
features: {
3: [
{ name: 'Arcane Archer Lore', text: 'Arcane Archer. Treat Arcana or Nature as proficient, and learn Prestidigitation or Druidcraft.' },
{ name: 'Arcane Shot', text: 'Twice per short rest, when you hit with a bow: add an arcane effect (choose two to know, more later): Banishing, Beguiling, Bursting (2d6 force to all within 10 ft), Enfeebling, Grasping, Piercing (a 30 ft line), Seeking (finds a hidden target), Shadow (blinds).' },
],
7: [
{ name: 'Magic Arrow', text: 'Your arrows count as magical.' },
{ name: 'Curving Shot', text: 'Bonus action when you miss with a magic arrow: reroll the attack against another target within 60 ft of the first.' },
],
10: [{ name: 'Arcane Shot', text: 'Learn one more Arcane Shot option (three in all); damage from shots grows to 4d6 at level 18.' }],
15: [{ name: 'Ever-Ready Shot', text: 'When you roll initiative with no Arcane Shots left, regain one.' }],
18: [{ name: 'Arcane Shot', text: 'Your Arcane Shot effects deal their improved damage.' }],
},
},
{
key: 'CAVALIER',
name: 'Cavalier',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A mounted guardian who holds the line and punishes anyone who ignores them.',
features: {
3: [
{ name: 'Born to the Saddle', text: 'Cavalier. Advantage on saves against falling off your mount; mounting or dismounting costs 5 ft of movement.' },
{ name: 'Unwavering Mark', text: 'When you hit a creature, mark it until your next turn: it has disadvantage on attacks against anyone else, and if it damages someone else you may attack it as a bonus action with advantage, adding half your level to the damage.' },
],
7: [{ name: 'Warding Maneuver', text: 'A few times per long rest (Constitution modifier), reaction: add 1d8 to an adjacent ally’s AC against an attack; if it still hits, it takes half damage.' }],
10: [{ name: 'Hold the Line', text: 'Creatures provoke opportunity attacks from you when they move 5 ft or more within your reach; a hit stops them.' }],
15: [{ name: 'Ferocious Charger', text: 'Once per turn, after moving 10 ft straight before a hit, the target makes a Strength save or is knocked prone.' }],
18: [{ name: 'Vigilant Defender', text: 'You get a special reaction on every other creature’s turn, used only for opportunity attacks.' }],
},
},
{
key: 'ECHO_KNIGHT',
name: 'Echo Knight',
source: 'Explorer’s Guide to Wildemount',
edition: '2014',
summary: 'Fights from two places at once with an echo of a possible self.',
features: {
3: [
{ name: 'Manifest Echo', text: 'Echo Knight. Bonus action: a translucent echo of you appears within 15 ft (AC 14 + proficiency bonus, 1 HP). You can attack from its space, swap places with it (15 ft of movement), and it moves 30 ft as a bonus action.' },
{ name: 'Unleash Incarnation', text: 'A few times per long rest (Constitution modifier), when you take the Attack action: make one extra attack from your echo’s position.' },
],
7: [{ name: 'Echo Avatar', text: 'Action: see and hear through your echo for 10 minutes; it can travel up to 1,000 ft away.' }],
10: [{ name: 'Shadow Martyr', text: 'Once per short rest, reaction: your echo teleports in front of an ally and takes the attack instead.' }],
15: [{ name: 'Reclaim Potential', text: 'A few times per long rest (Constitution modifier), when your echo is destroyed: gain 2d6 + Constitution modifier Temp HP.' }],
18: [{ name: 'Legion of One', text: 'You can have two echoes at once, and you regain a use of Unleash Incarnation when you roll initiative with none.' }],
},
},
{
key: 'ELDRITCH_KNIGHT',
name: 'Eldritch Knight',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A warrior who weaves wizard spells between sword strokes.',
features: {
3: [
{ name: 'Spellcasting', text: 'Eldritch Knight. You cast wizard spells (mostly abjuration and evocation) with Intelligence, as a third caster: the DM grants spells within the unlock schedule.' },
{ name: 'Weapon Bond', text: 'Bond with up to two weapons: they can’t be disarmed from you, and you can summon one to your hand as a bonus action.' },
],
7: [{ name: 'War Magic', text: 'When you cast a cantrip with your action, make one weapon attack as a bonus action.' }],
10: [{ name: 'Eldritch Strike', text: 'A creature you hit with a weapon has disadvantage on its next save against a spell you cast before the end of your next turn.' }],
15: [{ name: 'Arcane Charge', text: 'When you use Action Surge, teleport up to 30 ft.' }],
18: [{ name: 'Improved War Magic', text: 'When you cast a spell with your action, make one weapon attack as a bonus action.' }],
},
},
{
key: 'PSI_WARRIOR',
name: 'Psi Warrior',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Telekinetic force behind every blow and shield.',
features: {
3: [{ name: 'Psionic Power', text: 'Psi Warrior. Psionic dice (twice your proficiency bonus, d6, growing to d12), back on a long rest. Protective Field: reaction, reduce damage to someone within 30 ft by a die + Intelligence modifier. Psionic Strike: +a die + Intelligence modifier force on a hit. Telekinetic Movement: move an object or ally 30 ft.' }],
7: [{ name: 'Telekinetic Adept', text: 'Psi-Powered Leap: bonus action, fly twice your speed this turn. Telekinetic Thrust: when you use Psionic Strike, the target makes a Strength save or is pushed 10 ft or knocked prone.' }],
10: [{ name: 'Guarded Mind', text: 'Psychic damage against you is halved; spend a psionic die to end being charmed or frightened.' }],
15: [{ name: 'Bulwark of Force', text: 'Once per long rest, bonus action: up to your Intelligence modifier creatures gain half cover for a minute.' }],
18: [{ name: 'Telekinetic Master', text: 'Once per long rest, cast Telekinesis without a slot; while concentrating on it, make one weapon attack as a bonus action each turn.' }],
},
},
{
key: 'PURPLE_DRAGON_KNIGHT',
name: 'Purple Dragon Knight (Banneret)',
source: 'Sword Coast Adventurer’s Guide',
edition: '2014',
summary: 'A battlefield leader whose second winds heal the whole squad.',
features: {
3: [{ name: 'Rallying Cry', text: 'Purple Dragon Knight. When you use Second Wind, up to three allies within 60 ft regain HP equal to your level.' }],
7: [{ name: 'Royal Envoy', text: 'Treat Persuasion as proficient, and double your proficiency bonus on it (add by hand).' }],
10: [{ name: 'Inspiring Surge', text: 'When you use Action Surge, one ally within 60 ft can make a weapon attack with its reaction.' }],
15: [{ name: 'Bulwark', text: 'When you use Indomitable on an Intelligence, Wisdom or Charisma save, an ally within 60 ft who failed the same save can reroll too.' }],
18: [{ name: 'Inspiring Surge', text: 'When you use Action Surge, two allies within 60 ft can each make a weapon attack with their reaction.', upgrade: true }],
},
},
{
key: 'RUNE_KNIGHT',
name: 'Rune Knight',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Giant runes carved into your gear, and the strength to grow giant-sized.',
features: {
3: [
{ name: 'Rune Carver', text: 'Rune Knight. Learn two runes (Cloud, Fire, Frost, Stone, Hill, Storm; more later) and inscribe them on your gear. Each has a passive gift and a once-per-short-rest power.' },
{ name: 'Giant’s Might', text: 'A few times per long rest (proficiency bonus), bonus action for a minute: grow Large, advantage on Strength checks and saves, and +1d6 damage once per turn.' },
],
7: [{ name: 'Runic Shield', text: 'A few times per long rest (proficiency bonus), reaction: force an attacker to reroll its attack against an ally within 60 ft.' }],
10: [{ name: 'Great Stature', text: 'You grow taller, and Giant’s Might deals 1d8 extra damage.' }],
15: [{ name: 'Master of Runes', text: 'Each rune’s power can be used twice per short rest.' }],
18: [{ name: 'Runic Juggernaut', text: 'Giant’s Might deals 1d10, lets you grow Huge, and adds 5 ft of reach.' }],
},
},
];
module.exports = FIGHTER;
+84
View File
@@ -0,0 +1,84 @@
// Subclasses: the path a character takes within their class, one file per
// class. Every official subclass is here (2014 books, and the 2024 Player’s
// Handbook where it brought a new one), as listed at
// dungeonsanddragonsfan.com/all-dnd-5e-classes-subclasses-complete-guide.
//
// Each entry is { key, name, source, edition, replaces?, summary, features }.
// - `key` is unique across all classes; it is what Character.subclass holds.
// - `source` is the book, `edition` '2014' or '2024'.
// - `replaces` names the 2014 subclass a 2024 one updates (Wild Heart for the
// Totem Warrior). A character on the old one can switch to the new.
// - `features` are by character level, in the same shape as LEVEL_FEATURES
// (levelFeatures.js): each becomes a line of the special abilities, and an
// `upgrade` rewrites the line that already has its name. A subclass's gifts
// from before its class chooses (a cleric domain's 1st-level ones) arrive
// with the choice. None of it feeds the calculation engine.
//
// The first subclass of each class is the path every character of that class
// followed before subclasses could be chosen (the Champion fighter, the Life
// cleric...), and the one a campaign without subclasses gives everybody. Its
// lines are word for word what the class tables used to write, so sheets
// written back then still match.
// The level at which a class chooses.
const SUBCLASS_LEVEL = {
ARTIFICER: 3,
BARBARIAN: 3,
BARD: 3,
CLERIC: 2,
DRUID: 2,
FIGHTER: 3,
MONK: 3,
PALADIN: 3,
RANGER: 3,
ROGUE: 3,
SORCERER: 3,
WARLOCK: 3,
WIZARD: 2,
};
const SUBCLASSES = {
ARTIFICER: require('./artificer'),
BARBARIAN: require('./barbarian'),
BARD: require('./bard'),
CLERIC: require('./cleric'),
DRUID: require('./druid'),
FIGHTER: require('./fighter'),
MONK: require('./monk'),
PALADIN: require('./paladin'),
RANGER: require('./ranger'),
ROGUE: require('./rogue'),
SORCERER: require('./sorcerer'),
WARLOCK: require('./warlock'),
WIZARD: require('./wizard'),
};
function subclassesOf(characterClass) {
return SUBCLASSES[characterClass] || [];
}
// One subclass of a class by its key; null when the class has no such subclass.
function findSubclass(characterClass, key) {
return subclassesOf(characterClass).find((subclass) => subclass.key === key) || null;
}
function subclassName(characterClass, key) {
const subclass = findSubclass(characterClass, key);
return subclass ? subclass.name : null;
}
// The path a class followed before subclasses could be chosen.
function defaultSubclass(characterClass) {
return subclassesOf(characterClass)[0] || null;
}
// The 2024 subclass that updates this one, if there is one.
function newerVersionOf(characterClass, key) {
return subclassesOf(characterClass).find((subclass) => subclass.replaces === key) || null;
}
// What a list of subclasses to choose from shows of each: everything but the
// features.
const subclassSummary = ({ key, name, summary, source, edition, replaces = null }) => ({ key, name, summary, source, edition, replaces });
module.exports = { SUBCLASS_LEVEL, SUBCLASSES, subclassesOf, findSubclass, subclassName, defaultSubclass, newerVersionOf, subclassSummary };
+171
View File
@@ -0,0 +1,171 @@
// Monk subclasses: the monastic traditions. The shape is described in index.js.
const MONK = [
{
key: 'OPEN_HAND',
name: 'Way of the Open Hand',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Pure martial arts: knocks down, pushes away, heals itself.',
features: {
3: [{ name: 'Open Hand Technique', text: 'Flurry hits can knock a target prone (Dexterity save), push it 15 ft (Strength save) or stop its reactions.' }],
6: [{ name: 'Wholeness of Body', text: 'Once per long rest, heal 3 × your level as an action.' }],
11: [{ name: 'Tranquility', text: 'After a long rest you are under Sanctuary until your next long rest or until you attack or cast on someone.' }],
17: [{ name: 'Quivering Palm', text: '3 ki on an unarmed hit: set up vibrations. Later, as an action, end them: the target makes a Constitution save or drops to 0 HP; on a success it takes 10d10 necrotic.' }],
},
},
{
key: 'SHADOW',
name: 'Way of Shadow',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A ninja: steps between shadows and strikes unseen.',
features: {
3: [{ name: 'Shadow Arts', text: 'Way of Shadow. 2 ki: cast Darkness, Darkvision, Pass without Trace or Silence without a slot. You can also make a small illusion at will.' }],
6: [{ name: 'Shadow Step', text: 'Bonus action, while in dim light or darkness: teleport up to 60 ft to another dim or dark spot you can see, and have advantage on your first melee attack this turn.' }],
11: [{ name: 'Cloak of Shadows', text: 'Action, in dim light or darkness: turn invisible until you attack, cast or step into bright light.' }],
17: [{ name: 'Opportunist', text: 'Reaction: when a creature within 5 ft is hit by someone else’s attack, make a melee attack against it.' }],
},
},
{
key: 'FOUR_ELEMENTS',
name: 'Way of the Four Elements',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Bends fire, water, earth and air with ki.',
features: {
3: [
{ name: 'Elemental Attunement', text: 'Way of the Four Elements. Action: a harmless trick of fire, water, earth or air within 30 ft (light or snuff a flame, chill or warm, shape a little earth or mist).' },
{ name: 'Elemental Disciplines', text: 'Choose one with your DM: Fangs of the Fire Snake (1 ki: your unarmed reach is 10 ft this turn and hits deal fire damage; +1 ki for +1d10 fire), Fist of Unbroken Air (2 ki: one creature within 30 ft takes 3d10 bludgeoning, is pushed 20 ft and knocked prone; Strength save for half and no push), Water Whip (2 ki: one creature within 30 ft takes 3d10 bludgeoning and is pulled 25 ft or knocked prone; Dexterity save for half), Shape the Flowing River (1 ki: reshape or freeze water or ice in a 30 ft cube).' },
],
6: [{ name: 'Elemental Disciplines', text: 'Choose one more with your DM (two in all). New at this level: Gong of the Summit (3 ki: cast Shatter), Clench of the North Wind (3 ki: cast Hold Person).' }],
11: [{ name: 'Elemental Disciplines', text: 'Choose one more with your DM (three in all). New at this level: Flames of the Phoenix (4 ki: cast Fireball), Eternal Mountain Defense (5 ki: cast Stoneskin on yourself).', upgrade: true }],
17: [{ name: 'Elemental Disciplines', text: 'Choose one more with your DM (four in all). New at this level: Breath of Winter (6 ki: cast Cone of Cold), River of Hungry Flame (5 ki: cast Wall of Fire), Wave of Rolling Earth (6 ki: cast Wall of Stone).', upgrade: true }],
},
},
{
key: 'ASCENDANT_DRAGON',
name: 'Way of the Ascendant Dragon',
source: 'Fizban’s Treasury of Dragons',
edition: '2014',
summary: 'Draconic breath and wings, channelled through ki.',
features: {
3: [
{ name: 'Draconic Disciple', text: 'Way of the Ascendant Dragon. Your unarmed strikes can deal acid, cold, fire, lightning or poison damage; you can reroll a failed Intimidation or Persuasion check once per long rest.' },
{ name: 'Breath of the Dragon', text: 'Replace one attack with a 20 ft cone or 30 ft line of draconic breath: two martial arts dice of your element (Dexterity save for half). Free a few times per long rest (proficiency bonus), then 2 ki.' },
],
6: [{ name: 'Wings Unfurled', text: 'When you use Step of the Wind, sprout wings and fly until the end of the turn (a few times per long rest).' }],
11: [{ name: 'Aspect of the Wyrm', text: 'Bonus action, 3 ki: a 10 ft aura of frightful presence or elemental resistance for a minute.' }],
17: [{ name: 'Ascendant Aspect', text: 'Blindsight 10 ft; Breath of the Dragon uses three dice; when you hit with the breath, deal extra damage to creatures in your aura.' }],
},
},
{
key: 'ASTRAL_SELF',
name: 'Way of the Astral Self',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Summons spectral arms, then a whole astral body, to fight.',
features: {
3: [{ name: 'Arms of the Astral Self', text: 'Way of the Astral Self. Bonus action, 1 ki: spectral arms for 10 minutes. Wisdom replaces Strength for checks and saves, your reach is 10 ft, and arm strikes deal force using Dexterity or Wisdom.' }],
6: [{ name: 'Visage of the Astral Self', text: 'Bonus action, 1 ki: an astral mask for 10 minutes: darkvision 120 ft, advantage on Insight and Intimidation, and you can speak to one creature alone.' }],
11: [{ name: 'Body of the Astral Self', text: 'With arms and visage summoned: reaction, reduce acid, cold, fire, force, lightning or thunder damage by 1d10 + Wisdom modifier; once per turn an arm hit deals +1 martial arts die.' }],
17: [{ name: 'Awakened Astral Self', text: 'Bonus action, 5 ki: your whole astral self appears for 10 minutes: +2 AC, and three arm attacks when you take the Attack action.' }],
},
},
{
key: 'DRUNKEN_MASTER',
name: 'Way of the Drunken Master',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Sways, staggers and somehow never gets hit.',
features: {
3: [
{ name: 'Bonus Proficiencies', text: 'Way of the Drunken Master. Treat Performance as proficient, and you are proficient with brewer’s supplies.' },
{ name: 'Drunken Technique', text: 'When you use Flurry of Blows, you also gain Disengage and +10 ft speed this turn.' },
],
6: [{ name: 'Tipsy Sway', text: 'Leap to Your Feet: standing up costs only 5 ft. Redirect Attack: reaction, 1 ki, when a creature misses you, turn its attack onto another creature next to you.' }],
11: [{ name: 'Drunkard’s Luck', text: '2 ki: cancel disadvantage on a check, attack or save.' }],
17: [{ name: 'Intoxicated Frenzy', text: 'When you use Flurry of Blows, make up to three extra attacks, each against a different creature.' }],
},
},
{
key: 'KENSEI',
name: 'Way of the Kensei',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A weapon master who makes chosen weapons part of their martial arts.',
features: {
3: [
{ name: 'Path of the Kensei', text: 'Way of the Kensei. Choose two kensei weapons with your DM (one melee, one ranged): they count as monk weapons. Agile Parry: after an unarmed strike, +2 AC while holding a kensei weapon. Kensei’s Shot: bonus action, +1d4 to ranged kensei weapon damage this turn.' },
],
6: [
{ name: 'One with the Blade', text: 'Your kensei weapons count as magical.' },
{ name: 'Deft Strike', text: 'Once per turn, 1 ki on a kensei weapon hit: add a martial arts die to the damage.' },
],
11: [{ name: 'Sharpen the Blade', text: 'Bonus action, up to 3 ki: a kensei weapon gains that much to attack and damage for a minute.' }],
17: [{ name: 'Unerring Accuracy', text: 'Once per turn, reroll a missed monk weapon attack.' }],
},
},
{
key: 'LONG_DEATH',
name: 'Way of the Long Death',
source: 'Sword Coast Adventurer’s Guide',
edition: '2014',
summary: 'Studies death itself: feeds on the dying and refuses to fall.',
features: {
3: [{ name: 'Touch of Death', text: 'Way of the Long Death. When you reduce a creature within 5 ft to 0 HP, gain Temp HP equal to your Wisdom modifier + your level.' }],
6: [{ name: 'Hour of Reaping', text: 'Action: creatures within 30 ft that can see you make a Wisdom save or are frightened of you until your next turn.' }],
11: [{ name: 'Mastery of Death', text: 'When you drop to 0 HP, spend 1 ki to drop to 1 HP instead.' }],
17: [{ name: 'Touch of the Long Death', text: 'Action, 1 to 10 ki: touch a creature; it takes 2d10 necrotic per ki (Constitution save for half).' }],
},
},
{
key: 'MERCY',
name: 'Way of Mercy',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'A masked healer whose touch mends friends and harms foes.',
features: {
3: [
{ name: 'Implements of Mercy', text: 'Way of Mercy. Treat Insight and Medicine as proficient, and you are proficient with an herbalism kit.' },
{ name: 'Hand of Healing', text: '1 ki (or in place of one Flurry strike): a creature you touch heals a martial arts die + Wisdom modifier.' },
{ name: 'Hand of Harm', text: 'Once per turn, 1 ki on an unarmed hit: +a martial arts die + Wisdom modifier necrotic.' },
],
6: [{ name: 'Physician’s Touch', text: 'Hand of Healing also ends a disease or a condition (blinded, deafened, paralyzed, poisoned, stunned); Hand of Harm also poisons the target until your next turn.' }],
11: [{ name: 'Flurry of Healing and Harm', text: 'Each Flurry of Blows strike can use Hand of Healing for free, and once per turn Hand of Harm for free.' }],
17: [{ name: 'Hand of Ultimate Mercy', text: 'Once per long rest, 5 ki: return to life a creature dead no more than 24 hours, with 4d10 + Wisdom modifier HP.' }],
},
},
{
key: 'SUN_SOUL',
name: 'Way of the Sun Soul',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Hurls bolts of radiant ki from range.',
features: {
3: [{ name: 'Radiant Sun Bolt', text: 'Way of the Sun Soul. A ranged spell attack (30 ft) using Dexterity: a martial arts die + Dexterity radiant. It can replace your attacks, and Flurry can be bolts for 1 ki.' }],
6: [{ name: 'Searing Arc Strike', text: 'After the Attack action, 2 ki (+1 per extra level): cast Burning Hands as a bonus action.' }],
11: [{ name: 'Searing Sunburst', text: 'Action: a 20 ft burst of radiance within 150 ft: 2d6 radiant (Constitution save negates), +2d6 per 1 ki spent (up to 3).' }],
17: [{ name: 'Sun Shield', text: 'Shed bright light at will; when a creature hits you with a melee attack, it takes 5 + Wisdom modifier radiant.' }],
},
},
{
key: 'WARRIOR_OF_ELEMENTS',
name: 'Warrior of the Elements',
source: 'Player’s Handbook (2024)',
edition: '2024',
replaces: 'FOUR_ELEMENTS',
summary: 'The 2024 elemental monk: elemental reach and bursts, simpler to run.',
features: {
3: [
{ name: 'Elemental Attunement', text: 'Warrior of the Elements. 1 ki: for 10 minutes your unarmed reach grows 10 ft, your strikes can deal acid, cold, fire, lightning or thunder, and a hit can push or pull the target 10 ft (Strength save).' },
{ name: 'Manipulate Elements', text: 'You know Elementalism.' },
],
6: [{ name: 'Elemental Burst', text: 'Action, 2 ki: a 20 ft sphere within 120 ft; three martial arts dice of an element (Dexterity save for half).' }],
11: [{ name: 'Stride of the Elements', text: 'While attuned, you have a fly and swim speed equal to your walking speed.' }],
17: [{ name: 'Elemental Epitome', text: 'While attuned: resistance to one element, Step of the Wind adds 20 ft and damages those you pass, and once per turn a strike deals +a martial arts die.' }],
},
},
];
module.exports = MONK;
+150
View File
@@ -0,0 +1,150 @@
// Paladin subclasses: the sacred oaths. The shape is described in index.js.
const PALADIN = [
{
key: 'DEVOTION',
name: 'Oath of Devotion',
source: 'Player’s Handbook',
edition: '2014',
summary: 'The shining knight: honesty, courage, a blessed blade.',
features: {
3: [
{ name: 'Sacred Weapon', text: 'Oath of Devotion, Channel Divinity. Add your Charisma modifier to attack rolls with your weapon for 1 minute; it sheds light.' },
{ name: 'Turn the Unholy', text: 'Channel Divinity. Fiends and undead within 30 ft make a Wisdom save or flee for 1 minute.' },
],
7: [{ name: 'Aura of Devotion', text: 'You and allies within 10 ft can’t be charmed.' }],
15: [{ name: 'Purity of Spirit', text: 'You are always under Protection from Evil and Good.' }],
20: [{ name: 'Holy Nimbus', text: 'Once per long rest, action for a minute: bright light 30 ft; enemies starting a turn in it take 10 radiant, and you have advantage on saves against fiend and undead spells.' }],
},
},
{
key: 'ANCIENTS',
name: 'Oath of the Ancients',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A green knight who guards life, light and laughter.',
features: {
3: [
{ name: 'Nature’s Wrath', text: 'Oath of the Ancients, Channel Divinity. Spectral vines restrain a creature within 10 ft (Strength or Dexterity save; it repeats the save at the end of each of its turns).' },
{ name: 'Turn the Faithless', text: 'Channel Divinity. Fey and fiends within 30 ft make a Wisdom save or flee for 1 minute.' },
],
7: [{ name: 'Aura of Warding', text: 'You and allies within 10 ft take half damage from spells.' }],
15: [{ name: 'Undying Sentinel', text: 'Once per long rest, when you drop to 0 HP and are not killed, drop to 1 HP instead. You no longer suffer the frailty of old age.' }],
20: [{ name: 'Elder Champion', text: 'Once per long rest, action for a minute: regain 10 HP each turn, cast paladin spells as a bonus action, and enemies within 10 ft have disadvantage on saves against your spells and Channel Divinity.' }],
},
},
{
key: 'VENGEANCE',
name: 'Oath of Vengeance',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A relentless hunter of one sworn enemy at a time.',
features: {
3: [
{ name: 'Vow of Enmity', text: 'Oath of Vengeance, Channel Divinity. Bonus action: for 1 minute you have advantage on attack rolls against one creature within 10 ft.' },
{ name: 'Abjure Enemy', text: 'Channel Divinity. One creature within 60 ft makes a Wisdom save or is frightened and cannot move for 1 minute; on a success its speed is halved.' },
],
7: [{ name: 'Relentless Avenger', text: 'When you hit with an opportunity attack, move up to half your speed straight afterwards without provoking attacks.' }],
15: [{ name: 'Soul of Vengeance', text: 'Reaction: when the target of your Vow of Enmity attacks, make a melee attack against it.' }],
20: [{ name: 'Avenging Angel', text: 'Once per long rest, action for an hour: wings (fly 60 ft) and a 30 ft aura; enemies entering it make a Wisdom save or are frightened, and attacks against them have advantage.' }],
},
},
{
key: 'CONQUEST',
name: 'Oath of Conquest',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Rules through fear: enemies who can’t run and can’t win.',
features: {
3: [
{ name: 'Conquering Presence', text: 'Oath of Conquest, Channel Divinity. Action: creatures of your choice within 30 ft make a Wisdom save or are frightened for a minute.' },
{ name: 'Guided Strike', text: 'Channel Divinity. Add +10 to an attack roll after seeing it.' },
],
7: [{ name: 'Aura of Conquest', text: 'A creature frightened of you within 10 ft has speed 0 and takes psychic damage of half your level when it starts its turn there.' }],
15: [{ name: 'Scornful Rebuke', text: 'A creature that hits you takes psychic damage equal to your Charisma modifier.' }],
20: [{ name: 'Invincible Conqueror', text: 'Once per long rest, action for a minute: all damage against you is halved, you attack one extra time with the Attack action, and you crit on 19–20.' }],
},
},
{
key: 'CROWN',
name: 'Oath of the Crown',
source: 'Sword Coast Adventurer’s Guide',
edition: '2014',
summary: 'A loyal protector who takes the blows meant for others.',
features: {
3: [
{ name: 'Champion Challenge', text: 'Oath of the Crown, Channel Divinity. Bonus action: creatures of your choice within 30 ft make a Wisdom save or can’t willingly move more than 30 ft away from you.' },
{ name: 'Turn the Tide', text: 'Channel Divinity. Bonus action: each creature of your choice within 30 ft below half HP heals 1d6 + your Charisma modifier.' },
],
7: [{ name: 'Divine Allegiance', text: 'Reaction: when a creature within 5 ft takes damage, take it yourself instead.' }],
15: [{ name: 'Unyielding Spirit', text: 'Advantage on saves against being paralyzed or stunned.' }],
20: [{ name: 'Exalted Champion', text: 'Once per long rest, action for an hour: non-magical weapon damage against you is halved, and you and allies within 30 ft have advantage on death saves and Wisdom saves.' }],
},
},
{
key: 'GLORY',
name: 'Oath of Glory',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'A hero of legend: athletic feats and inspiring smites.',
features: {
3: [
{ name: 'Peerless Athlete', text: 'Oath of Glory, Channel Divinity. Bonus action for 10 minutes: advantage on Athletics and Acrobatics, carry twice as much, and jump 10 ft further.' },
{ name: 'Inspiring Smite', text: 'Channel Divinity. Right after a Divine Smite: share 2d8 + your level Temp HP among creatures within 30 ft.' },
],
7: [{ name: 'Aura of Alacrity', text: '+10 ft speed; allies who start their turn within 5 ft of you gain +10 ft too.' }],
15: [{ name: 'Glorious Defense', text: 'A few times per long rest (Charisma modifier), reaction: add your Charisma modifier to the AC of someone within 10 ft against an attack; if it misses, you may attack the attacker.' }],
20: [{ name: 'Living Legend', text: 'Once per long rest, bonus action for a minute: advantage on Charisma checks, once per turn turn a miss into a hit, and reroll a failed save.' }],
},
},
{
key: 'OATHBREAKER',
name: 'Oathbreaker',
source: 'Dungeon Master’s Guide',
edition: '2014',
summary: 'A fallen paladin who commands the undead and feeds on fear.',
features: {
3: [
{ name: 'Control Undead', text: 'Oathbreaker, Channel Divinity. Action: an undead within 30 ft makes a Wisdom save or obeys you for 24 hours.' },
{ name: 'Dreadful Aspect', text: 'Channel Divinity. Action: creatures of your choice within 30 ft make a Wisdom save or are frightened for a minute.' },
],
7: [{ name: 'Aura of Hate', text: 'You, and fiends and undead within 10 ft, add your Charisma modifier to melee weapon damage.' }],
15: [{ name: 'Supernatural Resistance', text: 'Non-magical bludgeoning, piercing and slashing damage against you is halved.' }],
20: [{ name: 'Dread Lord', text: 'Once per long rest, action for a minute: a 30 ft aura of gloom; enemies starting in it take 4d10 psychic, and you can attack with shadows as a bonus action (3d10 necrotic).' }],
},
},
{
key: 'REDEMPTION',
name: 'Oath of Redemption',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Prefers peace, and makes violence hurt the one who started it.',
features: {
3: [
{ name: 'Emissary of Peace', text: 'Oath of Redemption, Channel Divinity. +5 to Persuasion checks for 10 minutes.' },
{ name: 'Rebuke the Violent', text: 'Channel Divinity. Reaction: when an attacker damages someone within 30 ft, it takes radiant damage equal to the damage it dealt (Wisdom save for half).' },
],
7: [{ name: 'Aura of the Guardian', text: 'Reaction: when a creature within 10 ft takes damage, take it yourself instead.' }],
15: [{ name: 'Protective Spirit', text: 'At the end of each turn, if below half HP, heal 1d6 + half your level.' }],
20: [{ name: 'Emissary of Redemption', text: 'All damage against you is halved, and a creature that damages you takes radiant damage equal to half of it (until you attack or cast on it).' }],
},
},
{
key: 'WATCHERS',
name: 'Oath of the Watchers',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Guards the world from extraplanar intruders, alert to every threat.',
features: {
3: [
{ name: 'Watcher’s Will', text: 'Oath of the Watchers, Channel Divinity. Up to your Charisma modifier creatures within 30 ft have advantage on Intelligence, Wisdom and Charisma saves for a minute.' },
{ name: 'Abjure the Extraplanar', text: 'Channel Divinity. Aberrations, celestials, elementals, fey and fiends within 30 ft make a Wisdom save or are turned for a minute.' },
],
7: [{ name: 'Aura of the Sentinel', text: 'You and allies within 10 ft add your proficiency bonus to initiative.' }],
15: [{ name: 'Vigilant Rebuke', text: 'Reaction: when someone within 30 ft succeeds on an Intelligence, Wisdom or Charisma save, the creature that forced it takes 2d8 + Charisma modifier force.' }],
20: [{ name: 'Mortal Bulwark', text: 'Once per long rest, bonus action for a minute: truesight 120 ft, advantage on attacks against extraplanar creatures, and a hit can banish one (Charisma save).' }],
},
},
];
module.exports = PALADIN;
+128
View File
@@ -0,0 +1,128 @@
// Ranger subclasses: the ranger conclaves. The shape is described in index.js.
const RANGER = [
{
key: 'HUNTER',
name: 'Hunter',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Brings down the biggest thing in the room.',
features: {
3: [{ name: 'Colossus Slayer', text: 'Hunter’s Prey. Once per turn, +1d8 damage to a creature that is already below its max HP.' }],
7: [{ name: 'Escape the Horde', text: 'Defensive Tactics. Opportunity attacks against you have disadvantage.' }],
11: [{ name: 'Volley', text: 'Multiattack. Action: make a ranged attack against every creature within 10 ft of a point you can see in range.' }],
15: [{ name: 'Uncanny Dodge', text: 'Superior Hunter’s Defense. Reaction: halve the damage of an attack that hits you.' }],
},
},
{
key: 'BEAST_MASTER',
name: 'Beast Master',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Fights beside an animal companion.',
features: {
3: [{ name: 'Ranger’s Companion', text: 'Beast Master. A beast of CR 1/4 or lower fights beside you (the DM adds it to encounters as a monster). It acts on your turn: it can move freely, and attacks, Dashes, Disengages or Helps when you use your action to tell it to. Add your proficiency bonus to its AC, attacks and damage. If it dies, bond with another over 8 hours.' }],
7: [{ name: 'Exceptional Training', text: 'On a turn your companion does not attack, it can Dash, Disengage or Help as your bonus action. Its attacks count as magical.' }],
11: [{ name: 'Bestial Fury', text: 'When you command your companion to attack, it can attack twice.' }],
15: [{ name: 'Share Spells', text: 'When you cast a spell targeting yourself, it can also affect your companion if within 30 ft.' }],
},
},
{
key: 'GLOOM_STALKER',
name: 'Gloom Stalker',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'An ambusher who owns the first round and the dark.',
features: {
3: [
{ name: 'Dread Ambusher', text: 'Gloom Stalker. Add your Wisdom modifier to initiative (ask your DM to count it). On your first turn of a fight: +10 ft speed and one extra weapon attack, which deals +1d8 damage on a hit.' },
{ name: 'Umbral Sight', text: 'You see in darkness to 60 ft, and creatures that rely on darkvision cannot see you in the dark.' },
],
7: [{ name: 'Iron Mind', text: 'Treat Wisdom saves as proficient (add your proficiency bonus by hand).' }],
11: [{ name: 'Stalker’s Flurry', text: 'Once per turn, when you miss with a weapon attack, make another weapon attack.' }],
15: [{ name: 'Shadowy Dodge', text: 'Reaction, when an attack without advantage targets you: give it disadvantage.' }],
},
},
{
key: 'DRAKEWARDEN',
name: 'Drakewarden',
source: 'Fizban’s Treasury of Dragons',
edition: '2014',
summary: 'Raises a drake that grows into a mount and a breath weapon.',
features: {
3: [
{ name: 'Draconic Gift', text: 'Drakewarden. You speak Draconic and know Thaumaturgy.' },
{ name: 'Drake Companion', text: 'Action: summon a drake (acid, cold, fire, lightning or poison) that fights beside you (the DM adds it as a monster). It acts on your turn and attacks when you command it with a bonus action; its bite deals an extra 1d6 of its element.' },
],
7: [{ name: 'Bond of Fang and Scale', text: 'Your drake grows Medium; you can ride it if you are Small, it can fly, you resist its element, and its bite counts as magical and deals +1d6 more.' }],
11: [{ name: 'Drake’s Breath', text: 'Once per long rest (or by spending a 3rd-level slot), action: you or the drake exhale a 30 ft cone: 8d6 of its element (Dexterity save for half; 10d6 at level 15).' }],
15: [{ name: 'Perfected Bond', text: 'Your drake grows Large and can carry you in flight; its bite deals +1d6 more; reaction, halve damage it takes or you take while near it.' }],
},
},
{
key: 'FEY_WANDERER',
name: 'Fey Wanderer',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Fey charm and psychic strikes: the bewitching ranger.',
features: {
3: [
{ name: 'Dreadful Strikes', text: 'Fey Wanderer. Once per turn per creature, a weapon hit deals +1d4 psychic (1d6 from level 11).' },
{ name: 'Otherworldly Glamour', text: 'Add your Wisdom modifier to Charisma checks, and treat one of Deception, Performance or Persuasion as proficient.' },
],
7: [{ name: 'Beguiling Twist', text: 'Advantage on saves against being charmed or frightened. Reaction when someone near you resists one: another creature makes a Wisdom save or is charmed or frightened for a minute.' }],
11: [{ name: 'Fey Reinforcements', text: 'Cast Summon Fey once per long rest without a slot or material, and you may skip concentration (lasting a minute).' }],
15: [{ name: 'Misty Wanderer', text: 'A few times per long rest (Wisdom modifier), cast Misty Step without a slot, bringing one willing creature within 5 ft along.' }],
},
},
{
key: 'HORIZON_WALKER',
name: 'Horizon Walker',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Guards the borders between worlds, stepping through space itself.',
features: {
3: [
{ name: 'Detect Portal', text: 'Horizon Walker. Once per short rest, sense the distance and direction of the nearest planar portal within 1 mile.' },
{ name: 'Planar Warrior', text: 'Bonus action: your next hit on a creature within 30 ft this turn deals all force damage and +1d8 (2d8 from level 11).' },
],
7: [{ name: 'Ethereal Step', text: 'Once per short rest, bonus action: step into the Ethereal Plane until the end of the turn.' }],
11: [{ name: 'Distant Strike', text: 'When you take the Attack action, teleport 10 ft before each attack; hitting two different creatures gives one extra attack against a third.' }],
15: [{ name: 'Spectral Defense', text: 'Reaction: halve the damage of an attack that hits you.' }],
},
},
{
key: 'MONSTER_SLAYER',
name: 'Monster Slayer',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A specialist in one dangerous quarry at a time.',
features: {
3: [
{ name: 'Hunter’s Sense', text: 'Monster Slayer. A few times per long rest (Wisdom modifier), action: learn a creature’s immunities, resistances and vulnerabilities.' },
{ name: 'Slayer’s Prey', text: 'Bonus action: mark a creature within 60 ft; the first hit on it each turn deals +1d6.' },
],
7: [{ name: 'Supernatural Defense', text: 'When your prey forces you to save or tries to escape your grapple, add 1d6.' }],
11: [{ name: 'Magic-User’s Nemesis', text: 'Once per short rest, reaction: a creature casting a spell or teleporting within 60 ft makes a Wisdom save or the attempt fails.' }],
15: [{ name: 'Slayer’s Counter', text: 'Reaction when your prey forces a save: attack it; on a hit you succeed on the save automatically.' }],
},
},
{
key: 'SWARMKEEPER',
name: 'Swarmkeeper',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'A cloud of spirit insects or birds moves foes and lifts you.',
features: {
3: [
{ name: 'Gathered Swarm', text: 'Swarmkeeper. Once per turn after a hit, your swarm either deals +1d6 piercing, pushes the target 15 ft (Strength save), or moves you 5 ft.' },
{ name: 'Swarmkeeper Magic', text: 'You know Mage Hand, and gain extra spells from your swarm as you level.' },
],
7: [{ name: 'Writhing Tide', text: 'A few times per long rest (proficiency bonus), bonus action: your swarm lifts you to fly 10 ft for a minute.' }],
11: [{ name: 'Mighty Swarm', text: 'Gathered Swarm deals 1d8, a pushed creature can also be knocked prone, and moving yourself gives you half cover.' }],
15: [{ name: 'Swarming Dispersal', text: 'A few times per long rest (proficiency bonus), reaction when you take damage: halve it and teleport 30 ft.' }],
},
},
];
module.exports = RANGER;
+151
View File
@@ -0,0 +1,151 @@
// Rogue subclasses: the roguish archetypes. The shape is described in index.js.
const ROGUE = [
{
key: 'THIEF',
name: 'Thief',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Quick fingers, rooftops and anything that can be used.',
features: {
3: [
{ name: 'Fast Hands', text: 'Bonus action: Sleight of Hand, pick a lock, disarm a trap or Use an Object.' },
{ name: 'Second-Story Work', text: 'Climbing costs no extra movement; running jumps + Dexterity modifier feet.' },
],
9: [{ name: 'Supreme Sneak', text: 'Advantage on Stealth if you move no more than half your speed.' }],
13: [{ name: 'Use Magic Device', text: 'Ignore class, species and level requirements on magic items.' }],
17: [{ name: 'Thief’s Reflexes', text: 'In the first round of a fight you take two turns: one at your initiative, one at your initiative − 10.' }],
},
},
{
key: 'ASSASSIN',
name: 'Assassin',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Strikes first, and the first strike is the worst.',
features: {
3: [
{ name: 'Assassinate', text: 'Assassin. Advantage on attacks against any creature that has not yet taken a turn in this fight, and any hit on a surprised creature is a critical hit.' },
{ name: 'Tools of the Trade', text: 'You are practised with disguises and poisons: advantage on checks to make or use them.' },
],
9: [{ name: 'Infiltration Expertise', text: 'With a week and 25 gold you build a false identity with papers and a history; nobody doubts it without an obvious reason.' }],
13: [{ name: 'Impostor', text: 'After three hours of study, mimic a person’s speech, writing and manner perfectly; advantage on checks to avoid being found out.' }],
17: [{ name: 'Death Strike', text: 'When you hit a surprised creature, it makes a Constitution save or takes double damage.' }],
},
},
{
key: 'SWASHBUCKLER',
name: 'Swashbuckler',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A duellist: in, strike, out, and a smile.',
features: {
3: [
{ name: 'Fancy Footwork', text: 'Swashbuckler. A creature you made a melee attack against this turn cannot make opportunity attacks against you until the turn ends.' },
{ name: 'Rakish Audacity', text: 'Add your Charisma modifier to initiative (ask your DM to count it). You can Sneak Attack a creature with nobody else next to you or it, without needing advantage.' },
],
9: [{ name: 'Panache', text: 'Action: Persuasion against a creature’s Insight. A hostile one has disadvantage on attacks against anyone but you for 1 minute; any other is charmed by you for 1 minute.' }],
13: [{ name: 'Elegant Maneuver', text: 'Bonus action: advantage on your next Acrobatics or Athletics check this turn.' }],
17: [{ name: 'Master Duelist', text: 'Once per short rest, reroll a missed attack with advantage.' }],
},
},
{
key: 'ARCANE_TRICKSTER',
name: 'Arcane Trickster',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A thief with wizard tricks and an invisible hand.',
features: {
3: [
{ name: 'Spellcasting', text: 'Arcane Trickster. You cast wizard spells (mostly enchantment and illusion) with Intelligence, as a third caster: the DM grants spells within the unlock schedule.' },
{ name: 'Mage Hand Legerdemain', text: 'Your Mage Hand is invisible, and can pick locks, disarm traps and pick pockets (bonus action to control it).' },
],
9: [{ name: 'Magical Ambush', text: 'When you cast a spell while hidden, the target has disadvantage on its save.' }],
13: [{ name: 'Versatile Trickster', text: 'Bonus action: your Mage Hand distracts a creature within 5 ft of it, giving you advantage on attacks against it this turn.' }],
17: [{ name: 'Spell Thief', text: 'Once per long rest, reaction when a spell targets you: the caster makes a save; on a failure the spell fails and you can cast it for 8 hours.' }],
},
},
{
key: 'INQUISITIVE',
name: 'Inquisitive',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A detective who reads lies and finds the weak spot.',
features: {
3: [
{ name: 'Ear for Deceit', text: 'Inquisitive. Treat an Insight roll of 7 or lower as an 8 when sensing lies.' },
{ name: 'Eye for Detail', text: 'Bonus action: Perception to spot a hidden creature or object, or Investigation to uncover clues.' },
{ name: 'Insightful Fighting', text: 'Bonus action: Insight against a creature’s Deception; on a success you can Sneak Attack it without advantage for a minute.' },
],
9: [{ name: 'Steady Eye', text: 'Advantage on Perception and Investigation if you move no more than half your speed.' }],
13: [{ name: 'Unerring Eye', text: 'A few times per long rest (Wisdom modifier), action: sense illusions, shapechangers and other magical deceptions within 30 ft.' }],
17: [{ name: 'Eye for Weakness', text: 'Sneak Attack deals +3d6 against a creature you have read with Insightful Fighting.' }],
},
},
{
key: 'MASTERMIND',
name: 'Mastermind',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A schemer who helps from afar and reads everyone.',
features: {
3: [
{ name: 'Master of Intrigue', text: 'Mastermind. Proficient with disguise and forgery kits and one gaming set; two extra languages; mimic accents perfectly.' },
{ name: 'Master of Tactics', text: 'Help as a bonus action, and at a range of 30 ft.' },
],
9: [{ name: 'Insightful Manipulator', text: 'After a minute of watching or talking, learn how a creature compares to you in two of: Intelligence, Wisdom, Charisma, class levels.' }],
13: [{ name: 'Misdirection', text: 'Reaction: when you are attacked while a creature gives you cover, the attack targets that creature instead.' }],
17: [{ name: 'Soul of Deceit', text: 'Your thoughts can’t be read unless you allow it, you can show false thoughts, and magic can’t compel you to tell the truth.' }],
},
},
{
key: 'PHANTOM',
name: 'Phantom',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Walks with the dead and borrows their skills.',
features: {
3: [
{ name: 'Whispers of the Dead', text: 'Phantom. After a rest, gain proficiency in one skill or tool of your choice until your next rest.' },
{ name: 'Wails from the Grave', text: 'A few times per long rest (proficiency bonus), right after Sneak Attack: a second creature within 30 ft takes half your Sneak Attack dice as necrotic.' },
],
9: [{ name: 'Tokens of the Departed', text: 'Reaction when a creature dies within 30 ft: make a soul trinket. While you carry one you have advantage on death and Constitution saves, and can spend it on a free Wails from the Grave.' }],
13: [{ name: 'Ghost Walk', text: 'Bonus action: become spectral for 10 minutes: fly 10 ft, pass through objects, and attacks against you have disadvantage.' }],
17: [{ name: 'Death’s Friend', text: 'Wails from the Grave hits both creatures with necrotic damage, and you always have a soul trinket after a long rest.' }],
},
},
{
key: 'SCOUT',
name: 'Scout',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A skirmisher of the wilds, always first to move.',
features: {
3: [
{ name: 'Skirmisher', text: 'Scout. Reaction when an enemy ends its turn within 5 ft: move half your speed without provoking.' },
{ name: 'Survivalist', text: 'Treat Nature and Survival as proficient, with double your proficiency bonus (add by hand).' },
],
9: [{ name: 'Superior Mobility', text: '+10 ft to your walking speed (and climb or swim speeds).' }],
13: [{ name: 'Ambush Master', text: 'Advantage on initiative; in the first round, the first creature you hit is easier for everyone to hit until your next turn.' }],
17: [{ name: 'Sudden Strike', text: 'After the Attack action, make one more attack as a bonus action; it can Sneak Attack even if you already have, against a different target.' }],
},
},
{
key: 'SOULKNIFE',
name: 'Soulknife',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Psychic blades conjured from the mind, and telepathy to match.',
features: {
3: [
{ name: 'Psionic Power', text: 'Soulknife. Psionic dice (twice your proficiency bonus, d6, growing to d12), back on a long rest. Psi-Bolstered Knack: add a die to a failed proficient check. Psychic Whispers: link minds with allies for hours.' },
{ name: 'Psychic Blades', text: 'Manifest blades: 1d6 psychic (finesse, thrown 60 ft), and a second blade as a bonus action for 1d4.' },
],
9: [{ name: 'Soul Blades', text: 'Homing Strikes: add a psionic die to a missed blade attack. Psychic Teleportation: bonus action, throw a blade and teleport up to 10 × the die’s roll in feet.' }],
13: [{ name: 'Psychic Veil', text: 'Once per long rest (or by spending a psionic die), action: invisible for an hour, until you damage or force a save.' }],
17: [{ name: 'Rend Mind', text: 'Once per long rest (or by spending three psionic dice), when your blade Sneak Attacks: the target makes a Wisdom save or is stunned for a minute.' }],
},
},
];
module.exports = ROGUE;
@@ -0,0 +1,138 @@
// Sorcerer subclasses: the sorcerous origins. The shape is described in index.js.
// An origin’s first-level gifts arrive with the choice, at level 3.
const SORCERER = [
{
key: 'DRACONIC',
name: 'Draconic Bloodline',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Dragon blood: tougher skin and one element at your call.',
features: {
3: [{ name: 'Dragon Ancestor', text: 'Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier (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.' }],
14: [{ name: 'Dragon Wings', text: 'Bonus action: sprout dragon wings and fly at your speed until you dismiss them.' }],
18: [{ name: 'Draconic Presence', text: 'Action, 5 sorcery points: for a minute, creatures within 60 ft make a Wisdom save or are charmed (awe) or frightened (fear) of you.' }],
},
},
{
key: 'WILD_MAGIC',
name: 'Wild Magic',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Raw chaos: luck you can bend, and surprises you cannot.',
features: {
3: [
{ name: 'Wild Magic Surge', text: 'Wild Magic. After you cast a sorcerer spell of 1st level or higher, the DM may have you roll a d20: on a 1, something strange happens (the DM decides, or rolls on a table of their own).' },
{ name: 'Tides of Chaos', text: 'Once per long rest, gain advantage on one attack, check or save. The DM may give it back by calling for a Wild Magic Surge.' },
],
6: [{ name: 'Bend Luck', text: 'Reaction, 2 sorcery points: add or subtract 1d4 from another creature’s attack, check or save.' }],
14: [{ name: 'Controlled Chaos', text: 'When you roll on the wild magic table, roll twice and use either.' }],
18: [{ name: 'Spell Bombardment', text: 'Once per turn, when a spell’s damage dice roll their highest on a die, roll that die again and add it.' }],
},
},
{
key: 'STORM',
name: 'Storm Sorcery',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Wind and lightning: never stands still for long.',
features: {
3: [
{ name: 'Tempestuous Magic', text: 'Storm Sorcery. Bonus action, just before or after casting a spell of 1st level or higher: fly 10 ft without provoking opportunity attacks.' },
{ name: 'Wind Speaker', text: 'You speak the tongue of air, water, earth and fire elementals.' },
],
6: [
{ name: 'Heart of the Storm', text: 'You resist lightning and thunder damage. When you start casting a lightning or thunder spell of 1st level or higher, creatures of your choice within 10 ft take half your level in lightning or thunder damage.' },
{ name: 'Storm Guide', text: 'Action: stop rain falling within 20 ft of you. Bonus action: choose which way the wind blows within 100 ft until your next turn.' },
],
14: [{ name: 'Storm’s Fury', text: 'Reaction when a creature hits you in melee: it takes lightning damage equal to your level and is pushed up to 20 ft (Strength save negates the push).' }],
18: [{ name: 'Wind Soul', text: 'Immune to lightning and thunder; fly 60 ft. Once per short rest, give up to three creatures a 30 ft fly speed for an hour.' }],
},
},
{
key: 'ABERRANT_MIND',
name: 'Aberrant Mind',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'A psionic mind touched by the Far Realm.',
features: {
3: [
{ name: 'Psionic Spells', text: 'Aberrant Mind. Learn psionic spells as you level (Arms of Hadar, Dissonant Whispers, Mind Sliver and more; the DM grants them).' },
{ name: 'Telepathic Speech', text: 'Bonus action: speak mind to mind with a creature within 30 ft for minutes equal to your level.' },
],
6: [
{ name: 'Psionic Sorcery', text: 'Cast a psionic spell with sorcery points equal to its level instead of a slot, with no words or gestures.' },
{ name: 'Psychic Defenses', text: 'Psychic damage against you is halved; advantage on saves against being charmed or frightened.' },
],
14: [{ name: 'Revelation in Flesh', text: 'Bonus action, 1+ sorcery points, for 10 minutes: see invisible things, fly, breathe and swim underwater, or squeeze through tiny gaps.' }],
18: [{ name: 'Warping Implosion', text: 'Once per long rest (or 5 sorcery points), action: teleport 120 ft; creatures within 30 ft of where you were take 3d10 force and are pulled in (Strength save for half).' }],
},
},
{
key: 'CLOCKWORK_SOUL',
name: 'Clockwork Soul',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'The cosmic order of Mechanus balances luck and shields friends.',
features: {
3: [
{ name: 'Clockwork Magic', text: 'Clockwork Soul. Learn order-themed spells as you level (Alarm, Protection from Evil and Good, Aid…; the DM grants them).' },
{ name: 'Restore Balance', text: 'A few times per long rest (proficiency bonus), reaction: cancel advantage or disadvantage on a roll within 60 ft.' },
],
6: [{ name: 'Bastion of Law', text: 'Action, 1 to 5 sorcery points: a ward of that many d8s on a creature within 30 ft, which it spends to reduce damage.' }],
14: [{ name: 'Trance of Order', text: 'Once per long rest (or 5 sorcery points), bonus action for a minute: attacks against you lack advantage, and your attack rolls, checks and saves of 9 or lower count as 10.' }],
18: [{ name: 'Clockwork Cavalcade', text: 'Once per long rest (or 7 sorcery points), action: a 30 ft cube of spirits restores 100 HP among those inside, repairs objects and ends spells of 6th level or lower.' }],
},
},
{
key: 'DIVINE_SOUL',
name: 'Divine Soul',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A god’s spark: sorcery with the cleric’s spell list.',
features: {
3: [
{ name: 'Divine Magic', text: 'Divine Soul. You can learn cleric spells as sorcerer spells, and gain one affinity spell (Cure Wounds, Inflict Wounds, Bless, Bane or Protection from Evil and Good).' },
{ name: 'Favored by the Gods', text: 'Once per short rest, add 2d4 to a failed save or missed attack.' },
],
6: [{ name: 'Empowered Healing', text: '1 sorcery point: reroll healing dice for you or an ally within 5 ft.' }],
14: [{ name: 'Otherworldly Wings', text: 'Bonus action: sprout feathered or bat-like wings and fly 30 ft until dismissed.' }],
18: [{ name: 'Unearthly Recovery', text: 'Once per long rest, bonus action while below half HP: regain HP equal to half your maximum.' }],
},
},
{
key: 'LUNAR_SORCERY',
name: 'Lunar Sorcery',
source: 'Dragonlance: Shadow of the Dragon Queen',
edition: '2014',
summary: 'Draws power from the moon’s phases: full, new and crescent.',
features: {
3: [
{ name: 'Lunar Embodiment', text: 'Lunar Sorcery. Learn moon spells as you level, and choose a phase after each long rest (full, new or crescent); once per long rest cast that phase’s 1st-level spell without a slot.' },
{ name: 'Moon Fire', text: 'You know Sacred Flame, and can target two creatures within 5 ft of each other with it.' },
],
6: [{ name: 'Lunar Boons', text: 'A few times per long rest (proficiency bonus), reduce a spell’s sorcery-point cost for the current phase’s school by 1.' }],
14: [{ name: 'Waxing and Waning', text: 'Bonus action, 1 sorcery point: change your phase.' }],
18: [{ name: 'Lunar Phenomenon', text: 'Once per long rest, bonus action: unleash your phase (full: blinding light; new: dark cloud and invisibility; crescent: teleport and resistance).' }],
},
},
{
key: 'SHADOW_MAGIC',
name: 'Shadow Magic',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Shadowfell-touched: darkness you can see through and a hound of ill omen.',
features: {
3: [
{ name: 'Eyes of the Dark', text: 'Shadow Magic. Darkvision 120 ft; cast Darkness with 2 sorcery points and see through it.' },
{ name: 'Strength of the Grave', text: 'Once per long rest, when you drop to 0 HP (not from radiant or a crit), make a Charisma save to drop to 1 HP instead.' },
],
6: [{ name: 'Hound of Ill Omen', text: 'Bonus action, 3 sorcery points: a shadow hound hunts a creature within 120 ft (the DM adds it); while it is near, the target has disadvantage on saves against your spells.' }],
14: [{ name: 'Shadow Walk', text: 'Bonus action, in dim light or darkness: teleport up to 120 ft to another dim or dark spot.' }],
18: [{ name: 'Umbral Form', text: 'Bonus action, 6 sorcery points, for a minute: become shadow, resisting all damage but force and radiant, and move through creatures and objects.' }],
},
},
];
module.exports = SORCERER;
+140
View File
@@ -0,0 +1,140 @@
// Warlock subclasses: the otherworldly patrons. The shape is described in index.js.
// A patron’s first-level gifts arrive with the choice, at level 3.
const WARLOCK = [
{
key: 'FIEND',
name: 'The Fiend',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A pact with something from the hells: power fed by the fallen.',
features: {
3: [{ name: 'Dark One’s Blessing', text: 'The Fiend. When you bring a hostile creature to 0 HP, gain Temp HP equal to your Charisma modifier + your level (the DM adds it).' }],
6: [{ name: 'Patron’s Luck', text: 'Once per short rest, add a d10 to an ability check or saving throw after seeing the roll.' }],
10: [{ name: 'Patron’s Resilience', text: 'After each rest, choose one damage type: you resist it until you choose another.' }],
14: [{ name: 'Hurl Through Hell', text: 'Once per long rest, when you hit a creature: it vanishes into the lower planes until your next turn and takes 10d10 psychic (unless it is a fiend).' }],
},
},
{
key: 'ARCHFEY',
name: 'The Archfey',
source: 'Player’s Handbook',
edition: '2014',
summary: 'A fey lord’s favourite: glamour, vanishing and beguilement.',
features: {
3: [{ name: 'Fey Presence', text: 'The Archfey. Once per short rest, action: each creature in a 10 ft cube from you makes a Wisdom save or is charmed or frightened (your choice) until the end of your next turn.' }],
6: [{ name: 'Misty Escape', text: 'Once per short rest, reaction when you take damage: turn invisible and teleport up to 60 ft. You stay invisible until your next turn, or until you attack or cast.' }],
10: [{ name: 'Beguiling Defenses', text: 'You can’t be charmed. When a creature tries, turn it back: it makes a Wisdom save or is charmed by you for 1 minute.' }],
14: [{ name: 'Dark Delirium', text: 'Once per short rest, action: a creature within 60 ft makes a Wisdom save or is lost in an illusory realm for a minute, charmed or frightened by you.' }],
},
},
{
key: 'GREAT_OLD_ONE',
name: 'The Great Old One',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Something vast and unknowable whispers through you.',
features: {
3: [{ name: 'Awakened Mind', text: 'The Great Old One. Speak mind to mind with any creature you can see within 30 ft; it needs no shared language, only to know one.' }],
6: [{ name: 'Entropic Ward', text: 'Once per short rest, reaction: an attack against you has disadvantage. If it misses, your next attack against that creature has advantage.' }],
10: [{ name: 'Thought Shield', text: 'Nobody reads your thoughts unless you allow it. You resist psychic damage, and whoever deals it to you takes the same amount.' }],
14: [{ name: 'Create Thrall', text: 'Action: touch an incapacitated humanoid; it is charmed by you until Remove Curse is cast, and you can speak to it telepathically on the same plane.' }],
},
},
{
key: 'CELESTIAL',
name: 'The Celestial',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A patron of light: the warlock who heals.',
features: {
3: [
{ name: 'Bonus Cantrips', text: 'The Celestial. You know Light and Sacred Flame.' },
{ name: 'Healing Light', text: 'A pool of d6s equal to 1 + your level per long rest. Bonus action: heal a creature within 60 ft with up to your Charisma modifier of them.' },
],
6: [{ name: 'Radiant Soul', text: 'Radiant damage against you is halved; add your Charisma modifier to one radiant or fire damage roll of your spells.' }],
10: [{ name: 'Celestial Resilience', text: 'After a rest, you gain your level + Charisma modifier Temp HP, and up to five allies gain half your level + Charisma modifier.' }],
14: [{ name: 'Searing Vengeance', text: 'Once per long rest, when you would make a death save: stand up with half your HP, and enemies within 30 ft take 2d8 + Charisma modifier radiant and are blinded until the turn ends.' }],
},
},
{
key: 'FATHOMLESS',
name: 'The Fathomless',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'A patron of the deep sea, with a spectral tentacle at your call.',
features: {
3: [
{ name: 'Tentacle of the Deeps', text: 'The Fathomless. A few times per long rest (proficiency bonus), bonus action: a 10 ft spectral tentacle within 60 ft for a minute; it strikes for 1d8 cold and slows by 10 ft.' },
{ name: 'Gift of the Sea', text: 'Swim speed 40 ft, and you can breathe underwater.' },
],
6: [
{ name: 'Oceanic Soul', text: 'Cold damage against you is halved; you can speak with anything underwater.' },
{ name: 'Guardian Coil', text: 'Reaction: your tentacle reduces damage to a creature within 10 ft of it by 1d8 (2d8 from level 10).' },
],
10: [{ name: 'Grasping Tentacles', text: 'Learn Evard’s Black Tentacles and cast it once per long rest without a slot; while casting it you gain Temp HP equal to your level, and damage cannot break your concentration on it.' }],
14: [{ name: 'Fathomless Plunge', text: 'Once per short rest, action: you and up to five willing creatures within 30 ft teleport to a body of water you have seen within 1 mile.' }],
},
},
{
key: 'GENIE',
name: 'The Genie',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'A noble genie’s vassal, with a bottle to hide in.',
features: {
3: [
{ name: 'Genie’s Vessel', text: 'The Genie. A tiny vessel serves as your focus. Bottled Respite: hide inside it for hours. Genie’s Wrath: once per turn a hit deals extra damage equal to your proficiency bonus, of your genie’s element.' },
],
6: [{ name: 'Elemental Gift', text: 'Resist your genie’s element, and a few times per long rest (proficiency bonus) fly 30 ft for 10 minutes as a bonus action.' }],
10: [{ name: 'Sanctuary Vessel', text: 'Up to five willing creatures can join you in your vessel, and those who rest there for 10 minutes gain the benefits of a short rest plus extra healing.' }],
14: [{ name: 'Limited Wish', text: 'Once every 1d4 long rests, action: ask your patron for a 6th-level or lower spell from any list, cast without material.' }],
},
},
{
key: 'HEXBLADE',
name: 'The Hexblade',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'A sentient weapon from the Shadowfell: the warlock who melees.',
features: {
3: [
{ name: 'Hexblade’s Curse', text: 'The Hexblade. Once per short rest, bonus action: curse a creature within 30 ft for a minute: +proficiency bonus damage against it, crit on 19–20, and you heal your level + Charisma modifier if it dies.' },
{ name: 'Hex Warrior', text: 'Proficient with medium armor, shields and martial weapons; one-handed (later pact) weapons can use Charisma for attack and damage.' },
],
6: [{ name: 'Accursed Specter', text: 'Once per long rest, when you slay a humanoid: its spirit rises as a specter under your command until your next long rest.' }],
10: [{ name: 'Armor of Hexes', text: 'When the target of your Hexblade’s Curse hits you, roll a d6: on 4 or higher, it misses.' }],
14: [{ name: 'Master of Hexes', text: 'When the cursed creature dies, move the curse to another creature within 30 ft (no healing from that move).' }],
},
},
{
key: 'UNDEAD',
name: 'The Undead',
source: 'Van Richten’s Guide to Ravenloft',
edition: '2014',
summary: 'A deathless patron: a terrifying form and necrotic power.',
features: {
3: [{ name: 'Form of Dread', text: 'The Undead. A few times per long rest (proficiency bonus), bonus action for a minute: gain 1d10 + your level Temp HP, you can’t be frightened, and once per turn a hit can frighten the target (Wisdom save).' }],
6: [{ name: 'Grave Touched', text: 'You need no food, water or air. Once per turn, a hit can become necrotic; in Form of Dread it deals one extra die.' }],
10: [{ name: 'Necrotic Husk', text: 'Necrotic damage against you is halved (immune in Form of Dread). Once per long rest, when you drop to 0 HP, drop to 1 and blast creatures within 30 ft with necrotic damage.' }],
14: [{ name: 'Spirit Projection', text: 'Once per long rest, action: project your spirit for an hour; it can fly, pass through objects, and resist damage.' }],
},
},
{
key: 'UNDYING',
name: 'The Undying',
source: 'Sword Coast Adventurer’s Guide',
edition: '2014',
summary: 'A patron who cheated death, and teaches you to.',
features: {
3: [
{ name: 'Among the Dead', text: 'The Undying. You know Spare the Dying; advantage on saves against disease; undead must make a Wisdom save to attack you.' },
],
6: [{ name: 'Defy Death', text: 'Once per long rest, when you succeed on a death save or stabilize someone with Spare the Dying, heal 1d8 + Constitution modifier.' }],
10: [{ name: 'Undying Nature', text: 'You can hold your breath indefinitely, need no food, water or sleep, and age a tenth as fast.' }],
14: [{ name: 'Indestructible Life', text: 'Once per short rest, bonus action: heal 1d8 + your level and reattach a severed body part.' }],
},
},
];
module.exports = WARLOCK;
+213
View File
@@ -0,0 +1,213 @@
// Wizard subclasses: the arcane traditions. The shape is described in index.js.
const WIZARD = [
{
key: 'EVOCATION',
name: 'School of Evocation',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Blasting, with friends left standing in the middle of it.',
features: {
2: [
{ name: 'Evocation Savant', text: 'School of Evocation. Copying evocation spells costs half the time and gold.' },
{ name: 'Sculpt Spells', text: 'When you cast an evocation spell that hits an area, choose 1 + the spell’s level creatures to automatically save and take no damage.' },
],
6: [{ name: 'Potent Cantrip', text: 'Creatures that save against your damaging cantrips still take half damage.' }],
10: [{ name: 'Empowered Evocation', text: 'Add your Intelligence modifier to the damage of your evocation spells.' }],
14: [{ name: 'Overchannel', text: 'When you cast a damaging spell of 1st to 5th level, deal maximum damage. Doing it again before a long rest costs you necrotic damage that grows each time.' }],
},
},
{
key: 'ABJURATION',
name: 'School of Abjuration',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Wards and counters: a shield of magic that takes the hits.',
features: {
2: [
{ name: 'Abjuration Savant', text: 'School of Abjuration. Copying abjuration spells costs half the time and gold.' },
{ name: 'Arcane Ward', text: 'When you cast an abjuration spell of 1st level or higher, a ward appears with HP of twice your level + your Intelligence modifier; it takes damage before you do (the DM tracks it as Temp HP). It lasts until a long rest; later abjuration spells restore twice their level to it.' },
],
6: [{ name: 'Projected Ward', text: 'Reaction: when a creature within 30 ft takes damage, your Arcane Ward takes it instead.' }],
10: [{ name: 'Improved Abjuration', text: 'Add your proficiency bonus to ability checks made as part of an abjuration spell (Counterspell, Dispel Magic).' }],
14: [{ name: 'Spell Resistance', text: 'Advantage on saves against spells, and spell damage against you is halved.' }],
},
},
{
key: 'ILLUSION',
name: 'School of Illusion',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Tricks of light and sound that hold up to a second look.',
features: {
2: [
{ name: 'Illusion Savant', text: 'School of Illusion. Copying illusion spells costs half the time and gold.' },
{ name: 'Improved Minor Illusion', text: 'You know Minor Illusion (the DM grants it), and it makes a sound and an image with one casting.' },
],
6: [{ name: 'Malleable Illusions', text: 'Action: change what an illusion of yours looks like while it lasts, if it lasts a minute or more.' }],
10: [{ name: 'Illusory Self', text: 'Once per short rest, reaction when a creature attacks you: a copy of you takes the blow and the attack misses.' }],
14: [{ name: 'Illusory Reality', text: 'Bonus action, when you cast an illusion of 1st level or higher: make one inanimate, harmless object in it real for a minute.' }],
},
},
{
key: 'BLADESINGING',
name: 'Bladesinging',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'An elven sword-dance: a wizard who fights up close.',
features: {
2: [
{ name: 'Training in War and Song', text: 'Bladesinging. Proficient with light armor and one one-handed melee weapon, and treat Performance as proficient.' },
{ name: 'Bladesong', text: 'A few times per long rest (proficiency bonus), bonus action for a minute (in light or no armor): +Intelligence modifier to AC and concentration saves, +10 ft speed, advantage on Acrobatics.' },
],
6: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action; one attack can be a cantrip instead.' }],
10: [{ name: 'Song of Defense', text: 'Reaction while in Bladesong: spend a spell slot to reduce damage by five times its level.' }],
14: [{ name: 'Song of Victory', text: 'While in Bladesong, add your Intelligence modifier to melee weapon damage.' }],
},
},
{
key: 'CHRONURGY',
name: 'Chronurgy Magic',
source: 'Explorer’s Guide to Wildemount',
edition: '2014',
summary: 'Bends time: rerolls fate and freezes foes.',
features: {
2: [
{ name: 'Chronal Shift', text: 'Chronurgy Magic. Twice per long rest, reaction: force a creature within 30 ft to reroll an attack, check or save.' },
{ name: 'Temporal Awareness', text: 'Add your Intelligence modifier to initiative.' },
],
6: [{ name: 'Momentary Stasis', text: 'A few times per long rest (Intelligence modifier), action: a Large or smaller creature within 60 ft makes a Constitution save or is frozen in time until the end of your next turn.' }],
10: [{ name: 'Arcane Abeyance', text: 'Once per short rest, cast a spell of 4th level or lower into a bead; anyone can release it later as an action.' }],
14: [{ name: 'Convergent Future', text: 'Reaction: decide that an attack, check or save within 60 ft succeeds or fails; you gain a level of exhaustion each time.' }],
},
},
{
key: 'CONJURATION',
name: 'School of Conjuration',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Summons, teleports and makes objects out of nothing.',
features: {
2: [
{ name: 'Conjuration Savant', text: 'School of Conjuration. Copying conjuration spells costs half the time and gold.' },
{ name: 'Minor Conjuration', text: 'Action: conjure a non-magical object up to 3 ft across and 10 lb, for an hour.' },
],
6: [{ name: 'Benign Transposition', text: 'Action: teleport 30 ft, or swap places with a willing creature; again after a long rest or casting a conjuration spell.' }],
10: [{ name: 'Focused Conjuration', text: 'Damage can’t break your concentration on a conjuration spell.' }],
14: [{ name: 'Durable Summons', text: 'Creatures you summon gain 30 Temp HP.' }],
},
},
{
key: 'DIVINATION',
name: 'School of Divination',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Sees the future: replaces rolls with dice foretold at dawn.',
features: {
2: [
{ name: 'Divination Savant', text: 'School of Divination. Copying divination spells costs half the time and gold.' },
{ name: 'Portent', text: 'After a long rest roll two d20s and note them. You can swap any attack, check or save you can see for one of those rolls, before it is made.' },
],
6: [{ name: 'Expert Divination', text: 'Casting a divination spell of 2nd level or higher gives back a spell slot of a lower level (up to 5th).' }],
10: [{ name: 'The Third Eye', text: 'Action, until your next rest: darkvision, see into the Ethereal, read any language, or see invisible things within 10 ft.' }],
14: [{ name: 'Greater Portent', text: 'Roll three d20s for Portent instead of two.' }],
},
},
{
key: 'ENCHANTMENT',
name: 'School of Enchantment',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Charm and compulsion: makes enemies forget, freeze or fight each other.',
features: {
2: [
{ name: 'Enchantment Savant', text: 'School of Enchantment. Copying enchantment spells costs half the time and gold.' },
{ name: 'Hypnotic Gaze', text: 'Action: a creature within 5 ft makes a Wisdom save or is charmed and incapacitated until the end of your next turn; you can extend it each turn.' },
],
6: [{ name: 'Instinctive Charm', text: 'Reaction when attacked: the attacker makes a Wisdom save or must attack someone else nearby (or waste the attack).' }],
10: [{ name: 'Split Enchantment', text: 'An enchantment spell that targets one creature can target a second.' }],
14: [{ name: 'Alter Memories', text: 'A creature you charm doesn’t know it was charmed, and can be made to forget up to a few hours of what happened (Intelligence save).' }],
},
},
{
key: 'GRAVITURGY',
name: 'Graviturgy Magic',
source: 'Explorer’s Guide to Wildemount',
edition: '2014',
summary: 'Commands gravity: makes things heavy, light, or pulled together.',
features: {
2: [
{ name: 'Adjust Density', text: 'Graviturgy Magic. Action: halve or double the weight of a Large or smaller creature or object within 30 ft for a minute, changing its speed and Strength rolls.' },
],
6: [{ name: 'Gravity Well', text: 'When you cast a spell on a creature, you can move it 5 ft to a free space.' }],
10: [{ name: 'Violent Attraction', text: 'A few times per long rest (Intelligence modifier), reaction: +1d10 damage to a weapon hit within 60 ft, or +2d10 to a creature’s fall damage.' }],
14: [{ name: 'Event Horizon', text: 'Once per long rest, action for a minute: hostile creatures within 30 ft take 2d10 force each turn and have speed 0 (Strength save for half and partial speed).' }],
},
},
{
key: 'NECROMANCY',
name: 'School of Necromancy',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Drains life to heal itself, and raises stronger undead.',
features: {
2: [
{ name: 'Necromancy Savant', text: 'School of Necromancy. Copying necromancy spells costs half the time and gold.' },
{ name: 'Grim Harvest', text: 'Once per turn, when your spell kills a creature, heal twice the spell’s level (three times for necromancy).' },
],
6: [{ name: 'Undead Thralls', text: 'Animate Dead raises one extra corpse, and your undead have extra HP (your level) and add your proficiency bonus to damage.' }],
10: [{ name: 'Inured to Undeath', text: 'Necrotic damage against you is halved, and your maximum HP can’t be reduced.' }],
14: [{ name: 'Command Undead', text: 'Action: an undead within 60 ft makes a Charisma save or becomes friendly and obeys you.' }],
},
},
{
key: 'SCRIBES',
name: 'Order of Scribes',
source: 'Tasha’s Cauldron of Everything',
edition: '2014',
summary: 'Your spellbook is alive, and rewrites your spells’ damage.',
features: {
2: [
{ name: 'Wizardly Quill', text: 'Order of Scribes. A magic quill: copy spells in 2 minutes per level, and erase writing.' },
{ name: 'Awakened Spellbook', text: 'Your book is your focus. When you cast with a slot, change the spell’s damage type to another from your book. Once per long rest, cast a ritual at normal speed.' },
],
6: [{ name: 'Manifest Mind', text: 'Bonus action: a spectral mind within 60 ft; see and hear through it, and cast spells from it a few times per long rest (proficiency bonus).' }],
10: [{ name: 'Master Scrivener', text: 'After a long rest, make a scroll of a 1st- or 2nd-level spell from your book; cast from it, it counts one level higher.' }],
14: [{ name: 'One with the Word', text: 'Advantage on Arcana. Once per long rest, reaction when you take damage with your mind manifested: take none, and instead lose spells from your book worth 3d6 spell levels until the book is restored.' }],
},
},
{
key: 'TRANSMUTATION',
name: 'School of Transmutation',
source: 'Player’s Handbook',
edition: '2014',
summary: 'Changes matter: a philosopher’s stone and shapechanging.',
features: {
2: [
{ name: 'Transmutation Savant', text: 'School of Transmutation. Copying transmutation spells costs half the time and gold.' },
{ name: 'Minor Alchemy', text: 'Spend 10 minutes per cubic foot to turn wood, stone, iron, copper or silver into another of them for an hour.' },
],
6: [{ name: 'Transmuter’s Stone', text: 'Make a stone that grants one benefit to whoever holds it: darkvision, +10 ft speed, Constitution save proficiency, or resistance to one element.' }],
10: [{ name: 'Shapechanger', text: 'Cast Polymorph on yourself once per short rest without a slot, as a beast of CR 1 or lower.' }],
14: [{ name: 'Master Transmuter', text: 'Destroy your stone for a great effect: transmute an object, cure all ailments, cast Raise Dead without a slot, or restore youth.' }],
},
},
{
key: 'WAR_MAGIC',
name: 'War Magic',
source: 'Xanathar’s Guide to Everything',
edition: '2014',
summary: 'Battle wizardry: deflects attacks and surges with power.',
features: {
2: [
{ name: 'Arcane Deflection', text: 'War Magic. Reaction when hit or failing a save: +2 AC or +4 to the save; you can then cast only cantrips until your next turn.' },
{ name: 'Tactical Wit', text: 'Add your Intelligence modifier to initiative.' },
],
6: [{ name: 'Power Surge', text: 'Store surges (up to your Intelligence modifier): spend one to add half your level as force damage to a spell; you gain one when you end a spell with Dispel Magic or Counterspell.' }],
10: [{ name: 'Durable Magic', text: '+2 to AC and saves while concentrating on a spell.' }],
14: [{ name: 'Deflecting Shroud', text: 'When you use Arcane Deflection, up to three creatures within 60 ft take force damage of half your level.' }],
},
},
];
module.exports = WIZARD;
+63
View File
@@ -0,0 +1,63 @@
// Default weapon library — the SRD simple + martial weapon list, offered to
// any campaign so a DM isn't hand-typing common weapons from scratch. Shape
// 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
{ key: 'club', name: 'Club', category: 'Simple Melee', damageDice: '1d4', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['light'], rangeNormal: null, rangeLong: null },
{ key: 'dagger', name: 'Dagger', category: 'Simple Melee', damageDice: '1d4', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'thrown'], rangeNormal: 20, rangeLong: 60 },
{ key: 'greatclub', name: 'Greatclub', category: 'Simple Melee', damageDice: '1d8', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'handaxe', name: 'Handaxe', category: 'Simple Melee', damageDice: '1d6', damageType: 'slashing', attackAbility: 'STR', properties: ['light', 'thrown'], rangeNormal: 20, rangeLong: 60 },
{ key: 'javelin', name: 'Javelin', category: 'Simple Melee', damageDice: '1d6', damageType: 'piercing', attackAbility: 'STR', properties: ['thrown'], rangeNormal: 30, rangeLong: 120 },
{ key: 'light-hammer', name: 'Light Hammer', category: 'Simple Melee', damageDice: '1d4', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['light', 'thrown'], rangeNormal: 20, rangeLong: 60 },
{ key: 'mace', name: 'Mace', category: 'Simple Melee', damageDice: '1d6', damageType: 'bludgeoning', attackAbility: 'STR', properties: [], rangeNormal: null, rangeLong: null },
{ key: 'quarterstaff', name: 'Quarterstaff', category: 'Simple Melee', damageDice: '1d6', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['versatile (1d8)'], rangeNormal: null, rangeLong: null },
{ key: 'sickle', name: 'Sickle', category: 'Simple Melee', damageDice: '1d4', damageType: 'slashing', attackAbility: 'STR', properties: ['light'], rangeNormal: null, rangeLong: null },
{ key: 'spear', name: 'Spear', category: 'Simple Melee', damageDice: '1d6', damageType: 'piercing', attackAbility: 'STR', properties: ['thrown', 'versatile (1d8)'], rangeNormal: 20, rangeLong: 60 },
// Simple Ranged
{ key: 'light-crossbow', name: 'Light Crossbow', category: 'Simple Ranged', damageDice: '1d8', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'loading', 'two-handed'], rangeNormal: 80, rangeLong: 320 },
{ key: 'dart', name: 'Dart', category: 'Simple Ranged', damageDice: '1d4', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'thrown'], rangeNormal: 20, rangeLong: 60 },
{ key: 'shortbow', name: 'Shortbow', category: 'Simple Ranged', damageDice: '1d6', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'two-handed'], rangeNormal: 80, rangeLong: 320 },
{ key: 'sling', name: 'Sling', category: 'Simple Ranged', damageDice: '1d4', damageType: 'bludgeoning', attackAbility: 'DEX', properties: ['ammunition'], rangeNormal: 30, rangeLong: 120 },
// Martial Melee
{ key: 'battleaxe', name: 'Battleaxe', category: 'Martial Melee', damageDice: '1d8', damageType: 'slashing', attackAbility: 'STR', properties: ['versatile (1d10)'], rangeNormal: null, rangeLong: null },
{ key: 'flail', name: 'Flail', category: 'Martial Melee', damageDice: '1d8', damageType: 'bludgeoning', attackAbility: 'STR', properties: [], rangeNormal: null, rangeLong: null },
{ key: 'glaive', name: 'Glaive', category: 'Martial Melee', damageDice: '1d10', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'reach', 'two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'greataxe', name: 'Greataxe', category: 'Martial Melee', damageDice: '1d12', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'greatsword', name: 'Greatsword', category: 'Martial Melee', damageDice: '2d6', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'halberd', name: 'Halberd', category: 'Martial Melee', damageDice: '1d10', damageType: 'slashing', attackAbility: 'STR', properties: ['heavy', 'reach', 'two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'lance', name: 'Lance', category: 'Martial Melee', damageDice: '1d12', damageType: 'piercing', attackAbility: 'STR', properties: ['reach', 'special'], rangeNormal: null, rangeLong: null },
{ key: 'longsword', name: 'Longsword', category: 'Martial Melee', damageDice: '1d8', damageType: 'slashing', attackAbility: 'STR', properties: ['versatile (1d10)'], rangeNormal: null, rangeLong: null },
{ key: 'maul', name: 'Maul', category: 'Martial Melee', damageDice: '2d6', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['heavy', 'two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'morningstar', name: 'Morningstar', category: 'Martial Melee', damageDice: '1d8', damageType: 'piercing', attackAbility: 'STR', properties: [], rangeNormal: null, rangeLong: null },
{ key: 'pike', name: 'Pike', category: 'Martial Melee', damageDice: '1d10', damageType: 'piercing', attackAbility: 'STR', properties: ['heavy', 'reach', 'two-handed'], rangeNormal: null, rangeLong: null },
{ key: 'rapier', name: 'Rapier', category: 'Martial Melee', damageDice: '1d8', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse'], rangeNormal: null, rangeLong: null },
{ key: 'scimitar', name: 'Scimitar', category: 'Martial Melee', damageDice: '1d6', damageType: 'slashing', attackAbility: 'FINESSE', properties: ['finesse', 'light'], rangeNormal: null, rangeLong: null },
{ key: 'shortsword', name: 'Shortsword', category: 'Martial Melee', damageDice: '1d6', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light'], rangeNormal: null, rangeLong: null },
{ key: 'trident', name: 'Trident', category: 'Martial Melee', damageDice: '1d6', damageType: 'piercing', attackAbility: 'STR', properties: ['thrown', 'versatile (1d8)'], rangeNormal: 20, rangeLong: 60 },
{ key: 'war-pick', name: 'War Pick', category: 'Martial Melee', damageDice: '1d8', damageType: 'piercing', attackAbility: 'STR', properties: [], rangeNormal: null, rangeLong: null },
{ key: 'warhammer', name: 'Warhammer', category: 'Martial Melee', damageDice: '1d8', damageType: 'bludgeoning', attackAbility: 'STR', properties: ['versatile (1d10)'], rangeNormal: null, rangeLong: null },
{ key: 'whip', name: 'Whip', category: 'Martial Melee', damageDice: '1d4', damageType: 'slashing', attackAbility: 'FINESSE', properties: ['finesse', 'reach'], rangeNormal: null, rangeLong: null },
// Martial Ranged
{ key: 'blowgun', name: 'Blowgun', category: 'Martial Ranged', damageDice: '1', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'loading'], rangeNormal: 25, rangeLong: 100 },
{ key: 'hand-crossbow', name: 'Hand Crossbow', category: 'Martial Ranged', damageDice: '1d6', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'light', 'loading'], rangeNormal: 30, rangeLong: 120 },
{ 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 };
+105
View File
@@ -0,0 +1,105 @@
// Dice: reading an expression ("2d6+3"), rolling it, and checking dice that
// were rolled at the table and typed in. Used by the table's dice roller
// (POST /campaigns/:id/rolls) and by attacks in an encounter (game/combat.js).
// Pure but for the random source, which can be passed in for tests.
const crypto = require('crypto');
const rollDie = (sides) => crypto.randomInt(1, sides + 1);
const MAX_DICE = 100;
const MAX_SIDES = 1000;
// Reads the dice expressions the app writes: "1d8", "2d6", "1d6+1d8",
// "2d4+2", "1d8 - 1". Returns { dice: [{ count, sides, sign }], flat }, or null
// for anything else (free text is allowed where a DM types dice) and for
// something too big to be a real roll. Mirrors parseDice in the frontend's
// lib/diceRoller.ts.
function parseDice(expression) {
const terms = String(expression || '')
.replace(/\s+/g, '')
.replace(/[−–]/g, '-')
.toLowerCase()
.replace(/-/g, '+-')
.split('+')
.filter(Boolean);
if (terms.length === 0) return null;
const parsed = { dice: [], flat: 0 };
let total = 0;
for (const term of terms) {
const die = /^(-?)(\d*)d(\d+)$/.exec(term);
if (die) {
const count = Number(die[2]) || 1;
const sides = Number(die[3]);
total += count;
if (total > MAX_DICE || sides < 2 || sides > MAX_SIDES) return null;
parsed.dice.push({ count, sides, sign: die[1] ? -1 : 1 });
} else if (/^-?\d+$/.test(term)) {
parsed.flat += Number(term);
} else {
return null;
}
}
return parsed.dice.length > 0 ? parsed : null;
}
// The dice at the start of a line of text that says more: "2d8+3" from
// "2d8+3 fire". Null when it does not start with dice.
function leadingDice(text) {
const match = /^\s*((?:\d*d\d+|\d+)(?:\s*[+\-−–]\s*(?:\d*d\d+|\d+))*)/i.exec(String(text || ''));
return match && parseDice(match[1]) ? match[1].replace(/\s+/g, '') : null;
}
// Every die of a parsed expression, one entry each, in order: [{ sides, sign }].
function diceOf(parsed) {
return parsed.dice.flatMap((term) => Array.from({ length: term.count }, () => ({ sides: term.sides, sign: term.sign })));
}
// Whether `values` could be what these dice showed: one whole number per die,
// each on its die.
function validValues(dice, values) {
return Array.isArray(values) && values.length === dice.length && dice.every((die, i) => Number.isInteger(values[i]) && values[i] >= 1 && values[i] <= die.sides);
}
// Rolls a parsed expression, or takes `values` (dice rolled at the table,
// already checked with validValues) in place of rolling. Returns
// { dice: [{ sides, value, kept, sign }], modifier, total }.
function rollExpression(parsed, { values = null, roll = rollDie } = {}) {
const dice = diceOf(parsed).map((die, i) => ({ sides: die.sides, value: values ? values[i] : roll(die.sides), kept: true, sign: die.sign }));
return { dice, modifier: parsed.flat, total: dice.reduce((sum, die) => sum + die.sign * die.value, 0) + parsed.flat };
}
const D20_MODES = ['NORMAL', 'ADVANTAGE', 'DISADVANTAGE'];
// How many d20s a mode rolls: two with advantage or disadvantage.
const d20Count = (mode) => (mode === 'NORMAL' ? 1 : 2);
// A d20 plus a modifier, as the mode has it: one die, or two with the higher
// (advantage) or lower (disadvantage) kept. `values` as in rollExpression.
// Returns { dice, modifier, total, natural }: `natural` is the d20 that counted.
function rollD20(modifier = 0, mode = 'NORMAL', { values = null, roll = rollDie } = {}) {
const rolled = Array.from({ length: d20Count(mode) }, (_, i) => (values ? values[i] : roll(20)));
const natural = mode === 'ADVANTAGE' ? Math.max(...rolled) : mode === 'DISADVANTAGE' ? Math.min(...rolled) : rolled[0];
// Of two equal dice only the first is marked as the one kept.
const keptIndex = rolled.indexOf(natural);
return {
dice: rolled.map((value, i) => ({ sides: 20, value, kept: i === keptIndex, sign: 1 })),
modifier,
total: natural + modifier,
natural,
};
}
// What a critical hit does to damage dice (Campaign.critRule). DOUBLE_DICE:
// every die is rolled twice. MAX_PLUS_ROLL: every die counts at its maximum,
// and is rolled once more on top. Takes and returns a parsed expression's
// roll; the flat modifier is never doubled.
function rollCritical(parsed, critRule = 'DOUBLE_DICE', { roll = rollDie } = {}) {
const base = diceOf(parsed);
const first = base.map((die) => ({ sides: die.sides, value: critRule === 'MAX_PLUS_ROLL' ? die.sides : roll(die.sides), kept: true, sign: die.sign, maxed: critRule === 'MAX_PLUS_ROLL' }));
const second = base.map((die) => ({ sides: die.sides, value: roll(die.sides), kept: true, sign: die.sign }));
const dice = [...first, ...second];
return { dice, modifier: parsed.flat, total: dice.reduce((sum, die) => sum + die.sign * die.value, 0) + parsed.flat };
}
module.exports = { rollDie, parseDice, leadingDice, diceOf, validValues, rollExpression, rollD20, d20Count, rollCritical, D20_MODES, MAX_DICE };
+77
View File
@@ -0,0 +1,77 @@
const { parseDice, leadingDice, diceOf, validValues, rollExpression, rollD20, rollCritical } = require('./dice');
// A die that always shows the same face, or the faces given in turn.
const fixed = (...faces) => {
let i = 0;
return () => faces[i++ % faces.length];
};
describe('parseDice', () => {
test('reads the expressions the app writes', () => {
expect(parseDice('2d6+3')).toEqual({ dice: [{ count: 2, sides: 6, sign: 1 }], flat: 3 });
expect(parseDice('1d6 + 1d8 − 1')).toEqual({ dice: [{ count: 1, sides: 6, sign: 1 }, { count: 1, sides: 8, sign: 1 }], flat: -1 });
expect(parseDice('d20')).toEqual({ dice: [{ count: 1, sides: 20, sign: 1 }], flat: 0 });
});
test('refuses free text, bare numbers and absurd rolls', () => {
expect(parseDice('a big bite')).toBeNull();
expect(parseDice('7')).toBeNull();
expect(parseDice('101d6')).toBeNull();
expect(parseDice('60d6+60d6')).toBeNull();
expect(parseDice('1d1')).toBeNull();
});
test('leadingDice takes the dice off the front of a line', () => {
expect(leadingDice('2d8+3 fire')).toBe('2d8+3');
expect(leadingDice('fire, 2d8')).toBeNull();
});
});
describe('rolling', () => {
test('rolls every die and adds the flat part', () => {
const roll = rollExpression(parseDice('2d6+1d4+2'), { roll: fixed(3, 5, 4) });
expect(roll.dice.map((d) => [d.sides, d.value])).toEqual([[6, 3], [6, 5], [4, 4]]);
expect(roll).toMatchObject({ modifier: 2, total: 14 });
});
test('a subtracted die takes away', () => {
expect(rollExpression(parseDice('1d8-1d4'), { roll: fixed(6, 2) }).total).toBe(4);
});
test('dice rolled at the table are checked against each die, then used as they are', () => {
const dice = diceOf(parseDice('1d8+1d6'));
expect(validValues(dice, [8, 6])).toBe(true);
expect(validValues(dice, [9, 1])).toBe(false);
expect(validValues(dice, [3])).toBe(false);
expect(validValues(dice, [3, 2.5])).toBe(false);
expect(rollExpression(parseDice('1d8+1d6+1'), { values: [8, 6] }).total).toBe(15);
});
});
describe('a d20', () => {
test('rolls one die and adds the modifier', () => {
expect(rollD20(5, 'NORMAL', { roll: fixed(12) })).toMatchObject({ total: 17, natural: 12, dice: [{ sides: 20, value: 12, kept: true }] });
});
test('keeps the higher with advantage and the lower with disadvantage', () => {
const adv = rollD20(2, 'ADVANTAGE', { roll: fixed(7, 15) });
expect(adv).toMatchObject({ natural: 15, total: 17 });
expect(adv.dice.map((d) => d.kept)).toEqual([false, true]);
expect(rollD20(0, 'DISADVANTAGE', { roll: fixed(7, 15) }).natural).toBe(7);
expect(rollD20(0, 'ADVANTAGE', { values: [20, 20] }).dice.map((d) => d.kept)).toEqual([true, false]);
});
});
describe('a critical hit', () => {
test('rolls every damage die twice, and the flat part once', () => {
const crit = rollCritical(parseDice('1d8+3'), 'DOUBLE_DICE', { roll: fixed(5, 7) });
expect(crit.dice.map((d) => d.value)).toEqual([5, 7]);
expect(crit.total).toBe(15);
});
test('or takes the dice at their most and rolls them once more', () => {
const crit = rollCritical(parseDice('2d6+1'), 'MAX_PLUS_ROLL', { roll: fixed(2, 3) });
expect(crit.dice.map((d) => d.value)).toEqual([6, 6, 2, 3]);
expect(crit.total).toBe(18);
});
});
+303
View File
@@ -0,0 +1,303 @@
// 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');
const { classLevels, classOptions, joiningFeatures } = require('./multiclass');
// 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 of the ${subclassesOf(characterClass).length} with your DM (the level-up checklist lists them all).`,
});
// Everything a subclass has given by `level`, in the order it came.
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 and class levels loaded (each weapon with its pool entry).
// `subclasses` is the campaign's setting: off, there is no subclass to pick,
// and the class's first one is taken without asking (`subclassTaken`), its
// features listed with the rest. `multiclass` (the campaign's setting) lists
// the classes the level could go to (`classChoice`, by the final ability
// `scores`), and `toClass` is the one it goes to: the character's first class
// unless another is named. Features, Boons, spells and the subclass all go by
// that class's own level.
function levelUpPlan(character, { subclasses = true, multiclass = false, toClass = null, scores = {} } = {}) {
const toLevel = character.level + 1;
const characterClass = toClass || character.characterClass;
const entry = classLevels(character).find((e) => e.characterClass === characterClass) || { characterClass, level: 0, subclass: null };
const classLevel = entry.level + 1;
const joining = entry.level === 0;
const taken = subclasses || joining ? null : subclassByDefault(characterClass, classLevel, entry.subclass);
const entries = joining
? joiningFeatures(characterClass)
: [
...featuresAt(characterClass, classLevel, taken ? null : entry.subclass),
...(taken ? subclassFeaturesThrough(findSubclass(characterClass, taken), classLevel) : []),
];
return {
fromLevel: character.level,
toLevel,
// The class taking the level, and its own level once it has.
characterClass,
classLevel,
// True when this is the character's first level in this class.
joining,
classChoice: multiclass ? { options: classOptions(character, scores) } : null,
// Set when a subclass has to be picked on the way to this level.
subclass: subclasses ? subclassChoice(characterClass, classLevel, entry.subclass) : null,
subclassTaken: taken,
features: entries.filter((e) => !e.boon).map(({ name, text, upgrade }) => ({ name, text, upgrade: Boolean(upgrade) })),
boon: entries.some((e) => e.boon) ? boonOptions(character.specialAbilities) : null,
spells: spellsGainedAt(characterClass, classLevel),
// The free upgrade: one weapon of standard growth goes up a level, never
// past its holder.
weapons: (character.weapons || [])
.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 20; past it only the numbers move.
beyondTables: classLevel > MAX_FEATURE_LEVEL,
};
}
// What taking a planned level does to a character's special abilities (see
// advance): a first level in a new class writes what that class grants at
// level 1 and teaches; any other goes through advance at the class's own level.
function advanceIn(specialAbilities, plan, boonKey, { key = null, chosen = false } = {}) {
if (plan.joining) return { specialAbilities: addFeatures(specialAbilities, joiningFeatures(plan.characterClass)), armorBonus: 0, tempHp: 0 };
return advance(specialAbilities, plan.characterClass, plan.classLevel, boonKey, { key, chosen });
}
// The road from level 1 to 20 for a class (and subclass): what every level
// gives, as the progression timeline shows it. Level 1 is what the builder
// writes; a level that grants a Boon says so. `fromSubclass` marks the
// subclass's own lines.
function progression(characterClass, subclassKey = null) {
const subclass = findSubclass(characterClass, subclassKey);
const levels = [];
for (let level = 1; level <= MAX_FEATURE_LEVEL; level++) {
const own = level === 1 ? CLASS_FEATURES[characterClass] || [] : featuresAt(characterClass, level);
const fromSubclass = subclass ? subclass.features[level] || [] : [];
const slots = maxSpellLevel(characterClass, level);
levels.push({
level,
features: [
...own.filter((entry) => !entry.boon).map(({ name, text }) => ({ name, text, fromSubclass: false })),
...fromSubclass.map(({ name, text }) => ({ name, text, fromSubclass: true })),
],
boon: own.some((entry) => entry.boon),
// The level a subclass is chosen, when none is yet.
choosesSubclass: !subclass && subclassesOf(characterClass).length > 0 && level === SUBCLASS_LEVEL[characterClass],
// Set when this level opens up a new level of spells.
unlocksSpellLevel: slots > (level === 1 ? 0 : maxSpellLevel(characterClass, level - 1)) ? slots : null,
});
}
return levels;
}
module.exports = { LevelUpError, progression, advanceIn, featuresAt, owesSubclass, subclassChoice, subclassByDefault, switchSubclass, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
+267
View File
@@ -0,0 +1,267 @@
const { featuresAt, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan, switchSubclass, progression, LevelUpError } = require('./levelUp');
const { SUBCLASSES, SUBCLASS_LEVEL } = require('./data/subclasses');
const { LEVEL_FEATURES, BOONS } = require('./data/levelFeatures');
const { CLASSES } = require('./data/classes');
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('the tables go on to level 20', () => {
expect(featuresAt('FIGHTER', 11).map((f) => f.name)).toEqual(['Extra Attack']);
expect(featuresAt('FIGHTER', 20).map((f) => f.name)).toEqual(['Extra Attack']);
expect(featuresAt('ROGUE', 19).map((f) => f.boon || f.name)).toEqual([true, 'Sneak Attack']);
expect(featuresAt('ARTIFICER', 7).map((f) => f.name)).toEqual(['Flash of Genius']);
});
});
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, Resolve.');
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(5);
// Only standard growth, and never past the character's new level.
expect(plan.weapons).toEqual([{ weaponId: 'w', name: 'Longsword', equipped: true, level: 1, damageDice: '1d8', nextDamageDice: '1d10' }]);
});
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', 'ARCANE_ARCHER', 'CAVALIER', 'ECHO_KNIGHT', 'ELDRITCH_KNIGHT', 'PSI_WARRIOR', 'PURPLE_DRAGON_KNIGHT', 'RUNE_KNIGHT']);
expect(plan.subclass.options[0].features.map((f) => f.name)).toEqual(['Improved Critical']);
// A wizard chooses a level earlier.
expect(levelUpPlan({ ...fighter, characterClass: 'WIZARD', level: 1 }).subclass.level).toBe(2);
// 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 its full catalogue, each with a summary, a book and features from its choice level up to 20', () => {
const keys = new Set();
for (const cls of Object.keys(CLASSES)) {
expect(SUBCLASSES[cls].length).toBeGreaterThanOrEqual(4);
for (const subclass of SUBCLASSES[cls]) {
expect(subclass.key && subclass.name && subclass.summary && subclass.source).toBeTruthy();
expect(['2014', '2024']).toContain(subclass.edition);
if (subclass.replaces) expect(SUBCLASSES[cls].map((s) => s.key)).toContain(subclass.replaces);
const levels = Object.keys(subclass.features).map(Number);
expect(Math.min(...levels)).toBe(SUBCLASS_LEVEL[cls]);
expect(Math.max(...levels)).toBeLessThanOrEqual(20);
expect(Math.max(...levels)).toBeGreaterThan(10); // nobody stops at the old tables
for (const f of Object.values(subclass.features).flat()) {
expect(f.name && f.text).toBeTruthy();
expect(f.name).not.toMatch(/—|–|--/); // the dash is what ends a name on the sheet
}
// Keys are unique across classes: a multiclass character holds one per class.
expect(keys.has(subclass.key)).toBe(false);
keys.add(subclass.key);
}
}
expect(keys.size).toBe(123);
});
test('the first subclass of each class is still the one it always followed', () => {
const firsts = Object.fromEntries(Object.entries(SUBCLASSES).map(([cls, list]) => [cls, list[0].key]));
expect(firsts).toMatchObject({ BARBARIAN: 'BERSERKER', BARD: 'LORE', CLERIC: 'LIFE', DRUID: 'LAND', FIGHTER: 'CHAMPION', MONK: 'OPEN_HAND', PALADIN: 'DEVOTION', RANGER: 'HUNTER', ROGUE: 'THIEF', SORCERER: 'DRACONIC', WARLOCK: 'FIEND', WIZARD: 'EVOCATION' });
});
test('a subclass adds its features to the class\'s at each level', () => {
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 of the 10 with your DM (the level-up checklist lists them all).');
// Said once, however many levels go by.
expect(advance(owed, 'FIGHTER', 5).specialAbilities.split('\n').filter((l) => l.startsWith('Subclass — '))).toHaveLength(1);
});
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');
});
});
describe('progression', () => {
test('runs from level 1 to 20, with the subclass marked and the Boons and spell levels shown', () => {
const road = progression('FIGHTER', 'CHAMPION');
expect(road).toHaveLength(20);
expect(road[0].features.map((f) => f.name)).toEqual(['Second Wind', 'Fighting Style']);
expect(road[2].features).toEqual([expect.objectContaining({ name: 'Improved Critical', fromSubclass: true })]);
expect(road[3]).toMatchObject({ boon: true, features: [] });
expect(road[17].features.map((f) => f.name)).toEqual(['Survivor']);
});
test('without a subclass, says where one is chosen; for a caster, where spell levels open', () => {
const road = progression('WIZARD');
expect(road[1].choosesSubclass).toBe(true);
expect(road.map((l) => l.unlocksSpellLevel).filter(Boolean)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
expect(progression('ARTIFICER')[0].unlocksSpellLevel).toBe(1);
});
});
+227
View File
@@ -0,0 +1,227 @@
// Multiclassing (the 2014 Player’s Handbook rules): a character takes a level
// in another class instead of the one they have. Their levels add up to their
// character level; hit dice, hit points, class features and counters go by
// each class's own level, and spell slots by the levels of every class that
// casts. Pure: no DB calls.
//
// A character's first class is Character.characterClass (with its subclass);
// the others are CharacterClassLevel rows. The first class's own level is the
// character level less the others'.
const { CLASSES } = require('./data/classes');
const { FULL_CASTER_SLOTS, PACT_SLOTS } = require('./data/spellSlots');
const { CLASS_FEATURES } = require('./data/characterOptions');
const { resourceState } = require('./classResources');
const calculations = require('./calculations');
// What a class asks of the scores before anyone can take a level in it (or
// leave it for another). Each entry is a list of alternatives; every ability
// in an alternative must be 13 or more.
const MULTICLASS_PREREQS = {
ARTIFICER: [['INT']],
BARBARIAN: [['STR']],
BARD: [['CHA']],
CLERIC: [['WIS']],
DRUID: [['WIS']],
FIGHTER: [['STR'], ['DEX']],
MONK: [['DEX', 'WIS']],
PALADIN: [['STR', 'CHA']],
RANGER: [['DEX', 'WIS']],
ROGUE: [['DEX']],
SORCERER: [['CHA']],
WARLOCK: [['CHA']],
WIZARD: [['INT']],
};
const PREREQ_SCORE = 13;
const ABILITY_NAMES = { STR: 'Strength', DEX: 'Dexterity', CON: 'Constitution', INT: 'Intelligence', WIS: 'Wisdom', CHA: 'Charisma' };
// "Strength 13 or Dexterity 13", "Dexterity 13 and Wisdom 13".
function prereqText(characterClass) {
return (MULTICLASS_PREREQS[characterClass] || [])
.map((all) => all.map((ability) => `${ABILITY_NAMES[ability]} ${PREREQ_SCORE}`).join(' and '))
.join(' or ');
}
// Whether final scores ({ STR: 14, ... }) meet a class's requirement.
function meetsPrereq(characterClass, scores) {
return (MULTICLASS_PREREQS[characterClass] || []).some((all) => all.every((ability) => (scores[ability] || 0) >= PREREQ_SCORE));
}
// What a class teaches someone who comes to it from another (the 2014
// multiclassing table), as a line for the special abilities.
const MULTICLASS_PROFICIENCIES = {
ARTIFICER: 'light and medium armor, shields, thieves’ tools and tinker’s tools',
BARBARIAN: 'shields, simple and martial weapons',
BARD: 'light armor, one skill of your choice and one musical instrument',
CLERIC: 'light and medium armor and shields',
DRUID: 'light and medium armor and shields (no metal)',
FIGHTER: 'light and medium armor, shields, simple and martial weapons',
MONK: 'simple weapons and shortswords',
PALADIN: 'light and medium armor, shields, simple and martial weapons',
RANGER: 'light and medium armor, shields, simple and martial weapons, and one skill from the ranger’s list',
ROGUE: 'light armor, one skill from the rogue’s list, and thieves’ tools',
SORCERER: 'nothing new: a sorcerer’s magic is in the blood',
WARLOCK: 'light armor and simple weapons',
WIZARD: 'nothing new: a wizard’s training is the spellbook',
};
// The lines a character's first level in another class writes: what the class
// grants at level 1 (multiclassing does not bring its saves or skills), and
// what it teaches.
function joiningFeatures(characterClass) {
return [
...(CLASS_FEATURES[characterClass] || []),
{ name: `${CLASSES[characterClass].name} Training`, text: `Multiclassing into ${CLASSES[characterClass].name} teaches ${MULTICLASS_PROFICIENCIES[characterClass]}.` },
];
}
// Every class a character has levels in, the first first:
// [{ characterClass, level, subclass, first }]. Takes a character with its
// classLevels (an empty list or none: a single class).
function classLevels(character) {
const others = [...(character.classLevels || [])]
.filter((row) => row.level > 0 && row.characterClass !== character.characterClass)
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
const elsewhere = others.reduce((sum, row) => sum + row.level, 0);
return [
{ characterClass: character.characterClass, level: Math.max(1, (character.level || 1) - elsewhere), subclass: character.subclass || null, first: true },
...others.map((row) => ({ characterClass: row.characterClass, level: row.level, subclass: row.subclass || null, first: false })),
];
}
// The classes a character could put their next level in: those they have
// (always), and those whose requirement they and every class they have meet.
// `scores` are the final ability scores.
function classOptions(character, scores) {
const have = classLevels(character);
const leaving = have.filter((entry) => !meetsPrereq(entry.characterClass, scores));
return Object.keys(CLASSES).map((characterClass) => {
const current = have.find((entry) => entry.characterClass === characterClass);
if (current) return { characterClass, level: current.level, eligible: true, problems: [] };
const problems = [];
if (!meetsPrereq(characterClass, scores)) problems.push(`${CLASSES[characterClass].name} needs ${prereqText(characterClass)}.`);
for (const entry of leaving) problems.push(`Leaving ${CLASSES[entry.characterClass].name} for another class needs ${prereqText(entry.characterClass)}.`);
return { characterClass, level: 0, eligible: problems.length === 0, problems };
});
}
// The character as it would be with one more level in `characterClass`: the
// level and the class rows moved on. For working out the sheet afterwards.
function withLevelIn(character, characterClass, subclass = undefined) {
const next = { ...character, level: character.level + 1 };
if (characterClass === character.characterClass) {
if (subclass !== undefined) next.subclass = subclass;
return next;
}
const rows = [...(character.classLevels || [])];
const at = rows.findIndex((row) => row.characterClass === characterClass);
if (at >= 0) rows[at] = { ...rows[at], level: rows[at].level + 1, ...(subclass !== undefined ? { subclass } : {}) };
else rows.push({ characterClass, level: 1, subclass: subclass ?? null, createdAt: new Date() });
next.classLevels = rows;
return next;
}
// Hit points: the first class's die in full at level 1, then each level the
// average of its own class's die (rounded up), plus Constitution every level.
function multiclassHpMax(classes, conMod) {
let total = 0;
classes.forEach((entry, i) => {
const die = calculations.getClassHitDieSize(entry.characterClass);
const average = Math.floor(die / 2) + 1;
const levels = entry.level;
total += i === 0 ? die + conMod + (levels - 1) * (average + conMod) : levels * (average + conMod);
});
return total;
}
// Hit Dice: one per level, of each class's own size. Spent ones are counted
// together; `size` is the first class's, the one rests roll.
function multiclassHitDice(classes, used = 0) {
const count = classes.reduce((sum, entry) => sum + entry.level, 0);
const spent = Math.min(Math.max(Number(used) || 0, 0), count);
return {
size: calculations.getClassHitDieSize(classes[0].characterClass),
count,
used: spent,
remaining: count - spent,
pools: classes.map((entry) => ({ characterClass: entry.characterClass, size: calculations.getClassHitDieSize(entry.characterClass), count: entry.level })),
};
}
// How many levels a class adds to the shared caster level: all of a full
// caster's, half a half caster's (an artificer's rounded up), a third of a
// casting subclass's, and none of a warlock's (pact slots stand apart).
function casterLevelOf(entry) {
const kind = calculations.casterKind(entry.characterClass, entry.subclass);
if (kind === 'FULL') return entry.level;
if (kind === 'HALF') return entry.characterClass === 'ARTIFICER' ? Math.ceil(entry.level / 2) : Math.floor(entry.level / 2);
if (kind === 'THIRD') return Math.floor(entry.level / 3);
return 0;
}
// The spell slots of a multiclassed character (pact slots aside): a single
// casting class keeps its own table; two or more add up to a caster level on
// the full caster table. A character whose only casting is a warlock's has
// those pact slots. Null for none.
function multiclassSpellSlots(classes) {
const casters = classes.filter((entry) => {
const kind = calculations.casterKind(entry.characterClass, entry.subclass);
return kind && kind !== 'PACT';
});
if (casters.length === 0) {
const warlock = classes.find((entry) => calculations.casterKind(entry.characterClass) === 'PACT');
return warlock ? calculations.spellSlotsFor('WARLOCK', warlock.level) : null;
}
if (casters.length === 1) return calculations.spellSlotsFor(casters[0].characterClass, casters[0].level, casters[0].subclass);
const casterLevel = casters.reduce((sum, entry) => sum + casterLevelOf(entry), 0);
if (casterLevel < 1) return null;
return { kind: 'FULL', restoresOn: 'LONG', max: [...FULL_CASTER_SLOTS[Math.min(casterLevel, FULL_CASTER_SLOTS.length) - 1]] };
}
// A multiclassed warlock's pact slots, kept apart from the others' (they come
// back on a short rest): { count, slotLevel }, or null when the warlock's are
// the only slots (they are then the sheet's spell slots) or there is none.
function pactSlotsOf(classes) {
const warlock = classes.find((entry) => calculations.casterKind(entry.characterClass) === 'PACT');
if (!warlock) return null;
const others = classes.some((entry) => {
const kind = calculations.casterKind(entry.characterClass, entry.subclass);
return kind && kind !== 'PACT';
});
if (!others) return null;
const [count, slotLevel] = PACT_SLOTS[Math.min(warlock.level, PACT_SLOTS.length) - 1];
return { count, slotLevel };
}
// The class counters of every class, each by its own class level. Where two
// classes give the same counter (Channel Divinity), the larger one counts.
function multiclassResources(classes, resourcesUsed, abilities, profBonus) {
const byKey = new Map();
for (const entry of classes) {
for (const resource of resourceState({ ...entry, resourcesUsed }, abilities, profBonus)) {
const had = byKey.get(resource.key);
if (!had || resource.max > had.max) byKey.set(resource.key, resource);
}
}
return [...byKey.values()];
}
// Added to (not replacing) the exports object: calculations.js holds it from
// before this file has finished loading.
Object.assign(module.exports, {
MULTICLASS_PREREQS,
PREREQ_SCORE,
prereqText,
meetsPrereq,
joiningFeatures,
classLevels,
classOptions,
withLevelIn,
multiclassHpMax,
multiclassHitDice,
casterLevelOf,
multiclassSpellSlots,
pactSlotsOf,
multiclassResources,
});
+146
View File
@@ -0,0 +1,146 @@
const {
prereqText,
meetsPrereq,
classLevels,
classOptions,
withLevelIn,
multiclassHpMax,
multiclassSpellSlots,
pactSlotsOf,
casterLevelOf,
} = require('./multiclass');
const { computeCharacterSheet, spellSlotsFor } = require('./calculations');
const { levelUpPlan, advanceIn } = require('./levelUp');
const { MULTICLASS_PATHS } = require('./data/multiclassPaths');
const { findSubclass } = require('./data/subclasses');
const scores = (over = {}) => ({ STR: 10, DEX: 10, CON: 10, INT: 10, WIS: 10, CHA: 10, ...over });
describe('requirements', () => {
test('say what a class needs, with either-or where there is a choice', () => {
expect(prereqText('FIGHTER')).toBe('Strength 13 or Dexterity 13');
expect(prereqText('MONK')).toBe('Dexterity 13 and Wisdom 13');
});
test('are met by final scores of 13 or more', () => {
expect(meetsPrereq('FIGHTER', scores({ DEX: 13 }))).toBe(true);
expect(meetsPrereq('PALADIN', scores({ STR: 15, CHA: 12 }))).toBe(false);
});
test('apply to the class left as well as the one joined', () => {
const wizard = { characterClass: 'WIZARD', level: 3, classLevels: [] };
const options = classOptions(wizard, scores({ CHA: 14 })); // INT only 10: can't leave wizard
const warlock = options.find((o) => o.characterClass === 'WARLOCK');
expect(warlock.eligible).toBe(false);
expect(warlock.problems.join(' ')).toMatch(/Leaving Wizard/);
expect(options.find((o) => o.characterClass === 'WIZARD')).toMatchObject({ eligible: true, level: 3 });
expect(classOptions(wizard, scores({ INT: 15, CHA: 14 })).find((o) => o.characterClass === 'WARLOCK').eligible).toBe(true);
});
});
describe('class levels', () => {
test('the first class has the levels the others leave', () => {
const character = { characterClass: 'PALADIN', subclass: 'DEVOTION', level: 5, classLevels: [{ characterClass: 'WARLOCK', level: 2, subclass: null }] };
expect(classLevels(character)).toEqual([
{ characterClass: 'PALADIN', level: 3, subclass: 'DEVOTION', first: true },
{ characterClass: 'WARLOCK', level: 2, subclass: null, first: false },
]);
expect(classLevels({ characterClass: 'BARD', level: 4 })).toHaveLength(1);
});
test('a level in a class moves the character and that class on', () => {
const character = { characterClass: 'FIGHTER', level: 2, classLevels: [] };
const next = withLevelIn(character, 'ROGUE');
expect(next.level).toBe(3);
expect(classLevels(next).map((e) => [e.characterClass, e.level])).toEqual([['FIGHTER', 2], ['ROGUE', 1]]);
expect(classLevels(withLevelIn(next, 'FIGHTER')).map((e) => e.level)).toEqual([3, 1]);
});
});
describe('hit points', () => {
test('the first class’s die in full, then each level’s own class average', () => {
// Fighter 1 (10) + Wizard 2 (4 + 4), Constitution +1 at each of 3 levels.
expect(multiclassHpMax([{ characterClass: 'FIGHTER', level: 1 }, { characterClass: 'WIZARD', level: 2 }], 1)).toBe(10 + 8 + 3);
});
});
describe('spell slots', () => {
test('one casting class keeps its own table', () => {
expect(multiclassSpellSlots([{ characterClass: 'FIGHTER', level: 5 }, { characterClass: 'WIZARD', level: 3 }]).max).toEqual([4, 2]);
});
test('two casting classes add up to a caster level on the full table', () => {
// Paladin 6 (3) + Sorcerer 4 (4) = caster level 7.
expect(multiclassSpellSlots([{ characterClass: 'PALADIN', level: 6 }, { characterClass: 'SORCERER', level: 4 }]).max).toEqual([4, 3, 3, 1]);
expect(casterLevelOf({ characterClass: 'ARTIFICER', level: 3 })).toBe(2); // rounds up
expect(casterLevelOf({ characterClass: 'FIGHTER', level: 6, subclass: 'ELDRITCH_KNIGHT' })).toBe(2);
});
test('a warlock’s pact slots stand apart', () => {
const classes = [{ characterClass: 'SORCERER', level: 5 }, { characterClass: 'WARLOCK', level: 3 }];
expect(multiclassSpellSlots(classes).max).toEqual([4, 3, 2]);
expect(pactSlotsOf(classes)).toEqual({ count: 2, slotLevel: 2 });
expect(multiclassSpellSlots([{ characterClass: 'FIGHTER', level: 4 }, { characterClass: 'WARLOCK', level: 2 }]).kind).toBe('PACT');
});
test('the Eldritch Knight and Arcane Trickster cast from level 3', () => {
expect(spellSlotsFor('FIGHTER', 3, 'ELDRITCH_KNIGHT').max).toEqual([2]);
expect(spellSlotsFor('ROGUE', 7, 'ARCANE_TRICKSTER').max).toEqual([4, 2]);
expect(spellSlotsFor('FIGHTER', 3, 'CHAMPION')).toBeNull();
});
});
describe('the sheet', () => {
const base = { strScore: 16, dexScore: 12, conScore: 14, intScore: 10, wisScore: 10, chaScore: 14, species: 'HUMAN' };
test('a multiclassed character adds up hit dice, hit points, slots and counters by class', () => {
const sheet = computeCharacterSheet({ ...base, characterClass: 'PALADIN', level: 5, classLevels: [{ characterClass: 'SORCERER', level: 2 }] });
expect(sheet.hitDice).toMatchObject({ count: 5, pools: [{ characterClass: 'PALADIN', size: 10, count: 3 }, { characterClass: 'SORCERER', size: 6, count: 2 }] });
expect(sheet.hp.max).toBe(10 + 2 * 6 + 2 * 4 + 5 * 2); // CON 15 (14 + 1): +2
expect(sheet.spellSlots.levels.map((l) => l.max)).toEqual([4, 2]); // caster level 1 + 2 = 3
expect(sheet.spellcastingByClass.map((c) => c.characterClass)).toEqual(['PALADIN', 'SORCERER']);
expect(sheet.resources.find((r) => r.key === 'SORCERY_POINTS').max).toBe(2);
expect(sheet.resources.find((r) => r.key === 'LAY_ON_HANDS').max).toBe(15); // 5 × paladin level 3
});
test('a single class is worked out as before', () => {
const sheet = computeCharacterSheet({ ...base, characterClass: 'FIGHTER', level: 3, subclass: 'ELDRITCH_KNIGHT' });
expect(sheet.hitDice.pools).toBeUndefined();
expect(sheet.spellcasting.ability).toBe('INT');
expect(sheet.spellSlots.levels).toEqual([{ level: 1, max: 2, used: 0, remaining: 2 }]);
});
});
describe('levelling up into another class', () => {
const fighter = { characterClass: 'FIGHTER', level: 4, specialAbilities: null, weapons: [], classLevels: [] };
test('plans the new class at its own level 1, with what it grants and teaches', () => {
const plan = levelUpPlan(fighter, { multiclass: true, toClass: 'ROGUE', scores: scores({ STR: 15, DEX: 14 }) });
expect(plan).toMatchObject({ characterClass: 'ROGUE', classLevel: 1, joining: true, toLevel: 5, boon: null, subclass: null });
expect(plan.features.map((f) => f.name)).toEqual(['Sneak Attack', 'Thieves’ Cant', 'Rogue Training']);
expect(plan.classChoice.options.find((o) => o.characterClass === 'ROGUE').eligible).toBe(true);
const { specialAbilities } = advanceIn(null, plan, undefined);
expect(specialAbilities).toMatch(/^Rogue Training — /m);
});
test('a class’s Boons and subclass go by its own level', () => {
const multi = { ...fighter, level: 5, classLevels: [{ characterClass: 'ROGUE', level: 2 }] };
const plan = levelUpPlan(multi, { multiclass: true, toClass: 'ROGUE' });
expect(plan).toMatchObject({ classLevel: 3, joining: false });
expect(plan.subclass.options.map((o) => o.key)).toContain('ARCANE_TRICKSTER');
expect(levelUpPlan(multi, { multiclass: true }).characterClass).toBe('FIGHTER');
});
test('without the setting there is no class to choose', () => {
expect(levelUpPlan(fighter).classChoice).toBeNull();
});
});
test('every suggested route names real classes and subclasses', () => {
for (const path of MULTICLASS_PATHS) {
expect(path.steps.length).toBeGreaterThan(0);
for (const step of path.steps) {
if (step.subclass) expect(findSubclass(step.class, step.subclass)).not.toBeNull();
}
}
});
+87
View File
@@ -0,0 +1,87 @@
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();
});
});
describe('the artificer', () => {
test('is a half caster from level 1, rounding up', () => {
expect(spellSlotsFor('ARTIFICER', 1)).toEqual({ kind: 'HALF', restoresOn: 'LONG', max: [2] });
expect(spellSlotsFor('ARTIFICER', 5).max).toEqual([4, 2]);
expect(maxSpellLevel('ARTIFICER', 20)).toBe(5);
expect(spellSlotsFor('PALADIN', 1).max).toEqual([]); // a paladin still waits for level 2
});
});
+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
+29
View File
@@ -18,6 +18,11 @@ const loginSchema = z.object({
password: z.string().min(1),
});
const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8, 'Password must be at least 8 characters'),
});
function issueToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, displayName: user.displayName },
@@ -76,4 +81,28 @@ router.get('/me', requireAuth, async (req, res, next) => {
}
});
// Tokens already issued stay valid until they expire; this only changes what
// the next login needs.
router.post('/password', requireAuth, async (req, res, next) => {
try {
const parsed = changePasswordSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { currentPassword, newPassword } = parsed.data;
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (!user) return res.status(404).json({ error: 'User not found' });
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) return res.status(400).json({ error: 'Your current password is incorrect' });
if (newPassword === currentPassword) {
return res.status(400).json({ error: 'Your new password must be different from the current one' });
}
await prisma.user.update({ where: { id: user.id }, data: { passwordHash: await bcrypt.hash(newPassword, 12) } });
return res.status(204).end();
} catch (err) {
return next(err);
}
});
module.exports = router;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6
View File
@@ -34,6 +34,12 @@ router.post('/', async (req, res, next) => {
if (resolvedCampaignId) {
const membership = await loadCampaignMembership(req.user.id, resolvedCampaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
// A campaign-level (not character-level) DM_ONLY note is the DM's
// private scratchpad — only the DM may create one of those.
if (visibility === 'DM_ONLY' && !characterId && membership.role !== 'DM') {
return res.status(403).json({ error: 'Only the DM can post a DM-only campaign note.' });
}
}
const note = await prisma.note.create({
+38
View File
@@ -0,0 +1,38 @@
// Rules the app knows, for any signed-in user: nothing here belongs to a
// campaign or a character.
const express = require('express');
const { z } = require('zod');
const { requireAuth } = require('../middleware/auth');
const { CLASSES } = require('../game/data/classes');
const { findSubclass } = require('../game/data/subclasses');
const { progression } = require('../game/levelUp');
const { MULTICLASS_PREREQS, PREREQ_SCORE, prereqText } = require('../game/multiclass');
const { MULTICLASS_PATHS } = require('../game/data/multiclassPaths');
const router = express.Router();
router.use(requireAuth);
// GET /reference/progression?class=FIGHTER&subclass=CHAMPION — what each
// level from 1 to 20 gives a class, with the subclass's lines when one is
// named. Shown as the progression timeline in the builder, on the sheet and
// when levelling up.
router.get('/progression', (req, res) => {
const parsed = z.object({ class: z.enum(Object.keys(CLASSES)), subclass: z.string().optional() }).safeParse(req.query);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { class: characterClass, subclass } = parsed.data;
if (subclass && !findSubclass(characterClass, subclass)) return res.status(400).json({ error: 'That is not a subclass of this class.' });
res.json({ characterClass, subclass: subclass || null, levels: progression(characterClass, subclass) });
});
// GET /reference/multiclass — what each class asks of the ability scores to
// multiclass into or out of it, and the suggested routes (data/multiclassPaths.js).
router.get('/multiclass', (req, res) => {
res.json({
prereqScore: PREREQ_SCORE,
prereqs: Object.fromEntries(Object.keys(CLASSES).map((cls) => [cls, { alternatives: MULTICLASS_PREREQS[cls], text: prereqText(cls) }])),
paths: MULTICLASS_PATHS,
});
});
module.exports = router;

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