Compare commits

...
4 Commits
Author SHA1 Message Date
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
87 changed files with 9604 additions and 906 deletions
+42 -3
View File
@@ -18,6 +18,7 @@ monsters, and a real-time encounter/combat tracker.
| `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
@@ -33,13 +34,28 @@ The same four destinations are always on screen: **Campaigns** (the list),
including a character sheet or an encounter) and **Account**.
- On a wide screen they are links along the top bar, and the dice roller
floats at the bottom left.
floats at the bottom left. **A number with a dotted underline rolls when
clicked** (a skill, a save, an attack, damage), with the bonus already in
and advantage or disadvantage if the roller is set to it. The DM's "🎲 Call
for a roll…" rolls one check for the whole party.
- On a phone or small tablet (900px wide or less) they are a bar along the
bottom of the screen, with the **dice roller** as its last button. Nothing
floats over the page: the 🧙 character button sits in the top bar.
- The **🧙 character button** is on every page (floating at the bottom right
on a wide screen). It opens a panel with your character's numbers and
everything they hold; the DM can open it for any character.
on a wide screen). It opens a panel with your character's numbers, saving
throws, skills and everything they hold; the DM can open it for any
character. "📋 Sheet" on an encounter card and 📋 on the party overview open
the same panel for that character, without leaving the page.
- **In an encounter** a strip across the top lists everyone in turn order and
stays in view; a click goes to that card. On a wide screen the stat block
of whoever is on turn stays in view beside the cards, and **📌 Pin** keeps
anyone else's there instead. For the DM, **N** is Next Turn and **S** opens
the sheet of whoever is on turn.
- The DM adds **several of a monster at once** (numbered for them), rolls
initiative for all the monsters and NPCs with one button, and can give a
**condition a number of rounds**, after which it comes off by itself.
Hovering over a condition shows its rule, and damaging someone who is
Concentrating shows the save to call for.
- The **campaign page shows one part at a time**, picked from a row of
buttons under its name: Overview (characters and encounters), NPCs,
Locations, Weapons, Items, Spells, Monsters, Notes and Settings for the DM;
@@ -69,6 +85,29 @@ including a character sheet or an encounter) and **Account**.
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.
- **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…** on a sheet (DM) takes the character up a level with the
whole checklist: HP, the class's new special abilities, a Boon, spells and
the free weapon upgrade.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.14.0",
"version": "1.23.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -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';
+116 -4
View File
@@ -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,6 +117,7 @@ enum BuildStage {
NOT_STARTED
ROLLING_SPECIES
ROLLING_CLASS
CHOOSING_SUBCLASS
ROLLING_MOTIVATION
ASSIGNING_SCORES
ASSIGNING_PROFICIENCIES
@@ -138,6 +173,42 @@ enum DiceMode {
PHYSICAL
}
// Which dice a campaign plays with. BOTH leaves it to each character (and
// every roll can still be typed in from a real die).
enum CampaignDiceMode {
DIGITAL
PHYSICAL
BOTH
}
// How a character's maximum HP grows on a level up: the fixed average of the
// hit die (rounded up), or a roll of it.
enum HpMethod {
AVERAGE
ROLL
}
// How the builder arrives at the six ability scores.
enum ScoreMethod {
ROLL_4D6
STANDARD_ARRAY
POINT_BUY
}
// Who sees a roll. ALL: everyone at the table. OWN: a player sees their own;
// the DM always sees every roll.
enum RollVisibility {
ALL
OWN
}
// What a critical hit does to the damage: roll the dice twice (the rule), or
// take the dice at their maximum and roll them once more on top.
enum CritRule {
DOUBLE_DICE
MAX_PLUS_ROLL
}
// ---------------------------------------------------------------------------
// Identity & campaigns
// ---------------------------------------------------------------------------
@@ -179,6 +250,28 @@ model Campaign {
// bear at one time. A pair of daggers is one weapon, made as such.
maxEquipped Int @default(2)
maxEquippedWeapons Int @default(1)
// Off: nobody chooses a subclass. Each class follows its first one without
// being asked, and subclasses are not named anywhere.
subclassesEnabled Boolean @default(true)
// 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.
@@ -260,6 +353,9 @@ model Character {
name String
species Species?
characterClass CharacterClass?
// The path taken within the class: a key from game/data/subclasses.js. Null
// until the class reaches the level it chooses at.
subclass String?
level Int @default(1)
xp Int @default(0)
motivation String?
@@ -272,9 +368,10 @@ 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)
@@ -289,6 +386,15 @@ model Character {
// 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?
@@ -596,6 +702,9 @@ model Encounter {
archivedAt DateTime?
// Place in the campaign's own order, lowest first. The DM rearranges it.
sortOrder Int @default(0)
// The experience points handed out for this encounter, in all; null until
// the DM awards them. They are awarded once.
xpAwarded Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -684,7 +793,10 @@ model EncounterParticipant {
initiative Int @default(0)
currentHp Int
maxHp Int
conditions String[] @default([]) // e.g. ["poisoned", "prone"]
conditions String[] @default([]) // e.g. ["poisoned", "prone"]; a character's own are on the Character row
// Conditions that wear off by themselves, as { condition: rounds left }.
// Counted down each time the round goes up; at 0 the condition is removed.
conditionRounds Json @default("{}")
isMonster Boolean
encounter Encounter @relation(fields: [encounterId], references: [id], onDelete: Cascade)
+19 -2
View File
@@ -16,6 +16,7 @@ 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');
@@ -116,7 +117,8 @@ function builderLists() {
].join('\n\n');
}
// What "Level Up" writes for each class at each level, and the Boons.
// 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)
@@ -127,7 +129,22 @@ function levelFeatures() {
const features = featuresAt(key, level);
rows.push([level, features.length > 0 ? features.map(line).join('<br>') : 'No new feature']);
}
return `### ${CLASSES[key].name}\n\n${table(['Level', 'Written into Special Abilities'], rows)}`;
const 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'],
+2 -1
View File
@@ -10,5 +10,6 @@
{ "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": "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."}
]
+2
View File
@@ -7,6 +7,7 @@ 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();
@@ -27,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' });
+49 -9
View File
@@ -9,15 +9,21 @@ const { SPECIES_TRAITS, CLASS_FEATURES, TALENTS, TALENT_PICKS } = require('./dat
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'],
@@ -25,7 +31,7 @@ const CLASS_ROLL_TABLE = {
4: ['CLERIC', 'PALADIN'],
5: ['BARD', 'WARLOCK'],
6: ['DRUID', 'MONK'],
7: 'REROLL',
7: 'ARTIFICER_OR_REROLL',
8: 'WILDCARD',
};
@@ -48,6 +54,31 @@ function rollAbilityScore() {
return abilityScoreFromDice(dice);
}
// The other two ways to make ability scores (Campaign.abilityScoreMethod).
// The standard array is six fixed scores, assigned like rolled ones. Point
// buy starts every score at 8 and spends a budget to raise them, to 15 at most.
const STANDARD_ARRAY = [15, 14, 13, 12, 10, 8];
const POINT_BUY_BUDGET = 27;
const POINT_BUY_COSTS = { 8: 0, 9: 1, 10: 2, 11: 3, 12: 4, 13: 5, 14: 7, 15: 9 };
// What a set of point-buy scores costs, or null if a score is out of range.
function pointBuyCost(scores) {
let total = 0;
for (const score of Object.values(scores)) {
const cost = POINT_BUY_COSTS[score];
if (cost === undefined) return null;
total += cost;
}
return total;
}
// Which dice a character rolls: the campaign's, unless it leaves the choice
// to each character (BOTH).
function effectiveDiceMode(campaign, character) {
if (campaign && campaign.diceMode && campaign.diceMode !== 'BOTH') return campaign.diceMode;
return character.diceMode;
}
// Fisher-Yates-ish random pick of `count` distinct items from `options`,
// using the same crypto RNG as dice rolls (never Math.random for anything
// that ends up on a character sheet).
@@ -139,21 +170,30 @@ function speciesChoicesForRoll(roll) {
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,
CLASS_ROLL_DIE,
ALL_SPECIES,
ALL_CLASSES,
rollDie,
rollAbilityScore,
abilityScoreFromDice,
STANDARD_ARRAY,
POINT_BUY_BUDGET,
POINT_BUY_COSTS,
pointBuyCost,
effectiveDiceMode,
speciesChoicesForRoll,
classChoicesForRoll,
randomSample,
+37
View File
@@ -0,0 +1,37 @@
const { pointBuyCost, POINT_BUY_BUDGET, STANDARD_ARRAY, effectiveDiceMode } = require('./buildFlow');
describe('point buy', () => {
test('all eights cost nothing', () => {
expect(pointBuyCost({ STR: 8, DEX: 8, CON: 8, INT: 8, WIS: 8, CHA: 8 })).toBe(0);
});
test('the classic 15/15/15/8/8/8 spends exactly the budget', () => {
expect(pointBuyCost({ STR: 15, DEX: 15, CON: 15, INT: 8, WIS: 8, CHA: 8 })).toBe(POINT_BUY_BUDGET);
});
test('14 and 15 cost two points a step', () => {
expect(pointBuyCost({ STR: 13 })).toBe(5);
expect(pointBuyCost({ STR: 14 })).toBe(7);
expect(pointBuyCost({ STR: 15 })).toBe(9);
});
test('a score out of range is refused', () => {
expect(pointBuyCost({ STR: 16 })).toBeNull();
expect(pointBuyCost({ STR: 7 })).toBeNull();
});
});
test('the standard array is the six usual scores', () => {
expect(STANDARD_ARRAY).toEqual([15, 14, 13, 12, 10, 8]);
});
describe('effectiveDiceMode', () => {
test('a campaign with one kind of dice decides for everyone', () => {
expect(effectiveDiceMode({ diceMode: 'PHYSICAL' }, { diceMode: 'DIGITAL' })).toBe('PHYSICAL');
expect(effectiveDiceMode({ diceMode: 'DIGITAL' }, { diceMode: 'PHYSICAL' })).toBe('DIGITAL');
});
test('BOTH leaves it to the character', () => {
expect(effectiveDiceMode({ diceMode: 'BOTH' }, { diceMode: 'PHYSICAL' })).toBe('PHYSICAL');
});
});
+76 -16
View File
@@ -5,7 +5,9 @@
const { SPECIES } = require('./data/species');
const { CLASSES } = require('./data/classes');
const { SKILLS } = require('./data/skills');
const { FULL_CASTER_SLOTS, HALF_CASTER_SLOTS, PACT_SLOTS, CASTER_KIND, MAX_SLOT_LEVEL } = require('./data/spellSlots');
const { resourceState } = require('./classResources');
const { CR_XP, XP_THRESHOLDS, COUNT_MULTIPLIERS, LEVEL_XP } = require('./data/xp');
const { FULL_CASTER_SLOTS, HALF_CASTER_SLOTS, PACT_SLOTS, CASTER_KIND, CLASS_SLOT_TABLES, MAX_SLOT_LEVEL } = require('./data/spellSlots');
const ABILITIES = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
@@ -17,20 +19,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) {
@@ -55,6 +55,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;
}
@@ -143,7 +148,8 @@ function spellSlotsFor(characterClass, level) {
const [count, slotLevel] = PACT_SLOTS[row];
return { kind, restoresOn: 'SHORT', max: Array.from({ length: slotLevel }, (_, i) => (i === slotLevel - 1 ? count : 0)) };
}
return { kind, restoresOn: 'LONG', max: [...(kind === 'FULL' ? FULL_CASTER_SLOTS : HALF_CASTER_SLOTS)[row]] };
const table = CLASS_SLOT_TABLES[characterClass] || (kind === 'FULL' ? FULL_CASTER_SLOTS : HALF_CASTER_SLOTS);
return { kind, restoresOn: 'LONG', max: [...table[row]] };
}
// The slot tracker: for each slot level the character has, how many slots
@@ -176,6 +182,54 @@ function hitDiceState(characterClass, level, used = 0) {
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
@@ -244,15 +298,13 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
intScore = 10,
wisScore = 10,
chaScore = 10,
halfElfBonusAbility1 = null,
halfElfBonusAbility2 = null,
speciesBonusChoices = {},
armorBonus = 0,
speedOverride = null,
spellSlotsUsed = [],
hitDiceUsed = 0,
} = character;
const halfElfBonusAbilities = [halfElfBonusAbility1, halfElfBonusAbility2].filter(Boolean);
const rolled = { STR: strScore, DEX: dexScore, CON: conScore, INT: intScore, WIS: wisScore, CHA: chaScore };
const gear = activeGear(character);
@@ -262,7 +314,7 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
const abilities = {};
for (const ability of ABILITIES) {
// A score never drops below 1, whatever is weighing the character down.
const final = Math.max(1, finalAbilityScore(rolled[ability], ability, species, halfElfBonusAbilities) + fromGear(ability));
const final = Math.max(1, finalAbilityScore(rolled[ability], ability, species, speciesBonusChoices) + fromGear(ability));
abilities[ability] = {
rolled: rolled[ability],
final,
@@ -307,6 +359,8 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
saves,
skills,
passivePerception: passivePerception(skills.PERCEPTION.bonus) + fromGear('PASSIVE_PERCEPTION'),
passiveInvestigation: passiveScore(skills.INVESTIGATION.bonus),
passiveInsight: passiveScore(skills.INSIGHT.bonus),
// What equipped gear is doing to the numbers above, and what else it does.
gear,
spellcasting: spellcastingAbility
@@ -318,6 +372,8 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
: null,
// The slot tracker; null for a character with no spell slots.
spellSlots: spellSlotState(characterClass, level, spellSlotsUsed),
// The class counters (Rage, Ki...), with how many of each are left.
resources: resourceState(character, abilities, profBonus),
};
}
@@ -452,6 +508,7 @@ module.exports = {
saveBonus,
skillBonus,
passivePerception,
passiveScore,
armorClass,
initiative,
hpMax,
@@ -470,6 +527,9 @@ module.exports = {
spellSlotState,
maxSpellLevel,
hitDiceState,
challengeXp,
encounterDifficulty,
xpProgress,
GEAR_STATS,
activeGear,
computeCharacterSheet,
+15 -6
View File
@@ -58,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', () => {
@@ -199,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
+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'] });
});
});
+200
View File
@@ -60,6 +60,202 @@ const SPECIES_TRAITS = {
{ 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.
@@ -112,6 +308,10 @@ const CLASS_FEATURES = {
{ 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.
+5
View File
@@ -71,6 +71,11 @@ const CLASSES = {
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 };
+153 -49
View File
@@ -1,8 +1,8 @@
// What each class gains at each level after the first: the "Levelling-Up
// Checklist" tables of the Hollowcrown book (Rules Cheat Sheet), levels 2 to
// 10. The site has no subclasses, so each class follows one simple path (the
// Champion fighter, the Life cleric...). Level 1 features are in
// characterOptions.js and are written by the builder.
// 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
@@ -55,17 +55,25 @@ const LEVEL_FEATURES = {
{ name: 'Reckless Attack', text: 'On your first attack each turn you may attack with advantage; attacks against you have advantage until your next turn.' },
{ name: 'Danger Sense', text: 'Advantage on Dexterity saves against effects you can see (traps, breath, spells).' },
],
3: [
{ name: 'Frenzy', text: 'Path of the Berserker. While raging you may make one extra melee attack as a bonus action each turn; when the rage ends, gain one level of exhaustion.' },
rage('Three times', 2),
],
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: [{ name: 'Mindless Rage', text: 'You can’t be charmed or frightened while raging.' }, rage('Four times', 2)],
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)],
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.' }],
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: [
@@ -73,15 +81,12 @@ const LEVEL_FEATURES = {
{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d6.' },
],
3: [
{ name: 'Cutting Words', text: 'College of Lore. Reaction: spend a Bardic Inspiration to subtract the die from an enemy’s attack, check or damage roll.' },
{ name: 'Bonus Proficiencies', text: 'Treat three extra skills of your choice as proficient (add your proficiency bonus by hand).' },
{ name: 'Expertise', text: 'Double proficiency on two proficient skills (add by hand).' },
],
4: [BOON],
5: [bardicInspiration('d8'), { name: 'Font of Inspiration', text: 'Regain Bardic Inspiration on a short rest.' }],
6: [
{ name: 'Countercharm', text: 'Action: allies within 30 ft get advantage on saves against fear and charm until your next turn.' },
{ name: 'Additional Magical Secrets', text: 'Learn two spells from any class list (the DM grants them within the unlock schedule).' },
],
8: [BOON],
9: [{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d8.', upgrade: true }],
@@ -90,47 +95,73 @@ const LEVEL_FEATURES = {
{ 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 Preserve Life.' },
{ 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.' },
{ name: 'Preserve Life', text: 'Channel Divinity. Action: share out healing of 5 × your level among creatures within 30 ft (up to half their max HP each).' },
],
4: [BOON],
5: [{ name: 'Destroy Undead', text: 'Undead of CR 1/2 or lower that fail against your Turn Undead are destroyed.' }],
6: [
{ name: 'Channel Divinity', text: 'Twice per short rest, use Turn Undead or Preserve Life.', upgrade: true },
{ name: 'Blessed Healer', text: 'When you heal someone else with a spell, you heal yourself 2 + the spell’s level.' },
{ name: 'Channel Divinity', text: 'Twice per short rest, use Turn Undead or your domain’s Channel Divinity.', upgrade: true },
],
8: [
BOON,
{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 radiant damage.' },
{ name: 'Destroy Undead', text: 'Undead of CR 1 or lower that fail against your Turn Undead are destroyed.', upgrade: true },
],
10: [{ name: 'Divine Intervention', text: 'Once per week, call on your god: roll d100; if you roll your level or lower, the god helps (the DM decides how).' }],
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)'),
{ name: 'Natural Recovery', text: 'Once per day on a short rest, recover spent spell slots totalling up to half your level (rounded up).' },
{ name: 'Bonus Cantrip', text: 'Learn one extra cantrip (the DM grants it).' },
],
4: [BOON, wildShape('CR 1/2 or less (it may swim, not fly)')],
6: [{ name: 'Land’s Stride', text: 'Ordinary difficult terrain and plants do not slow you; advantage on saves against magical plants.' }],
8: [BOON, wildShape('CR 1 or less (it may swim or fly)')],
10: [{ name: 'Nature’s Ward', text: 'Immune to poison and disease; you can’t be charmed or frightened by fey or elementals.' }],
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.' }],
3: [{ name: 'Improved Critical', text: 'Your weapon attacks score a critical hit on a roll of 19 or 20.' }],
4: [BOON],
5: [EXTRA_ATTACK],
6: [BOON],
7: [{ name: 'Remarkable Athlete', text: 'Add half your proficiency bonus to Strength, Dexterity and Constitution checks you are not proficient in; long jump + Strength modifier feet.' }],
8: [BOON],
9: [{ name: 'Indomitable', text: 'Once per long rest, reroll a failed saving throw.' }],
10: [{ name: 'Additional Fighting Style', text: 'Choose a second option from your Fighting Style list. (If it is +1 AC in armor, the DM raises Extra AC by 1.)' }],
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: [
@@ -141,7 +172,6 @@ const LEVEL_FEATURES = {
{ name: 'Unarmored Movement', text: '+10 ft speed without armor (ask your DM to raise your speed).' },
],
3: [
{ name: 'Open Hand Technique', text: 'Flurry hits can knock a target prone (Dexterity save), push it 15 ft (Strength save) or stop its reactions.' },
{ name: 'Deflect Missiles', text: 'Reaction: reduce ranged weapon damage by 1d10 + Dexterity modifier + your level.' },
],
4: [BOON, { name: 'Slow Fall', text: 'Reaction: reduce falling damage by 5 × your level.' }],
@@ -152,7 +182,6 @@ const LEVEL_FEATURES = {
],
6: [
{ name: 'Ki-Empowered Strikes', text: 'Your unarmed strikes count as magical.' },
{ name: 'Wholeness of Body', text: 'Once per long rest, heal 3 × your level as an action.' },
{ name: 'Unarmored Movement', text: '+15 ft speed without armor (ask your DM to raise your speed).', upgrade: true },
],
7: [EVASION, { name: 'Stillness of Mind', text: 'Action: end charm or fear on yourself.' }],
@@ -162,6 +191,22 @@ const LEVEL_FEATURES = {
{ 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: [
@@ -171,15 +216,21 @@ const LEVEL_FEATURES = {
],
3: [
{ name: 'Divine Health', text: 'Immune to disease.' },
{ name: 'Sacred Weapon', text: 'Oath of Devotion, Channel Divinity. Add your Charisma modifier to attack rolls with your weapon for 1 minute; it sheds light.' },
{ name: 'Turn the Unholy', text: 'Channel Divinity. Fiends and undead within 30 ft make a Wisdom save or flee for 1 minute.' },
],
4: [BOON],
5: [EXTRA_ATTACK],
6: [{ name: 'Aura of Protection', text: 'You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious.' }],
7: [{ name: 'Aura of Devotion', text: 'You and allies within 10 ft can’t be charmed.' }],
8: [BOON],
10: [{ name: 'Aura of Courage', text: 'You and allies within 10 ft can’t be frightened.' }],
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: [
@@ -187,7 +238,6 @@ const LEVEL_FEATURES = {
{ name: 'Spellcasting', text: 'Ranger spells unlock: you now have spell slots.' },
],
3: [
{ name: 'Colossus Slayer', text: 'Hunter’s Prey. Once per turn, +1d8 damage to a creature that is already below its max HP.' },
{ name: 'Primeval Awareness', text: 'Spend a spell slot: sense aberrations, dragons, fey, fiends, undead and elementals within 1 mile for 1 minute per slot level.' },
],
4: [BOON],
@@ -196,61 +246,113 @@ const LEVEL_FEATURES = {
{ name: 'Favored Enemy', text: 'Choose a second favored enemy with your DM.' },
{ name: 'Natural Explorer', text: 'Choose a second favored terrain with your DM.' },
],
7: [{ name: 'Escape the Horde', text: 'Defensive Tactics. Opportunity attacks against you have disadvantage.' }],
8: [BOON, LANDS_STRIDE],
10: [
{ name: 'Hide in Plain Sight', text: 'Spend 1 minute camouflaging: +10 to Stealth while you stay still.' },
{ name: 'Natural Explorer', text: 'Choose a third favored terrain with your DM.' },
],
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'),
{ 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.' },
],
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'), { name: 'Supreme Sneak', text: 'Advantage on Stealth if you move no more than half your speed.' }],
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],
6: [{ name: 'Elemental Affinity', text: 'Draconic Bloodline. Add your Charisma modifier to one damage roll of your dragon’s element; spend 1 sorcery point to resist that element for 1 hour.' }],
8: [BOON],
10: [{ name: 'Metamagic', text: 'Choose a third option with your DM.' }],
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')],
6: [{ name: 'Patron’s Luck', text: 'Once per short rest, add a d10 to an ability check or saving throw after seeing the roll.' }],
7: [invocations('four')],
8: [BOON],
9: [invocations('five')],
10: [{ name: 'Patron’s Resilience', text: 'After each rest, choose one damage type: you resist it until you choose another.' }],
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: {
2: [
{ name: 'Evocation Savant', text: 'School of Evocation. Copying evocation spells costs half the time and gold.' },
{ name: 'Sculpt Spells', text: 'When you cast an evocation spell that hits an area, choose 1 + the spell’s level creatures to automatically save and take no damage.' },
],
4: [BOON],
6: [{ name: 'Potent Cantrip', text: 'Creatures that save against your damaging cantrips still take half damage.' }],
8: [BOON],
10: [{ name: 'Empowered Evocation', text: 'Add your Intelligence modifier to the damage of your evocation spells.' }],
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 stop here; a higher level still raises the numbers.
const MAX_FEATURE_LEVEL = 10;
// 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.
@@ -283,6 +385,8 @@ const BOONS = [
{ 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 };
+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 };
+29 -1
View File
@@ -50,6 +50,30 @@ const HALF_CASTER_SLOTS = [
[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],
];
// Warlock: a few slots, all of one slot level. [how many, which level].
const PACT_SLOTS = [
[1, 1],
@@ -84,9 +108,13 @@ const CASTER_KIND = {
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 };
const MAX_SLOT_LEVEL = 9;
module.exports = { FULL_CASTER_SLOTS, HALF_CASTER_SLOTS, PACT_SLOTS, CASTER_KIND, MAX_SLOT_LEVEL };
module.exports = { FULL_CASTER_SLOTS, HALF_CASTER_SLOTS, ARTIFICER_SLOTS, PACT_SLOTS, CASTER_KIND, CLASS_SLOT_TABLES, MAX_SLOT_LEVEL };
@@ -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;
+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 };
+144 -11
View File
@@ -3,16 +3,21 @@
// special abilities to say so. Pure: no DB calls.
const { LEVEL_FEATURES, MAX_FEATURE_LEVEL, CANTRIP_LEVEL, CANTRIP_FEATURE, BOONS } = require('./data/levelFeatures');
const { SUBCLASS_LEVEL, subclassesOf, findSubclass, defaultSubclass } = require('./data/subclasses');
const { CLASS_FEATURES } = require('./data/characterOptions');
const { CASTER_KIND } = require('./data/spellSlots');
const { getClassSpellcastingAbility, maxSpellLevel, scaledDamageDice, MAX_GEAR_LEVEL } = require('./calculations');
// A choice that cannot be made: the message is for whoever made it.
class LevelUpError extends Error {}
// What a class gains on reaching `level`, in the order it is written down.
// An entry is { name, text, upgrade? } or { boon: true }.
function featuresAt(characterClass, level) {
// 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;
}
@@ -44,6 +49,84 @@ function addFeatures(specialAbilities, features) {
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) {
@@ -70,11 +153,17 @@ function hardyTempHp(specialAbilities) {
// What reaching `level` does to a character's special abilities. `boonKey` is
// the Boon picked, at a level that grants one; without it the line says a
// choice is still owed. Also returns what the Boon does beyond the text: how
// much Extra AC it adds, and the Temp HP it gives straight away.
function advance(specialAbilities, characterClass, level, boonKey) {
const entries = featuresAt(characterClass, level);
// 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)) {
@@ -113,14 +202,31 @@ function spellsGainedAt(characterClass, level) {
};
}
// In a campaign with subclasses switched off nobody chooses: on reaching the
// level their class would choose at, a character is put on the class's first
// subclass. Returns that subclass's key when this is the moment, else null.
function subclassByDefault(characterClass, level, currentKey) {
if (!owesSubclass(characterClass, level) || findSubclass(characterClass, currentKey)) return null;
return defaultSubclass(characterClass).key;
}
// The checklist for a character's next level. Takes the character with its
// weapons loaded (each with its pool entry).
function levelUpPlan(character) {
// weapons loaded (each with its pool entry). `subclasses` is the campaign's
// setting: off, there is no subclass to pick, and the class's first one is
// taken without asking (`subclassTaken`), its features listed with the rest.
function levelUpPlan(character, { subclasses = true } = {}) {
const toLevel = character.level + 1;
const entries = featuresAt(character.characterClass, toLevel);
const taken = subclasses ? null : subclassByDefault(character.characterClass, toLevel, character.subclass);
const entries = [
...featuresAt(character.characterClass, toLevel, taken ? null : character.subclass),
...(taken ? subclassFeaturesThrough(findSubclass(character.characterClass, taken), toLevel) : []),
];
return {
fromLevel: character.level,
toLevel,
// Set when a subclass has to be picked on the way to this level.
subclass: subclasses ? subclassChoice(character.characterClass, toLevel, character.subclass) : null,
subclassTaken: taken,
features: entries.filter((entry) => !entry.boon).map(({ name, text, upgrade }) => ({ name, text, upgrade: Boolean(upgrade) })),
boon: entries.some((entry) => entry.boon) ? boonOptions(character.specialAbilities) : null,
spells: spellsGainedAt(character.characterClass, toLevel),
@@ -141,4 +247,31 @@ function levelUpPlan(character) {
};
}
module.exports = { LevelUpError, featuresAt, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
// 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, featuresAt, owesSubclass, subclassChoice, subclassByDefault, switchSubclass, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
+148 -11
View File
@@ -1,9 +1,11 @@
const { featuresAt, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan, LevelUpError } = require('./levelUp');
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', () => {
@@ -32,8 +34,11 @@ describe('the class tables', () => {
expect(featuresAt('FIGHTER', 5).map((f) => f.name)).toEqual(['Extra Attack']);
});
test('nothing is written past level 10', () => {
expect(featuresAt('FIGHTER', 11)).toEqual([]);
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']);
});
});
@@ -61,7 +66,7 @@ describe('addFeatures', () => {
describe('advance', () => {
test('a rogue reaching level 3 has a bigger Sneak Attack and two new features', () => {
const level1 = composeSpecialAbilities('HUMAN', 'ROGUE', []);
const { specialAbilities } = advance(advance(level1, 'ROGUE', 2).specialAbilities, 'ROGUE', 3);
const { 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);
@@ -70,18 +75,18 @@ describe('advance', () => {
});
test('a Boon is written down, and says what it does beyond the text', () => {
expect(advance(null, 'FIGHTER', 4, 'TOUGHENED')).toEqual({ specialAbilities: 'Toughened — +1 AC (counted in your Extra AC).', armorBonus: 1, tempHp: 0 });
const hardy = advance(null, 'FIGHTER', 4, 'HARDY');
expect(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');
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').specialAbilities;
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);
@@ -92,8 +97,8 @@ describe('advance', () => {
});
test('with no Boon picked the line says one is owed', () => {
const { specialAbilities, armorBonus } = advance(null, 'WIZARD', 4);
expect(specialAbilities).toBe('Boon (level 4) — Choose one with your DM: Weapon Mastery, Arcane Focus, Toughened, Hardy.');
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);
});
});
@@ -117,7 +122,7 @@ describe('levelUpPlan', () => {
test('lists the features, the Boon and the weapons that can take the free upgrade', () => {
const plan = levelUpPlan({ level: 3, characterClass: 'FIGHTER', specialAbilities: null, weapons: [held(), held({ weaponId: 'p', weapon: { name: 'Dawnblade', damageDice: '1d8', damageScaling: 2, minLevel: 1 } }), held({ weaponId: 'top', level: 4 })] });
expect(plan).toMatchObject({ fromLevel: 3, toLevel: 4, features: [], spells: null, beyondTables: false });
expect(plan.boon).toHaveLength(4);
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' }]);
});
@@ -125,6 +130,138 @@ describe('levelUpPlan', () => {
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);
});
});
+9
View File
@@ -76,3 +76,12 @@ describe('the sheet', () => {
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 });
});
});
+598 -23
View File
@@ -49,6 +49,22 @@ const STORY_PICKS = {
appearance: { build: ['MUSCULAR'], height: ['TALL'], hair: ['RED'], eyes: ['GREEN'], complexion: ['WEATHERED'], features: ['SCAR'], style: ['CLOAK'] },
};
// The body that picks a species, with the bonus choices it needs: a Half-Elf
// by its older two picks, any other species with a choice by +2/+1.
const FLEXIBLE_SPECIES = new Set(Object.entries(require('../game/data/species').SPECIES).filter(([, s]) => s.flexible).map(([key]) => key));
function speciesPick(species) {
if (species === 'HALF_ELF') return { species, halfElfBonusAbilities: ['STR', 'INT'] };
if (FLEXIBLE_SPECIES.has(species)) return { species, bonusChoices: { STR: 2, DEX: 1 } };
return { species };
}
// Chooses a class, then (where the campaign asks) leaves the subclass for later.
async function chooseClass(characterId, auth, characterClass) {
const res = await request(app).post(`/characters/${characterId}/build/choose`).set(auth).send({ characterClass });
if (res.body.buildStage !== 'CHOOSING_SUBCLASS') return res;
return request(app).post(`/characters/${characterId}/build/choose`).set(auth).send({ subclass: null });
}
// Walks a character from the special-abilities step through to equipment.
async function storySteps(characterId, auth) {
const choose = (body) => request(app).post(`/characters/${characterId}/build/choose`).set(auth).send(body);
@@ -150,7 +166,7 @@ describe('campaigns + characters + build flow', () => {
let chooseRes = await request(app)
.post(`/characters/${characterId}/build/choose`)
.set(auth)
.send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
.send(speciesPick(species));
expect(chooseRes.status).toBe(200);
expect(chooseRes.body.buildStage).toBe('ROLLING_CLASS');
@@ -162,6 +178,11 @@ describe('campaigns + characters + build flow', () => {
const characterClass = rollRes.body.buildState.classChoices.options[0];
chooseRes = await request(app).post(`/characters/${characterId}/build/choose`).set(auth).send({ characterClass });
expect(chooseRes.status).toBe(200);
expect(chooseRes.body.buildStage).toBe('CHOOSING_SUBCLASS');
// Step 3: the subclass, which can wait until the class chooses
chooseRes = await request(app).post(`/characters/${characterId}/build/choose`).set(auth).send({ subclass: null });
expect(chooseRes.status).toBe(200);
// skipMotivationRoll was true, so motivation is skipped entirely.
expect(chooseRes.body.buildStage).toBe('ASSIGNING_SCORES');
@@ -261,13 +282,13 @@ async function buildCharacter(campaignId, auth, name, { stopBeforeEquipment = fa
await request(app)
.post(`/characters/${characterId}/build/choose`)
.set(auth)
.send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
.send(speciesPick(species));
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
while (rollRes.body.buildState.classChoices.type === 'reroll') {
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
}
const characterClass = rollRes.body.buildState.classChoices.options[0];
await request(app).post(`/characters/${characterId}/build/choose`).set(auth).send({ characterClass });
await chooseClass(characterId, auth, characterClass);
await request(app).post(`/characters/${characterId}/build/roll`).set(auth);
await request(app)
.post(`/characters/${characterId}/build/choose`)
@@ -1136,7 +1157,7 @@ describe('dice mode', () => {
expect(modeRes.status).toBe(200);
expect(modeRes.body.diceMode).toBe('PHYSICAL');
const tooHigh = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth).send({ value: 7 });
const tooHigh = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth).send({ value: 9 });
expect(tooHigh.status).toBe(400);
const validRoll = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth).send({ value: 3 });
@@ -1150,7 +1171,7 @@ describe('dice mode', () => {
// Physical ability scores: six sets of four d6, lowest die dropped.
await request(app).post(`/characters/${characterId}/build/choose`).set(dmAuth).send({ species: validRoll.body.buildState.speciesChoices.options[0] });
await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth).send({ value: 1 });
await request(app).post(`/characters/${characterId}/build/choose`).set(dmAuth).send({ characterClass: 'FIGHTER' });
await chooseClass(characterId, dmAuth, 'FIGHTER');
const sets = [[6, 6, 5, 1], [5, 5, 4, 2], [4, 4, 4, 1], [3, 3, 4, 2], [6, 2, 2, 1], [5, 4, 3, 3]];
const badSets = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth).send({ abilitySets: [[7, 1, 1, 1], ...sets.slice(1)] });
expect(badSets.status).toBe(400);
@@ -1171,17 +1192,18 @@ describe('story steps: special abilities, background and appearance', () => {
expect(options.talentPicks).toBe(2);
expect(options.background.map((g) => g.key)).toEqual(['upbringing', 'home', 'turningPoint', 'personality', 'flaw']);
expect(options.appearance.map((g) => g.key)).toEqual(['build', 'height', 'hair', 'eyes', 'complexion', 'features', 'style']);
expect(Object.keys(options.speciesTraits)).toHaveLength(10);
expect(Object.keys(options.classFeatures)).toHaveLength(12);
expect(Object.keys(options.speciesTraits)).toHaveLength(43);
expect(Object.keys(options.classFeatures)).toHaveLength(13);
const id = (await request(app).post(`/campaigns/${campaign.id}/characters`).set(playerAuth).send({ name: 'Storied' })).body.character.id;
const b = `/characters/${id}/build`;
const post = (path, body) => request(app).post(`${b}/${path}`).set(playerAuth).send(body);
await request(app).patch(`${b}/dice-mode`).set(playerAuth).send({ diceMode: 'PHYSICAL' });
await post('roll', { value: 3 });
await post('roll', { value: 2 });
await post('choose', { species: 'DWARF' });
await post('roll', { value: 1 });
await post('choose', { characterClass: 'BARBARIAN' });
await post('choose', { subclass: null });
await post('roll', { abilitySets: [[6, 6, 5, 1], [5, 5, 4, 2], [4, 4, 4, 1], [3, 3, 4, 2], [6, 2, 2, 1], [5, 4, 3, 3]] });
await post('choose', { assignments: { STR: 0, DEX: 1, CON: 2, INT: 3, WIS: 4, CHA: 5 } });
expect((await post('choose', {})).body.buildStage).toBe('CHOOSING_ABILITIES');
@@ -1246,7 +1268,7 @@ describe('story steps: special abilities, background and appearance', () => {
describe('going back in the builder', () => {
itIfDb('returns to earlier steps with the same rolls, lets the choice change, and never re-rolls', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('back');
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ skipMotivationRoll: false });
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ skipMotivationRoll: false, subclassesEnabled: false });
const id = (await request(app).post(`/campaigns/${campaign.id}/characters`).set(playerAuth).send({ name: 'Undecided' })).body.character.id;
const b = `/characters/${id}/build`;
const post = (path, body, auth = playerAuth) => request(app).post(`${b}/${path}`).set(auth).send(body);
@@ -1255,15 +1277,15 @@ describe('going back in the builder', () => {
expect((await post('back')).status).toBe(400); // nothing before the first step
await request(app).patch(`${b}/dice-mode`).set(playerAuth).send({ diceMode: 'PHYSICAL' });
// Species: a 3 offers Dwarf or Halfling.
await post('roll', { value: 3 });
// Species: a 2 offers the dwarves, gnomes and halflings.
await post('roll', { value: 2 });
expect((await post('roll', { value: 6 })).status).toBe(400); // one roll only
expect(stage(await post('choose', { species: 'DWARF' }))).toBe('ROLLING_CLASS');
// Back before rolling for class: same species roll, and the pick can change.
const backToSpecies = await post('back');
expect(stage(backToSpecies)).toBe('ROLLING_SPECIES');
expect(backToSpecies.body.buildState).toMatchObject({ speciesRoll: 3, species: 'DWARF' });
expect(backToSpecies.body.buildState).toMatchObject({ speciesRoll: 2, species: 'DWARF' });
expect((await post('roll', { value: 6 })).status).toBe(400); // still no re-roll
expect((await post('choose', { species: 'ELF' })).status).toBe(400); // not offered by the roll
await post('choose', { species: 'HALFLING' });
@@ -1358,6 +1380,7 @@ describe('going back in the builder', () => {
expect((await back()).body.buildStage).toBe('CHOOSING_ABILITIES');
expect((await back()).body.buildStage).toBe('ASSIGNING_PROFICIENCIES');
expect((await back()).body.buildStage).toBe('ASSIGNING_SCORES');
expect((await back()).body.buildStage).toBe('CHOOSING_SUBCLASS');
expect((await back()).body.buildStage).toBe('ROLLING_CLASS');
expect((await back()).body.buildStage).toBe('ROLLING_SPECIES');
expect((await back()).status).toBe(400);
@@ -1378,14 +1401,14 @@ describe('auto-generated skill proficiencies', () => {
await request(app)
.post(`/characters/${characterId}/build/choose`)
.set(dmAuth)
.send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
.send(speciesPick(species));
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
while (rollRes.body.buildState.classChoices.type === 'reroll') {
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
}
const characterClass = rollRes.body.buildState.classChoices.options[0];
await request(app).post(`/characters/${characterId}/build/choose`).set(dmAuth).send({ characterClass });
await chooseClass(characterId, dmAuth, characterClass);
await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
const scoresRes = await request(app)
@@ -1590,13 +1613,13 @@ describe('encounter combat info', () => {
await request(app)
.post(`/characters/${characterId}/build/choose`)
.set(dmAuth)
.send(species === 'HALF_ELF' ? { species, halfElfBonusAbilities: ['STR', 'INT'] } : { species });
.send(speciesPick(species));
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
while (rollRes.body.buildState.classChoices.type === 'reroll') {
rollRes = await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
}
const characterClass = rollRes.body.buildState.classChoices.options[0];
await request(app).post(`/characters/${characterId}/build/choose`).set(dmAuth).send({ characterClass });
await chooseClass(characterId, dmAuth, characterClass);
await request(app).post(`/characters/${characterId}/build/roll`).set(dmAuth);
await request(app)
.post(`/characters/${characterId}/build/choose`)
@@ -1623,6 +1646,38 @@ describe('encounter combat info', () => {
expect(participant.characterCombatInfo.weapons[0].name).toBe('Dagger');
expect(typeof participant.characterCombatInfo.weapons[0].attackBonus).toBe('number');
});
itIfDb('sends saves, skills and passive scores with a combatant, in the character panel and on the party list', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('glance');
const characterId = await buildCharacter(campaign.id, playerAuth, 'Glance');
const { sheet } = (await request(app).get(`/characters/${characterId}`).set(dmAuth)).body.character;
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Glance' })).body.encounter.id;
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId });
const info = (await request(app).get(`/encounters/${encounterId}`).set(dmAuth)).body.encounter.participants[0].characterCombatInfo;
expect(info.skills).toEqual(sheet.skills);
expect(info.saves).toEqual(sheet.saves);
expect(info.passiveInvestigation).toBe(10 + sheet.skills.INVESTIGATION.bonus);
expect(info.passiveInsight).toBe(10 + sheet.skills.INSIGHT.bonus);
const quick = (await request(app).get(`/characters/${characterId}/quick-stats`).set(dmAuth)).body.quickStats;
expect(quick.skills).toEqual(sheet.skills);
expect(quick.saves).toEqual(sheet.saves);
expect(quick.passiveInsight).toBe(sheet.passiveInsight);
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters.find((c) => c.id === characterId);
expect(listed.passives).toEqual({ perception: sheet.passivePerception, investigation: sheet.passiveInvestigation, insight: sheet.passiveInsight });
expect(listed.spellSaveDC).toBe(sheet.spellcasting ? sheet.spellcasting.saveDC : null);
// And what each rolls with, for a check called for from the whole party.
expect(listed.checks).toEqual({ abilities: sheet.abilities, saves: sheet.saves, skills: sheet.skills });
// A held weapon says what its holder rolls with it.
const weaponId = (await request(app).post(`/campaigns/${campaign.id}/weapons/from-library`).set(dmAuth).send({ key: 'dagger' })).body.weapon.id;
await request(app).post(`/characters/${characterId}/weapons`).set(dmAuth).send({ weaponId });
const held = (await request(app).get(`/characters/${characterId}`).set(dmAuth)).body.character.weapons[0];
const finesse = Math.max(sheet.abilities.STR.modifier, sheet.abilities.DEX.modifier);
expect(held).toMatchObject({ attackBonus: finesse + sheet.proficiencyBonus, damageBonus: finesse });
});
});
describe('changing a password', () => {
@@ -1718,13 +1773,31 @@ describe('ordering, archiving and deleting encounters', () => {
}
const names = async (auth) => (await request(app).get(`${c}/encounters`).set(auth)).body.encounters.map((e) => e.name);
// New encounters go to the top.
expect(await names(dmAuth)).toEqual(['Three', 'Two', 'One']);
// New encounters go to the bottom.
expect(await names(dmAuth)).toEqual(['One', 'Two', 'Three']);
const order = (encounterIds, auth = dmAuth) => request(app).put(`${c}/encounters/order`).set(auth).send({ encounterIds });
expect((await order([ids.One, ids.Two, ids.Three], playerAuth)).status).toBe(403);
expect((await order([ids.Three, ids.Two, ids.One], playerAuth)).status).toBe(403);
expect((await order([ids.One, ids.One])).status).toBe(400);
expect((await order([ids.One, 'not-an-encounter'])).status).toBe(400);
expect((await order([ids.Three, ids.One, ids.Two])).status).toBe(204);
expect(await names(playerAuth)).toEqual(['Three', 'One', 'Two']);
// A new one, of either kind, still lands at the bottom of a rearranged list,
// unless the DM says where: the top, or straight after another.
const add = async (body) => (await request(app).post(`${c}/encounters`).set(dmAuth).send(body)).body.encounter.id;
const extra = [await add({ name: 'Four', mode: 'QUEST' })];
expect(await names(dmAuth)).toEqual(['Three', 'One', 'Two', 'Four']);
extra.push(await add({ name: 'First', position: 'TOP' }));
expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'Two', 'Four']);
extra.push(await add({ name: 'After One', mode: 'QUEST', afterEncounterId: ids.One }));
expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'After One', 'Two', 'Four']);
extra.push(await add({ name: 'Last', position: 'BOTTOM' }));
extra.push(await add({ name: 'After Last', position: 'TOP', afterEncounterId: extra[3] }));
expect(await names(dmAuth)).toEqual(['First', 'Three', 'One', 'After One', 'Two', 'Four', 'Last', 'After Last']);
expect((await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Lost', afterEncounterId: 'not-an-encounter' })).status).toBe(400);
expect((await request(app).post(`${c}/encounters`).set(dmAuth).send({ name: 'Lost', position: 'MIDDLE' })).status).toBe(400);
for (const id of extra) await request(app).delete(`/encounters/${id}`).set(dmAuth);
expect((await order([ids.One, ids.Two, ids.Three])).status).toBe(204);
expect(await names(playerAuth)).toEqual(['One', 'Two', 'Three']);
@@ -2986,8 +3059,17 @@ describe('levelling up', () => {
expect(character.currentHp).toBe(plan.maxHp.to);
expect(character.specialAbilities).toBe('Action Surge — Once per short rest, take one extra action on your turn.');
// Level 4 grants a Boon, which has to be picked.
await levelUp(dmAuth);
// Level 3 is where a fighter picks a subclass, which has to be one of theirs.
const choice = (await request(app).get(`/characters/${characterId}/level-up`).set(dmAuth)).body.plan.subclass;
expect(choice.options.map((o) => o.key).slice(0, 3)).toEqual(['CHAMPION', 'BATTLE_MASTER', 'SAMURAI']);
expect(choice.options).toHaveLength(10);
expect((await levelUp(dmAuth)).status).toBe(400);
expect((await levelUp(dmAuth, { subclass: 'LIFE' })).status).toBe(400);
const champion = (await levelUp(dmAuth, { subclass: 'CHAMPION' })).body.character;
expect(champion).toMatchObject({ level: 3, subclass: 'CHAMPION', subclassName: 'Champion' });
// Level 4 grants a Boon, which has to be picked. There is no second subclass to pick.
expect((await levelUp(dmAuth, { boon: 'TOUGHENED', subclass: 'SAMURAI' })).status).toBe(400);
expect((await levelUp(dmAuth)).status).toBe(400);
expect((await levelUp(dmAuth, { boon: 'FLIGHT' })).status).toBe(400);
const toughened = (await levelUp(dmAuth, { boon: 'TOUGHENED' })).body.character;
@@ -3034,9 +3116,11 @@ describe('levelling up', () => {
expect((await levelUp({ weaponId: 'not-held' })).status).toBe(400);
expect((await levelUp({ spellIds: [foreignSpellId] })).status).toBe(400);
// A spell already known is simply kept.
const character = (await levelUp({ weaponId, spellIds: [boltId, wardId] })).body.character;
// A wizard picks a school at level 2. A spell already known is simply kept.
expect(plan.subclass.level).toBe(2);
const character = (await levelUp({ weaponId, spellIds: [boltId, wardId], subclass: 'ILLUSION' })).body.character;
expect(character.level).toBe(2);
expect(character.specialAbilities).toMatch(/^Illusion Savant — School of Illusion/m);
expect(character.weapons[0]).toMatchObject({ weaponId, level: 2, scaledDamageDice: '1d6' });
expect(character.spells.map((s) => s.spell.name).sort()).toEqual(['Test Bolt', 'Test Ward']);
expect(character.sheet.spellSlots.levels).toEqual([{ level: 1, max: 3, used: 0, remaining: 3 }]);
@@ -3057,6 +3141,7 @@ describe('levelling up', () => {
expect(lines[0]).toMatch(/^Sneak Attack — .*2d6/);
expect(lines).toContainEqual(expect.stringMatching(/^Cunning Action — /));
expect(lines).toContainEqual(expect.stringMatching(/^Boon \(level 4\) — Choose one/));
expect(lines).toContainEqual('Subclass — Choose one of the 9 with your DM (the level-up checklist lists them all).');
// Going down takes nothing away, and coming back up adds nothing twice.
await patch({ level: 1 });
@@ -3068,4 +3153,494 @@ describe('levelling up', () => {
await patch({ level: 20 });
expect((await request(app).post(`/characters/${characterId}/level-up`).set(dmAuth).send({})).status).toBe(400);
});
itIfDb('the DM sets and changes a subclass on the sheet; its lines follow', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('subclass');
const characterId = await buildAs(campaign.id, playerAuth, 'Sly', 'ROGUE');
const patch = (auth, body) => request(app).patch(`/characters/${characterId}`).set(auth).send(body);
const names = (res) => (res.body.character.specialAbilities || '').split('\n').map((line) => line.split(' — ')[0]);
// Early is fine (the builder does it, and nothing is written yet); not theirs, or set by the player, is not.
const early = await patch(dmAuth, { subclass: 'THIEF' });
expect(early.status).toBe(200);
expect(names(early)).not.toContain('Fast Hands');
await patch(dmAuth, { subclass: null });
await patch(dmAuth, { level: 3 });
expect((await patch(dmAuth, { subclass: 'CHAMPION' })).status).toBe(400);
expect((await patch(playerAuth, { subclass: 'THIEF' })).status).toBe(403);
const thief = await patch(dmAuth, { subclass: 'THIEF' });
expect(thief.body.character).toMatchObject({ subclass: 'THIEF', subclassName: 'Thief' });
expect(names(thief)).toEqual(['Cunning Action', 'Sneak Attack', 'Fast Hands', 'Second-Story Work']);
// From here the Level field writes the subclass's features too.
expect(names(await patch(dmAuth, { level: 9 }))).toContain('Supreme Sneak');
const assassin = await patch(dmAuth, { subclass: 'ASSASSIN' });
expect(names(assassin)).toEqual(expect.arrayContaining(['Assassinate', 'Tools of the Trade', 'Infiltration Expertise', 'Uncanny Dodge']));
expect(names(assassin)).not.toContain('Fast Hands');
expect(names(assassin)).not.toContain('Supreme Sneak');
// Everyone who can see the character sees the subclass.
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters.find((c) => c.id === characterId);
expect(listed.subclassName).toBe('Assassin');
expect((await request(app).get(`/characters/${characterId}/quick-stats`).set(playerAuth)).body.quickStats.subclassName).toBe('Assassin');
// Cleared, the choice is owed again.
const cleared = await patch(dmAuth, { subclass: null });
expect(cleared.body.character.subclass).toBeNull();
expect(names(cleared)).toContain('Subclass');
expect(names(cleared)).not.toContain('Assassinate');
});
});
describe('character state', () => {
itIfDb('class counters: the player spends, only the DM gives back, and rests refill them', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('counters');
const characterId = await buildAs(campaign.id, playerAuth, 'Brakk', 'FIGHTER', 9);
const resources = (auth, body) => request(app).post(`/characters/${characterId}/resources`).set(auth).send(body);
const left = (res) => Object.fromEntries(res.body.character.sheet.resources.map((r) => [r.key, r.remaining]));
const sheet = (await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.sheet;
expect(sheet.resources.map((r) => r.key)).toEqual(['SECOND_WIND', 'ACTION_SURGE', 'INDOMITABLE']);
const used = await resources(playerAuth, { action: 'use', key: 'SECOND_WIND' });
expect(used.status).toBe(200);
expect(left(used)).toEqual({ SECOND_WIND: 0, ACTION_SURGE: 1, INDOMITABLE: 1 });
// None left, no such counter, and not the player's to give back.
const again = await resources(playerAuth, { action: 'use', key: 'SECOND_WIND' });
expect(again.status).toBe(400);
expect(again.body.error).toMatch(/short or long rest/);
expect((await resources(playerAuth, { action: 'use', key: 'RAGE' })).status).toBe(400);
expect((await resources(playerAuth, { action: 'set', key: 'SECOND_WIND', used: 0 })).status).toBe(403);
expect((await resources(playerAuth, { action: 'restore' })).status).toBe(403);
await resources(playerAuth, { action: 'use', key: 'INDOMITABLE' });
expect(left(await resources(dmAuth, { action: 'set', key: 'SECOND_WIND', used: 0 }))).toMatchObject({ SECOND_WIND: 1, INDOMITABLE: 0 });
await resources(playerAuth, { action: 'use', key: 'ACTION_SURGE' });
// A short rest gives back Action Surge, not Indomitable; a long rest everything.
const rest = (type) => request(app).post(`/campaigns/${campaign.id}/rest`).set(dmAuth).send({ type, characters: [{ characterId }] });
expect((await rest('SHORT')).body.results[0].notes).toContain('Back: Action Surge.');
const after = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters.find((c) => c.id === characterId);
expect(after.resources.find((r) => r.key === 'INDOMITABLE').remaining).toBe(0);
expect((await rest('LONG')).body.results[0].notes).toContain('Back: Indomitable.');
expect(left(await resources(dmAuth, { action: 'restore' }))).toEqual({ SECOND_WIND: 1, ACTION_SURGE: 1, INDOMITABLE: 1 });
// A pool is spent several at a time, never past what is left.
const paladinId = await buildAs(campaign.id, playerAuth, 'Sera', 'PALADIN', 2);
const pool = (body) => request(app).post(`/characters/${paladinId}/resources`).set(playerAuth).send(body);
expect(left(await pool({ action: 'use', key: 'LAY_ON_HANDS', amount: 7 })).LAY_ON_HANDS).toBe(3);
const tooMuch = await pool({ action: 'use', key: 'LAY_ON_HANDS', amount: 4 });
expect(tooMuch.status).toBe(400);
expect(tooMuch.body.error).toMatch(/only 3/);
});
itIfDb('inspiration is given by the DM and spent by the player; exhaustion and conditions are for the DM', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('state');
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
const patch = (auth, body) => request(app).patch(`/characters/${characterId}`).set(auth).send(body);
expect((await patch(playerAuth, { inspiration: true })).status).toBe(403);
expect((await patch(dmAuth, { inspiration: true })).body.character.inspiration).toBe(true);
expect((await patch(playerAuth, { inspiration: false })).body.character.inspiration).toBe(false);
expect((await patch(playerAuth, { exhaustion: 0 })).status).toBe(403);
expect((await patch(playerAuth, { conditions: [] })).status).toBe(403);
expect((await patch(dmAuth, { exhaustion: 7 })).status).toBe(400);
const state = (await patch(dmAuth, { exhaustion: 2, conditions: ['Poisoned', 'Poisoned', 'Prone'] })).body.character;
expect(state).toMatchObject({ exhaustion: 2, conditions: ['Poisoned', 'Prone'] });
// A long rest takes one level of exhaustion off; conditions stay.
await request(app).post(`/campaigns/${campaign.id}/rest`).set(dmAuth).send({ type: 'LONG', characters: [{ characterId }] });
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters.find((c) => c.id === characterId);
expect(listed).toMatchObject({ exhaustion: 1, conditions: ['Poisoned', 'Prone'], inspiration: false });
});
itIfDb('conditions belong to the character: an encounter shows and changes them, and they outlast it', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('conditions');
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ conditions: ['Poisoned'] });
await request(app).post(`/campaigns/${campaign.id}/monsters/from-library`).set(dmAuth).send({ key: 'kobold' });
const monsterId = (await request(app).get(`/campaigns/${campaign.id}/monsters`).set(dmAuth)).body.monsters[0].id;
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Bog' })).body.encounter.id;
const add = (body) => request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send(body);
const hero = (await add({ characterId })).body.participant;
const kobold = (await add({ monsterId })).body.participant;
const view = async () => (await request(app).get(`/encounters/${encounterId}`).set(playerAuth)).body.encounter.participants;
const find = (list, id) => list.find((p) => p.id === id);
// Poisoned before the fight, poisoned in it.
expect(find(await view(), hero.id).conditions).toEqual(['Poisoned']);
const patch = (id, body) => request(app).patch(`/encounters/${encounterId}/participants/${id}`).set(dmAuth).send(body);
await patch(hero.id, { conditions: ['Poisoned', 'Prone'] });
await patch(kobold.id, { conditions: ['Frightened'] });
const during = await view();
expect(find(during, hero.id).conditions).toEqual(['Poisoned', 'Prone']);
expect(find(during, kobold.id).conditions).toEqual(['Frightened']);
await request(app).delete(`/encounters/${encounterId}`).set(dmAuth);
expect((await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.conditions).toEqual(['Poisoned', 'Prone']);
});
});
describe('faster battles', () => {
async function scene(prefix) {
const { dmAuth, playerAuth, campaign } = await setupCampaign(prefix);
await request(app).post(`/campaigns/${campaign.id}/monsters/from-library`).set(dmAuth).send({ key: 'kobold' });
const monsterId = (await request(app).get(`/campaigns/${campaign.id}/monsters`).set(dmAuth)).body.monsters[0].id;
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Warren' })).body.encounter.id;
const add = (body) => request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send(body);
return { dmAuth, playerAuth, campaign, monsterId, encounterId, add };
}
itIfDb('adds several of a monster at once, numbered on from those already there', async () => {
const { dmAuth, playerAuth, campaign, monsterId, encounterId, add } = await scene('several');
const first = await add({ monsterId, count: 3 });
expect(first.status).toBe(201);
expect(first.body.participants.map((p) => p.displayName)).toEqual(['Kobold 1', 'Kobold 2', 'Kobold 3']);
expect((await add({ monsterId, count: 2, initiativeRoll: 10 })).body.participants.map((p) => p.displayName)).toEqual(['Kobold 4', 'Kobold 5']);
// One on its own keeps the plain name; more than twenty at once is refused.
expect((await add({ monsterId })).body.participant.displayName).toBe('Kobold');
expect((await add({ monsterId, count: 21 })).status).toBe(400);
// Only monsters come in numbers.
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
expect((await add({ characterId, count: 2 })).status).toBe(400);
expect((await request(app).get(`/encounters/${encounterId}`).set(dmAuth)).body.encounter.participants).toHaveLength(6);
});
itIfDb('rolls initiative for the monsters and NPCs that have not rolled, and leaves the rest alone', async () => {
const { dmAuth, playerAuth, campaign, monsterId, encounterId, add } = await scene('rollall');
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
const hero = (await add({ characterId })).body.participant;
const rolledAlready = (await add({ monsterId, initiativeRoll: 20 })).body.participant;
await add({ monsterId, count: 3 });
const npcId = (await request(app).post(`/campaigns/${campaign.id}/npcs`).set(dmAuth).send({ name: 'Guide' })).body.npc.id;
await add({ npcId });
expect((await request(app).post(`/encounters/${encounterId}/roll-initiative`).set(playerAuth)).status).toBe(403);
const res = await request(app).post(`/encounters/${encounterId}/roll-initiative`).set(dmAuth);
expect(res.status).toBe(200);
expect(res.body.rolled).toBe(4);
for (const p of res.body.encounter.participants) {
if (p.id === hero.id) expect(p.initiativeRoll).toBeNull();
else {
expect(p.initiativeRoll).toBeGreaterThanOrEqual(1);
expect(p.initiativeRoll).toBeLessThanOrEqual(20);
expect(p.initiative).toBe(p.initiativeRoll + p.initiativeBonus);
}
}
expect(res.body.encounter.participants.find((p) => p.id === rolledAlready.id).initiativeRoll).toBe(20);
// Nothing left to roll the second time.
expect((await request(app).post(`/encounters/${encounterId}/roll-initiative`).set(dmAuth)).body.rolled).toBe(0);
});
itIfDb('a condition given for a number of rounds counts down as rounds begin, and wears off', async () => {
const { dmAuth, playerAuth, campaign, monsterId, encounterId, add } = await scene('timed');
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
const hero = (await add({ characterId, initiativeRoll: 15 })).body.participant;
const kobold = (await add({ monsterId, initiativeRoll: 5 })).body.participant;
await request(app).patch(`/encounters/${encounterId}`).set(dmAuth).send({ status: 'ACTIVE' });
const patch = (id, body) => request(app).patch(`/encounters/${encounterId}/participants/${id}`).set(dmAuth).send(body);
const find = (res, id) => res.body.encounter.participants.find((p) => p.id === id);
// The hero is frightened for a round and poisoned until cured; the kobold is prone for two rounds.
const given = await patch(hero.id, { conditions: ['Frightened', 'Poisoned'], conditionRounds: { Frightened: 1, Blinded: 3 } });
expect(find(given, hero.id).conditionRounds).toEqual({ Frightened: 1 }); // no countdown for a condition they are not under
await patch(kobold.id, { conditions: ['Prone'], conditionRounds: { Prone: 2 } });
const turn = () => request(app).post(`/encounters/${encounterId}/next-turn`).set(dmAuth);
// Within the round nothing moves.
const within = await turn();
expect(within.body.expired).toEqual([]);
expect(find(within, kobold.id).conditionRounds).toEqual({ Prone: 2 });
// Round 2 begins: the fright is over, on the sheet too.
const round2 = await turn();
expect(round2.body.encounter.currentRound).toBe(2);
expect(round2.body.expired).toEqual([{ name: 'Aria', condition: 'Frightened' }]);
expect(find(round2, hero.id).conditions).toEqual(['Poisoned']);
expect(find(round2, kobold.id).conditionRounds).toEqual({ Prone: 1 });
expect((await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.conditions).toEqual(['Poisoned']);
// Skipping to round 3 counts too. Removing a condition by hand drops its countdown.
const round3 = await request(app).post(`/encounters/${encounterId}/advance-round`).set(dmAuth);
expect(round3.body.expired).toEqual([{ name: 'Kobold', condition: 'Prone' }]);
expect(find(round3, kobold.id)).toMatchObject({ conditions: [], conditionRounds: {} });
await patch(kobold.id, { conditions: ['Stunned'], conditionRounds: { Stunned: 4 } });
expect(find(await patch(kobold.id, { conditions: [] }), kobold.id).conditionRounds).toEqual({});
});
});
describe('experience and difficulty', () => {
itIfDb('the DM sees how hard an encounter is, and awards its XP once, in equal shares', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('xp');
await request(app).post(`/campaigns/${campaign.id}/monsters/from-library`).set(dmAuth).send({ key: 'kobold' }); // CR 1/8: 25 XP
const monsterId = (await request(app).get(`/campaigns/${campaign.id}/monsters`).set(dmAuth)).body.monsters[0].id;
const ariaId = await buildCharacter(campaign.id, playerAuth, 'Aria');
const boId = await buildCharacter(campaign.id, playerAuth, 'Bo');
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Warren' })).body.encounter.id;
const add = (body) => request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send(body);
const award = (auth, body = {}) => request(app).post(`/encounters/${encounterId}/award-xp`).set(auth).send(body);
// Nobody to give it to yet.
await add({ monsterId, count: 3 });
expect((await award(dmAuth)).status).toBe(400);
await add({ characterId: ariaId });
await add({ characterId: boId });
// Three kobolds against two level 1 characters: 75 XP, doubled for their number.
const dmView = (await request(app).get(`/encounters/${encounterId}`).set(dmAuth)).body.encounter;
expect(dmView.difficulty).toEqual({
xp: 75,
adjustedXp: 150,
multiplier: 2,
rating: 'HARD',
thresholds: { easy: 50, medium: 100, hard: 150, deadly: 200 },
monsters: 3,
unrated: 0,
});
expect(dmView.xpAwarded).toBeNull();
// It is the DM's to know and to award.
expect((await request(app).get(`/encounters/${encounterId}`).set(playerAuth)).body.encounter.difficulty).toBeUndefined();
expect((await award(playerAuth)).status).toBe(403);
const res = await award(dmAuth);
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ xp: 75, share: 37, awardedTo: ['Aria', 'Bo'] });
expect(res.body.encounter.xpAwarded).toBe(75);
const aria = (await request(app).get(`/characters/${ariaId}`).set(playerAuth)).body.character;
expect(aria).toMatchObject({ xp: 37, level: 1, xpProgress: { nextLevelAt: 300, levelUpDue: false } });
// Once only.
const again = await award(dmAuth, { xp: 500 });
expect(again.status).toBe(400);
expect(again.body.error).toMatch(/already been awarded/);
// The DM can set XP on the sheet; enough of it says a level is due, and changes nothing else.
expect((await request(app).patch(`/characters/${ariaId}`).set(playerAuth).send({ xp: 300 })).status).toBe(403);
const due = (await request(app).patch(`/characters/${ariaId}`).set(dmAuth).send({ xp: 300 })).body.character;
expect(due).toMatchObject({ xp: 300, level: 1, xpProgress: { nextLevelAt: 300, levelUpDue: true } });
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters.find((c) => c.id === ariaId);
expect(listed).toMatchObject({ xp: 300, xpProgress: { levelUpDue: true } });
});
itIfDb('an amount can be typed, for a scene with nothing rated to fight', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('xpquest');
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Riddle', mode: 'QUEST' })).body.encounter.id;
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId });
const award = (body) => request(app).post(`/encounters/${encounterId}/award-xp`).set(dmAuth).send(body);
expect((await request(app).get(`/encounters/${encounterId}`).set(dmAuth)).body.encounter.difficulty).toMatchObject({ xp: 0, rating: null, monsters: 0 });
expect((await award({})).status).toBe(400);
expect((await award({ xp: 0 })).status).toBe(400);
expect((await award({ xp: 120 })).body).toMatchObject({ xp: 120, share: 120 });
expect((await request(app).get(`/characters/${characterId}`).set(playerAuth)).body.character.xp).toBe(120);
});
});
describe('a campaign without subclasses', () => {
itIfDb('nobody is asked: each class follows its first subclass, and none is named', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('nosubclass');
expect(campaign.subclassesEnabled).toBe(true);
const settings = await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ subclassesEnabled: false });
expect(settings.body.campaign.subclassesEnabled).toBe(false);
expect((await request(app).patch(`/campaigns/${campaign.id}`).set(playerAuth).send({ subclassesEnabled: true })).status).toBe(403);
const characterId = await buildAs(campaign.id, playerAuth, 'Brakk', 'FIGHTER', 2);
const levelUp = (body = {}) => request(app).post(`/characters/${characterId}/level-up`).set(dmAuth).send(body);
const names = (character) => character.specialAbilities.split('\n').map((line) => line.split(' — ')[0]);
// Level 3 asks for no subclass, and writes the Champion's feature all the same.
const plan = (await request(app).get(`/characters/${characterId}/level-up`).set(dmAuth)).body.plan;
expect(plan.subclass).toBeNull();
expect(plan.features.map((f) => f.name)).toEqual(['Improved Critical']);
expect((await levelUp({ subclass: 'SAMURAI' })).status).toBe(400);
const character = (await levelUp()).body.character;
expect(character.level).toBe(3);
expect(names(character)).toEqual(['Improved Critical']);
// Nothing names it or offers a choice, on the sheet, the party list or the panel.
expect(character).toMatchObject({ subclass: null, subclassName: null, subclassLevel: null, subclassOptions: [] });
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(dmAuth)).body.characters.find((c) => c.id === characterId);
expect(listed.subclassName).toBeNull();
expect((await request(app).get(`/characters/${characterId}/quick-stats`).set(dmAuth)).body.quickStats.subclassName).toBeNull();
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ subclass: 'SAMURAI' })).status).toBe(400);
// The Level field does the same, with no line saying a choice is owed.
const rogueId = await buildAs(campaign.id, playerAuth, 'Sly', 'ROGUE');
const rogue = (await request(app).patch(`/characters/${rogueId}`).set(dmAuth).send({ level: 9 })).body.character;
expect(names(rogue)).toEqual(expect.arrayContaining(['Fast Hands', 'Second-Story Work', 'Supreme Sneak']));
expect(names(rogue)).not.toContain('Subclass');
// Switched back on, what they were following shows, and can be changed.
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ subclassesEnabled: true });
const shown = (await request(app).get(`/characters/${characterId}`).set(dmAuth)).body.character;
expect(shown).toMatchObject({ subclass: 'CHAMPION', subclassName: 'Champion', subclassLevel: 3 });
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ subclass: 'SAMURAI' })).body.character.subclassName).toBe('Samurai');
});
});
describe('campaign settings', () => {
itIfDb('are kept, checked, and read back by every member', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('settings');
expect(campaign).toMatchObject({
diceMode: 'BOTH',
multiclassEnabled: false,
autoRollMonsters: false,
autoRollNpcs: false,
trackXp: true,
hpOnLevelUp: 'AVERAGE',
abilityScoreMethod: 'ROLL_4D6',
rollVisibility: 'ALL',
hideMonsterRolls: true,
critRule: 'DOUBLE_DICE',
});
const patch = (auth, body) => request(app).patch(`/campaigns/${campaign.id}`).set(auth).send(body);
expect((await patch(playerAuth, { trackXp: false })).status).toBe(403);
expect((await patch(dmAuth, { diceMode: 'SOMETIMES' })).status).toBe(400);
const changed = await patch(dmAuth, { diceMode: 'PHYSICAL', critRule: 'MAX_PLUS_ROLL', multiclassEnabled: true, rollVisibility: 'OWN' });
expect(changed.status).toBe(200);
const seen = (await request(app).get(`/campaigns/${campaign.id}`).set(playerAuth)).body.campaign;
expect(seen).toMatchObject({ diceMode: 'PHYSICAL', critRule: 'MAX_PLUS_ROLL', multiclassEnabled: true, rollVisibility: 'OWN' });
});
itIfDb("a campaign's dice decide for every character, unless it leaves them to each", async () => {
const { dmAuth, campaign } = await setupCampaign('settingsdice');
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ diceMode: 'PHYSICAL' });
const character = (await request(app).post(`/campaigns/${campaign.id}/characters`).set(dmAuth).send({ name: 'Rolla' })).body.character;
const read = (await request(app).get(`/characters/${character.id}`).set(dmAuth)).body.character;
expect(read).toMatchObject({ diceMode: 'PHYSICAL', diceModeChoosable: false });
expect((await request(app).patch(`/characters/${character.id}/build/dice-mode`).set(dmAuth).send({ diceMode: 'DIGITAL' })).status).toBe(400);
// Physical dice must be typed in.
expect((await request(app).post(`/characters/${character.id}/build/roll`).set(dmAuth).send({})).status).toBe(400);
expect((await request(app).post(`/characters/${character.id}/build/roll`).set(dmAuth).send({ value: 2 })).status).toBe(200);
});
// Walks a new character to the ability-score step.
async function toScores(campaignId, auth) {
const id = (await request(app).post(`/campaigns/${campaignId}/characters`).set(auth).send({ name: 'Scorer' })).body.character.id;
const species = (await request(app).post(`/characters/${id}/build/roll`).set(auth)).body.buildState.speciesChoices.options[0];
await request(app).post(`/characters/${id}/build/choose`).set(auth).send(speciesPick(species));
let classRoll = await request(app).post(`/characters/${id}/build/roll`).set(auth);
while (classRoll.body.buildState.classChoices.type === 'reroll') classRoll = await request(app).post(`/characters/${id}/build/roll`).set(auth);
await chooseClass(id, auth, classRoll.body.buildState.classChoices.options[0]);
return id;
}
itIfDb('the standard array gives everyone the same six scores to place', async () => {
const { dmAuth, campaign } = await setupCampaign('settingsarray');
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ abilityScoreMethod: 'STANDARD_ARRAY' });
const id = await toScores(campaign.id, dmAuth);
const rolled = await request(app).post(`/characters/${id}/build/roll`).set(dmAuth);
expect(rolled.status).toBe(200);
expect(rolled.body.buildState.abilityRolls.map((r) => r.total)).toEqual([15, 14, 13, 12, 10, 8]);
const chosen = await request(app).post(`/characters/${id}/build/choose`).set(dmAuth).send({ assignments: { STR: 0, DEX: 1, CON: 2, INT: 3, WIS: 4, CHA: 5 } });
expect(chosen.status).toBe(200);
});
itIfDb('point buy spends 27 points on scores from 8 to 15, and nothing is rolled', async () => {
const { dmAuth, campaign } = await setupCampaign('settingsbuy');
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ abilityScoreMethod: 'POINT_BUY' });
const id = await toScores(campaign.id, dmAuth);
expect((await request(app).post(`/characters/${id}/build/roll`).set(dmAuth)).status).toBe(400);
const buy = (pointBuy) => request(app).post(`/characters/${id}/build/choose`).set(dmAuth).send({ pointBuy });
expect((await buy({ STR: 15, DEX: 15, CON: 15, INT: 15, WIS: 8, CHA: 8 })).status).toBe(400); // 36 points
expect((await buy({ STR: 16, DEX: 8, CON: 8, INT: 8, WIS: 8, CHA: 8 })).status).toBe(400);
const bought = await buy({ STR: 15, DEX: 14, CON: 14, INT: 8, WIS: 10, CHA: 10 }); // 9 + 7 + 7 + 0 + 2 + 2
expect(bought.status).toBe(200);
expect(bought.body.buildState.abilityRolls.map((r) => r.total)).toEqual([15, 14, 14, 8, 10, 10]);
expect(bought.body.buildStage).toBe('ASSIGNING_PROFICIENCIES');
});
itIfDb('without XP tracking there is nothing to award', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('settingsxp');
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ trackXp: false });
const characterId = await buildCharacter(campaign.id, playerAuth, 'Aria');
const encounterId = (await request(app).post(`/campaigns/${campaign.id}/encounters`).set(dmAuth).send({ name: 'Riddle', mode: 'QUEST' })).body.encounter.id;
await request(app).post(`/encounters/${encounterId}/participants`).set(dmAuth).send({ characterId });
const res = await request(app).post(`/encounters/${encounterId}/award-xp`).set(dmAuth).send({ xp: 100 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/experience/);
});
});
describe('choosing a subclass in the builder', () => {
itIfDb('comes right after the class, can be suggested by a die, and starts giving at the class\'s level', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('buildsub');
const options = (await request(app).get('/characters/build/options').set(playerAuth)).body;
expect(options.subclasses.FIGHTER.map((s) => s.key)).toContain('ECHO_KNIGHT');
expect(options.subclasses.FIGHTER[0]).toMatchObject({ key: 'CHAMPION', source: 'Player’s Handbook', edition: '2014' });
expect(options.subclassLevels).toMatchObject({ FIGHTER: 3, WIZARD: 2, ARTIFICER: 3 });
const id = (await request(app).post(`/campaigns/${campaign.id}/characters`).set(playerAuth).send({ name: 'Pathfinder' })).body.character.id;
const post = (path, body) => request(app).post(`/characters/${id}/build/${path}`).set(playerAuth).send(body);
await request(app).patch(`/characters/${id}/build/dice-mode`).set(playerAuth).send({ diceMode: 'PHYSICAL' });
await post('roll', { value: 3 });
await post('choose', { species: 'DRAGONBORN' });
await post('roll', { value: 1 });
expect((await post('choose', { characterClass: 'FIGHTER' })).body.buildStage).toBe('CHOOSING_SUBCLASS');
// The die has a face per subclass, and only suggests.
expect((await post('roll', { value: 11 })).status).toBe(400);
const suggested = await post('roll', { value: 6 });
expect(suggested.body.buildState).toMatchObject({ subclassRoll: 6, subclassSuggestion: 'ECHO_KNIGHT' });
expect((await post('roll', { value: 2 })).status).toBe(400);
expect((await post('choose', { subclass: 'LIFE' })).status).toBe(400); // a cleric's
const chosen = await post('choose', { subclass: 'RUNE_KNIGHT' });
expect(chosen.body.buildStage).toBe('ASSIGNING_SCORES'); // skipMotivationRoll
// Back to the class and a change of class lets the subclass go.
expect((await post('back')).body.buildStage).toBe('CHOOSING_SUBCLASS');
expect((await post('back')).body.buildStage).toBe('ROLLING_CLASS');
const barbarian = await post('choose', { characterClass: 'BARBARIAN' });
expect(barbarian.body.buildState.subclass).toBeUndefined();
await post('back');
await post('choose', { characterClass: 'FIGHTER' });
await post('choose', { subclass: 'RUNE_KNIGHT' });
await post('roll', { abilitySets: [[6, 6, 5, 1], [5, 5, 4, 2], [4, 4, 4, 1], [3, 3, 4, 2], [6, 2, 2, 1], [5, 4, 3, 3]] });
await post('choose', { assignments: { STR: 0, DEX: 1, CON: 2, INT: 3, WIS: 4, CHA: 5 } });
await post('choose', {});
await storySteps(id, playerAuth);
await post('choose', { weaponIds: [], itemIds: [], spellIds: [] });
const character = (await post('finalize')).body.character;
expect(character).toMatchObject({ subclass: 'RUNE_KNIGHT', subclassName: 'Rune Knight', subclassLevel: 3 });
expect(character.specialAbilities).not.toMatch(/Rune Carver/); // not yet: level 1
// At level 3 its gifts arrive, with nothing left to ask.
for (let i = 0; i < 2; i++) {
const plan = (await request(app).get(`/characters/${id}/level-up`).set(dmAuth)).body.plan;
expect(plan.subclass).toBeNull();
await request(app).post(`/characters/${id}/level-up`).set(dmAuth).send({});
}
const atThree = (await request(app).get(`/characters/${id}`).set(playerAuth)).body.character;
expect(atThree.level).toBe(3);
expect(atThree.specialAbilities).toMatch(/Rune Carver/);
expect(atThree.specialAbilities).not.toMatch(/^Subclass —/m);
});
itIfDb('a 2014 subclass offers its 2024 version', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('buildsubnew');
const id = await buildAs(campaign.id, playerAuth, 'Totem', 'BARBARIAN', 3);
await request(app).patch(`/characters/${id}`).set(dmAuth).send({ subclass: 'TOTEM_WARRIOR' });
const character = (await request(app).get(`/characters/${id}`).set(playerAuth)).body.character;
expect(character.subclassUpgrade).toMatchObject({ key: 'WILD_HEART', edition: '2024', replaces: 'TOTEM_WARRIOR' });
expect(character.subclassOptions.length).toBe(11);
});
});
describe('reference', () => {
itIfDb('gives the road from level 1 to 20 for a class and subclass', async () => {
const { playerAuth } = await setupCampaign('reference');
const res = await request(app).get('/reference/progression?class=BARBARIAN&subclass=WORLD_TREE').set(playerAuth);
expect(res.status).toBe(200);
expect(res.body.levels).toHaveLength(20);
expect(res.body.levels[2].features.map((f) => f.name)).toContain('Vitality of the Tree');
expect((await request(app).get('/reference/progression?class=BARBARIAN&subclass=LIFE').set(playerAuth)).status).toBe(400);
expect((await request(app).get('/reference/progression?class=BARBARIAN')).status).toBe(401);
});
});
+80 -10
View File
@@ -4,7 +4,7 @@ const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { requireCampaignMember, requireDM } = require('../middleware/campaignAccess');
const { generateInviteCode } = require('../utils/inviteCode');
const { computeCharacterSheet, GEAR_STATS, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL, MAX_LEVEL } = require('../game/calculations');
const { computeCharacterSheet, xpProgress, GEAR_STATS, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL, MAX_LEVEL } = require('../game/calculations');
const { rollDie } = require('../game/buildFlow');
const { SHEET_INCLUDE, sheetOf, settleHp } = require('../utils/sheet');
const { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
@@ -12,6 +12,7 @@ const { visitedLocationIds } = require('../utils/locations');
const { deathSaveInfo, lifeState } = require('../utils/hp');
const { hitDiceProblem, shortRest, longRest } = require('../utils/rest');
const { CURRENCIES } = require('../utils/grants');
const { subclassName } = require('../game/data/subclasses');
const { findSeed, normalizeCode, CODE_LENGTH } = require('../seeds');
const { createCampaignFromSeed } = require('../seeds/createCampaign');
@@ -34,6 +35,17 @@ function campaignSummary(campaign, role) {
areaAttackAcPenalty: campaign.areaAttackAcPenalty,
maxEquipped: campaign.maxEquipped,
maxEquippedWeapons: campaign.maxEquippedWeapons,
subclassesEnabled: campaign.subclassesEnabled,
multiclassEnabled: campaign.multiclassEnabled,
diceMode: campaign.diceMode,
autoRollMonsters: campaign.autoRollMonsters,
autoRollNpcs: campaign.autoRollNpcs,
trackXp: campaign.trackXp,
hpOnLevelUp: campaign.hpOnLevelUp,
abilityScoreMethod: campaign.abilityScoreMethod,
rollVisibility: campaign.rollVisibility,
hideMonsterRolls: campaign.hideMonsterRolls,
critRule: campaign.critRule,
ownerId: campaign.ownerId,
archivedAt: campaign.archivedAt,
role,
@@ -69,6 +81,18 @@ const createCampaignSchema = z.object({
// they are checked when something is put on.
maxEquipped: z.number().int().min(1).max(20).optional(),
maxEquippedWeapons: z.number().int().min(0).max(20).optional(),
// Off: nobody chooses a subclass, and each class follows its first one.
subclassesEnabled: z.boolean().optional(),
multiclassEnabled: z.boolean().optional(),
diceMode: z.enum(['DIGITAL', 'PHYSICAL', 'BOTH']).optional(),
autoRollMonsters: z.boolean().optional(),
autoRollNpcs: z.boolean().optional(),
trackXp: z.boolean().optional(),
hpOnLevelUp: z.enum(['AVERAGE', 'ROLL']).optional(),
abilityScoreMethod: z.enum(['ROLL_4D6', 'STANDARD_ARRAY', 'POINT_BUY']).optional(),
rollVisibility: z.enum(['ALL', 'OWN']).optional(),
hideMonsterRolls: z.boolean().optional(),
critRule: z.enum(['DOUBLE_DICE', 'MAX_PLUS_ROLL']).optional(),
});
// POST /campaigns — create, creator becomes DM/owner
@@ -213,7 +237,8 @@ router.post('/:id/invite', requireCampaignMember(), requireDM(), async (req, res
// Characters within a campaign
// ---------------------------------------------------------------------------
function characterSummary(character) {
// `subclasses` is the campaign's setting: off, no subclass is named.
function characterSummary(character, subclasses = true) {
const sheet = computeCharacterSheet(
character,
character.skillProficiencies || [],
@@ -227,23 +252,38 @@ function characterSummary(character) {
name: character.name,
species: character.species,
characterClass: character.characterClass,
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
level: character.level,
xp: character.xp,
xpProgress: xpProgress(character.xp, character.level),
buildStage: character.buildStage,
currentHp: character.currentHp,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
inspiration: character.inspiration,
exhaustion: character.exhaustion,
conditions: character.conditions,
gold: character.gold,
silver: character.silver,
copper: character.copper,
cash: character.cash,
maxHp: character.buildStage === 'COMPLETE' ? sheet.hp.max : null,
armorClass: character.buildStage === 'COMPLETE' ? sheet.armorClass : null,
// What the DM reads off without asking for a roll.
passives:
character.buildStage === 'COMPLETE'
? { perception: sheet.passivePerception, investigation: sheet.passiveInvestigation, insight: sheet.passiveInsight }
: null,
spellSaveDC: character.buildStage === 'COMPLETE' && sheet.spellcasting ? sheet.spellcasting.saveDC : null,
// What each rolls with, for a check the DM calls for from the whole party.
checks: character.buildStage === 'COMPLETE' ? { abilities: sheet.abilities, saves: sheet.saves, skills: sheet.skills } : null,
// What is left to spend before the next rest: the slot tracker (null for
// a character with no spell slots) and Hit Dice, with the Constitution
// modifier each die healed is rolled with.
spellSlots: character.buildStage === 'COMPLETE' ? sheet.spellSlots : null,
hitDice: character.buildStage === 'COMPLETE' ? { ...sheet.hitDice, conModifier: sheet.abilities.CON.modifier } : null,
// The class counters (Rage, Ki...), with how many of each are left.
resources: character.buildStage === 'COMPLETE' ? sheet.resources : [],
currentLocationId: character.currentLocationId,
currentLocationName: character.currentLocation ? character.currentLocation.name : null,
};
@@ -256,7 +296,7 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
include: { ...SHEET_INCLUDE, currentLocation: true },
orderBy: { createdAt: 'asc' },
});
const summaries = characters.map(characterSummary);
const summaries = characters.map((character) => characterSummary(character, req.campaign.subclassesEnabled));
if (req.membership.role === 'DM') return res.json({ characters: summaries });
// A player sees the party's headline numbers, but another character's
@@ -706,6 +746,9 @@ router.delete('/:id/monsters/:monsterId', requireCampaignMember(), requireDM(),
// Encounters
// ---------------------------------------------------------------------------
// The campaign's own order, which the DM arranges.
const ENCOUNTER_ORDER = [{ sortOrder: 'asc' }, { createdAt: 'asc' }];
router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) => {
try {
const isDM = req.membership.role === 'DM';
@@ -714,7 +757,7 @@ router.get('/:id/encounters', requireCampaignMember(), async (req, res, next) =>
const mine = { archivedAt: null, participants: { some: { character: { userId: req.user.id } } } };
const encounters = await prisma.encounter.findMany({
where: { campaignId: req.campaign.id, ...(isDM ? {} : mine) },
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'desc' }],
orderBy: ENCOUNTER_ORDER,
});
// dmNotes never leaves the server for a player.
res.json({ encounters: isDM ? encounters : encounters.map(({ dmNotes, ...encounter }) => encounter) });
@@ -728,21 +771,48 @@ const createEncounterSchema = z.object({
mode: z.enum(['BATTLE', 'QUEST']).optional(),
description: z.string().max(4000).optional().nullable(),
locationId: z.string().optional().nullable(),
// Where it goes in the campaign's order: the top, the bottom, or straight
// after another encounter (which wins over `position`). The bottom if
// neither is given.
position: z.enum(['TOP', 'BOTTOM']).optional(),
afterEncounterId: z.string().optional().nullable(),
});
router.post('/:id/encounters', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = createEncounterSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (parsed.data.locationId) {
const location = await prisma.location.findFirst({ where: { id: parsed.data.locationId, campaignId: req.campaign.id } });
const { position, afterEncounterId, ...fields } = parsed.data;
if (fields.locationId) {
const location = await prisma.location.findFirst({ where: { id: fields.locationId, campaignId: req.campaign.id } });
if (!location) return res.status(400).json({ error: 'That location is not part of this campaign.' });
}
// A new encounter goes to the top of the campaign's order.
const first = await prisma.encounter.aggregate({ where: { campaignId: req.campaign.id }, _min: { sortOrder: true } });
const encounter = await prisma.encounter.create({
data: { ...parsed.data, campaignId: req.campaign.id, sortOrder: (first._min.sortOrder ?? 1) - 1 },
const order = await prisma.encounter.findMany({
where: { campaignId: req.campaign.id },
orderBy: ENCOUNTER_ORDER,
select: { id: true, sortOrder: true },
});
let index = position === 'TOP' ? 0 : order.length;
if (afterEncounterId) {
const at = order.findIndex((e) => e.id === afterEncounterId);
if (at === -1) return res.status(400).json({ error: 'The encounter to put this one after is not part of this campaign.' });
index = at + 1;
}
const encounter = await prisma.$transaction(async (tx) => {
// At the bottom nothing else moves. Anywhere else, the whole order is
// numbered afresh around the newcomer.
if (index === order.length) {
const last = order.length > 0 ? order[order.length - 1].sortOrder : -1;
return tx.encounter.create({ data: { ...fields, campaignId: req.campaign.id, sortOrder: last + 1 } });
}
const created = await tx.encounter.create({ data: { ...fields, campaignId: req.campaign.id, sortOrder: index } });
for (const [i, existing] of order.entries()) {
const sortOrder = i < index ? i : i + 1;
if (existing.sortOrder !== sortOrder) await tx.encounter.update({ where: { id: existing.id }, data: { sortOrder } });
}
return created;
});
res.status(201).json({ encounter });
} catch (err) {
+279 -64
View File
@@ -12,26 +12,36 @@ const {
scaledDamageDice,
canUseGear,
spellSlotState,
xpProgress,
MAX_GEAR_LEVEL,
MAX_LEVEL,
MAX_SLOT_LEVEL,
ABILITIES,
} = require('../game/calculations');
const { LevelUpError, advance, levelUpPlan } = require('../game/levelUp');
const { LevelUpError, advance, levelUpPlan, switchSubclass, subclassByDefault } = require('../game/levelUp');
const { resourceTally, MAX_EXHAUSTION } = require('../game/classResources');
const { SUBCLASS_LEVEL, SUBCLASSES, subclassesOf, findSubclass, subclassName, newerVersionOf, subclassSummary } = require('../game/data/subclasses');
const {
rollDie,
rollAbilityScore,
abilityScoreFromDice,
speciesChoicesForRoll,
classChoicesForRoll,
SPECIES_ROLL_DIE,
CLASS_ROLL_DIE,
autoSelectSkills,
validateManualRoll,
validatePicks,
composeFromPicks,
validateTalents,
composeSpecialAbilities,
STANDARD_ARRAY,
POINT_BUY_BUDGET,
pointBuyCost,
effectiveDiceMode,
} = require('../game/buildFlow');
const characterOptions = require('../game/data/characterOptions');
const { speciesBonusProblem } = require('../game/data/species');
const { SKILLS } = require('../game/data/skills');
const { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
const { recordVisits } = require('../utils/locations');
@@ -52,10 +62,15 @@ async function loadFullCharacter(id) {
weapons: { include: { weapon: true } },
spells: { include: { spell: true } },
currentLocation: true,
campaign: { select: { subclassesEnabled: true, diceMode: true } },
},
});
}
// Whether a character's campaign uses subclasses. Takes a character loaded
// with its campaign (loadFullCharacter).
const usesSubclasses = (character) => !character.campaign || character.campaign.subclassesEnabled;
// privateNotes is a real privacy boundary, not a UI hide — it's only ever
// included in the payload for the character's own owning player.
//
@@ -67,6 +82,8 @@ async function loadFullCharacter(id) {
function serializeCharacter(character, isOwner, isDM = true) {
const restricted = !isOwner && !isDM;
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
// With subclasses switched off for the campaign, none is named or offered.
const subclasses = usesSubclasses(character);
return {
id: character.id,
campaignId: character.campaignId,
@@ -75,11 +92,18 @@ function serializeCharacter(character, isOwner, isDM = true) {
name: character.name,
species: character.species,
characterClass: character.characterClass,
subclass: subclasses ? character.subclass : null,
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
// What the class can choose from, and the level it chooses at.
subclassLevel: subclasses ? (SUBCLASS_LEVEL[character.characterClass] ?? null) : null,
subclassOptions: subclasses ? subclassesOf(character.characterClass).map(subclassSummary) : [],
// The 2024 subclass that updates the one the character follows, if any.
subclassUpgrade: subclasses && newerVersionOf(character.characterClass, character.subclass) ? subclassSummary(newerVersionOf(character.characterClass, character.subclass)) : null,
level: character.level,
xp: character.xp,
xpProgress: xpProgress(character.xp, character.level),
motivation: character.motivation,
halfElfBonusAbility1: character.halfElfBonusAbility1,
halfElfBonusAbility2: character.halfElfBonusAbility2,
speciesBonusChoices: character.speciesBonusChoices || {},
rolledScores: {
STR: character.strScore,
DEX: character.dexScore,
@@ -91,12 +115,18 @@ function serializeCharacter(character, isOwner, isDM = true) {
currentHp: character.currentHp,
tempHp: character.tempHp,
deathSaves: deathSaveInfo(character),
inspiration: character.inspiration,
exhaustion: character.exhaustion,
conditions: character.conditions,
armorBonus: character.armorBonus,
specialAbilities: character.specialAbilities,
background: character.background,
appearance: character.appearance,
privateNotes: isOwner ? character.privateNotes : undefined,
diceMode: character.diceMode,
// The dice this character rolls with, and whether that is theirs to choose
// (the campaign leaves it to each character) or the campaign's.
diceMode: effectiveDiceMode(character.campaign, character),
diceModeChoosable: !character.campaign || character.campaign.diceMode === 'BOTH',
currentLocationId: restricted ? null : character.currentLocationId,
currentLocation: restricted ? null : character.currentLocation || null,
gold: restricted ? null : character.gold,
@@ -117,11 +147,17 @@ function serializeCharacter(character, isOwner, isDM = true) {
: null,
usable: canUseGear(character.level, ii.level),
})),
weapons: character.weapons.map((cw) => ({
...cw,
scaledDamageDice: scaledDamageDice(cw.weapon.damageDice, cw.level, cw.weapon.damageScaling, cw.weapon.minLevel),
usable: canUseGear(character.level, cw.level),
})),
weapons: character.weapons.map((cw) => {
// What this character rolls with it: to hit, and on top of its dice.
const { attackBonus, damageBonus } = computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true, cw.level);
return {
...cw,
scaledDamageDice: scaledDamageDice(cw.weapon.damageDice, cw.level, cw.weapon.damageScaling, cw.weapon.minLevel),
attackBonus,
damageBonus,
usable: canUseGear(character.level, cw.level),
};
}),
spells: character.spells.map((cs) => ({ ...cs, usable: canUseGear(character.level, cs.spell.level) })),
sheet: character.buildStage === 'COMPLETE' ? sheet : null,
};
@@ -139,6 +175,10 @@ router.get('/build/options', (req, res) => {
talentPicks: TALENT_PICKS,
background: BACKGROUND_GROUPS,
appearance: APPEARANCE_GROUPS,
// Each class's subclasses, as the builder's subclass step lists them, and
// the level each class gets its subclass's first gifts at.
subclasses: Object.fromEntries(Object.entries(SUBCLASSES).map(([cls, list]) => [cls, list.map(subclassSummary)])),
subclassLevels: SUBCLASS_LEVEL,
});
});
@@ -163,8 +203,12 @@ const patchCharacterSchema = z.object({
privateNotes: z.string().max(4000).nullable().optional(),
currentLocationId: z.string().nullable().optional(),
level: z.number().int().min(1).max(MAX_LEVEL).optional(),
xp: z.number().int().min(0).optional(),
subclass: z.string().max(40).nullable().optional(), // a key from game/data/subclasses.js; null clears it
xp: z.number().int().min(0).max(10000000).optional(),
hitDiceUsed: z.number().int().min(0).max(MAX_LEVEL).optional(),
inspiration: z.boolean().optional(),
exhaustion: z.number().int().min(0).max(MAX_EXHAUSTION).optional(),
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
});
// Progression and placement are the DM's call, and so is rewriting what the
@@ -174,8 +218,10 @@ const patchCharacterSchema = z.object({
// edit the rest of their own sheet. Gold is not patchable at all — it only
// moves through the ledger (POST /:id/gold). The armor bonus is the DM's
// adjustment on top of what equipped gear gives, so it is the DM's too. Hit
// Dice are spent on a rest, which the DM runs.
const DM_ONLY_PATCH_FIELDS = ['currentHp', 'tempHp', 'level', 'xp', 'currentLocationId', 'speedOverride', 'armorBonus', 'specialAbilities', 'background', 'appearance', 'hitDiceUsed'];
// Dice are spent on a rest, which the DM runs. Exhaustion and conditions are
// the DM's to give and take away. Inspiration is the DM's to give; the player
// may spend it (checked below).
const DM_ONLY_PATCH_FIELDS = ['currentHp', 'tempHp', 'level', 'subclass', 'xp', 'currentLocationId', 'speedOverride', 'armorBonus', 'specialAbilities', 'background', 'appearance', 'hitDiceUsed', 'exhaustion', 'conditions'];
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
@@ -190,8 +236,12 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (!req.isDM && DM_ONLY_PATCH_FIELDS.some((field) => parsed.data[field] !== undefined)) {
return res.status(403).json({ error: 'Only the DM can change HP, temp HP, Hit Dice, level, XP, speed, armor bonus, location, special abilities, background or appearance.' });
return res.status(403).json({ error: 'Only the DM can change HP, temp HP, Hit Dice, level, subclass, XP, speed, armor bonus, location, exhaustion, conditions, special abilities, background or appearance.' });
}
if (!req.isDM && parsed.data.inspiration === true) {
return res.status(403).json({ error: 'Only the DM can give inspiration. You can spend it.' });
}
if (parsed.data.conditions) parsed.data.conditions = [...new Set(parsed.data.conditions)];
const newLevel = parsed.data.level ?? req.character.level;
if (parsed.data.level !== undefined || parsed.data.hitDiceUsed !== undefined) {
@@ -205,15 +255,36 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
for (const slot of slots ? slots.levels : []) tally[slot.level - 1] = slot.used;
parsed.data.spellSlotsUsed = Array.from(tally, (used) => used || 0);
}
if (req.character.buildStage === 'COMPLETE' && newLevel > req.character.level && parsed.data.specialAbilities === undefined) {
// Setting a higher level by hand still writes down what the class gains
// on the way there. A Boon is left as a line saying one is owed: picking
// it is part of levelling up properly (POST /:id/level-up).
let abilities = req.character.specialAbilities;
const characterClass = req.character.characterClass;
const askedSubclass = parsed.data.subclass; // before a level change may take one by default
let abilities = parsed.data.specialAbilities === undefined ? req.character.specialAbilities : null;
const writesAbilities = req.character.buildStage === 'COMPLETE' && parsed.data.specialAbilities === undefined;
if (writesAbilities && newLevel > req.character.level) {
// Setting a higher level by hand still writes down what the class and
// subclass give on the way there. A Boon, and a subclass not yet picked,
// are left as a line saying one is owed: picking them is part of
// levelling up properly (POST /:id/level-up).
let key = req.character.subclass;
for (let level = req.character.level + 1; level <= newLevel; level++) {
abilities = advance(abilities, req.character.characterClass, level).specialAbilities;
// With subclasses switched off, the class's first one is taken at the
// level it would have been chosen.
const taken = req.campaign.subclassesEnabled ? null : subclassByDefault(characterClass, level, key);
abilities = advance(abilities, characterClass, level, undefined, taken ? { key: taken, chosen: true } : { key }).specialAbilities;
if (taken) key = taken;
}
parsed.data.specialAbilities = abilities;
if (key !== req.character.subclass && askedSubclass === undefined) parsed.data.subclass = key;
}
if (askedSubclass !== undefined && askedSubclass !== req.character.subclass) {
if (!req.campaign.subclassesEnabled) return res.status(400).json({ error: 'Subclasses are switched off for this campaign.' });
const picked = askedSubclass;
if (picked !== null) {
if (!findSubclass(characterClass, picked)) return res.status(400).json({ error: 'That is not a subclass of this character\'s class.' });
// A path can be set before the class reaches it, as the builder does;
// its lines are written as the levels come.
}
// The old subclass's lines come off the sheet and the new one's go on.
if (writesAbilities) parsed.data.specialAbilities = switchSubclass(abilities, characterClass, newLevel, req.character.subclass, picked);
}
// Damage is soaked by temp HP before it reaches real HP — unless this
@@ -401,6 +472,65 @@ router.post('/:id/spell-slots', requireCharacterAccess({ write: true }), async (
}
});
// ---------------------------------------------------------------------------
// Class counters — Rage, Ki, Channel Divinity and the like. Which a character
// has, and how many, comes from class, subclass and level
// (game/classResources.js); the character row keeps how many are spent.
// ---------------------------------------------------------------------------
const resourceKey = z.string().min(1).max(40);
const resourceSchema = z.discriminatedUnion('action', [
// Using it: one is spent, or `amount` from a pool (Lay on Hands, Ki).
z.object({ action: z.literal('use'), key: resourceKey, amount: z.number().int().min(1).max(999).default(1) }),
// The DM setting how many are spent: giving some back, or fixing a mistake.
z.object({ action: z.literal('set'), key: resourceKey, used: z.number().int().min(0).max(999) }),
// The DM giving every counter back.
z.object({ action: z.literal('restore') }),
]);
// The owning player spends their own. Getting any back is the DM's: by hand
// here, or for everyone with a rest (POST /campaigns/:id/rest).
router.post('/:id/resources', requireCharacterAccess({ write: true }), async (req, res, next) => {
try {
const parsed = resourceSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (req.character.buildStage !== 'COMPLETE') return res.status(400).json({ error: 'Character build is not finished yet.' });
const input = parsed.data;
if (input.action !== 'use' && !req.isDM) return res.status(403).json({ error: 'Only the DM can give a use back.' });
const character = await loadFullCharacter(req.character.id);
const resources = sheetOf(character).resources;
if (input.action === 'restore') {
for (const resource of resources) resource.used = 0;
} else {
const resource = resources.find((r) => r.key === input.key);
if (!resource) return res.status(400).json({ error: `${character.name} has no such counter.` });
if (input.action === 'use') {
if (input.amount > resource.remaining) {
const back = resource.restoresOn === 'SHORT' ? 'short or long' : 'long';
return res.status(400).json({
error:
resource.remaining === 0
? `${character.name} has no ${resource.name} left. It comes back on a ${back} rest.`
: `${character.name} has only ${resource.remaining} ${resource.name} left.`,
});
}
resource.used += input.amount;
} else {
resource.used = Math.min(input.used, resource.max);
}
}
await prisma.character.update({ where: { id: character.id }, data: { resourcesUsed: resourceTally(resources) } });
const full = await loadFullCharacter(character.id);
emitCharacterUpdated(req);
res.json({ character: serializeCharacter(full, req.isOwner) });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Levelling up — one level at a time, with the checklist that goes with it
// (game/levelUp.js): the new level, full HP, the class's new special
@@ -423,7 +553,7 @@ router.get('/:id/level-up', requireCharacterAccess(), async (req, res, next) =>
try {
const character = await loadFullCharacter(req.character.id);
if (!canLevelUp(req, res, character)) return;
const plan = levelUpPlan(character);
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character) });
const after = sheetOf({ ...character, level: plan.toLevel, spellSlotsUsed: [] });
res.json({ plan: { ...plan, maxHp: { from: sheetOf(character).hp.max, to: after.hp.max }, spellSlots: after.spellSlots } });
} catch (err) {
@@ -432,6 +562,7 @@ router.get('/:id/level-up', requireCharacterAccess(), async (req, res, next) =>
});
const levelUpSchema = z.object({
subclass: z.string().optional(), // which subclass, at the level the class chooses one
boon: z.string().optional(), // which Boon, at a level that grants one
weaponId: z.string().nullable().optional(), // the weapon that gets the free upgrade
spellIds: z.array(z.string()).max(50).default([]), // spells newly granted
@@ -441,11 +572,16 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
try {
const parsed = levelUpSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { boon, weaponId, spellIds } = parsed.data;
const { subclass, boon, weaponId, spellIds } = parsed.data;
const character = await loadFullCharacter(req.character.id);
if (!canLevelUp(req, res, character)) return;
const plan = levelUpPlan(character);
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character) });
if (plan.subclass && !subclass) return res.status(400).json({ error: `Level ${plan.toLevel} needs a subclass: pick one.` });
if (!plan.subclass && subclass) return res.status(400).json({ error: `There is no subclass to pick at level ${plan.toLevel}.` });
if (subclass && !plan.subclass.options.some((option) => option.key === subclass)) {
return res.status(400).json({ error: 'That is not a subclass of this character\'s class.' });
}
if (plan.boon && !boon) return res.status(400).json({ error: `Level ${plan.toLevel} grants a Boon: pick one.` });
if (!plan.boon && boon) return res.status(400).json({ error: `Level ${plan.toLevel} does not grant a Boon.` });
if (weaponId && !plan.weapons.some((w) => w.weaponId === weaponId)) {
@@ -458,7 +594,9 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
let gained;
try {
gained = advance(character.specialAbilities, character.characterClass, plan.toLevel, boon);
// The subclass picked, or the one taken for them where the campaign has no choosing.
const newSubclass = subclass || plan.subclassTaken;
gained = advance(character.specialAbilities, character.characterClass, plan.toLevel, boon, newSubclass ? { key: newSubclass, chosen: true } : { key: character.subclass });
} catch (err) {
if (!(err instanceof LevelUpError)) throw err;
return res.status(400).json({ error: err.message });
@@ -478,6 +616,7 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
where: { id: character.id },
data: {
level: plan.toLevel,
...(subclass || plan.subclassTaken ? { subclass: subclass || plan.subclassTaken } : {}),
specialAbilities: gained.specialAbilities,
armorBonus,
tempHp: Math.max(character.tempHp, gained.tempHp),
@@ -603,6 +742,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
name: source.name,
species: source.species,
characterClass: source.characterClass,
subclass: source.subclass,
level: source.level,
xp: source.xp,
motivation: source.motivation,
@@ -612,8 +752,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
intScore: source.intScore,
wisScore: source.wisScore,
chaScore: source.chaScore,
halfElfBonusAbility1: source.halfElfBonusAbility1,
halfElfBonusAbility2: source.halfElfBonusAbility2,
speciesBonusChoices: source.speciesBonusChoices,
currentHp: sheet.hp.max,
armorBonus: source.armorBonus,
speedOverride: source.speedOverride,
@@ -670,7 +809,7 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
// a user-facing error string on failure, otherwise returns the value(s) in
// the exact same shape the digital path would have produced.
function resolveSingleRoll(character, body, sides) {
if (character.diceMode !== 'PHYSICAL') return rollDie(sides);
if (effectiveDiceMode(character.campaign, character) !== 'PHYSICAL') return rollDie(sides);
const value = Number(body?.value);
if (!validateManualRoll(sides, value)) {
throw new Error(`Enter the d${sides} you rolled — a whole number from 1 to ${sides}.`);
@@ -679,7 +818,11 @@ function resolveSingleRoll(character, body, sides) {
}
function resolveAbilityRolls(character, body) {
if (character.diceMode !== 'PHYSICAL') {
// The standard array is not rolled at all: the same six scores for everyone.
if (character.campaign.abilityScoreMethod === 'STANDARD_ARRAY') {
return STANDARD_ARRAY.map((total) => ({ dice: [], dropped: null, total }));
}
if (effectiveDiceMode(character.campaign, character) !== 'PHYSICAL') {
return Array.from({ length: 6 }, () => rollAbilityScore());
}
const sets = body?.abilitySets;
@@ -700,6 +843,9 @@ router.patch('/:id/build/dice-mode', requireCharacterAccess({ write: true }), as
const parsed = z.object({ diceMode: z.enum(['DIGITAL', 'PHYSICAL']) }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (req.campaign.diceMode !== 'BOTH') {
return res.status(400).json({ error: `This campaign plays with ${req.campaign.diceMode === 'PHYSICAL' ? 'physical' : 'digital'} dice only.` });
}
const alreadyRolling = character.buildStage !== 'ROLLING_SPECIES' || Boolean((character.buildState || {}).speciesRoll);
if (alreadyRolling) {
return res.status(400).json({ error: 'Dice mode can only be chosen before the first roll of the build.' });
@@ -725,22 +871,36 @@ router.post('/:id/build/roll', requireCharacterAccess({ write: true }), async (r
if (buildState.speciesRoll) {
return res.status(400).json({ error: 'Species die already rolled for this character — choose from what it offered.' });
}
const roll = resolveSingleRoll(character, req.body, 6);
const roll = resolveSingleRoll(character, req.body, SPECIES_ROLL_DIE);
buildState.speciesRoll = roll;
buildState.speciesChoices = speciesChoicesForRoll(roll);
} else if (stage === 'ROLLING_CLASS') {
const alreadyRolled = buildState.classChoices && buildState.classChoices.type !== 'reroll';
if (alreadyRolled) {
// A 7 may be rerolled once (a build from before kept a bare "reroll").
const previous = buildState.classChoices;
const mayReroll = previous && (previous.canReroll || previous.type === 'reroll');
if (previous && !mayReroll) {
return res.status(400).json({ error: 'Class die already rolled for this character — submit a choice, or reroll if the current roll allows it.' });
}
const roll = resolveSingleRoll(character, req.body, 8);
const roll = resolveSingleRoll(character, req.body, CLASS_ROLL_DIE);
buildState.classRoll = roll;
buildState.classChoices = classChoicesForRoll(roll);
buildState.classChoices = classChoicesForRoll(roll, { rerolled: Boolean(mayReroll) });
delete buildState.characterClass;
} else if (stage === 'CHOOSING_SUBCLASS') {
// A die with a face per subclass suggests one; the player still picks freely.
if (buildState.subclassRoll) {
return res.status(400).json({ error: 'The subclass die was already rolled — pick from the list.' });
}
const options = subclassesOf(buildState.characterClass);
const roll = resolveSingleRoll(character, req.body, options.length);
buildState.subclassRoll = roll;
buildState.subclassSuggestion = options[roll - 1].key;
} else if (stage === 'ASSIGNING_SCORES') {
if (buildState.abilityRolls) {
return res.status(400).json({ error: 'Ability scores were already rolled for this character.' });
}
if (req.campaign.abilityScoreMethod === 'POINT_BUY') {
return res.status(400).json({ error: 'This campaign uses point buy: nothing is rolled, the scores are bought.' });
}
buildState.abilityRolls = resolveAbilityRolls(character, req.body);
} else {
return res.status(400).json({ error: `No die roll is required at the "${stage}" step.` });
@@ -756,26 +916,34 @@ router.post('/:id/build/roll', requireCharacterAccess({ write: true }), async (r
}
});
const HALF_ELF_ELIGIBLE_ABILITIES = ABILITIES.filter((a) => a !== 'CHA');
const chooseSchemas = {
// `bonusChoices` are the species' flexible bonuses, { ability: amount }.
// `halfElfBonusAbilities` is the older way to give a Half-Elf's two +1s.
ROLLING_SPECIES: z.object({
species: z.string(),
halfElfBonusAbilities: z.array(z.enum(HALF_ELF_ELIGIBLE_ABILITIES)).length(2).optional(),
bonusChoices: z.partialRecord(z.enum(ABILITIES), z.number().int().min(0).max(2)).optional(),
halfElfBonusAbilities: z.array(z.enum(ABILITIES)).length(2).optional(),
}),
ROLLING_CLASS: z.object({ characterClass: z.string() }),
// null: decide later, at the level the class chooses.
CHOOSING_SUBCLASS: z.object({ subclass: z.string().nullable() }),
// Motivation is story only, so it is simply picked from the list (the stage
// keeps its old name from when it was a die roll).
ROLLING_MOTIVATION: z.object({ motivation: z.number().int() }),
// Rolled scores (and the standard array) are assigned by index; point buy
// sends the six scores themselves.
ASSIGNING_SCORES: z.object({
assignments: z.object({
STR: z.number().int().min(0).max(5),
DEX: z.number().int().min(0).max(5),
CON: z.number().int().min(0).max(5),
INT: z.number().int().min(0).max(5),
WIS: z.number().int().min(0).max(5),
CHA: z.number().int().min(0).max(5),
}),
pointBuy: z.record(z.enum(ABILITIES), z.number().int().min(8).max(15)).optional(),
assignments: z
.object({
STR: z.number().int().min(0).max(5),
DEX: z.number().int().min(0).max(5),
CON: z.number().int().min(0).max(5),
INT: z.number().int().min(0).max(5),
WIS: z.number().int().min(0).max(5),
CHA: z.number().int().min(0).max(5),
})
.optional(),
}),
// Skill proficiencies are now auto-generated (see autoSelectSkills, applied
// when this stage is entered from ASSIGNING_SCORES below) — this payload is
@@ -814,6 +982,7 @@ async function allStartingOptions(model, ids, campaignId, reach) {
const STAGE_ORDER = [
'ROLLING_SPECIES',
'ROLLING_CLASS',
'CHOOSING_SUBCLASS',
'ROLLING_MOTIVATION',
'ASSIGNING_SCORES',
'ASSIGNING_PROFICIENCIES',
@@ -824,9 +993,19 @@ const STAGE_ORDER = [
'COMPLETE',
];
function nextStage(current) {
const idx = STAGE_ORDER.indexOf(current);
return STAGE_ORDER[idx + 1];
// Steps a campaign can leave out: the subclass (without subclasses) and the
// motivation (when it skips it).
function stageSkipped(stage, campaign) {
if (stage === 'CHOOSING_SUBCLASS') return !campaign.subclassesEnabled;
if (stage === 'ROLLING_MOTIVATION') return campaign.skipMotivationRoll;
return false;
}
// The next (or, with step -1, the previous) step this campaign uses.
function nextStage(current, campaign, step = 1) {
let idx = STAGE_ORDER.indexOf(current) + step;
while (campaign && STAGE_ORDER[idx] && stageSkipped(STAGE_ORDER[idx], campaign)) idx += step;
return STAGE_ORDER[idx];
}
router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async (req, res, next) => {
@@ -840,7 +1019,7 @@ router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const buildState = { ...(character.buildState || {}) };
let advanceTo = nextStage(stage);
let advanceTo = nextStage(stage, req.campaign);
if (stage === 'ROLLING_SPECIES') {
const choices = buildState.speciesChoices;
@@ -849,17 +1028,18 @@ router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async
if (choices.type !== 'wildcard' && !choices.options.includes(species)) {
return res.status(400).json({ error: `${species} was not offered by this roll.` });
}
if (species === 'HALF_ELF') {
if (!halfElfBonusAbilities) {
return res.status(400).json({ error: 'Half-Elf requires choosing two abilities (other than CHA) for the +1/+1 bonus.' });
}
let bonusChoices = parsed.data.bonusChoices || {};
if (halfElfBonusAbilities) {
if (new Set(halfElfBonusAbilities).size !== 2) {
return res.status(400).json({ error: 'The two Half-Elf bonus abilities must be different.' });
}
buildState.halfElfBonusAbilities = halfElfBonusAbilities;
} else {
delete buildState.halfElfBonusAbilities;
bonusChoices = Object.fromEntries(halfElfBonusAbilities.map((ability) => [ability, 1]));
}
bonusChoices = Object.fromEntries(Object.entries(bonusChoices).filter(([, amount]) => amount));
const problem = speciesBonusProblem(species, bonusChoices);
if (problem) return res.status(400).json({ error: problem });
delete buildState.halfElfBonusAbilities;
buildState.speciesBonusChoices = bonusChoices;
buildState.species = species;
} else if (stage === 'ROLLING_CLASS') {
const choices = buildState.classChoices;
@@ -870,19 +1050,43 @@ router.post('/:id/build/choose', requireCharacterAccess({ write: true }), async
if (choices.type !== 'wildcard' && !choices.options.includes(characterClass)) {
return res.status(400).json({ error: `${characterClass} was not offered by this roll.` });
}
buildState.characterClass = characterClass;
if (req.campaign.skipMotivationRoll) {
advanceTo = 'ASSIGNING_SCORES';
buildState.motivationText = null;
// A subclass picked for another class goes with it.
if (buildState.characterClass !== characterClass) {
delete buildState.subclass;
delete buildState.subclassRoll;
delete buildState.subclassSuggestion;
}
buildState.characterClass = characterClass;
if (req.campaign.skipMotivationRoll) buildState.motivationText = null;
} else if (stage === 'CHOOSING_SUBCLASS') {
const { subclass } = parsed.data;
if (subclass !== null && !findSubclass(buildState.characterClass, subclass)) {
return res.status(400).json({ error: 'That is not a subclass of this class.' });
}
buildState.subclass = subclass;
} else if (stage === 'ROLLING_MOTIVATION') {
const motivation = characterOptions.MOTIVATIONS.find((m) => m.key === parsed.data.motivation);
if (!motivation) return res.status(400).json({ error: 'Pick a motivation from the list.' });
buildState.motivationChoice = motivation.key;
buildState.motivationText = motivation.label;
} else if (stage === 'ASSIGNING_SCORES') {
let { assignments } = parsed.data;
if (req.campaign.abilityScoreMethod === 'POINT_BUY') {
const scores = parsed.data.pointBuy;
if (!scores || ABILITIES.some((ability) => scores[ability] === undefined)) {
return res.status(400).json({ error: 'Buy a score for each of the six abilities.' });
}
const cost = pointBuyCost(scores);
if (cost === null || cost > POINT_BUY_BUDGET) {
return res.status(400).json({ error: `Those scores cost ${cost} points; you have ${POINT_BUY_BUDGET}.` });
}
// Kept the way rolled scores are, each assigned to its own ability.
buildState.abilityRolls = ABILITIES.map((ability) => ({ dice: [], dropped: null, total: scores[ability] }));
buildState.pointBuy = scores;
assignments = Object.fromEntries(ABILITIES.map((ability, i) => [ability, i]));
}
if (!buildState.abilityRolls) return res.status(400).json({ error: 'Roll ability scores first.' });
const { assignments } = parsed.data;
if (!assignments) return res.status(400).json({ error: 'Assign each score to an ability.' });
const usedIndexes = Object.values(assignments);
const uniqueIndexes = new Set(usedIndexes);
if (uniqueIndexes.size !== 6 || Math.max(...usedIndexes) > 5) {
@@ -978,8 +1182,7 @@ router.post('/:id/build/back', requireCharacterAccess({ write: true }), async (r
if (idx <= 0 || stage === 'COMPLETE') {
return res.status(400).json({ error: 'There is no earlier step to go back to.' });
}
backTo = STAGE_ORDER[idx - 1];
if (backTo === 'ROLLING_MOTIVATION' && req.campaign.skipMotivationRoll) backTo = 'ROLLING_CLASS';
backTo = nextStage(stage, req.campaign, -1);
}
delete buildState.equipment;
@@ -1010,7 +1213,9 @@ router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), asyn
rolledScores[ability] = bs.abilityRolls[rollIndex].total;
}
const [halfElfBonusAbility1, halfElfBonusAbility2] = bs.halfElfBonusAbilities || [null, null];
// A build begun before chosen bonuses were general kept a Half-Elf's two picks on their own.
const speciesBonusChoices =
bs.speciesBonusChoices || Object.fromEntries((bs.halfElfBonusAbilities || []).map((ability) => [ability, 1]));
const saveProfs = getClassSaveProficiencies(bs.characterClass);
const result = await prisma.$transaction(async (tx) => {
@@ -1022,6 +1227,8 @@ router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), asyn
data: {
species: bs.species,
characterClass: bs.characterClass,
// Chosen now, its gifts still come at the level the class chooses at.
subclass: req.campaign.subclassesEnabled && findSubclass(bs.characterClass, bs.subclass) ? bs.subclass : null,
motivation: bs.motivationText || null,
// Composed here, from the picks, so the text can only ever say what
// the lists allow.
@@ -1034,8 +1241,7 @@ router.post('/:id/build/finalize', requireCharacterAccess({ write: true }), asyn
intScore: rolledScores.INT,
wisScore: rolledScores.WIS,
chaScore: rolledScores.CHA,
halfElfBonusAbility1,
halfElfBonusAbility2,
speciesBonusChoices,
buildStage: 'COMPLETE',
buildState: null,
skillProficiencies: {
@@ -1135,6 +1341,7 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
level: character.level,
species: character.species,
characterClass: character.characterClass,
subclassName: usesSubclasses(character) ? subclassName(character.characterClass, character.subclass) : null,
currentHp: character.currentHp,
maxHp: sheet.hp.max,
tempHp: character.tempHp,
@@ -1146,8 +1353,16 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
spellcasting: sheet.spellcasting,
spellSlots: sheet.spellSlots,
hitDice: sheet.hitDice,
resources: sheet.resources,
inspiration: character.inspiration,
exhaustion: character.exhaustion,
conditions: character.conditions,
passivePerception: sheet.passivePerception,
passiveInvestigation: sheet.passiveInvestigation,
passiveInsight: sheet.passiveInsight,
abilities: sheet.abilities,
saves: sheet.saves,
skills: sheet.skills,
specialAbilities: character.specialAbilities,
// What equipped gear is adding and taking away, and what else it does.
gear: sheet.gear,
+185 -26
View File
@@ -1,13 +1,15 @@
const crypto = require('crypto');
const express = require('express');
const { z } = require('zod');
const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { loadCampaignMembership } = require('../middleware/campaignAccess');
const { computeCharacterSheet, computeWeaponStats, scaledDamageDice, canUseGear, abilityModifier, MAX_GEAR_LEVEL } = require('../game/calculations');
const { computeCharacterSheet, computeWeaponStats, scaledDamageDice, canUseGear, abilityModifier, encounterDifficulty, MAX_GEAR_LEVEL } = require('../game/calculations');
const { CURRENCIES, InsufficientGoldError, adjustGold, spendCoins, giveGear } = require('../utils/grants');
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
const { SHEET_INCLUDE } = require('../utils/sheet');
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
const { subclassName } = require('../game/data/subclasses');
const router = express.Router();
@@ -15,8 +17,10 @@ router.use(requireAuth);
async function requireEncounterAccess(req, res, next) {
try {
const encounter = await prisma.encounter.findUnique({ where: { id: req.params.id } });
if (!encounter) return res.status(404).json({ error: 'Encounter not found' });
const found = await prisma.encounter.findUnique({ where: { id: req.params.id }, include: { campaign: true } });
if (!found) return res.status(404).json({ error: 'Encounter not found' });
// The campaign rides along for its settings (req.campaign), not on the encounter.
const { campaign, ...encounter } = found;
const membership = await loadCampaignMembership(req.user.id, encounter.campaignId);
if (!membership) return res.status(403).json({ error: 'Not a member of this campaign' });
@@ -33,6 +37,7 @@ async function requireEncounterAccess(req, res, next) {
}
req.encounter = encounter;
req.campaign = campaign;
req.membership = membership;
req.isDM = isDM;
return next();
@@ -104,6 +109,41 @@ function revivalSpell(character) {
return character.spells.map((cs) => cs.spell).find((spell) => spell.revives && canUseGear(character.level, spell.level));
}
// The rounds left on a combatant's timed conditions, squared up with the
// conditions they are actually under: { condition: rounds }.
function roundsFor(conditionRounds, conditions) {
const rounds = conditionRounds && typeof conditionRounds === 'object' ? conditionRounds : {};
return Object.fromEntries(conditions.filter((condition) => Number.isInteger(rounds[condition]) && rounds[condition] > 0).map((condition) => [condition, rounds[condition]]));
}
// A new round: every timed condition has one round less to run, and those
// that run out are removed (from the character too, when it is a character's).
// Returns what wore off, as [{ name, condition }], for the DM to read out.
async function tickConditions(req) {
const participants = await prisma.encounterParticipant.findMany({
where: { encounterId: req.encounter.id },
include: { character: { select: { id: true, conditions: true } } },
});
const expired = [];
for (const p of participants) {
const conditions = p.character ? p.character.conditions : p.conditions;
const before = roundsFor(p.conditionRounds, conditions);
if (Object.keys(before).length === 0) continue;
const after = {};
for (const [condition, rounds] of Object.entries(before)) {
if (rounds > 1) after[condition] = rounds - 1;
else expired.push({ name: p.displayName, condition });
}
const left = conditions.filter((condition) => before[condition] === undefined || after[condition] !== undefined);
await prisma.encounterParticipant.update({ where: { id: p.id }, data: { conditionRounds: after, conditions: left } });
if (p.character && left.length !== conditions.length) {
await prisma.character.update({ where: { id: p.character.id }, data: { conditions: left } });
emitCharacterUpdated(req, p.character.id);
}
}
return expired;
}
const initiativeRollSchema = z.number().int().min(1, 'An initiative roll is a d20: 1 to 20').max(20, 'An initiative roll is a d20: 1 to 20');
// What every campaign member sees about a character in an encounter: the
@@ -113,7 +153,7 @@ const initiativeRollSchema = z.number().int().min(1, 'An initiative roll is a d2
// used; what is still in the pack is in the character panel, for the
// character's own player and the DM. Uses the
// same calculation-engine functions as the sheet, never re-deriving formulas.
function characterCombatInfo(character) {
function characterCombatInfo(character, subclasses = true) {
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
// Gear and spells above the character's level are held but can't be used,
// so they stay out of the fight; so does anything not equipped.
@@ -149,6 +189,7 @@ function characterCombatInfo(character) {
level: character.level,
species: character.species,
characterClass: character.characterClass,
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
armorClass: sheet.armorClass,
maxHp: sheet.hp.max,
tempHp: character.tempHp,
@@ -157,9 +198,16 @@ function characterCombatInfo(character) {
speed: sheet.speed,
initiativeBonus: sheet.initiative,
passivePerception: sheet.passivePerception,
passiveInvestigation: sheet.passiveInvestigation,
passiveInsight: sheet.passiveInsight,
proficiencyBonus: sheet.proficiencyBonus,
abilities: sheet.abilities,
saves: sheet.saves,
skills: sheet.skills,
spellSlots: sheet.spellSlots,
resources: sheet.resources,
inspiration: character.inspiration,
exhaustion: character.exhaustion,
specialAbilities: character.specialAbilities,
// What equipped gear is adding and taking away. The numbers above already include it.
gear: sheet.gear,
@@ -170,6 +218,16 @@ function characterCombatInfo(character) {
};
}
// How hard the scene is for the characters in it, and what its monsters are
// worth: every monster counts, standing or not. Takes the encounter's
// participants with their characters and monsters loaded.
function difficultyOf(participants) {
return encounterDifficulty(
participants.filter((p) => p.character).map((p) => p.character.level),
participants.filter((p) => p.monster).map((p) => p.monster.challengeRating)
);
}
// What is for sale, oldest first, with the weapon or item itself.
const WARES = { include: { weapon: true, item: true }, orderBy: { createdAt: 'asc' } };
@@ -189,7 +247,7 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
const encounter = await prisma.encounter.findUnique({
where: { id: encounterId },
include: {
campaign: { select: { showMonsterHp: true } },
campaign: { select: { showMonsterHp: true, subclassesEnabled: true } },
location: { select: { id: true, name: true, description: true } },
participants: {
include: {
@@ -224,12 +282,15 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
if (character) {
// A character combatant's HP is the character's own HP, not a
// per-encounter copy, so the tracker and the sheet can never disagree.
const { maxHp, ...combatInfo } = characterCombatInfo(character);
const { maxHp, ...combatInfo } = characterCombatInfo(character, encounter.campaign.subclassesEnabled);
out.currentHp = character.currentHp;
out.maxHp = maxHp;
// Likewise their conditions: the character's own, which outlast the scene.
out.conditions = character.conditions;
out.character = { id: character.id, name: character.name, userId: character.userId };
out.characterCombatInfo = combatInfo;
}
out.conditionRounds = roundsFor(p.conditionRounds, out.conditions);
if (monster) out.monster = isDM ? monster : { id: monster.id, name: monster.name };
if (npc) {
const { dmNotes, ac, maxHp, attackBonus, damageDice, campaignId, createdAt, ...publicNpc } = npc;
@@ -250,7 +311,7 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
...scene,
locationId: knowsPlace ? scene.locationId : null,
location: knowsPlace ? location : null,
...(isDM ? { dmNotes } : {}),
...(isDM ? { dmNotes, difficulty: difficultyOf(encounter.participants) } : {}),
// A player is not told that someone they cannot see is being targeted.
targetParticipantIds: isDM ? scene.targetParticipantIds : scene.targetParticipantIds.filter((id) => !hiddenIds.has(id)),
participants,
@@ -361,16 +422,19 @@ const participantSchema = z
npcId: z.string().optional(),
displayName: z.string().min(1).max(80).optional(),
initiativeRoll: initiativeRollSchema.optional(),
// Several of one monster at once: "Goblin 1" to "Goblin 4".
count: z.number().int().min(1).max(20).default(1),
})
.refine((data) => [data.characterId, data.monsterId, data.npcId].filter(Boolean).length === 1, {
message: 'Provide exactly one of characterId, monsterId or npcId',
});
})
.refine((data) => data.count === 1 || Boolean(data.monsterId), { message: 'Only monsters can be added several at a time' });
router.post('/:id/participants', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const parsed = participantSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { characterId, monsterId, npcId, displayName, initiativeRoll } = parsed.data;
const { characterId, monsterId, npcId, displayName, initiativeRoll, count } = parsed.data;
let currentHp, maxHp, resolvedName;
let bonus = 0;
@@ -411,22 +475,40 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
resolvedName = displayName || npc.name;
}
const participant = await prisma.encounterParticipant.create({
data: {
encounterId: req.encounter.id,
characterId: characterId || null,
monsterId: monsterId || null,
npcId: npcId || null,
displayName: resolvedName,
initiativeRoll: initiativeRoll ?? null,
initiative: initiativeRoll == null ? 0 : initiativeRoll + bonus,
currentHp,
maxHp,
isMonster: Boolean(monsterId),
},
});
// Several at once are numbered, carrying on from any of that name already
// in the scene ("Goblin 3" after "Goblin 1" and "Goblin 2").
let names = [resolvedName];
if (count > 1) {
const present = await prisma.encounterParticipant.findMany({ where: { encounterId: req.encounter.id }, select: { displayName: true } });
const numbered = present
.filter((p) => p.displayName.startsWith(`${resolvedName} `))
.map((p) => Number(p.displayName.slice(resolvedName.length + 1)))
.filter((n) => Number.isInteger(n) && n > 0);
const from = Math.max(0, ...numbered);
names = Array.from({ length: count }, (_, i) => `${resolvedName} ${from + i + 1}`.slice(-80));
}
await respondWithEncounter(req, res, 201, { participant });
const participants = [];
for (const name of names) {
participants.push(
await prisma.encounterParticipant.create({
data: {
encounterId: req.encounter.id,
characterId: characterId || null,
monsterId: monsterId || null,
npcId: npcId || null,
displayName: name,
initiativeRoll: initiativeRoll ?? null,
initiative: initiativeRoll == null ? 0 : initiativeRoll + bonus,
currentHp,
maxHp,
isMonster: Boolean(monsterId),
},
})
);
}
await respondWithEncounter(req, res, 201, { participant: participants[0], participants });
} catch (err) {
next(err);
}
@@ -437,6 +519,9 @@ const updateParticipantSchema = z.object({
maxHp: z.number().int().min(1).max(100000).optional(),
initiativeRoll: initiativeRollSchema.nullable().optional(), // null clears the roll
conditions: z.array(z.string().trim().min(1).max(40)).max(20).optional(),
// Rounds left on the conditions that wear off by themselves; one left out
// lasts until the DM removes it.
conditionRounds: z.record(z.string().max(40), z.number().int().min(1).max(100)).optional(),
displayName: z.string().min(1).max(80).optional(),
});
@@ -467,11 +552,22 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, async (
if (data.initiativeRoll !== undefined) {
data.initiative = data.initiativeRoll == null ? 0 : data.initiativeRoll + initiativeBonus(existing);
}
if (data.conditions) data.conditions = [...new Set(data.conditions)];
if (data.conditions !== undefined || data.conditionRounds !== undefined) {
// A condition that goes takes its countdown with it.
const conditions = data.conditions ?? (existing.character ? existing.character.conditions : existing.conditions);
data.conditionRounds = roundsFor(data.conditionRounds ?? existing.conditionRounds, conditions);
}
if (existing.character) {
// Max HP comes from the sheet, and damage/healing lands on the character.
const { character } = existing;
const maxHp = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies).hp.max;
data.maxHp = maxHp;
if (data.conditions !== undefined) {
// So do conditions: they are the character's, and stay after the scene.
await prisma.character.update({ where: { id: character.id }, data: { conditions: data.conditions } });
emitCharacterUpdated(req, character.id);
}
if (data.currentHp !== undefined) {
if (lifeState(character) === 'DEAD' && data.currentHp > 0) {
return res.status(400).json({ error: `${character.name} is dead. Revive them before healing.` });
@@ -563,6 +659,25 @@ router.post('/:id/participants/:participantId/revive', requireEncounterAccess, a
}
});
// Rolls initiative for every monster and NPC that has no roll yet: a d20 each,
// with their bonus added as when a roll is typed in. Characters are left to
// their players.
router.post('/:id/roll-initiative', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const waiting = await prisma.encounterParticipant.findMany({
where: { encounterId: req.encounter.id, characterId: null, initiativeRoll: null },
include: { monster: true },
});
for (const p of waiting) {
const roll = crypto.randomInt(1, 21);
await prisma.encounterParticipant.update({ where: { id: p.id }, data: { initiativeRoll: roll, initiative: roll + initiativeBonus(p) } });
}
await respondWithEncounter(req, res, 200, { rolled: waiting.length });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Targets — who the combatant on turn is going for
// ---------------------------------------------------------------------------
@@ -635,7 +750,9 @@ router.post('/:id/next-turn', requireEncounterAccess, requireDM, async (req, res
...(wrapped ? { currentRound: { increment: 1 } } : {}),
},
});
await respondWithEncounter(req, res);
// What wore off as the new round began, for the DM to read out.
const expired = wrapped ? await tickConditions(req) : [];
await respondWithEncounter(req, res, 200, { expired });
} catch (err) {
next(err);
}
@@ -654,12 +771,54 @@ router.post('/:id/advance-round', requireEncounterAccess, requireDM, async (req,
...(req.encounter.mode === 'BATTLE' ? { activeParticipantId: standingFrom(order, 0)?.id ?? null } : {}),
},
});
const expired = await tickConditions(req);
await syncCharacterLocations(req);
req.app.get('io').to(`campaign:${req.encounter.campaignId}`).emit('encounter:round-advanced', {
encounterId: updated.id,
currentRound: updated.currentRound,
});
res.json({ encounter: await serializeEncounter(updated.id, req.isDM, req.user.id) });
res.json({ expired, encounter: await serializeEncounter(updated.id, req.isDM, req.user.id) });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Experience — awarded once, shared evenly between the characters in the scene
// ---------------------------------------------------------------------------
const awardXpSchema = z.object({ xp: z.number().int().min(1).max(10000000).optional() });
// The DM hands out the encounter's XP: what its monsters are worth unless
// another amount is given, in equal shares (rounded down) to every character
// in the scene. Nobody levels up by it; the sheet says when a level is due.
router.post('/:id/award-xp', requireEncounterAccess, requireDM, async (req, res, next) => {
try {
const parsed = awardXpSchema.safeParse(req.body || {});
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (!req.campaign.trackXp) {
return res.status(400).json({ error: 'This campaign does not keep experience points.' });
}
if (req.encounter.xpAwarded != null) {
return res.status(400).json({ error: `XP has already been awarded for this encounter (${req.encounter.xpAwarded}). Change a character's XP on their sheet.` });
}
const participants = await prisma.encounterParticipant.findMany({
where: { encounterId: req.encounter.id },
include: { character: { select: { id: true, name: true, level: true } }, monster: { select: { challengeRating: true } } },
});
const characters = participants.filter((p) => p.character).map((p) => p.character);
if (characters.length === 0) return res.status(400).json({ error: 'There are no characters in this encounter to award XP to.' });
const xp = parsed.data.xp ?? difficultyOf(participants).xp;
if (!(xp > 0)) return res.status(400).json({ error: 'There is no XP to award: no monster here has a challenge rating. Type an amount.' });
const share = Math.floor(xp / characters.length);
await prisma.$transaction([
prisma.character.updateMany({ where: { id: { in: characters.map((c) => c.id) } }, data: { xp: { increment: share } } }),
prisma.encounter.update({ where: { id: req.encounter.id }, data: { xpAwarded: xp } }),
]);
for (const character of characters) emitCharacterUpdated(req, character.id);
await respondWithEncounter(req, res, 200, { xp, share, awardedTo: characters.map((c) => c.name) });
} catch (err) {
next(err);
}
+26
View File
@@ -0,0 +1,26 @@
// 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 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) });
});
module.exports = router;
+11
View File
@@ -179,6 +179,17 @@ const seedSchema = z.strictObject({
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
maxEquipped: z.number().int().min(1).max(20).optional(),
maxEquippedWeapons: z.number().int().min(0).max(20).optional(),
subclassesEnabled: z.boolean().optional(),
multiclassEnabled: z.boolean().optional(),
diceMode: z.enum(['DIGITAL', 'PHYSICAL', 'BOTH']).optional(),
autoRollMonsters: z.boolean().optional(),
autoRollNpcs: z.boolean().optional(),
trackXp: z.boolean().optional(),
hpOnLevelUp: z.enum(['AVERAGE', 'ROLL']).optional(),
abilityScoreMethod: z.enum(['ROLL_4D6', 'STANDARD_ARRAY', 'POINT_BUY']).optional(),
rollVisibility: z.enum(['ALL', 'OWN']).optional(),
hideMonsterRolls: z.boolean().optional(),
critRule: z.enum(['DOUBLE_DICE', 'MAX_PLUS_ROLL']).optional(),
}),
locations: z.array(z.strictObject({ name: text(80), description: optionalText(2000) })).default([]),
weapons: poolSchema,
+33 -7
View File
@@ -4,22 +4,31 @@
//
// Short rest (an hour): spend Hit Dice to heal, each one the class's die plus
// the Constitution modifier. A warlock's spell slots come back.
// Long rest (a night): all HP, all spell slots, Hit Dice up to half the level
// (at least one), and temp HP is gone, except what the Hardy Boon gives.
// Class counters that restore on a short rest come back.
// Long rest (a night): all HP, all spell slots, every class counter, Hit Dice
// up to half the level (at least one), one level of exhaustion less, and temp
// HP is gone, except what the Hardy Boon gives.
const { changeHp, lifeState, NO_DEATH_SAVES } = require('./hp');
const { hardyTempHp } = require('../game/levelUp');
const { restResources } = require('../game/classResources');
// Once a day on a short rest a wizard or a druid (from level 2) gets some
// Once a day on a short rest a wizard or a Land druid (from level 2) gets some
// spent slots back. Which ones is the player's choice, so the DM puts them
// back on the sheet; this is the reminder, or null when it does not apply.
function recoveryNote(character, sheet) {
const feature = character.characterClass === 'WIZARD' ? 'Arcane Recovery' : character.characterClass === 'DRUID' && character.level >= 2 ? 'Natural Recovery' : null;
const feature = character.characterClass === 'WIZARD' ? 'Arcane Recovery' : character.characterClass === 'DRUID' && character.level >= 2 && character.subclass === 'LAND' ? 'Natural Recovery' : null;
if (!feature || !sheet.spellSlots || !sheet.spellSlots.levels.some((slot) => slot.used > 0)) return null;
const budget = Math.ceil(character.level / 2);
return `${feature}: once per day, ${character.name} can regain spent slots adding up to ${budget} slot level${budget === 1 ? '' : 's'} (none of 6th level or higher). Put them back on the sheet.`;
}
// The class counters a rest refills: the tally to store and a line for the DM.
function refill(sheet, restType) {
const { resourcesUsed, restored } = restResources(sheet.resources || [], restType);
return { resourcesUsed, notes: restored.length > 0 ? [`Back: ${restored.join(', ')}.`] : [] };
}
// A problem with the Hit Dice asked for, or null. `rolled` is the total the
// dice showed, for dice rolled at the table.
function hitDiceProblem(character, sheet, dice, rolled) {
@@ -43,10 +52,13 @@ function shortRest(character, sheet, { dice = 0, rolls = null, rolled = null } =
const pact = Boolean(sheet.spellSlots) && sheet.spellSlots.restoresOn === 'SHORT';
const slotsRestored = pact && sheet.spellSlots.levels.some((slot) => slot.used > 0);
const counters = refill(sheet, 'SHORT');
const data = {
...(healed > 0 ? changeHp(character, character.currentHp + healed, sheet.hp.max) : {}),
hitDiceUsed: sheet.hitDice.used + dice,
...(pact ? { spellSlotsUsed: [] } : {}),
resourcesUsed: counters.resourcesUsed,
};
const hpAfter = data.currentHp ?? character.currentHp;
return {
@@ -58,7 +70,7 @@ function shortRest(character, sheet, { dice = 0, rolls = null, rolled = null } =
tempHp: character.tempHp,
hitDice: { size: sheet.hitDice.size, spent: dice, rolls, total, conBonus, healed: hpAfter - character.currentHp, left: sheet.hitDice.remaining - dice, of: sheet.hitDice.count, regained: 0 },
slotsRestored,
notes: [recoveryNote(character, sheet)].filter(Boolean),
notes: [...counters.notes, recoveryNote(character, sheet)].filter(Boolean),
},
};
}
@@ -66,7 +78,17 @@ function shortRest(character, sheet, { dice = 0, rolls = null, rolled = null } =
function longRest(character, sheet) {
const regained = Math.min(sheet.hitDice.used, Math.max(1, Math.floor(character.level / 2)));
const tempHp = hardyTempHp(character.specialAbilities);
const data = { currentHp: sheet.hp.max, tempHp, ...NO_DEATH_SAVES, hitDiceUsed: sheet.hitDice.used - regained, spellSlotsUsed: [] };
const counters = refill(sheet, 'LONG');
const exhaustion = Math.max(0, (character.exhaustion || 0) - 1);
const data = {
currentHp: sheet.hp.max,
tempHp,
...NO_DEATH_SAVES,
hitDiceUsed: sheet.hitDice.used - regained,
spellSlotsUsed: [],
resourcesUsed: counters.resourcesUsed,
exhaustion,
};
return {
data,
result: {
@@ -76,7 +98,11 @@ function longRest(character, sheet) {
tempHp,
hitDice: { size: sheet.hitDice.size, spent: 0, rolls: null, total: 0, conBonus: 0, healed: 0, left: sheet.hitDice.remaining + regained, of: sheet.hitDice.count, regained },
slotsRestored: Boolean(sheet.spellSlots) && sheet.spellSlots.levels.some((slot) => slot.used > 0),
notes: tempHp > 0 ? [`Hardy: ${tempHp} Temp HP for the day.`] : [],
notes: [
...counters.notes,
...(character.exhaustion > 0 ? [exhaustion > 0 ? `Exhaustion down to level ${exhaustion}.` : 'No longer exhausted.'] : []),
...(tempHp > 0 ? [`Hardy: ${tempHp} Temp HP for the day.`] : []),
],
},
};
}
+18 -2
View File
@@ -39,7 +39,14 @@ describe('shortRest', () => {
test('with no dice spent nothing changes for a fighter', () => {
const c = hero({ hitDiceUsed: 3 });
expect(shortRest(c, sheet(c)).data).toEqual({ hitDiceUsed: 3 });
expect(shortRest(c, sheet(c)).data).toEqual({ hitDiceUsed: 3, resourcesUsed: {} });
});
test('gives back the class counters that restore on a short rest, and only those', () => {
const c = hero({ level: 9, resourcesUsed: { SECOND_WIND: 1, ACTION_SURGE: 1, INDOMITABLE: 1 } });
const { data, result } = shortRest(c, sheet(c));
expect(data.resourcesUsed).toEqual({ INDOMITABLE: 1 });
expect(result.notes).toEqual(['Back: Second Wind, Action Surge.']);
});
test('gives a warlock their slots back, and nobody else', () => {
@@ -83,7 +90,7 @@ describe('longRest', () => {
test('heals to full, gives back every slot and half the level in Hit Dice', () => {
const c = hero({ characterClass: 'WIZARD', currentHp: 3, tempHp: 4, hitDiceUsed: 5, spellSlotsUsed: [4, 3, 2] });
const { data, result } = longRest(c, sheet(c));
expect(data).toEqual({ currentHp: sheet(c).hp.max, tempHp: 0, ...CLEAR, hitDiceUsed: 3, spellSlotsUsed: [] });
expect(data).toEqual({ currentHp: sheet(c).hp.max, tempHp: 0, ...CLEAR, hitDiceUsed: 3, spellSlotsUsed: [], resourcesUsed: {}, exhaustion: 0 });
expect(result.hitDice).toMatchObject({ regained: 2, left: 2, of: 5 });
expect(result.slotsRestored).toBe(true);
});
@@ -100,6 +107,15 @@ describe('longRest', () => {
expect(longRest(c, sheet(c)).data).toMatchObject({ currentHp: 44, ...CLEAR });
});
test('gives every class counter back and takes one level of exhaustion off', () => {
const c = hero({ level: 9, exhaustion: 2, resourcesUsed: { SECOND_WIND: 1, INDOMITABLE: 1 } });
const { data, result } = longRest(c, sheet(c));
expect(data).toMatchObject({ resourcesUsed: {}, exhaustion: 1 });
expect(result.notes).toEqual(['Back: Second Wind, Indomitable.', 'Exhaustion down to level 1.']);
const tired = hero({ exhaustion: 1 });
expect(longRest(tired, sheet(tired)).result.notes).toEqual(['No longer exhausted.']);
});
test('leaves the Hardy Boon its Temp HP', () => {
const c = hero({ tempHp: 2, specialAbilities: 'Hardy — +10 maximum HP: gain 10 Temp HP after each long rest.' });
const { data, result } = longRest(c, sheet(c));
+49
View File
@@ -0,0 +1,49 @@
# Campaign Settings
Each campaign has its own settings. The DM changes them on the campaign page
under **Campaign Settings → Edit**, and players read them but can't change
them. They are columns on the `Campaign` row (`backend/prisma/schema.prisma`),
returned with the campaign by `GET /campaigns/:id`, and changed with
`PATCH /campaigns/:id` (DM only). A world seed can set any of them
([SEEDS.md](SEEDS.md)).
Changing a setting never rewrites what already happened. For example,
turning XP tracking off keeps everyone's XP; it just stops showing it.
## Characters
| Setting | Default | What it does |
|---|---|---|
| `startingGold` | 0 | Gold every new character starts with. |
| `startingItemCount` | 0 | How many starting weapons and items (together) the builder lets a character take. |
| `startingSpellCount` | 0 | How many starting spells the builder lets a character take. |
| `abilityScoreMethod` | `ROLL_4D6` | How the builder makes the six scores: `ROLL_4D6` (roll 4d6 and drop the lowest, six times, then place them), `STANDARD_ARRAY` (place 15, 14, 13, 12, 10 and 8), or `POINT_BUY` (every score starts at 8 and is raised with 27 points, to 15 at most). |
| `skipMotivationRoll` | off | Leaves the motivation step out of the builder. |
| `subclassesEnabled` | on | Characters choose a subclass. Off: each class follows its first subclass, unasked and unnamed. |
| `multiclassEnabled` | off | On a level up, a character may take the level in another class whose ability requirements they meet. |
| `maxEquipped` / `maxEquippedWeapons` | 2 / 1 | How much a character can have equipped at once, and how much of it can be weapons. |
## Dice and rolls
| Setting | Default | What it does |
|---|---|---|
| `diceMode` | `BOTH` | `DIGITAL`: the app rolls every die. `PHYSICAL`: everyone rolls real dice and types in what they show (the server checks each value is on the die). `BOTH`: each character picks one or the other before their first build roll. |
| `rollVisibility` | `ALL` | `ALL`: everyone sees every roll. `OWN`: a player sees only their own rolls. The DM always sees every roll. |
| `hideMonsterRolls` | on | The DM's rolls for monsters and NPCs stay hidden from players until they are put into play. |
## Combat
| Setting | Default | What it does |
|---|---|---|
| `autoRollMonsters` | off | When a monster's turn comes up in a battle, the server picks its target, rolls the attack and the damage, and offers the result to the DM to accept, change or throw out. |
| `autoRollNpcs` | off | The same for NPCs. Friendly NPCs attack monsters; hostile ones attack the party. |
| `showMonsterHp` | on | Players see the exact HP of monsters and NPCs. Off: they see a health bar. |
| `critRule` | `DOUBLE_DICE` | What a critical hit does to damage. `DOUBLE_DICE`: the damage dice are rolled twice. `MAX_PLUS_ROLL`: the dice count at their maximum, plus one roll of them. |
| `areaAttackAcPenalty` | 2 | In an attack on several targets, each target's AC counts this much higher for every target after the first. 0 turns it off. |
## Progression
| Setting | Default | What it does |
|---|---|---|
| `trackXp` | on | Experience points are kept: on the sheets, awarded after encounters, with "level up due" shown. Off (milestone levelling): no XP is shown or awarded, and the DM decides when the party levels. |
| `hpOnLevelUp` | `AVERAGE` | How maximum HP grows on a level up. `AVERAGE`: the hit die's average, rounded up, plus Constitution. `ROLL`: a roll of the hit die plus Constitution. |
+1110 -74
View File
File diff suppressed because it is too large Load Diff
+174 -28
View File
@@ -84,8 +84,10 @@ the encounter does not take a visited place away again.
**Order.** The campaign page lists encounters on its "Overview", under the
characters, in the **campaign order**, which
the DM arranges with the ▲ and ▼ buttons and everyone sees. A new encounter
goes to the top. The "Sort by" menu re-sorts the list by name, status, type or
the DM arranges with the ▲ and ▼ buttons and everyone sees. A new encounter,
battle or quest, goes to the **bottom** unless the DM picks otherwise in the
menu beside the name when adding it: at the bottom, at the top, or straight
after any encounter on the list. Top or bottom is remembered per browser. The "Sort by" menu re-sorts the list by name, status, type or
date instead; that choice is remembered per browser and changes nothing for
anyone else. ▲ and ▼ only show while sorting by campaign order.
@@ -123,30 +125,70 @@ The DM adds people from the "Add to the scene" panel.
| Monster | The campaign's monster list ([MONSTERS.md](MONSTERS.md)) | Yes |
| NPC | The campaign's NPC list ([NPCS.md](NPCS.md)) | No |
**Finding your own character.** A player's own characters stand out: their
cards have a blue border and background and say "★ Your character", and a row
of buttons at the top of the page names each one with its HP ("★ Aria · 12/12
HP", plus "your turn" when it is) and scrolls to its card. The DM sees the
same for any character of their own.
**Several monsters at once.** With "Monster" chosen there is a number beside
Add, 1 to 20. More than one adds that many copies, numbered: "Goblin 1" to
"Goblin 4". Adding more later carries on from the highest number in the scene
("Goblin 5"). One on its own keeps the plain name. An initiative roll typed
in is given to all of them; left empty, each can be rolled separately
(section 7).
**Inventory.** A character's card has an "🎒 Inventory" button for the DM and
for that character's own player. It opens the character panel on that
character: everything they hold, what is equipped and what each thing does
**The turn strip.** With two or more in the scene, a row of small buttons
runs across the top of the page, one per combatant, in the order they act. It
stays under the top bar while the cards scroll (on a phone it scrolls
sideways). Each button has the name, HP where the viewer may see the numbers
(otherwise the initiative total), a thin health bar and "⚑" with the number of
conditions. Whoever is on turn is gold and marked ▶, a target is red and
marked 🎯, the viewer's own characters are blue and marked ★, and in a battle
those who have acted this round are dimmed. A click scrolls to that card. The
fallen are not on it.
**Finding your own character.** A player's own characters stand out: their
cards have a blue border and background and say "★ Your character", and their
buttons on the turn strip are blue and starred. The DM sees the same for any
character of their own.
**Sheet.** A character's card has a "📋 Sheet" button for the DM and for that
character's own player. It opens the character panel on that character,
beside the encounter: saving throws, every skill, passive scores, special
abilities, everything they hold, what is equipped and what each thing does
([CHARACTERS.md](CHARACTERS.md), section 14). The floating 🧙 button opens the
same panel from anywhere. A card's details also list what the character's
equipped gear is doing; the AC, speed and other numbers on the card already
include it.
**The spotlight.** On a wide screen the column beside the cards starts with
one combatant's whole stat block: whoever is **on turn** in a battle. It is
everything their card has under "details", so the DM reads the attacker's
numbers there while scrolling to a target. **📌 Pin** on any card with details
puts that combatant there instead (in a quest too, where nobody is on turn);
"✕ Unpin" or a second click on Pin goes back to whoever is on turn. Pinning is
for the viewer's own screen only. The side column stays in view and scrolls
on its own. On a screen narrower than 960px there is no spotlight and no Pin:
the list is one column and the card on turn is at its top.
**Rolling.** The underlined numbers on a card and in the spotlight roll when
clicked: a character's saves, skills, ability checks, initiative, attacks and
damage, and for the DM a monster's or NPC's attack and damage and a monster's
ability scores. "🎲 Call for a roll…" in the header (DM) rolls one check or
save for all the characters in the scene at once
([CHARACTERS.md](CHARACTERS.md), "Rolling from the sheet").
**Keys (DM, battle in progress).** **N** is Next Turn. **S** opens the sheet of
the character on turn, or pins the monster or NPC on turn to the spotlight.
Keys do nothing while the cursor is in a field.
Each has a card. The card's left edge is coloured by kind, and it shows:
- **Characters:** level, species and class; HP, AC, speed, initiative bonus,
passive perception, proficiency bonus and spell save DC. Under "details":
ability scores, saves, the weapon and items the character has **equipped**
(with attack and damage numbers), the spells they can cast, and special
abilities. A weapon that is not equipped is not on the card and cannot be
used; "No weapon equipped" shows when there is none. Equipping is changed
from "🎒 Inventory", at any point in the scene. The name links to the
character sheet.
ability scores, the weapon and items the character has **equipped**
(with attack and damage numbers), spellcasting numbers with the spell slots
left, the spells they can cast, **saving throws and all eighteen skills**
(● proficient, ◆ expertise) with passive Perception, Investigation and
Insight, and special abilities. A weapon that is not equipped is not on the
card and cannot be used; "No weapon equipped" shows when there is none.
Equipping is changed from "📋 Sheet", at any point in the scene. The name
links to the character sheet.
- **Monsters:** for the DM, AC, speed, attack and damage, and under "details"
ability scores, trait and notes.
- **NPCs:** role, attitude and description; for the DM also AC, attack,
@@ -256,19 +298,24 @@ Initiative is entered in battles and quests alike. Turns and rounds are for
battles only.
- Each combatant has an **initiative roll** field. Type what the d20 showed,
1 to 20; the field accepts nothing else. The app does not roll the die, but
it adds the bonus for you and shows the total, which can go past 20 (a 20 on
1 to 20; the field accepts nothing else. The app adds the bonus for you and
shows the total, which can go past 20 (a 20 on
the die with a +3 bonus is 23): a character's initiative
bonus from their sheet, a monster's Dexterity modifier, nothing for an NPC.
- The DM can enter the roll for anyone, when adding them or on the card. A
player can enter the roll for **their own character** on its card.
- **🎲 Roll initiative for N monsters and NPCs**, in "Add to the scene" while
any of them has no roll, rolls a d20 for each of those and adds their
bonus. Monsters added together each get their own roll. Rolls already
entered are left alone, and characters are left to their players.
- The list is ordered by the total, highest first; ties keep the order they
were added in. Anyone who has not rolled yet waits at the bottom.
- The bonus is worked out when the roll is entered. If a character's sheet
changes afterwards, enter the roll again to pick up the new bonus.
- **Begin Battle** puts the first combatant on turn. Their card is
highlighted and the header names them.
- **Next Turn** passes the turn to the next in the order. After the last
highlighted, the header names them, and their stats fill the spotlight
(section 5).
- **Next Turn** (or the **N** key) passes the turn to the next in the order. After the last
combatant it returns to the top and the **round** goes up by one.
- **Next Round** skips straight to the top of the next round.
- Anyone out of the fight (section 6, "The fallen") is passed over.
@@ -340,14 +387,42 @@ the roll with the AC shown and types the damage.
## 8. Conditions
The DM adds a condition to any card from the "+ Condition" list, and removes
one by clicking its ✕. Everyone sees them.
one by clicking its ✕. Everyone sees them. **Hovering over a condition shows
its rule** (on the card, in the spotlight, on the sheet and on the party
overview).
**A character's conditions are the character's own.** One given on an
encounter card is on their sheet and in their panel too, is still there when
the encounter ends, and shows on their card in the next one, until the DM
removes it (from any of those places). A monster's or an NPC's conditions
belong to that one card.
A character's card also shows **✨ Inspired** and **Exhaustion N** when they
apply, and under "details" (and in the spotlight) their **class counters**:
Rage, Ki, Channel Divinity and the rest, with a Use button for the DM and for
that character's own player ([CHARACTERS.md](CHARACTERS.md), section 7).
The list: Blinded, Charmed, Deafened, Frightened, Grappled, Incapacitated,
Invisible, Paralyzed, Petrified, Poisoned, Prone, Restrained, Stunned,
Unconscious, Concentrating.
Conditions are labels to remind the table. They do not change any numbers,
and they do not expire on their own.
**Timed conditions.** The "rounds" box beside "+ Condition" says how long the
next condition added lasts. Its chip shows what is left ("Frightened 2 rd").
Each time the **round goes up** (Next Turn past the last combatant, or Next
Round) every timed condition loses a round, and one that reaches 0 comes off
by itself, from the character's sheet too. The DM is told what wore off
("Frightened wore off Aria."). Given for 1 round, a condition lasts until the
next round begins; a condition added with the box empty stays until the DM
removes it. The countdown belongs to the encounter: it does not run outside
one, and removing a condition by hand drops its countdown.
**Concentration.** "Concentrating" marks someone holding a spell. When the DM
applies damage to them, from their card or the attack bar, a notice gives the
save to call for: Constitution, DC 10 or half the damage if that is higher,
with their Constitution save bonus (a monster's Constitution modifier). The
site does not roll it or end the spell: on a failure the DM removes the chip.
Conditions are reminders for the table. They do not change any numbers.
## 9. Rewards
@@ -379,6 +454,64 @@ Each can carry a short note ("Goblin chief's purse").
Until a reward is awarded only the DM can see it. Afterwards players see what
it was and who received it.
### Experience and difficulty
The DM's **Experience** card, above Rewards, reads the fight and hands out
its XP. Players see none of it.
**What the monsters are worth.** Each monster's challenge rating
([MONSTERS.md](MONSTERS.md)) is worth a set amount of XP, and the card adds
them up, standing or fallen. A monster whose challenge is empty or is not a
rating ("boss") is not counted, and the card says how many are not. NPCs have
no challenge rating and are never counted.
| Challenge | XP | Challenge | XP | Challenge | XP | Challenge | XP |
|---|---|---|---|---|---|---|---|
| 0 | 10 | 3 | 700 | 9 | 5,000 | 15 | 13,000 |
| 1/8 | 25 | 4 | 1,100 | 10 | 5,900 | 16 | 15,000 |
| 1/4 | 50 | 5 | 1,800 | 11 | 7,200 | 17 | 18,000 |
| 1/2 | 100 | 6 | 2,300 | 12 | 8,400 | 18 | 20,000 |
| 1 | 200 | 7 | 2,900 | 13 | 10,000 | 19 | 22,000 |
| 2 | 450 | 8 | 3,900 | 14 | 11,500 | 20 | 25,000 |
Ratings 21 to 30 run from 33,000 to 155,000. "0.5" is read as 1/2, and "CR 3"
as 3.
**How hard it is.** Several monsters are harder than their XP says, so to
judge the fight the total is multiplied for their number: ×1 for one, ×1.5
for two, ×2 for 3 to 6, ×2.5 for 7 to 10, ×3 for 11 to 14, ×4 for 15 or more.
That figure is held against what the characters in the scene can take, each
adding their own level's thresholds:
| Level | Easy | Medium | Hard | Deadly |
|---|---|---|---|---|
| 1 | 25 | 50 | 75 | 100 |
| 2 | 50 | 100 | 150 | 200 |
| 3 | 75 | 150 | 225 | 400 |
| 4 | 125 | 250 | 375 | 500 |
| 5 | 250 | 500 | 750 | 1,100 |
| 6 | 300 | 600 | 900 | 1,400 |
| 7 | 350 | 750 | 1,100 | 1,700 |
| 8 | 450 | 900 | 1,400 | 2,100 |
| 9 | 550 | 1,100 | 1,600 | 2,400 |
| 10 | 600 | 1,200 | 1,900 | 2,800 |
(Levels 11 to 20 continue to 2,800 / 5,700 / 8,500 / 12,700 at level 20.) The
fight is **Trivial** below the party's Easy figure, then **Easy**,
**Medium**, **Hard**, and **Deadly** from the Deadly figure up. The rating is
a badge on the card and in the page header, beside the round, and it moves as
monsters and characters are added. There is no rating until the scene has at
least one character and one monster with a challenge rating.
**Awarding it.** "Award N XP each" shares the XP evenly between **every
character in the encounter**, rounded down, and adds it to each one's XP. The
amount is what the monsters are worth (not the multiplied figure) unless the
DM types another: for a quest, a clever escape, or a scene with nothing rated
in it. It can be awarded at any point, and **once** per encounter; the card
then reads "✓ 75 XP awarded: 37 each". A mistake is put right on the
characters' sheets. XP never levels anyone up by itself
([CHARACTERS.md](CHARACTERS.md), section 6).
## 10. Trade
In an NPC quest the party can **change money** and **buy things**. Both take
@@ -427,12 +560,15 @@ when there is something to trade, and always for the DM in a quest.
| Scene description | Yes | Yes |
| DM notes | Yes | Never |
| Characters | Full card | Full card |
| A character's saves, skills and passive scores | Yes | Yes, as on the sheet |
| A character's inspiration, exhaustion, conditions and class counters | Yes, and can change them | Yes; a player can spend their own character's counters |
| A character's inventory, spells and purse | Yes, for everyone | Their own characters only |
| Monsters | Full stat block | Name, HP, conditions |
| NPCs | Everything | Name, role, attitude, description, HP bar |
| Monsters and NPCs at 0 HP | With "Show dead" ticked, marked hidden | Nothing |
| Dead characters | Full card with "Show dead" ticked, marked hidden | The name only, in the Fallen box |
| Rewards | All | Only awarded ones |
| Difficulty and the monsters' XP | Yes | Never (awarded XP shows on their own sheet) |
| Exchange rates and things for sale | Yes | Yes, for NPCs they can see and for the scene itself |
| Controls (add, damage, turns, rewards) | Yes | None |
@@ -457,6 +593,11 @@ information cannot be dug out of the page.
| Revive a dead character outright | Yes | No |
| Try to revive a dead character | Yes, for the character on turn | On their own character's turn, if it has a reviving spell |
| Advance turns and rounds | Yes | No |
| Roll initiative for the monsters and NPCs | Yes | No |
| Call for a roll from all the characters | Yes | No |
| See the difficulty and what the monsters are worth; award XP | Yes | No |
| Click a number to roll it | Yes | Yes, for what they can see |
| Give a condition a number of rounds | Yes | No |
| Add, remove or award rewards | Yes | No |
| Set exchange rates, and put things up for sale | Yes | No |
| Change money or buy, in a quest in progress | Yes, for any character in it | For their own character in it |
@@ -465,15 +606,20 @@ People outside the campaign cannot open its encounters.
## 13. What is not modelled
- Rolling initiative, attacks, damage or saves (use the dice roller, bottom
left of every page, or in the bottom bar on a phone)
- Applying an attack's damage automatically
- Rolling initiative for characters into their field (a player clicks their
Init chip to roll it, then types what came up)
- Applying an attack's damage automatically: attacks and damage are rolled
with a click ([CHARACTERS.md](CHARACTERS.md), "Rolling from the sheet") and
the DM enters the result
- Rolls that everyone at the table sees
- Death saves for monsters and NPCs, and prompting for a save on a dying
character's turn
- A roll, a spell slot or a time limit for reviving the dead
- Conditions with durations or mechanical effects
- The mechanical effects of conditions, durations counted in anything but
rounds, and rolling a concentration save
- Maps, positions, distances and areas of effect
- Experience points and encounter difficulty
- Adjusting difficulty for a very small or very large party, and awarding
XP to some of the characters only or in unequal shares
- Spells as rewards, and spells for sale
- Selling to an NPC, haggling, and buying more than one of a thing in a
single step
+8 -6
View File
@@ -119,15 +119,17 @@ should hide those fields entirely rather than show them as zero/blank.
## Species Ability Score Increases & Speed
See `seed-data/species.json` for the full table (10 species). Note: Half-Elf gets
+2 Charisma fixed, plus +1 to **two other** abilities of the player's choice — the
seed data defaults that flexible pair to STR/INT, but the character builder UI
must let the player pick which two abilities get the +1 during Half-Elf
character creation (don't hardcode the default in the UI).
See `seed-data/species.json` for the full table (43 species). Note: Half-Elf gets
+2 Charisma fixed, plus +1 to **two other** abilities of the player's choice.
The 2024 species (Aasimar, Goliath) and those of Monsters of the Multiverse
have no fixed bonuses: the player chooses +2 and +1, or +1 to three
abilities. These choices are a species' `flexible` patterns; the builder must
ask for them (don't default them in the UI), and they are stored on the
character as `speciesBonusChoices`.
## Class Quick Facts
See `seed-data/classes.json` for the full table (12 classes): Hit Die, Primary
See `seed-data/classes.json` for the full table (13 classes, the Artificer included): Hit Die, Primary
Ability, the two Saving Throw proficiencies, and Spellcasting Ability.
## Skill → Governing Ability
+19 -13
View File
@@ -40,13 +40,14 @@ This describes the code as it stands. The built-in list is
| Ability scores | STR, DEX, CON, INT, WIS, CHA. 10 unless set otherwise |
| Attack bonus | Added to the monster's attack roll |
| Damage | Dice for its main attack, e.g. `1d6+2`, with the damage type if you like: `1d6+2 necrotic` |
| Challenge rating | A rough danger rating, e.g. `1/4`, `2` |
| Challenge rating | A rough danger rating, e.g. `1/4`, `2`. It sets what the monster is worth in XP and how hard an encounter is ([ENCOUNTERS.md](ENCOUNTERS.md), section 9); a monster with none, or with text that is not a rating, is left out of both |
| Special trait | A short note such as "Pack Tactics" |
| Notes | Anything else: what it attacks with, how it behaves |
None of these are calculated or used in formulas. They are reference numbers
shown to the DM during a fight. A monster's ability modifiers are worked out
from its scores for display only.
Apart from the challenge rating, none of these are used in formulas. They
are reference numbers shown to the DM during a fight. A monster's ability
modifiers are worked out from its scores for display, for its initiative
bonus (Dexterity) and for the rolls a click makes (section 5).
## 3. Managing monsters
@@ -136,11 +137,14 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
## 5. Monsters in encounters
On an encounter page, the DM chooses "Monster" in the "Add to the scene"
panel, picks one, and types its initiative.
panel, picks one, says how many, and types its initiative or leaves it to be
rolled ("🎲 Roll initiative for N monsters and NPCs" rolls a d20 plus the
Dexterity modifier for each that has no roll).
- The same monster can be added **any number of times**. Each copy has its
own HP and conditions. Copies share the monster's name; the screen has no
way to rename one (the API allows it).
- The same monster can be added **any number of times**, up to 20 in one go.
Each copy has its own HP and conditions. Copies added together are numbered
("Goblin 1" to "Goblin 4"); one added on its own has the monster's name. The
screen has no way to rename one (the API allows it).
- A copy starts at the monster's max HP. Its HP stays between 0 and its max.
- At 0 HP a copy is marked **Down** and dimmed, but keeps its place in the
initiative order until the DM removes it.
@@ -148,7 +152,10 @@ panel, picks one, and types its initiative.
The DM's card for a monster shows AC, speed, attack bonus and damage at a
glance, and under "details" its six ability scores with modifiers, its trait
and its notes.
and its notes. **Attack, Damage and each ability score roll when clicked**: a
d20 plus the attack bonus, the damage dice, or a d20 plus the ability's
modifier. Of damage written with more after the dice ("2d8+3 fire") the dice
are rolled; damage that does not start with dice is shown but does not roll.
## 6. What players see
@@ -181,8 +188,7 @@ Then they get the health bar only.
- Multiple attacks, spells, legendary actions and resistances (use Notes)
- Trade: monsters do not sell things or change money (NPCs do)
- Rolling a monster's attacks or damage (use the dice roller)
- Saving throw bonuses and skills
- Experience points for defeating monsters
- Encounter difficulty calculated from challenge ratings
- Saving throw proficiencies and skills (on an encounter card a click on an
ability score rolls a d20 with its modifier, for a check or a save)
- XP typed in for a monster: it always comes from the challenge rating
- Monster hit dice, and rolling HP rather than using the fixed value
+25 -14
View File
@@ -11,23 +11,26 @@ the in-progress rolls/choices held in `Character.buildState` (JSON) until the
build is finalized, at which point the real columns (`species`, `characterClass`,
ability scores, proficiencies) get set and `buildStage` becomes `COMPLETE`.
## Step 1 — Roll Species (d6)
## Step 1 — Roll Species (d8)
Tell the player: **"Roll a d6."** Then offer the choice(s) that result maps to.
Most rolls offer 2 species to pick between; keep the flavor text short.
Tell the player: **"Roll a d8."** Then offer the family of species that result
maps to; keep the flavor text short.
| d6 Roll | Choose between |
| d8 Roll | Choose from |
|---|---|
| 1 | Human or Half-Elf |
| 2 | Elf or Gnome |
| 3 | Dwarf or Halfling |
| 4 | Dragonborn or Tiefling |
| 5 | Half-Orc or Orc |
| 6 | Wildcard — pick any species |
| 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, Earth, Fire or Water Genasi, Triton, Tortle |
| 7 | Tabaxi, Kenku, Aarakocra, Harengon, Lizardfolk, Owlin, Minotaur, Centaur, Satyr, Shifter, Firbolg, Fairy, Yuan-ti |
| 8 | Wildcard — pick any species |
If the player rolls Half-Elf, immediately prompt for which two abilities (other
than Charisma) get the flexible +1 — this choice has to happen here, not be
silently defaulted (see GAME_RULES_REFERENCE.md).
If the species has chosen bonuses, immediately prompt for where they go — this
choice has to happen here, not be silently defaulted. A Half-Elf picks two
abilities (other than Charisma) for +1 each; the 2024 and Multiverse species
pick +2 and +1, or +1 to three abilities.
## Step 2 — Roll Class (d8)
@@ -41,9 +44,17 @@ Tell the player: **"Roll a d8."**
| 4 | Cleric or Paladin |
| 5 | Bard or Warlock |
| 6 | Druid or Monk |
| 7 | Reroll once, keep either result |
| 7 | Artificer, or reroll once (the reroll stands) |
| 8 | Wildcard — pick any class |
## Step 2b — Choose a Subclass
When the campaign uses subclasses, the player now picks the path within their
class from its full list (each with its book and a level 1–20 preview), or
rolls a die with one face per subclass for a suggestion, or leaves it until
the class reaches the level it chooses at. A path chosen here only starts
giving at that level.
## Step 3 — Roll Motivation (d6, optional flavor)
Tell the player: **"Roll a d6"** (optional — DM can skip this step for the whole
+1 -1
View File
@@ -113,7 +113,7 @@ The file is ordinary JavaScript, so it can build its lists however it likes
| Field | What it is |
|---|---|
| `code` | Six capital letters or digits. What a DM types |
| `campaign` | The campaign's settings: `name`, and optionally `description`, `startingGold`, `startingItemCount`, `startingSpellCount`, `skipMotivationRoll`, `showMonsterHp`, `areaAttackAcPenalty`, `maxEquipped`, `maxEquippedWeapons`. Anything left out takes the app's default |
| `campaign` | The campaign's settings: `name`, and optionally `description`, `startingGold`, `startingItemCount`, `startingSpellCount`, `skipMotivationRoll`, `showMonsterHp`, `areaAttackAcPenalty`, `maxEquipped`, `maxEquippedWeapons`, `subclassesEnabled`, `multiclassEnabled`, `diceMode`, `autoRollMonsters`, `autoRollNpcs`, `trackXp`, `hpOnLevelUp`, `abilityScoreMethod`, `rollVisibility`, `hideMonsterRolls`, `critRule`. Anything left out takes the app's default |
| `locations` | `{ name, description }` |
| `weapons`, `items`, `spells`, `monsters` | The pools. Each entry is a row or a library copy (below) |
| `npcs` | NPC rows, each optionally with `wares` |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.14.0",
"version": "1.23.0",
"type": "module",
"scripts": {
"dev": "vite",
+78 -3
View File
@@ -3,6 +3,7 @@ import { apiClient } from './client';
import type {
BuildOptions,
Campaign,
CharacterClass,
CampaignLibrary,
CampaignMemberSummary,
CharacterDetail,
@@ -20,6 +21,7 @@ import type {
Monster,
Note,
Npc,
ProgressionLevel,
SaleOffer,
QuickStats,
RestResult,
@@ -216,6 +218,39 @@ export function useSpellSlots(characterId: string) {
});
}
// The class counters. Spending is for the character's own player and the DM,
// one use at a time or `amount` from a pool; setting how many are spent, and
// giving them all back, are the DM's alone.
export type ResourceAction = { action: 'use'; key: string; amount?: number } | { action: 'set'; key: string; used: number } | { action: 'restore' };
export function useResources(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: ResourceAction) =>
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/resources`, data)).data.character,
onSuccess: (character) => {
invalidateCharacter(qc, characterId);
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows its counters
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
},
});
}
// Inspiration, exhaustion and conditions. The DM sets all three; the
// character's own player may spend inspiration (set it to false).
export function useCharacterState(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { inspiration?: boolean; exhaustion?: number; conditions?: string[] }) =>
(await apiClient.patch<{ character: CharacterDetail }>(`/characters/${characterId}`, data)).data.character,
onSuccess: (character) => {
invalidateCharacter(qc, characterId);
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows all of it
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
},
});
}
// What a character's next level brings. DM only.
export function useLevelUpPlan(characterId: string, enabled: boolean) {
return useQuery({
@@ -230,7 +265,7 @@ export function useLevelUpPlan(characterId: string, enabled: boolean) {
export function useLevelUp(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { boon?: string; weaponId?: string | null; spellIds?: string[] }) =>
mutationFn: async (data: { subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[] }) =>
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/level-up`, data)).data.character,
onSuccess: (character) => {
invalidateCharacter(qc, characterId);
@@ -327,6 +362,21 @@ export function useBuildOptions() {
});
}
// What each level from 1 to 20 gives a class, with a subclass's lines when one is given.
export function useProgression(characterClass: CharacterClass | null | undefined, subclass?: string | null) {
return useQuery({
queryKey: ['progression', characterClass, subclass ?? null],
enabled: Boolean(characterClass),
staleTime: Infinity, // rules: they never change while the app is open
queryFn: async () =>
(
await apiClient.get<{ levels: ProgressionLevel[] }>('/reference/progression', {
params: { class: characterClass, ...(subclass ? { subclass } : {}) },
})
).data.levels,
});
}
function invalidateCharacter(qc: ReturnType<typeof useQueryClient>, characterId: string) {
qc.invalidateQueries({ queryKey: ['character', characterId] });
}
@@ -735,7 +785,8 @@ export function useCampaignEncounters(campaignId: string | undefined) {
export function useCreateEncounter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { name: string; mode?: EncounterMode }) =>
// Where it goes in the campaign's order: the top, the bottom (the default), or straight after another.
mutationFn: async (data: { name: string; mode?: EncounterMode; position?: 'TOP' | 'BOTTOM'; afterEncounterId?: string }) =>
(await apiClient.post<{ encounter: Encounter }>(`/campaigns/${campaignId}/encounters`, data)).data.encounter,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'encounters'] }),
});
@@ -806,7 +857,7 @@ function keepEncounter(qc: QueryClient, encounterId: string, data: unknown) {
export function useAddParticipant(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiativeRoll?: number }) =>
mutationFn: async (data: { characterId?: string; monsterId?: string; npcId?: string; displayName?: string; initiativeRoll?: number; count?: number }) =>
(await apiClient.post(`/encounters/${encounterId}/participants`, data)).data,
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
@@ -830,6 +881,15 @@ export function useRemoveParticipant(encounterId: string) {
});
}
// Rolls a d20 for every monster and NPC that has no initiative roll yet. DM only.
export function useRollInitiative(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => (await apiClient.post<{ rolled: number }>(`/encounters/${encounterId}/roll-initiative`)).data,
onSuccess: (data) => keepEncounter(qc, encounterId, data),
});
}
export function useAdvanceRound(encounterId: string) {
const qc = useQueryClient();
return useMutation({
@@ -920,6 +980,21 @@ export function useDeleteReward(encounterId: string) {
// Awarding is what actually changes the character: a gold ledger entry, or
// the weapon/item added to what they hold.
// Hands out the encounter's XP, shared evenly between the characters in it:
// what its monsters are worth, or the amount given. Once only. DM only.
export function useAwardXp(encounterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (xp?: number) =>
(await apiClient.post<{ xp: number; share: number; awardedTo: string[] }>(`/encounters/${encounterId}/award-xp`, xp ? { xp } : {})).data,
onSuccess: (data) => {
keepEncounter(qc, encounterId, data);
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['campaign'] });
},
});
}
export function useAwardReward(encounterId: string) {
const qc = useQueryClient();
return useMutation({
+177 -11
View File
@@ -10,7 +10,40 @@ export type Species =
| 'DRAGONBORN'
| 'TIEFLING'
| 'HALF_ORC'
| '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';
export type CharacterClass =
| 'FIGHTER'
@@ -24,12 +57,14 @@ export type CharacterClass =
| 'BARD'
| 'WARLOCK'
| 'DRUID'
| 'MONK';
| 'MONK'
| 'ARTIFICER';
export type BuildStage =
| 'NOT_STARTED'
| 'ROLLING_SPECIES'
| 'ROLLING_CLASS'
| 'CHOOSING_SUBCLASS'
| 'ROLLING_MOTIVATION'
| 'ASSIGNING_SCORES'
| 'ASSIGNING_PROFICIENCIES'
@@ -40,6 +75,12 @@ export type BuildStage =
| 'COMPLETE';
export type DiceMode = 'DIGITAL' | 'PHYSICAL';
// The campaign's dice: BOTH leaves it to each character, and any roll can be typed in.
export type CampaignDiceMode = DiceMode | 'BOTH';
export type HpMethod = 'AVERAGE' | 'ROLL';
export type ScoreMethod = 'ROLL_4D6' | 'STANDARD_ARRAY' | 'POINT_BUY';
export type RollVisibility = 'ALL' | 'OWN';
export type CritRule = 'DOUBLE_DICE' | 'MAX_PLUS_ROLL';
// The fixed lists the builder's story steps are made of (served by the API so
// the page never keeps its own copy).
@@ -75,6 +116,29 @@ export interface BuildOptions {
talentPicks: number;
background: PickGroup[];
appearance: PickGroup[];
// Every class's subclasses, and the level each class gets its subclass's first gifts at.
subclasses: Record<CharacterClass, SubclassSummary[]>;
subclassLevels: Record<CharacterClass, number>;
}
// A subclass as a list shows it: its book, whether it is from the 2014 or
// 2024 rules, and the 2014 subclass a 2024 one updates.
export interface SubclassSummary {
key: string;
name: string;
summary: string;
source: string;
edition: '2014' | '2024';
replaces: string | null;
}
// One level of a class's road from 1 to 20 (GET /reference/progression).
export interface ProgressionLevel {
level: number;
features: { name: string; text: string; fromSubclass: boolean }[];
boon: boolean;
choosesSubclass: boolean;
unlocksSpellLevel: number | null;
}
export interface User {
@@ -95,10 +159,23 @@ export interface Campaign {
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
// Added to each target's AC for every target beyond the first in an area attack; 0 is off.
areaAttackAcPenalty: number;
// Off: nobody chooses a subclass; each class follows its first one, unnamed.
subclassesEnabled: boolean;
// How many weapons and items a character can have equipped together, and
// how many of those can be weapons. Only equipped gear is used in a fight.
maxEquipped: number;
maxEquippedWeapons: number;
multiclassEnabled: boolean;
diceMode: CampaignDiceMode;
// On: a monster's (or NPC's) turn comes with its attack already rolled, for the DM to accept or change.
autoRollMonsters: boolean;
autoRollNpcs: boolean;
trackXp: boolean; // off: no XP anywhere, nobody shown as due a level
hpOnLevelUp: HpMethod;
abilityScoreMethod: ScoreMethod;
rollVisibility: RollVisibility;
hideMonsterRolls: boolean; // the DM's monster and NPC rolls stay with the DM until put into play
critRule: CritRule;
ownerId: string;
archivedAt: string | null; // set while the campaign is archived
role: 'DM' | 'PLAYER';
@@ -152,7 +229,46 @@ export interface HitDice {
remaining: number;
}
export interface CharacterSummary {
// A class counter (Rage, Ki, Channel Divinity...): how many uses there are,
// how many are spent, and the rest that gives them back.
export interface ClassResource {
key: string;
name: string;
max: number;
used: number;
remaining: number;
restoresOn: 'SHORT' | 'LONG';
}
// How a character is doing beyond hit points. Reminders for the table: none
// of it changes a number on the sheet.
export interface CharacterCondition {
inspiration: boolean;
exhaustion: number; // 0 to 6
conditions: string[];
}
// Where a character's XP puts them: what the next level takes (null at level
// 20), and whether they have it. Levelling is still the DM's to do.
export interface XpProgress {
nextLevelAt: number | null;
levelUpDue: boolean;
}
// How hard an encounter is for the characters in it (DM only). `xp` is what
// its monsters are worth; `adjustedXp` is that multiplied for their number,
// and is what the rating is judged on.
export interface EncounterDifficulty {
xp: number;
adjustedXp: number;
multiplier: number;
rating: 'TRIVIAL' | 'EASY' | 'MEDIUM' | 'HARD' | 'DEADLY' | null; // null with no party, or nothing rated to fight
thresholds: { easy: number; medium: number; hard: number; deadly: number } | null; // the party's; null with no party
monsters: number; // how many have a challenge rating
unrated: number; // and how many do not, left out of the rest
}
export interface CharacterSummary extends CharacterCondition {
id: string;
campaignId: string;
userId: string | null;
@@ -160,8 +276,10 @@ export interface CharacterSummary {
name: string;
species: Species | null;
characterClass: CharacterClass | null;
subclassName: string | null;
level: number;
xp: number;
xpProgress: XpProgress;
buildStage: BuildStage;
currentHp: number;
tempHp: number;
@@ -173,9 +291,15 @@ export interface CharacterSummary {
cash: number | null;
maxHp: number | null;
armorClass: number | null;
// Passive scores and the spell save DC; null until built (the DC also for a non-caster).
passives: { perception: number; investigation: number; insight: number } | null;
spellSaveDC: number | null;
// What the character rolls with; null until built.
checks: { abilities: Record<Ability, AbilityDetail>; saves: SaveBonuses; skills: SkillBonuses } | null;
// What is left before the next rest; both null until the character is built.
spellSlots: SpellSlots | null; // also null for a character with no spell slots
hitDice: (HitDice & { conModifier: number }) | null; // each die heals its roll plus this
resources: ClassResource[]; // the class counters; empty until built
currentLocationId: string | null;
currentLocationName: string | null;
}
@@ -209,6 +333,9 @@ export interface ActiveGear {
notes: { source: string; note: string }[];
}
export type SaveBonuses = Record<Ability, { proficient: boolean; bonus: number }>;
export type SkillBonuses = Record<string, { ability: Ability; proficient: boolean; expertise: boolean; bonus: number }>;
export interface CharacterSheet {
abilities: Record<Ability, AbilityDetail>;
proficiencyBonus: number;
@@ -217,11 +344,14 @@ export interface CharacterSheet {
speed: number;
hp: { max: number };
hitDice: HitDice;
saves: Record<Ability, { proficient: boolean; bonus: number }>;
skills: Record<string, { ability: Ability; proficient: boolean; expertise: boolean; bonus: number }>;
saves: SaveBonuses;
skills: SkillBonuses;
passivePerception: number;
passiveInvestigation: number;
passiveInsight: number;
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
spellSlots: SpellSlots | null; // null for a character with no spell slots
resources: ClassResource[]; // the class counters, with how many of each are left
gear: ActiveGear;
}
@@ -253,6 +383,8 @@ export interface CharacterWeapon {
equipped: boolean;
level: number;
scaledDamageDice: string; // the dice at this copy's level
attackBonus: number; // what this character adds to the d20 with it
damageBonus: number; // and to its dice
usable: boolean; // false while the character is below this copy's level
weapon: Weapon;
}
@@ -329,7 +461,7 @@ export interface SaveProficiency {
proficient: boolean;
}
export interface CharacterDetail {
export interface CharacterDetail extends CharacterCondition {
id: string;
campaignId: string;
userId: string | null;
@@ -337,11 +469,20 @@ export interface CharacterDetail {
name: string;
species: Species | null;
characterClass: CharacterClass | null;
// The path within the class: its key and name, null until one is chosen;
// what there is to choose from, and the level the class chooses at.
subclass: string | null;
subclassName: string | null;
subclassLevel: number | null;
subclassOptions: SubclassSummary[];
// The 2024 subclass that updates the one followed, if there is one.
subclassUpgrade: SubclassSummary | null;
level: number;
xp: number;
xpProgress: XpProgress;
motivation: string | null;
halfElfBonusAbility1: Ability | null;
halfElfBonusAbility2: Ability | null;
// The species bonuses the player chose, { ability: amount }: a Half-Elf's +1/+1, a Goblin's +2/+1...
speciesBonusChoices: Partial<Record<Ability, number>>;
rolledScores: Record<Ability, number>;
currentHp: number;
tempHp: number;
@@ -353,7 +494,8 @@ export interface CharacterDetail {
// Absent entirely (not null) in the API response for anyone but the
// owning player — a real server-enforced privacy boundary, not a UI hide.
privateNotes?: string | null;
diceMode: DiceMode;
diceMode: DiceMode; // the dice this character rolls with: the campaign's, or their own pick when the campaign leaves it to them
diceModeChoosable: boolean;
currentLocationId: string | null;
currentLocation: Location | null;
gold: number | null;
@@ -488,6 +630,7 @@ export interface CharacterCombatInfo {
level: number;
species: Species | null;
characterClass: CharacterClass | null;
subclassName: string | null;
armorClass: number;
tempHp: number;
deathSaves: DeathSaves;
@@ -495,9 +638,16 @@ export interface CharacterCombatInfo {
speed: number;
initiativeBonus: number;
passivePerception: number;
passiveInvestigation: number;
passiveInsight: number;
proficiencyBonus: number;
abilities: Record<Ability, AbilityDetail>;
saves: Record<Ability, { proficient: boolean; bonus: number }>;
saves: SaveBonuses;
skills: SkillBonuses;
spellSlots: SpellSlots | null;
resources: ClassResource[];
inspiration: boolean;
exhaustion: number;
specialAbilities: string | null;
gear: ActiveGear;
weapons: CombatWeapon[]; // the equipped ones: a weapon has to be in hand to be used
@@ -523,6 +673,8 @@ export interface EncounterParticipant {
maxHp: number | null;
hpPercent?: number | null;
conditions: string[];
// Rounds left on the conditions that wear off by themselves.
conditionRounds: Record<string, number>;
isMonster: boolean;
kind: 'CHARACTER' | 'MONSTER' | 'NPC';
// Out of the fight (a dead character, or a monster or NPC at 0 HP) and so
@@ -554,6 +706,8 @@ export interface Encounter {
location?: Pick<Location, 'id' | 'name' | 'description'> | null;
archivedAt: string | null; // archived encounters only reach the DM's list
sortOrder: number; // place in the campaign's own order, lowest first
xpAwarded?: number | null; // the XP handed out for it, in all; null until awarded
difficulty?: EncounterDifficulty; // DM only
createdAt: string;
participants?: EncounterParticipant[]; // a player's list leaves out the fallen
// The dead characters in the scene. Players do not get their cards, only
@@ -681,12 +835,13 @@ export interface QuickStatsSpell {
// A character at a glance, with everything it holds. The pack, the spell
// list and the purse are only sent to the owning player and the DM
// (`restricted` is true for anyone else).
export interface QuickStats {
export interface QuickStats extends CharacterCondition {
id: string;
name: string;
level: number;
species: Species | null;
characterClass: CharacterClass | null;
subclassName: string | null;
currentHp: number;
maxHp: number;
tempHp: number;
@@ -698,8 +853,13 @@ export interface QuickStats {
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
spellSlots: SpellSlots | null;
hitDice: HitDice;
resources: ClassResource[];
passivePerception: number;
passiveInvestigation: number;
passiveInsight: number;
abilities: Record<Ability, AbilityDetail>;
saves: SaveBonuses;
skills: SkillBonuses;
specialAbilities: string | null;
gear: ActiveGear;
weapons: QuickStatsWeapon[];
@@ -724,6 +884,12 @@ export interface LevelUpPlan {
fromLevel: number;
toLevel: number;
maxHp: { from: number; to: number };
// Set when a subclass has to be picked on the way to this level: the level
// the class chooses at, and each option with everything it would write.
subclass: { level: number; options: { key: string; name: string; summary: string; features: { name: string; text: string }[] }[] } | null;
// In a campaign without subclasses: the one taken without asking at this
// level, its features already among `features`. Null otherwise.
subclassTaken: string | null;
// What the class gains. An upgrade rewrites the line that has its name.
features: { name: string; text: string; upgrade: boolean }[];
boon: BoonOption[] | null; // null at a level that grants no Boon
+65 -18
View File
@@ -3,13 +3,17 @@ import { useCampaign, useCampaignCharacters, usePatchCharacterWeapon, usePatchIn
import { useAuth } from '../auth/useAuth';
import { useLastCampaignId } from '../lib/lastCampaign';
import { closeCharacterPanel, openCharacterPanel, useCharacterPanelId } from '../lib/characterPanel';
import { CLASS_LABELS, SPECIES_LABELS, fmtMod, formatPurse, spellLevelLabel } from '../lib/format';
import { SPECIES_LABELS, classLabel, fmtMod, formatPurse, spellLevelLabel } from '../lib/format';
import { effectLabel, effectsSummary } from '../lib/gear';
import { speciesIcon } from '../lib/speciesIcons';
import { Spinner, ErrorBanner } from './Spinner';
import { HpBar } from './HpBar';
import { DeathSaves } from './DeathSaves';
import { SpellSlotTracker } from './SpellSlots';
import { SheetNumbers } from './SheetNumbers';
import { CharacterState } from './CharacterState';
import { Rollable } from './Rollable';
import { withBonus } from '../lib/diceRoller';
import { useState } from 'react';
import { extractErrorMessage } from '../lib/format';
import type { ActiveGear } from '../api/types';
@@ -73,7 +77,7 @@ function PanelBody({ characterId, isDM }: { characterId: string; isDM: boolean }
<span aria-hidden="true">{speciesIcon(data.species)}</span> {data.name}
</h3>
<div className="combatant-sub">
{data.species && SPECIES_LABELS[data.species]} {data.characterClass && CLASS_LABELS[data.characterClass]} · Level {data.level}
{data.species && SPECIES_LABELS[data.species]} {classLabel(data.characterClass, data.subclassName)} · Level {data.level}
</div>
</div>
<Link to={`/characters/${data.id}`} className="btn" onClick={closeCharacterPanel}>
@@ -113,6 +117,37 @@ function PanelBody({ characterId, isDM }: { characterId: string; isDM: boolean }
))}
</div>
<div className="mt-sm">
<CharacterState
characterId={characterId}
name={data.name}
inspiration={data.inspiration}
exhaustion={data.exhaustion}
conditions={data.conditions}
resources={data.resources}
canUse={!data.restricted}
isDM={isDM}
onError={(err) => setEquipError(extractErrorMessage(err))}
/>
</div>
<details className="card mt-md" open>
<summary className="card-title">Saves and skills</summary>
<SheetNumbers
who={data.name}
saves={data.saves}
skills={data.skills}
passives={{ perception: data.passivePerception, investigation: data.passiveInvestigation, insight: data.passiveInsight }}
/>
</details>
{data.specialAbilities && (
<details className="card mt-md" open>
<summary className="card-title">Special Abilities</summary>
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{data.specialAbilities}</p>
</details>
)}
{(data.gear.effects.length > 0 || data.gear.notes.length > 0) && (
<div className="card mt-md">
<div className="card-title">From equipped gear</div>
@@ -125,7 +160,10 @@ function PanelBody({ characterId, isDM }: { characterId: string; isDM: boolean }
<div className="card mt-md">
<div className="card-title">Spellcasting</div>
<p style={{ margin: 0 }}>
Save DC {data.spellcasting.saveDC} · Attack {fmtMod(data.spellcasting.attackBonus)}
Save DC {data.spellcasting.saveDC} ·{' '}
<Rollable label={`${data.name}: spell attack`} modifier={data.spellcasting.attackBonus}>
Attack {fmtMod(data.spellcasting.attackBonus)}
</Rollable>
</p>
{/* The character's own player and the DM spend slots here; the DM can also give them back. */}
{data.spellSlots && (
@@ -158,7 +196,15 @@ function PanelBody({ characterId, isDM }: { characterId: string; isDM: boolean }
{w.quantity > 1 && <span className="combatant-sub"> ×{w.quantity}</span>}
<Flags equipped={w.equipped} usable={w.usable} level={w.level} />
<div className="combatant-sub">
Lv {w.level} · attack {fmtMod(w.attackBonus)} · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
Lv {w.level} ·{' '}
<Rollable label={`${data.name}: ${w.name} attack`} modifier={w.attackBonus}>
attack {fmtMod(w.attackBonus)}
</Rollable>{' '}
·{' '}
<Rollable label={`${data.name}: ${w.name} damage`} dice={withBonus(w.damageDice, w.damageBonus)}>
{w.damageDice} {fmtMod(w.damageBonus)}
</Rollable>{' '}
{w.damageType}
</div>
{effectsSummary(w.effects, w.effectNote) && <div className="combatant-sub">✦ {effectsSummary(w.effects, w.effectNote)}</div>}
</div>
@@ -181,8 +227,8 @@ function PanelBody({ characterId, isDM }: { characterId: string; isDM: boolean }
<p className="combatant-sub mt-md">Their pack, spells and purse are their own business.</p>
) : (
<>
<div className="card mt-md">
<div className="card-title">Inventory · {data.inventory.length}</div>
<details className="card mt-md" open>
<summary className="card-title">Inventory · {data.inventory.length}</summary>
{data.inventory.length === 0 && <p className="combatant-sub" style={{ margin: 0 }}>Nothing carried.</p>}
{data.inventory.map((item) => (
<div key={item.inventoryItemId} className="reward-row">
@@ -209,7 +255,7 @@ function PanelBody({ characterId, isDM }: { characterId: string; isDM: boolean }
</div>
</div>
))}
</div>
</details>
<div className="card mt-md">
<div className="card-title">
@@ -219,32 +265,33 @@ function PanelBody({ characterId, isDM }: { characterId: string; isDM: boolean }
</div>
{data.spells.length > 0 && (
<div className="card mt-md">
<div className="card-title">Spells · {data.spells.length}</div>
<details className="card mt-md" open>
<summary className="card-title">Spells · {data.spells.length}</summary>
{data.spells.map((s) => (
<div key={s.characterSpellId} className="reward-row">
<strong>{s.name}</strong>
<span className="combatant-sub">
{' '}
{spellLevelLabel(s.level)}
{s.damageDice && ` · ${s.damageDice}`}
{s.damageDice && (
<>
{' · '}
<Rollable label={`${data.name}: ${s.name}`} dice={s.damageDice}>
{s.damageDice}
</Rollable>
</>
)}
</span>
{s.prepared && <span className="badge badge-live" style={{ marginLeft: 6 }}>prepared</span>}
{!s.usable && <span className="badge badge-hostile" style={{ marginLeft: 6 }}>needs level {s.level}</span>}
{s.description && <div className="combatant-sub">{s.description}</div>}
</div>
))}
</div>
</details>
)}
</>
)}
{data.specialAbilities && (
<div className="card mt-md">
<div className="card-title">Special Abilities</div>
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{data.specialAbilities}</p>
</div>
)}
</div>
);
}
@@ -275,7 +322,7 @@ export function CharacterPanel() {
<button
className="fab-quickstats"
onClick={() => openCharacterPanel(fallback.id)}
title={isDM ? 'Characters: stats and inventory' : 'Your character: stats and inventory'}
title={isDM ? 'Characters: stats, skills and inventory' : 'Your character: stats, skills and inventory'}
aria-label="Open the character panel"
>
🧙
+257
View File
@@ -0,0 +1,257 @@
import { useState } from 'react';
import { useCharacterState, useResources } from '../api/queries';
import { CONDITIONS, MAX_EXHAUSTION, conditionRule, exhaustionRule } from '../lib/conditions';
import { NumberInput } from './NumberInput';
import type { ClassResource } from '../api/types';
// A counter with more uses than this is a pool (Lay on Hands, a high-level
// monk's Ki): it gets a number to spend in place of a row of pips.
const MOST_PIPS = 10;
// One condition, with its rule a hover away. `onRemove` adds the ✕.
export function ConditionChip({ name, note, onRemove }: { name: string; note?: string; onRemove?: () => void }) {
return (
<span className="condition-chip" title={conditionRule(name) || undefined}>
{name}
{note && <span className="condition-note">{note}</span>}
{onRemove && (
<button aria-label={`Remove ${name}`} onClick={onRemove}>
✕
</button>
)}
</span>
);
}
// The "+ Condition" menu: every condition not already in force.
export function ConditionPicker({ who, current, onAdd }: { who: string; current: string[]; onAdd: (condition: string) => void }) {
return (
<select aria-label={`Add a condition to ${who}`} value="" onChange={(e) => e.target.value && onAdd(e.target.value)} style={{ width: 'auto' }}>
<option value="">+ Condition</option>
{CONDITIONS.filter((c) => !current.includes(c)).map((c) => (
<option key={c} value={c} title={conditionRule(c)}>
{c}
</option>
))}
</select>
);
}
// Inspiration and exhaustion as badges, for places that only show them.
export function StateBadges({ inspiration, exhaustion }: { inspiration: boolean; exhaustion: number }) {
return (
<>
{inspiration && (
<span className="badge badge-live" title="Inspiration: spend it for advantage on one attack, check or save">
✨ Inspired
</span>
)}
{exhaustion > 0 && (
<span className="badge badge-hostile" title={exhaustionRule(exhaustion)}>
Exhaustion {exhaustion}
</span>
)}
</>
);
}
// The class counters (Rage, Ki, Channel Divinity...): pips that are filled
// while a use is left, and "Use" to spend one. `canUse` is the character's own
// player or the DM. Getting a use back is the DM's alone: for them each pip
// sets how many are left, and "All back" refills the lot.
export function ResourceTracker({
characterId,
name,
resources,
canUse,
isDM,
onError,
}: {
characterId: string;
name: string;
resources: ClassResource[];
canUse: boolean;
isDM: boolean;
onError?: (err: unknown) => void;
}) {
const mutation = useResources(characterId);
const send = (action: Parameters<typeof mutation.mutate>[0]) => mutation.mutate(action, { onError });
// What is typed into a pool's box, by counter.
const [amounts, setAmounts] = useState<Record<string, string>>({});
if (resources.length === 0) return null;
return (
<div className="resource-tracker">
{isDM && resources.some((r) => r.used > 0) && (
<div className="flex-between">
<span />
<button className="btn btn-ghost" onClick={() => send({ action: 'restore' })} disabled={mutation.isPending} title={`Give ${name} every use back`}>
All back
</button>
</div>
)}
{resources.map((r) => {
const rest = r.restoresOn === 'SHORT' ? 'short or long rest' : 'long rest';
const isPool = r.max > MOST_PIPS;
const amount = Math.max(1, Math.floor(Number(amounts[r.key])) || 1);
return (
<div key={r.key} className="slot-row">
<span className="resource-name" title={`Back on a ${rest}`}>
{r.name}
<span className="resource-rest">{r.restoresOn === 'SHORT' ? 'short rest' : 'long rest'}</span>
</span>
{isPool ? (
<span className="slot-pips" />
) : (
<span className="slot-pips" role="group" aria-label={`${r.remaining} of ${r.max} ${r.name} left`}>
{Array.from({ length: r.max }, (_, i) => i + 1).map((n) => {
const className = `slot-pip${n <= r.remaining ? ' is-filled' : ''}`;
const left = n === r.remaining ? n - 1 : n;
return isDM ? (
<button
key={n}
type="button"
className={className}
aria-label={`Set ${r.name} left to ${left}`}
disabled={mutation.isPending}
onClick={() => send({ action: 'set', key: r.key, used: r.max - left })}
/>
) : (
<span key={n} className={className} />
);
})}
</span>
)}
<span className="mono slot-count">
{r.remaining}/{r.max}
</span>
{canUse && isPool && (
<NumberInput
min={1}
max={r.max}
placeholder="1"
aria-label={`How much ${r.name} to spend`}
value={amounts[r.key] ?? ''}
onChange={(e) => setAmounts({ ...amounts, [r.key]: e.target.value })}
style={{ width: 60 }}
/>
)}
{canUse && (
<button
className="btn"
onClick={() => send({ action: 'use', key: r.key, amount: isPool ? amount : 1 })}
disabled={r.remaining === 0 || mutation.isPending}
title={r.remaining === 0 ? `None left. Back on a ${rest}` : `Spend ${isPool ? amount : 'one'}. Back on a ${rest}`}
aria-label={`Use ${r.name}`}
>
Use
</button>
)}
{isDM && isPool && (
<button
className="btn btn-ghost"
onClick={() => send({ action: 'set', key: r.key, used: Math.max(0, r.used - amount) })}
disabled={r.used === 0 || mutation.isPending}
title={`Give ${amount} back`}
aria-label={`Give ${r.name} back`}
>
+ Back
</button>
)}
</div>
);
})}
{canUse && !isDM && <p className="combatant-sub" style={{ margin: '0.3rem 0 0' }}>Once used, a rest or the DM gives it back.</p>}
</div>
);
}
// How a character is doing beyond hit points: inspiration, exhaustion,
// conditions and class counters. The DM gives and takes away all of it. The
// character's own player spends inspiration and counters. Everyone else reads.
// With `conditions` left out that row is not drawn (an encounter card has its
// own).
export function CharacterState({
characterId,
name,
inspiration,
exhaustion,
conditions,
resources,
canUse,
isDM,
onError,
}: {
characterId: string;
name: string;
inspiration: boolean;
exhaustion: number;
conditions?: string[];
resources: ClassResource[];
canUse: boolean;
isDM: boolean;
onError?: (err: unknown) => void;
}) {
const state = useCharacterState(characterId);
const set = (data: Parameters<typeof state.mutate>[0]) => state.mutate(data, { onError });
return (
<div className="character-state">
<div className="flex-row flex-wrap gap-sm">
{isDM ? (
<button
className={`btn btn-target${inspiration ? ' is-lit' : ''}`}
aria-pressed={inspiration}
disabled={state.isPending}
title={inspiration ? `Take ${name}'s inspiration away` : `Give ${name} inspiration: advantage on one attack, check or save of their choice`}
onClick={() => set({ inspiration: !inspiration })}
>
✨ {inspiration ? 'Inspired' : 'Give inspiration'}
</button>
) : inspiration ? (
<>
<StateBadges inspiration exhaustion={0} />
{canUse && (
<button className="btn btn-target" disabled={state.isPending} title="Spend it for advantage on one attack, check or save" onClick={() => set({ inspiration: false })}>
Spend
</button>
)}
</>
) : (
<span className="combatant-sub">No inspiration</span>
)}
{isDM ? (
<label className="flex-row gap-sm" title={exhaustionRule(exhaustion)}>
Exhaustion
<select
value={exhaustion}
disabled={state.isPending}
onChange={(e) => set({ exhaustion: Number(e.target.value) })}
style={{ width: 'auto' }}
>
{Array.from({ length: MAX_EXHAUSTION + 1 }, (_, level) => (
<option key={level} value={level}>
{level === 0 ? 'None' : `Level ${level}`}
</option>
))}
</select>
</label>
) : (
<StateBadges inspiration={false} exhaustion={exhaustion} />
)}
</div>
{exhaustion > 0 && <p className="combatant-sub" style={{ margin: '0.3rem 0 0', whiteSpace: 'pre-wrap' }}>{exhaustionRule(exhaustion)}</p>}
{conditions && (conditions.length > 0 || isDM) && (
<div className="flex-row flex-wrap gap-sm mt-sm">
{conditions.map((c) => (
<ConditionChip key={c} name={c} onRemove={isDM ? () => set({ conditions: conditions.filter((x) => x !== c) }) : undefined} />
))}
{isDM && <ConditionPicker who={name} current={conditions} onAdd={(c) => set({ conditions: [...conditions, c] })} />}
</div>
)}
<ResourceTracker characterId={characterId} name={name} resources={resources} canUse={canUse} isDM={isDM} onError={onError} />
</div>
);
}
+87 -25
View File
@@ -1,43 +1,88 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { fmtMod } from '../lib/format';
import { onRollRequest, parseDice, rollD20, rollDie, type RollMode, type RollRequest } from '../lib/diceRoller';
const DICE = [4, 6, 8, 10, 12, 20, 100];
const MAX_DICE = 20;
const HISTORY_LENGTH = 8;
const MODES: { mode: RollMode; label: string; title: string }[] = [
{ mode: 'DISADVANTAGE', label: 'Disadv.', title: 'Disadvantage: roll two d20s and keep the lower' },
{ mode: 'NORMAL', label: 'Normal', title: 'One d20' },
{ mode: 'ADVANTAGE', label: 'Adv.', title: 'Advantage: roll two d20s and keep the higher' },
];
interface Roll {
id: number;
sides: number;
label?: string; // what it was for, when it was asked for from a sheet
notation: string; // "1d20+3", "1d8+1d6+2"
dice: number[]; // every die rolled, in order
modifier: number;
dice: number[];
total: number;
}
// One fair roll of an n-sided die. Uses the browser's cryptographic random
// source, discarding values that would make some faces slightly likelier.
function rollDie(sides: number): number {
const limit = Math.floor(0x100000000 / sides) * sides;
const buffer = new Uint32Array(1);
do {
crypto.getRandomValues(buffer);
} while (buffer[0] >= limit);
return (buffer[0] % sides) + 1;
// The d20 that counted, for a roll of a single d20 (a natural 20 or 1 means something).
natural: number | null;
mode: RollMode; // how a single d20 was rolled
}
function notation(count: number, sides: number, modifier: number): string {
return `${count}d${sides}${modifier ? fmtMod(modifier) : ''}`;
}
// A table-side dice roller, reachable from every page. These rolls are for
// the player's own use at the table: they are not sent to the server and do
// not feed the character builder, whose dice stay locked in server-side.
// A d20 plus a bonus, with advantage or disadvantage as chosen.
function d20Roll(modifier: number, mode: RollMode, label?: string): Omit<Roll, 'id'> {
const { kept, dice } = rollD20(mode);
return { label, notation: notation(1, 20, modifier), dice, modifier, total: kept + modifier, natural: kept, mode };
}
// What a request from a sheet comes to; null for dice that cannot be read.
function answer(request: RollRequest, mode: RollMode): Omit<Roll, 'id'> | null {
if (request.kind === 'check') return d20Roll(request.modifier, mode, request.label);
const parsed = parseDice(request.expression);
if (!parsed) return null;
const dice: number[] = [];
let total = parsed.flat;
for (const term of parsed.dice) {
for (let i = 0; i < term.count; i++) {
const die = rollDie(term.sides);
dice.push(die);
total += term.sign * die;
}
}
return { label: request.label, notation: request.expression.replace(/\s+/g, ''), dice, modifier: parsed.flat, total, natural: null, mode: 'NORMAL' };
}
// A table-side dice roller, reachable from every page. It rolls what is set
// up here, and whatever is clicked on a sheet or an encounter card (a skill,
// a save, an attack, damage: lib/diceRoller.ts), with the bonus already in.
// These rolls are for the table: they are not sent to the server and do not
// feed the character builder, whose dice stay locked in server-side.
export function DiceRoller() {
const [open, setOpen] = useState(false);
const [sides, setSides] = useState(20);
const [count, setCount] = useState(1);
const [modifier, setModifier] = useState(0);
const [mode, setMode] = useState<RollMode>('NORMAL');
const [history, setHistory] = useState<Roll[]>([]);
const push = (roll: Omit<Roll, 'id'>) => setHistory((prev) => [{ ...roll, id: (prev[0]?.id ?? 0) + 1 }, ...prev].slice(0, HISTORY_LENGTH));
// A roll asked for from elsewhere on the page: rolled, and shown. It is
// rolled the way the roller is set at that moment.
const currentMode = useRef(mode);
useEffect(() => {
currentMode.current = mode;
});
useEffect(
() =>
onRollRequest((request) => {
const roll = answer(request, currentMode.current);
if (!roll) return;
push(roll);
setOpen(true);
}),
[]
);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false);
@@ -46,15 +91,16 @@ export function DiceRoller() {
}, [open]);
const latest = history[0];
const singleD20 = sides === 20 && count === 1;
function roll() {
if (singleD20) return push(d20Roll(modifier, mode));
const dice = Array.from({ length: count }, () => rollDie(sides));
const total = dice.reduce((sum, d) => sum + d, 0) + modifier;
setHistory((prev) => [{ id: (prev[0]?.id ?? 0) + 1, sides, modifier, dice, total }, ...prev].slice(0, HISTORY_LENGTH));
push({ notation: notation(count, sides, modifier), dice, modifier, total: dice.reduce((sum, d) => sum + d, 0) + modifier, natural: null, mode: 'NORMAL' });
}
// The natural 20 and natural 1 only mean something on a single d20.
const natural = latest && latest.sides === 20 && latest.dice.length === 1 ? latest.dice[0] : null;
const natural = latest?.natural ?? null;
const modeNote = (r: Roll) => (r.mode === 'ADVANTAGE' ? 'advantage' : r.mode === 'DISADVANTAGE' ? 'disadvantage' : '');
return (
<>
@@ -113,29 +159,44 @@ export function DiceRoller() {
</label>
</div>
{/* For any single d20: one rolled here, and every check, save and attack clicked on a sheet. */}
<div className="dice-choices mt-sm" role="group" aria-label="How a d20 is rolled">
{MODES.map((m) => (
<button key={m.mode} className={`die-btn${m.mode === mode ? ' is-selected' : ''}`} aria-pressed={m.mode === mode} title={m.title} onClick={() => setMode(m.mode)}>
{m.label}
</button>
))}
</div>
<button className="btn btn-primary btn-block btn-lg mt-sm" onClick={roll}>
🎲 Roll {notation(count, sides, modifier)}
{singleD20 && mode !== 'NORMAL' && ` (${modeNote({ mode } as Roll)})`}
</button>
<div className="dice-result" aria-live="polite">
{latest ? (
<>
{latest.label && <div className="dice-label roll-label">{latest.label}</div>}
{/* Keyed on the roll so the pop animation replays every time, even on a repeat result. */}
<div key={latest.id} className={`roll-result animate${natural === 20 ? ' is-crit' : natural === 1 ? ' is-fumble' : ''}`}>
{latest.total}
</div>
{natural === 20 && <div className="dice-callout">Natural 20!</div>}
{natural === 1 && <div className="dice-callout is-fumble">Natural 1</div>}
{(latest.dice.length > 1 || latest.modifier !== 0) && (
{(latest.dice.length > 1 || latest.modifier !== 0 || latest.label) && (
<div className="dice-breakdown mono">
{latest.dice.join(' + ')}
{latest.natural != null && latest.dice.length > 1
? `${latest.dice.join(' / ')} → ${latest.natural} (${modeNote(latest)})`
: latest.natural != null
? latest.natural
: `${latest.notation}: ${latest.dice.join(', ')}`}
{latest.modifier !== 0 && ` ${latest.modifier > 0 ? '+' : '−'} ${Math.abs(latest.modifier)}`} = {latest.total}
</div>
)}
</>
) : (
<p className="subtitle" style={{ margin: 0 }}>
Pick a die and roll.
Pick a die and roll, or click a skill, save or attack on a sheet.
</p>
)}
</div>
@@ -151,7 +212,8 @@ export function DiceRoller() {
{history.slice(1).map((r) => (
<div key={r.id} className="flex-between dice-history-row">
<span>
{notation(r.dice.length, r.sides, r.modifier)}
{r.label ? `${r.label} · ` : ''}
{r.notation}
{r.dice.length > 1 && <span className="dice-history-dice"> ({r.dice.join(', ')})</span>}
</span>
<strong className="mono">{r.total}</strong>
+175
View File
@@ -0,0 +1,175 @@
import { useState } from 'react';
import { Modal } from './Modal';
import { NumberInput } from './NumberInput';
import { ABILITY_LABELS, SKILL_LABELS, fmtMod } from '../lib/format';
import { rollD20, type RollMode } from '../lib/diceRoller';
import type { Ability, AbilityDetail, SaveBonuses, SkillBonuses } from '../api/types';
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
// What the modal needs to know about each character who might roll.
export interface Checker {
id: string;
name: string;
abilities: Record<Ability, AbilityDetail>;
saves: SaveBonuses;
skills: SkillBonuses;
}
interface Result {
id: string;
name: string;
dice: number[];
kept: number;
bonus: number;
total: number;
}
// A check is "SKILL:STEALTH", "SAVE:DEX" or "ABILITY:STR".
function describe(check: string): string {
const [kind, key] = check.split(':');
if (kind === 'SKILL') return SKILL_LABELS[key];
return `${ABILITY_LABELS[key]} ${kind === 'SAVE' ? 'save' : 'check'}`;
}
function bonusFor(c: Checker, check: string): number {
const [kind, key] = check.split(':');
if (kind === 'SKILL') return c.skills[key].bonus;
if (kind === 'SAVE') return c.saves[key as Ability].bonus;
return c.abilities[key as Ability].modifier;
}
// The DM calling for a roll from several characters at once: everyone makes
// a Stealth check, or a Dexterity save against a fireball. Pick the roll and
// who makes it; each gets their own d20 with their own bonus, and with a DC
// the list says who made it. Rolled in the browser, like every table roll.
export function GroupCheckModal({ characters, onClose }: { characters: Checker[]; onClose: () => void }) {
const [check, setCheck] = useState('SKILL:PERCEPTION');
const [dc, setDc] = useState('');
const [mode, setMode] = useState<RollMode>('NORMAL');
const [left, setLeft] = useState<Set<string>>(new Set()); // who sits this one out
const [results, setResults] = useState<{ check: string; dc: number | null; rows: Result[] } | null>(null);
const rolling = characters.filter((c) => !left.has(c.id));
const target = dc === '' ? null : Number(dc);
function roll() {
setResults({
check,
dc: target,
rows: rolling.map((c) => {
const { kept, dice } = rollD20(mode);
const bonus = bonusFor(c, check);
return { id: c.id, name: c.name, dice, kept, bonus, total: kept + bonus };
}),
});
}
const passed = results && results.dc != null ? results.rows.filter((r) => r.total >= results.dc!).length : 0;
return (
<Modal title="Call for a roll" subtitle="One d20 each, with each character's own bonus" onClose={onClose}>
<div className="flex-row flex-wrap gap-sm">
<select aria-label="What to roll" value={check} onChange={(e) => setCheck(e.target.value)} style={{ flex: 1, minWidth: 180 }}>
<optgroup label="Skills">
{Object.keys(SKILL_LABELS).map((skill) => (
<option key={skill} value={`SKILL:${skill}`}>
{SKILL_LABELS[skill]}
</option>
))}
</optgroup>
<optgroup label="Saving throws">
{ABILITIES.map((a) => (
<option key={a} value={`SAVE:${a}`}>
{ABILITY_LABELS[a]} save
</option>
))}
</optgroup>
<optgroup label="Ability checks">
{ABILITIES.map((a) => (
<option key={a} value={`ABILITY:${a}`}>
{ABILITY_LABELS[a]} check
</option>
))}
</optgroup>
</select>
<NumberInput
min={1}
max={40}
placeholder="DC"
title="The number to reach. Leave empty to just see the totals."
aria-label="Difficulty class"
value={dc}
onChange={(e) => setDc(e.target.value)}
style={{ width: 70 }}
/>
<select aria-label="How the d20s are rolled" value={mode} onChange={(e) => setMode(e.target.value as RollMode)} style={{ width: 'auto' }}>
<option value="NORMAL">Normal</option>
<option value="ADVANTAGE">Advantage</option>
<option value="DISADVANTAGE">Disadvantage</option>
</select>
</div>
<div className="level-spells mt-sm">
{characters.map((c) => (
<label key={c.id} className="checkbox-row level-choice">
<input
type="checkbox"
checked={!left.has(c.id)}
onChange={(e) => {
const next = new Set(left);
if (e.target.checked) next.delete(c.id);
else next.add(c.id);
setLeft(next);
}}
/>
<span>
{c.name} <span className="combatant-sub mono">{fmtMod(bonusFor(c, check))}</span>
</span>
</label>
))}
</div>
<button className="btn btn-primary btn-block mt-sm" onClick={roll} disabled={rolling.length === 0}>
🎲 Roll {describe(check)} for {rolling.length}
</button>
{results && (
<div className="mt-md" aria-live="polite">
<div className="section-label">
{describe(results.check)}
{results.dc != null && ` · DC ${results.dc} · ${passed} of ${results.rows.length} made it`}
</div>
{results.rows.map((r) => {
const made = results.dc == null ? null : r.total >= results.dc;
return (
<div key={r.id} className="detail-row">
<span>
{r.name}{' '}
<span className="combatant-sub mono">
{r.dice.length > 1 ? `${r.dice.join(' / ')} → ${r.kept}` : r.kept} {r.bonus >= 0 ? '+' : '−'} {Math.abs(r.bonus)}
{r.kept === 20 && ' · natural 20'}
{r.kept === 1 && ' · natural 1'}
</span>
</span>
<span>
<strong className="mono">{r.total}</strong>
{made != null && (
<span className={`badge ${made ? 'badge-live' : 'badge-hostile'}`} style={{ marginLeft: 6 }}>
{made ? 'made it' : 'failed'}
</span>
)}
</span>
</div>
);
})}
{results.dc != null && (
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>
A group check succeeds when at least half make it{passed * 2 >= results.rows.length ? ': this one does.' : ': this one does not.'}
</p>
)}
</div>
)}
</Modal>
);
}
+38 -7
View File
@@ -25,12 +25,13 @@ function allowance(spells: NonNullable<LevelUpPlan['spells']>): string {
}
// The levelling-up checklist for one character, one level at a time: the
// level, HP, the class's new special abilities (and a Boon where the level
// grants one), new spells and fresh slots, and the free weapon upgrade. The
// level, HP, the class's new special abilities (with a subclass or a Boon
// where the level calls for one), new spells and fresh slots, and the free weapon upgrade. The
// site does every step; the DM makes the choices and says yes.
export function LevelUpModal({ character, campaignSpells, onClose }: { character: CharacterDetail; campaignSpells: Spell[]; onClose: () => void }) {
const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true);
const levelUp = useLevelUp(character.id);
const [subclass, setSubclass] = useState('');
const [boon, setBoon] = useState('');
// undefined until picked: the equipped weapon is offered first.
const [weaponChoice, setWeaponChoice] = useState<string | undefined>(undefined);
@@ -51,6 +52,8 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
? campaignSpells.filter((s) => !known.has(s.id) && s.level <= plan.spells!.maxSpellLevel).sort((a, b) => a.level - b.level || a.name.localeCompare(b.name))
: [];
const pickedBoon = plan.boon?.find((b) => b.key === boon);
const pickedSubclass = plan.subclass?.options.find((option) => option.key === subclass);
const owed = plan.subclass && !subclass ? 'Choose a subclass first' : plan.boon && !boon ? 'Choose a Boon first' : null;
function toggleSpell(id: string) {
setSpellIds((current) => {
@@ -63,7 +66,7 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
async function handleLevelUp() {
setError(null);
try {
await levelUp.mutateAsync({ boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
await levelUp.mutateAsync({ subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
onClose();
} catch (err) {
setError(extractErrorMessage(err));
@@ -87,7 +90,35 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
</Step>
<Step n={3} title="Special abilities">
{plan.features.length === 0 && !plan.boon && (
{plan.subclass && (
<div role="radiogroup" aria-label="Subclass" style={{ marginBottom: '0.6rem' }}>
<p style={{ margin: '0 0 0.3rem' }}>
<strong>Subclass</strong>{' '}
<span className="combatant-sub">
{plan.subclass.level === plan.toLevel
? `chosen at level ${plan.subclass.level}. Choose one:`
: `owed since level ${plan.subclass.level}; everything it has given so far is written now. Choose one:`}
</span>
</p>
{plan.subclass.options.map((option) => (
<label key={option.key} className="checkbox-row level-choice">
<input type="radio" name="subclass" value={option.key} checked={subclass === option.key} onChange={() => setSubclass(option.key)} />
<span>
<strong>{option.name}</strong>
<span className="combatant-sub" style={{ display: 'block' }}>{option.summary}</span>
</span>
</label>
))}
{pickedSubclass?.features.map((feature) => (
<div key={feature.name + feature.text} className="reward-row">
<strong>{feature.name}</strong>
<span className="badge" style={{ marginLeft: 6 }}>{pickedSubclass.name}</span>
<div className="combatant-sub">{feature.text}</div>
</div>
))}
</div>
)}
{plan.features.length === 0 && !plan.boon && !plan.subclass && (
<p className="combatant-sub">
{plan.beyondTables ? 'The class tables stop at level 10. Add anything new to the special abilities by hand.' : 'No new feature at this level.'}
</p>
@@ -118,7 +149,7 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
{pickedBoon?.key === 'HARDY' && <p className="combatant-sub">The Temp HP is given now, and again after every long rest taken with the Rest button.</p>}
</div>
)}
{(plan.features.length > 0 || plan.boon) && (
{(plan.features.length > 0 || plan.boon || plan.subclass) && (
<p className="combatant-sub">Written into Special Abilities for you. Where a line says "choose", edit it there once the player has.</p>
)}
</Step>
@@ -179,8 +210,8 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
</Step>
<div className="modal-foot">
<button className="btn btn-primary" onClick={handleLevelUp} disabled={levelUp.isPending || Boolean(plan.boon && !boon)}>
{levelUp.isPending ? 'Levelling…' : plan.boon && !boon ? 'Choose a Boon first' : `Level up to ${plan.toLevel}`}
<button className="btn btn-primary" onClick={handleLevelUp} disabled={levelUp.isPending || Boolean(owed)}>
{levelUp.isPending ? 'Levelling…' : (owed ?? `Level up to ${plan.toLevel}`)}
</button>
</div>
</Modal>
@@ -0,0 +1,64 @@
import { useState } from 'react';
import { useProgression } from '../api/queries';
import { slotLevelLabel } from '../lib/format';
import { Spinner } from './Spinner';
import type { CharacterClass } from '../api/types';
// The road from level 1 to 20 for a class and subclass: what each level
// gives. With `currentLevel` the levels already reached are muted, the next
// one is marked "next up", and only the road ahead shows until "Show all".
export function ProgressionTimeline({
characterClass,
subclass,
currentLevel,
subclassName,
}: {
characterClass: CharacterClass;
subclass?: string | null;
currentLevel?: number;
subclassName?: string | null;
}) {
const { data: levels, isLoading } = useProgression(characterClass, subclass);
const [showAll, setShowAll] = useState(!currentLevel);
if (isLoading || !levels) return <Spinner label="Reading the road ahead…" />;
const shown = showAll || !currentLevel ? levels : levels.filter((l) => l.level >= currentLevel);
return (
<div className="progression">
<ol className="progression-list">
{shown.map((l) => {
const reached = currentLevel !== undefined && l.level <= currentLevel;
const nextUp = currentLevel !== undefined && l.level === currentLevel + 1;
const empty = l.features.length === 0 && !l.boon && !l.choosesSubclass && !l.unlocksSpellLevel;
return (
<li key={l.level} className={`progression-level${reached ? ' is-reached' : ''}${nextUp ? ' is-next' : ''}`}>
<span className="progression-badge">
{l.level}
{nextUp && <span className="badge badge-live">next up</span>}
</span>
<div className="progression-gains">
{l.features.map((f) => (
<div key={f.name} className="progression-feature" title={f.text}>
<strong>{f.name}</strong>
{f.fromSubclass && <span className="badge">{subclassName || 'subclass'}</span>}
<span className="combatant-sub"> — {f.text}</span>
</div>
))}
{l.boon && <div className="progression-feature">⭐ A Boon of your choice</div>}
{l.choosesSubclass && <div className="progression-feature">🧭 Choose your subclass</div>}
{l.unlocksSpellLevel && <div className="progression-feature">✨ {slotLevelLabel(l.unlocksSpellLevel)}-level spells unlock</div>}
{empty && <div className="combatant-sub">More hit points, and the numbers grow.</div>}
</div>
</li>
);
})}
</ol>
{currentLevel !== undefined && currentLevel > 1 && (
<button type="button" className="btn btn-ghost mt-sm" onClick={() => setShowAll((s) => !s)}>
{showAll ? 'Hide the levels already reached' : 'Show all 20 levels'}
</button>
)}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
import { leadingDice, rollCheck, rollDice } from '../lib/diceRoller';
// A number that rolls when clicked: a d20 plus `modifier` (a check, a save,
// an attack), or the dice in `dice` (damage). The roll opens in the dice
// roller under `label`. Of dice written with more after them ("2d8+3 fire")
// the dice are rolled; text that does not start with dice (a DM may type
// anything) is shown as it is.
export function Rollable({
label,
modifier,
dice,
className = '',
children,
}: {
label: string;
modifier?: number;
dice?: string;
className?: string;
children: ReactNode;
}) {
const readable = dice === undefined ? null : leadingDice(dice);
if (dice !== undefined && !readable) return <span className={className}>{children}</span>;
return (
<button
type="button"
className={`rollable ${className}`}
title={readable ? `Roll ${readable}: ${label}` : `Roll a d20 for ${label}`}
onClick={() => (readable ? rollDice(label, readable) : rollCheck(label, modifier ?? 0))}
>
{children}
</button>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { ABILITY_LABELS, SKILL_LABELS, fmtMod } from '../lib/format';
import type { Ability, SaveBonuses, SkillBonuses } from '../api/types';
import { Rollable } from './Rollable';
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
// Saving throws, skills and passive scores in one compact block: what the DM
// asks for mid-fight. Proficient lines are marked ● and expertise ◆. Used by
// the character panel and the encounter screen, so they always agree. A click
// on a line rolls it in the dice roller, under `who`'s name.
export function SheetNumbers({
who,
saves,
skills,
passives,
}: {
who: string;
saves: SaveBonuses;
skills: SkillBonuses;
passives: { perception: number; investigation: number; insight: number };
}) {
return (
<div className="sheet-numbers">
<div className="section-label">Saving throws</div>
<div className="number-grid is-saves">
{ABILITIES.map((a) => (
<Rollable key={a} label={`${who}: ${ABILITY_LABELS[a]} save`} modifier={saves[a].bonus} className={`number-row${saves[a].proficient ? ' is-proficient' : ''}`}>
<span>
<span className="prof-mark" aria-hidden="true">{saves[a].proficient ? '●' : '○'}</span> {a}
</span>
<b className="mono">{fmtMod(saves[a].bonus)}</b>
</Rollable>
))}
</div>
<div className="section-label">Skills</div>
<div className="number-grid">
{Object.entries(skills).map(([skill, detail]) => (
<Rollable key={skill} label={`${who}: ${SKILL_LABELS[skill]}`} modifier={detail.bonus} className={`number-row${detail.proficient ? ' is-proficient' : ''}`}>
<span>
<span className="prof-mark" aria-hidden="true">{detail.expertise ? '◆' : detail.proficient ? '●' : '○'}</span> {SKILL_LABELS[skill]}{' '}
<span className="number-ability">{detail.ability}</span>
</span>
<b className="mono">{fmtMod(detail.bonus)}</b>
</Rollable>
))}
</div>
<div className="stat-chips mt-sm">
<span className="stat-chip">
<span>Passive Perception</span>
<b>{passives.perception}</b>
</span>
<span className="stat-chip">
<span>Passive Investigation</span>
<b>{passives.investigation}</b>
</span>
<span className="stat-chip">
<span>Passive Insight</span>
<b>{passives.insight}</b>
</span>
</div>
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>● proficient · ◆ expertise · click a line to roll it</p>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useRef } from 'react';
// Single-key shortcuts for a page: { n: () => …, s: () => … }. A key does
// nothing while the cursor is in a field, while a modifier is held, or when
// its entry is undefined (so a page can switch one off by leaving it out).
export function useHotkeys(keys: Record<string, (() => void) | undefined>) {
const latest = useRef(keys);
useEffect(() => {
latest.current = keys;
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey || e.altKey || e.repeat) return;
const target = e.target as HTMLElement | null;
if (target && (target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))) return;
const action = latest.current[e.key.toLowerCase()];
if (!action) return;
e.preventDefault();
action();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
}
+336
View File
@@ -309,6 +309,12 @@ textarea {
font-size: 1rem;
}
.btn-sm {
padding: 0.2rem 0.6rem;
font-size: 0.85rem;
min-width: 2rem;
}
/* Big dice-roll button for the character builder */
.btn-roll {
font-family: var(--font-display);
@@ -397,6 +403,20 @@ input[type='radio'] {
font-size: 0.9rem;
}
/* A group of campaign settings under its own heading. */
.settings-group {
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.6rem 0.9rem 0.9rem;
margin: 0 0 0.9rem;
}
.settings-group legend {
padding: 0 0.4rem;
font-weight: 600;
color: var(--accent-strong);
}
/* ---------------------------------------------------------------------- */
/* Badges / stat blocks */
/* ---------------------------------------------------------------------- */
@@ -1027,6 +1047,61 @@ button.death-pip {
font-weight: 700;
}
/* The road from level 1 to 20 (ProgressionTimeline). */
.progression-list {
list-style: none;
margin: 0;
padding: 0;
border-left: 2px solid var(--border);
}
.progression-level {
display: flex;
gap: 0.75rem;
padding: 0.35rem 0 0.35rem 0.75rem;
position: relative;
}
.progression-level::before {
content: '';
position: absolute;
left: -6px;
top: 0.75rem;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--border);
}
.progression-level.is-reached {
opacity: 0.55;
}
.progression-level.is-reached::before {
background: var(--accent);
}
.progression-level.is-next::before {
background: var(--accent-strong);
box-shadow: 0 0 8px var(--accent-strong);
}
.progression-badge {
font-family: var(--font-heading, inherit);
font-weight: 700;
min-width: 2.2rem;
display: flex;
flex-direction: column;
gap: 0.2rem;
align-items: flex-start;
}
.progression-gains {
flex: 1;
min-width: 0;
font-size: 0.9rem;
}
.progression-feature + .progression-feature {
margin-top: 0.2rem;
}
.progression-feature .badge {
margin-left: 0.4rem;
font-size: 0.7rem;
}
.choice-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
@@ -2062,3 +2137,264 @@ button.slot-pip:disabled {
background: rgba(95, 138, 79, 0.14);
color: var(--success-strong);
}
/* ---------------------------------------------------------------------------
The battle at a glance: the turn strip, the spotlight, saves and skills
--------------------------------------------------------------------------- */
/* Everyone in the scene on one line that stays under the top bar. */
.turn-strip {
position: sticky;
top: 3.6rem;
z-index: 25;
display: flex;
gap: 0.4rem;
overflow-x: auto;
margin: 0 -0.25rem 1rem;
padding: 0.5rem 0.25rem;
background: var(--bg);
border-bottom: 1px solid var(--border-soft);
scrollbar-width: thin;
}
.turn-chip {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 5.5rem;
max-width: 10rem;
padding: 0.3rem 0.6rem;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-inset);
color: var(--text-muted);
font: inherit;
text-align: left;
cursor: pointer;
}
.turn-chip:hover {
border-color: var(--accent);
}
.turn-chip-name {
font-family: var(--font-display);
font-size: 0.78rem;
letter-spacing: 0.03em;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.turn-chip-sub {
font-size: 0.72rem;
color: var(--text-dim);
white-space: nowrap;
}
.turn-chip .hp-bar-track {
margin: 0;
}
.turn-chip.is-mine {
border-color: var(--arcane-strong);
}
.turn-chip.is-targeted {
border-color: var(--danger-strong);
background: var(--danger-soft);
}
.turn-chip.is-active {
border-color: var(--accent-strong);
background: var(--accent-soft);
box-shadow: 0 0 0 1px var(--accent-strong);
}
.turn-chip.is-active .turn-chip-name {
color: var(--accent-strong);
}
.turn-chip.has-acted {
opacity: 0.55;
}
/* The attack bar sits under the strip, not behind it. */
.attack-bar {
top: 7.6rem;
}
/* On a wide screen the side column stays in view, scrolling on its own. */
@media (min-width: 960px) {
.encounter-side {
position: sticky;
top: 7.6rem;
max-height: calc(100vh - 8.4rem);
overflow-y: auto;
scrollbar-width: thin;
padding-right: 2px;
}
}
.spotlight {
border-color: var(--accent);
}
.link-button {
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.link-button:hover {
color: var(--accent-strong);
}
.sheet-numbers .section-label {
margin: 0.6rem 0 0.25rem;
}
.number-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
column-gap: 1rem;
}
.number-grid.is-saves {
grid-template-columns: repeat(auto-fill, minmax(5rem, 1fr));
}
.number-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.4rem;
padding: 0.12rem 0;
font-size: 0.9rem;
color: var(--text-muted);
border-bottom: 1px dotted var(--border-soft);
}
.number-row.is-proficient {
color: var(--text);
}
.number-row.is-proficient b {
color: var(--accent-strong);
}
.prof-mark {
color: var(--accent);
font-size: 0.7rem;
}
.number-ability {
font-size: 0.62rem;
letter-spacing: 0.06em;
color: var(--text-dim);
}
/* A card that folds away: the title is the switch. */
summary.card-title {
cursor: pointer;
list-style: none;
}
summary.card-title::-webkit-details-marker {
display: none;
}
summary.card-title::after {
content: '▾';
color: var(--text-dim);
margin-left: auto;
}
details:not([open]) > summary.card-title {
margin-bottom: 0;
}
details:not([open]) > summary.card-title::after {
content: '▸';
}
@media (max-width: 900px) {
.turn-strip {
top: 2.9rem;
}
.attack-bar {
top: 6.6rem;
}
}
/* In one column the card on turn is already at the top of the list. */
@media (max-width: 959px) {
.spotlight,
.btn-pin {
display: none;
}
}
/* ---------------------------------------------------------------------------
Character state: inspiration, exhaustion, conditions, class counters
--------------------------------------------------------------------------- */
.btn-target.is-lit {
border-color: var(--accent-strong);
color: var(--accent-strong);
background: var(--accent-soft);
}
.condition-chip[title] {
cursor: help;
}
.condition-note {
opacity: 0.8;
font-size: 0.72rem;
}
.resource-tracker {
margin-top: 0.5rem;
}
.resource-name {
display: flex;
flex-direction: column;
min-width: 7.5rem;
max-width: 11rem;
line-height: 1.15;
}
.resource-rest {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
}
.notice-card {
border-color: var(--arcane-strong);
background: rgba(92, 127, 176, 0.12);
margin-bottom: 1rem;
}
/* ---------------------------------------------------------------------------
Numbers that roll when clicked
--------------------------------------------------------------------------- */
.rollable {
background: none;
border: 0;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
text-align: inherit;
cursor: pointer;
text-decoration: underline dotted var(--border);
text-underline-offset: 3px;
}
.rollable:hover,
.rollable:focus-visible {
color: var(--accent-strong);
text-decoration-color: var(--accent);
}
/* A whole row or chip that rolls keeps its own shape. */
.rollable.number-row {
width: 100%;
text-decoration: none;
border-bottom: 1px dotted var(--border-soft);
}
.rollable.number-row:hover {
background: var(--accent-soft);
}
.rollable:has(> .stat-chip) {
text-decoration: none;
display: inline-flex;
}
.rollable:hover > .stat-chip {
border-color: var(--accent);
}
.rollable.stat-sub {
display: block;
width: 100%;
text-align: center;
}
.roll-label {
text-align: center;
}
+3
View File
@@ -16,6 +16,7 @@ export const CLASS_SPELLCASTING_ABILITY: Record<CharacterClass, Ability | null>
WARLOCK: 'CHA',
DRUID: 'WIS',
MONK: null,
ARTIFICER: 'INT',
};
export const CLASS_HIT_DIE: Record<CharacterClass, number> = {
@@ -31,6 +32,7 @@ export const CLASS_HIT_DIE: Record<CharacterClass, number> = {
WARLOCK: 8,
DRUID: 8,
MONK: 8,
ARTIFICER: 8,
};
export const CLASS_SAVE_PROFICIENCIES: Record<CharacterClass, Ability[]> = {
@@ -46,4 +48,5 @@ export const CLASS_SAVE_PROFICIENCIES: Record<CharacterClass, Ability[]> = {
WARLOCK: ['WIS', 'CHA'],
DRUID: ['INT', 'WIS'],
MONK: ['STR', 'DEX'],
ARTIFICER: ['CON', 'INT'],
};
+47
View File
@@ -0,0 +1,47 @@
// The conditions a combatant can be under, each with its rule in a sentence
// or two. They are reminders for the table: the site changes no number for
// them. "Concentrating" is not a condition in the rules, but it is tracked the
// same way.
export const CONDITION_RULES: Record<string, string> = {
Blinded: 'Cannot see: fails any check that needs sight. Attacks against them have advantage; their own attacks have disadvantage.',
Charmed: 'Cannot attack the charmer or target them with anything harmful. The charmer has advantage on social checks with them.',
Deafened: 'Cannot hear: fails any check that needs hearing.',
Frightened: 'Disadvantage on ability checks and attacks while the source of the fear is in sight, and cannot willingly move closer to it.',
Grappled: 'Speed is 0. Ends if the grappler is incapacitated or the two are forced apart.',
Incapacitated: 'Cannot take actions or reactions.',
Invisible: 'Cannot be seen without magic or a special sense. Attacks against them have disadvantage; their own attacks have advantage.',
Paralyzed: 'Incapacitated; cannot move or speak. Fails Strength and Dexterity saves. Attacks against them have advantage, and a hit from within 5 ft is a critical hit.',
Petrified: 'Turned to stone: incapacitated, cannot move or speak, unaware. Attacks against them have advantage; fails Strength and Dexterity saves; resists all damage; immune to poison and disease.',
Poisoned: 'Disadvantage on attack rolls and ability checks.',
Prone: 'Can only crawl, and standing up costs half their speed. Disadvantage on their attacks. Attacks from within 5 ft have advantage against them; from further away, disadvantage.',
Restrained: 'Speed is 0. Attacks against them have advantage; their own have disadvantage. Disadvantage on Dexterity saves.',
Stunned: 'Incapacitated; cannot move and can barely speak. Fails Strength and Dexterity saves. Attacks against them have advantage.',
Unconscious: 'Incapacitated; cannot move or speak, unaware, drops what they hold and falls prone. Fails Strength and Dexterity saves. Attacks against them have advantage, and a hit from within 5 ft is a critical hit.',
Concentrating: 'Holding a spell. Taking damage calls for a Constitution save, DC 10 or half the damage if that is higher; a failure ends the spell. So does casting another concentration spell, or being incapacitated.',
};
export const CONDITIONS = Object.keys(CONDITION_RULES);
// What a condition does; empty for one the DM typed in by another name.
export function conditionRule(name: string): string {
return CONDITION_RULES[name] ?? '';
}
// Exhaustion has six levels, each adding to the ones before it.
export const EXHAUSTION_LEVELS = [
'Disadvantage on ability checks.',
'Speed halved.',
'Disadvantage on attack rolls and saving throws.',
'Hit point maximum halved.',
'Speed reduced to 0.',
'Death.',
];
export const MAX_EXHAUSTION = EXHAUSTION_LEVELS.length;
// Everything a level of exhaustion means, one effect per line.
export function exhaustionRule(level: number): string {
if (level <= 0) return 'Not exhausted.';
return EXHAUSTION_LEVELS.slice(0, level)
.map((effect, i) => `${i + 1}: ${effect}`)
.join('\n');
}
+102
View File
@@ -0,0 +1,102 @@
// Rolls asked for from elsewhere on the page: a skill on a sheet, an attack
// on an encounter card. Kept outside React, like the character panel's
// (lib/characterPanel.ts), so anything can ask without being wired to the
// layout. The dice roller picks each request up, rolls it and shows it. The
// rolls themselves never leave the browser.
// A d20 with a bonus (a check, a save, an attack), or a dice expression (damage).
export type RollRequest = { id: number; label: string } & ({ kind: 'check'; modifier: number } | { kind: 'dice'; expression: string });
let nextId = 1;
const listeners = new Set<(request: RollRequest) => void>();
function ask(request: RollRequest) {
listeners.forEach((listener) => listener(request));
}
// The dice roller listens here; returns the way to stop listening.
export function onRollRequest(listener: (request: RollRequest) => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
// Rolls a d20 and adds `modifier`, under a label such as "Aria: Stealth".
export function rollCheck(label: string, modifier: number) {
ask({ id: nextId++, label, kind: 'check', modifier });
}
// Rolls a dice expression such as "1d8+3", under a label such as "Aria: Longsword damage".
export function rollDice(label: string, expression: string) {
ask({ id: nextId++, label, kind: 'dice', expression });
}
// One fair roll of an n-sided die. Uses the browser's cryptographic random
// source, discarding values that would make some faces slightly likelier.
export function rollDie(sides: number): number {
const limit = Math.floor(0x100000000 / sides) * sides;
const buffer = new Uint32Array(1);
do {
crypto.getRandomValues(buffer);
} while (buffer[0] >= limit);
return (buffer[0] % sides) + 1;
}
export interface ParsedDice {
dice: { count: number; sides: number; sign: 1 | -1 }[];
flat: number;
}
// Reads the dice expressions the app writes: "1d8", "2d6", "1d6+1d8",
// "2d4+2", "1d8 - 1". Null for anything else (free text is allowed where a
// DM types dice), and for something too big to be a real roll.
export function parseDice(expression: string): ParsedDice | null {
const terms = expression
.replace(/\s+/g, '')
.replace(/[−–]/g, '-')
.toLowerCase()
.replace(/-/g, '+-')
.split('+')
.filter(Boolean);
if (terms.length === 0) return null;
const parsed: ParsedDice = { dice: [], flat: 0 };
for (const term of terms) {
const die = /^(-?)(\d*)d(\d+)$/.exec(term);
if (die) {
const count = Number(die[2]) || 1;
const sides = Number(die[3]);
if (count > 100 || sides < 2 || sides > 1000) return null;
parsed.dice.push({ count, sides, sign: die[1] ? -1 : 1 });
} else if (/^-?\d+$/.test(term)) {
parsed.flat += Number(term);
} else {
return null;
}
}
return parsed.dice.length > 0 ? parsed : null;
}
// The dice at the start of a line of text that says more: "2d8+3" from
// "2d8+3 fire". Null when it does not start with dice.
export function leadingDice(text: string): string | null {
const match = /^\s*((?:\d*d\d+|\d+)(?:\s*[+\-−–]\s*(?:\d*d\d+|\d+))*)/i.exec(text);
return match && parseDice(match[1]) ? match[1].replace(/\s+/g, '') : null;
}
// A dice expression from its parts: "1d8" and +3 make "1d8+3".
export function withBonus(dice: string, bonus: number): string {
return bonus === 0 ? dice : `${dice}${bonus > 0 ? '+' : '-'}${Math.abs(bonus)}`;
}
export type RollMode = 'NORMAL' | 'ADVANTAGE' | 'DISADVANTAGE';
// A d20 as the mode has it: one die, or two with the higher or lower kept.
export function rollD20(mode: RollMode): { kept: number; dice: number[] } {
if (mode === 'NORMAL') {
const die = rollDie(20);
return { kept: die, dice: [die] };
}
const dice = [rollDie(20), rollDie(20)];
return { kept: mode === 'ADVANTAGE' ? Math.max(...dice) : Math.min(...dice), dice };
}
+46
View File
@@ -11,6 +11,39 @@ export const SPECIES_LABELS: Record<string, string> = {
TIEFLING: 'Tiefling',
HALF_ORC: 'Half-Orc',
ORC: 'Orc',
AASIMAR: 'Aasimar',
GOLIATH: 'Goliath',
AARAKOCRA: 'Aarakocra',
BUGBEAR: 'Bugbear',
CENTAUR: 'Centaur',
CHANGELING: 'Changeling',
DEEP_GNOME: 'Deep Gnome',
DUERGAR: 'Duergar',
ELADRIN: 'Eladrin',
FAIRY: 'Fairy',
FIRBOLG: 'Firbolg',
AIR_GENASI: 'Air Genasi',
EARTH_GENASI: 'Earth Genasi',
FIRE_GENASI: 'Fire Genasi',
WATER_GENASI: 'Water Genasi',
GITHYANKI: 'Githyanki',
GITHZERAI: 'Githzerai',
GOBLIN: 'Goblin',
HARENGON: 'Harengon',
HOBGOBLIN: 'Hobgoblin',
KENKU: 'Kenku',
KOBOLD: 'Kobold',
LIZARDFOLK: 'Lizardfolk',
MINOTAUR: 'Minotaur',
SEA_ELF: 'Sea Elf',
SHADAR_KAI: 'Shadar-kai',
SHIFTER: 'Shifter',
TABAXI: 'Tabaxi',
TORTLE: 'Tortle',
TRITON: 'Triton',
YUAN_TI: 'Yuan-ti',
OWLIN: 'Owlin',
SATYR: 'Satyr',
};
export const CLASS_LABELS: Record<string, string> = {
@@ -26,6 +59,7 @@ export const CLASS_LABELS: Record<string, string> = {
WARLOCK: 'Warlock',
DRUID: 'Druid',
MONK: 'Monk',
ARTIFICER: 'Artificer',
};
export const SKILL_LABELS: Record<string, string> = {
@@ -58,6 +92,18 @@ export const ABILITY_LABELS: Record<string, string> = {
CHA: 'Charisma',
};
// "37 / 300 XP": what a character has and what the next level takes.
export function xpLabel(xp: number, progress: { nextLevelAt: number | null }): string {
return progress.nextLevelAt == null ? `${xp.toLocaleString()} XP` : `${xp.toLocaleString()} / ${progress.nextLevelAt.toLocaleString()} XP`;
}
// "Fighter" or, with a subclass chosen, "Fighter (Champion)".
export function classLabel(characterClass: string | null, subclassName?: string | null): string {
if (!characterClass) return '';
const label = CLASS_LABELS[characterClass] ?? characterClass;
return subclassName ? `${label} (${subclassName})` : label;
}
export function fmtMod(mod: number): string {
return mod >= 0 ? `+${mod}` : `${mod}`;
}
+173 -1
View File
@@ -16,6 +16,39 @@ export const SPECIES_SPEED: Record<Species, number> = {
TIEFLING: 30,
HALF_ORC: 30,
ORC: 30,
AASIMAR: 30,
GOLIATH: 35,
AARAKOCRA: 30,
BUGBEAR: 30,
CENTAUR: 40,
CHANGELING: 30,
DEEP_GNOME: 30,
DUERGAR: 30,
ELADRIN: 30,
FAIRY: 30,
FIRBOLG: 30,
AIR_GENASI: 35,
EARTH_GENASI: 30,
FIRE_GENASI: 30,
WATER_GENASI: 30,
GITHYANKI: 30,
GITHZERAI: 30,
GOBLIN: 30,
HARENGON: 30,
HOBGOBLIN: 30,
KENKU: 30,
KOBOLD: 30,
LIZARDFOLK: 30,
MINOTAUR: 30,
SEA_ELF: 30,
SHADAR_KAI: 30,
SHIFTER: 30,
TABAXI: 30,
TORTLE: 30,
TRITON: 30,
YUAN_TI: 30,
OWLIN: 30,
SATYR: 35,
};
export const SPECIES_GUIDE: Record<Species, { blurb: string; goodFor: string }> = {
@@ -59,6 +92,138 @@ export const SPECIES_GUIDE: Record<Species, { blurb: string; goodFor: string }>
blurb: 'Powerful and relentless, raised to survive.',
goodFor: 'Strong (+2 Strength) and hardy (+1 Constitution): built for the front line.',
},
AASIMAR: {
blurb: 'Touched by the heavens, carrying a spark of celestial light.',
goodFor: 'You choose your bonuses (+2/+1 or +1/+1/+1). Healing hands and a radiant transformation suit Paladins, Clerics and Warlocks.',
},
GOLIATH: {
blurb: 'Towering mountain folk descended from giants, who measure themselves against every peak.',
goodFor: 'You choose your bonuses (+2/+1 or +1/+1/+1), and you are quick on your feet (35 ft). A great front-liner.',
},
AARAKOCRA: {
blurb: 'Bird folk of the high winds, at home in open sky.',
goodFor: 'You choose your bonuses, and you can fly from level 1. Superb for archers and casters who like to stay out of reach.',
},
BUGBEAR: {
blurb: 'Hulking, stealthy goblinoids who strike from the shadows.',
goodFor: 'You choose your bonuses. Long reach and a big opening strike suit Rogues, Rangers and Fighters.',
},
CENTAUR: {
blurb: 'Fey wanderers, half humanoid and half horse, swift across open country.',
goodFor: 'You choose your bonuses, and you are the fastest species (40 ft). Built for charging warriors and scouts.',
},
CHANGELING: {
blurb: 'Shapeshifters who can wear any face, and sometimes forget their own.',
goodFor: 'You choose your bonuses. Perfect for spies, tricksters and Charisma casters.',
},
DEEP_GNOME: {
blurb: 'Quiet gnomes of the deep caverns, hard to find and harder to enchant.',
goodFor: 'You choose your bonuses. Sneaky and resistant to magic: a fine Rogue, Ranger or Wizard.',
},
DUERGAR: {
blurb: 'Grim dwarves of the Underdark, hardened by long servitude.',
goodFor: 'You choose your bonuses. Tough, hard to control, and able to grow huge: built for melee.',
},
ELADRIN: {
blurb: 'Elves of the Feywild whose moods shift like the seasons.',
goodFor: 'You choose your bonuses. Teleporting at will makes for a slippery caster or duelist.',
},
FAIRY: {
blurb: 'Tiny fey with wings, mischief and a sparkle of magic.',
goodFor: 'You choose your bonuses and fly from level 1. A natural for Druids, Bards and Sorcerers.',
},
FIRBOLG: {
blurb: 'Gentle forest giants-kin who guard the wild places.',
goodFor: 'You choose your bonuses. Quiet magic and a knack for vanishing suit Druids and Clerics.',
},
AIR_GENASI: {
blurb: 'Born of the elemental air: restless, quick and light.',
goodFor: 'You choose your bonuses, and you move fast (35 ft). Good for Monks, Rogues and casters.',
},
EARTH_GENASI: {
blurb: 'Born of the elemental earth: steady, solid and patient.',
goodFor: 'You choose your bonuses. Sturdy and stealthy: front-liners and Rangers.',
},
FIRE_GENASI: {
blurb: 'Born of the elemental fire: passionate and quick to anger.',
goodFor: 'You choose your bonuses. Built-in fire magic suits Sorcerers, Warlocks and Wizards.',
},
WATER_GENASI: {
blurb: 'Born of the elemental water: calm one moment, crashing the next.',
goodFor: 'You choose your bonuses, and you breathe and swim underwater. Great for sea campaigns.',
},
GITHYANKI: {
blurb: 'Warrior raiders from the Astral Sea, with silver swords and iron will.',
goodFor: 'You choose your bonuses. Flexible skills and a free teleport suit Fighters, Paladins and Eldritch Knights.',
},
GITHZERAI: {
blurb: 'Ascetic monks of Limbo who bend chaos with pure thought.',
goodFor: 'You choose your bonuses. Calm minds and a free Shield suit Monks and Wizards.',
},
GOBLIN: {
blurb: 'Small, scrappy and fearless survivors.',
goodFor: 'You choose your bonuses. Hide and slip away every turn: a natural Rogue.',
},
HARENGON: {
blurb: 'Rabbit folk of the Feywild, quick of foot and quicker to act.',
goodFor: 'You choose your bonuses. Always first in initiative: great for any class.',
},
HOBGOBLIN: {
blurb: 'Disciplined goblinoids who prize order and teamwork.',
goodFor: 'You choose your bonuses. Built to fight shoulder to shoulder: Fighters and Paladins.',
},
KENKU: {
blurb: 'Wingless raven folk with perfect memories and borrowed voices.',
goodFor: 'You choose your bonuses. Skilled and observant: Rogues and Rangers.',
},
KOBOLD: {
blurb: 'Small, clever dragon-kin who win by working together.',
goodFor: 'You choose your bonuses. Your rallying cry makes the whole party hit more often.',
},
LIZARDFOLK: {
blurb: 'Cold-blooded, practical hunters of the swamps.',
goodFor: 'You choose your bonuses. Natural armor and a healing bite make you hard to kill.',
},
MINOTAUR: {
blurb: 'Horned, powerful and never lost in a maze.',
goodFor: 'You choose your bonuses. Charging and shoving: a natural Barbarian or Fighter.',
},
SEA_ELF: {
blurb: 'Elves of the deep ocean, as at home among the waves as on land.',
goodFor: 'You choose your bonuses. A must for ocean adventures.',
},
SHADAR_KAI: {
blurb: 'Elves bound to the Shadowfell and its somber queen.',
goodFor: 'You choose your bonuses. Teleporting and shrugging off damage suits any melee class.',
},
SHIFTER: {
blurb: 'Descendants of lycanthropes, with a beast just under the skin.',
goodFor: 'You choose your bonuses. Bursts of toughness and speed suit melee classes.',
},
TABAXI: {
blurb: 'Curious cat folk who chase stories and shiny things.',
goodFor: 'You choose your bonuses. Bursts of speed and sharp senses: Rogues, Monks and Rangers.',
},
TORTLE: {
blurb: 'Turtle folk who carry their homes on their backs.',
goodFor: 'You choose your bonuses. AC 17 from level 1, whatever your Dexterity: superb for casters and Monks… and anyone.',
},
TRITON: {
blurb: 'Proud guardians of the ocean depths.',
goodFor: 'You choose your bonuses. Water magic and swimming for sea campaigns.',
},
YUAN_TI: {
blurb: 'Serpent-blooded schemers with cold eyes and colder plans.',
goodFor: 'You choose your bonuses. Advantage on every save against spells is hard to beat.',
},
OWLIN: {
blurb: 'Owl folk, silent on the wing and sharp-eyed in the dark.',
goodFor: 'You choose your bonuses. Flight and stealth from level 1: scouts and Wizards.',
},
SATYR: {
blurb: 'Merry fey of song and wine, quick-footed and hard to enchant.',
goodFor: 'You choose your bonuses, move quickly (35 ft) and resist magic. A natural Bard.',
},
};
export type Toughness = 'Fragile' | 'Average' | 'Sturdy' | 'Very tough';
@@ -151,6 +316,13 @@ export const CLASS_GUIDE: Record<
skillCount: 2,
difficulty: 'Medium',
},
ARTIFICER: {
blurb: 'An inventor who makes magic with tools, gadgets and infused gear.',
playsLike: 'Support and versatility: buff the party’s gear, cast handy spells, and build a cannon, armor or a steel companion.',
keyAbilities: ['INT', 'CON'],
skillCount: 2,
difficulty: 'Tricky',
},
};
export function hitDieToughness(hitDie: number): Toughness {
@@ -226,4 +398,4 @@ export const STEP_GUIDE = {
EQUIPMENT: 'What you set out with. Your DM decides what is on offer here, and can hand out more as you play.',
} as const;
export const STEP_NAMES = ['Species', 'Class', 'Motivation', 'Scores', 'Skills', 'Abilities', 'Background', 'Appearance', 'Equipment'] as const;
export const STEP_NAMES = ['Species', 'Class', 'Subclass', 'Motivation', 'Scores', 'Skills', 'Abilities', 'Background', 'Appearance', 'Equipment'] as const;
+93 -4
View File
@@ -14,13 +14,102 @@ export const SPECIES_ABILITY_BONUSES: Record<Species, Partial<Record<Ability, nu
TIEFLING: { INT: 1, CHA: 2 },
HALF_ORC: { STR: 2, CON: 1 },
ORC: { STR: 2, CON: 1 },
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: {},
};
export function speciesBonusFor(species: Species | null, ability: Ability, halfElfBonusAbilities: Ability[] = []): number {
// The bonuses a player chooses on top of the fixed ones: one of `patterns`
// (+2 and +1, or +1 three times...), each to a different ability, never one
// in `exclude`.
export interface FlexibleBonus {
patterns: number[][];
exclude?: Ability[];
}
export const SPECIES_FLEXIBLE_BONUS: Partial<Record<Species, FlexibleBonus>> = {
HALF_ELF: { patterns: [[1, 1]], exclude: ['CHA'] },
AASIMAR: { patterns: [[2, 1], [1, 1, 1]] },
GOLIATH: { patterns: [[2, 1], [1, 1, 1]] },
AARAKOCRA: { patterns: [[2, 1], [1, 1, 1]] },
BUGBEAR: { patterns: [[2, 1], [1, 1, 1]] },
CENTAUR: { patterns: [[2, 1], [1, 1, 1]] },
CHANGELING: { patterns: [[2, 1], [1, 1, 1]] },
DEEP_GNOME: { patterns: [[2, 1], [1, 1, 1]] },
DUERGAR: { patterns: [[2, 1], [1, 1, 1]] },
ELADRIN: { patterns: [[2, 1], [1, 1, 1]] },
FAIRY: { patterns: [[2, 1], [1, 1, 1]] },
FIRBOLG: { patterns: [[2, 1], [1, 1, 1]] },
AIR_GENASI: { patterns: [[2, 1], [1, 1, 1]] },
EARTH_GENASI: { patterns: [[2, 1], [1, 1, 1]] },
FIRE_GENASI: { patterns: [[2, 1], [1, 1, 1]] },
WATER_GENASI: { patterns: [[2, 1], [1, 1, 1]] },
GITHYANKI: { patterns: [[2, 1], [1, 1, 1]] },
GITHZERAI: { patterns: [[2, 1], [1, 1, 1]] },
GOBLIN: { patterns: [[2, 1], [1, 1, 1]] },
HARENGON: { patterns: [[2, 1], [1, 1, 1]] },
HOBGOBLIN: { patterns: [[2, 1], [1, 1, 1]] },
KENKU: { patterns: [[2, 1], [1, 1, 1]] },
KOBOLD: { patterns: [[2, 1], [1, 1, 1]] },
LIZARDFOLK: { patterns: [[2, 1], [1, 1, 1]] },
MINOTAUR: { patterns: [[2, 1], [1, 1, 1]] },
SEA_ELF: { patterns: [[2, 1], [1, 1, 1]] },
SHADAR_KAI: { patterns: [[2, 1], [1, 1, 1]] },
SHIFTER: { patterns: [[2, 1], [1, 1, 1]] },
TABAXI: { patterns: [[2, 1], [1, 1, 1]] },
TORTLE: { patterns: [[2, 1], [1, 1, 1]] },
TRITON: { patterns: [[2, 1], [1, 1, 1]] },
YUAN_TI: { patterns: [[2, 1], [1, 1, 1]] },
OWLIN: { patterns: [[2, 1], [1, 1, 1]] },
SATYR: { patterns: [[2, 1], [1, 1, 1]] },
};
// A character's chosen bonuses, as { ability: amount }.
export type BonusChoices = Partial<Record<Ability, number>>;
export function speciesBonusFor(species: Species | null, ability: Ability, choices: BonusChoices = {}): number {
if (!species) return 0;
let bonus = SPECIES_ABILITY_BONUSES[species][ability] || 0;
if (species === 'HALF_ELF' && halfElfBonusAbilities.includes(ability)) bonus += 1;
return bonus;
return (SPECIES_ABILITY_BONUSES[species][ability] || 0) + (SPECIES_FLEXIBLE_BONUS[species] ? choices[ability] || 0 : 0);
}
// Whether a set of choices is complete and allowed for the species (mirrors
// speciesBonusProblem on the server).
export function bonusChoicesValid(species: Species | null, choices: BonusChoices): boolean {
const flexible = species ? SPECIES_FLEXIBLE_BONUS[species] : undefined;
const entries = Object.entries(choices).filter(([, amount]) => amount);
if (!flexible) return entries.length === 0;
if (entries.some(([ability]) => flexible.exclude?.includes(ability as Ability))) return false;
const amounts = entries.map(([, amount]) => amount as number).sort((a, b) => b - a).join(',');
return flexible.patterns.some((pattern) => [...pattern].sort((a, b) => b - a).join(',') === amounts);
}
export function abilityModifier(score: number): number {
+33
View File
@@ -12,6 +12,39 @@ export const SPECIES_ICONS: Record<Species, string> = {
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: '🐐',
};
export function speciesIcon(species: Species | null): string {
+185 -59
View File
@@ -41,7 +41,7 @@ import { effectsSummary } from '../lib/gear';
import { extractErrorMessage, spellLevelLabel } from '../lib/format';
import { matchesSearch, type EntryKind, type EntryRef } from '../lib/campaignEntries';
import { speciesIcon } from '../lib/speciesIcons';
import type { CharacterSummary, Encounter, EncounterMode } from '../api/types';
import type { CampaignDiceMode, CharacterSummary, CritRule, Encounter, EncounterMode, HpMethod, RollVisibility, ScoreMethod } from '../api/types';
// How the encounter list can be ordered. "custom" is the campaign's own order,
// which the DM arranges; the rest only change what this browser shows.
@@ -62,6 +62,15 @@ function storedEncounterSort(): EncounterSort {
return stored && stored in ENCOUNTER_SORT_LABELS ? (stored as EncounterSort) : 'custom';
}
// Where a new encounter goes in the campaign's order: the bottom, the top, or
// straight after another one ("after:<id>"). Top or bottom is remembered.
const ENCOUNTER_PLACE_KEY = 'dnd_encounter_place';
const AFTER = 'after:';
function storedEncounterPlace(): string {
return localStorage.getItem(ENCOUNTER_PLACE_KEY) === 'TOP' ? 'TOP' : 'BOTTOM';
}
// `encounters` arrives in campaign order, and the sort is stable, so ties
// (same status, same type) stay in that order.
function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter[] {
@@ -91,7 +100,7 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
</div>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>
{character.species ? `${character.species} ` : ''}
{character.characterClass || (inProgress ? 'Not yet built' : '')} · Level {character.level}
{character.characterClass || (inProgress ? 'Not yet built' : '')}{character.subclassName && ` (${character.subclassName})`} · Level {character.level}
{character.currentLocationName && ` · 📍 ${character.currentLocationName}`}
</p>
</div>
@@ -161,6 +170,7 @@ export function CampaignHomePage() {
const [newCharName, setNewCharName] = useState('');
const [newEncounterName, setNewEncounterName] = useState('');
const [newEncounterMode, setNewEncounterMode] = useState<EncounterMode>('BATTLE');
const [newEncounterPlace, setNewEncounterPlace] = useState(storedEncounterPlace);
const [encounterSort, setEncounterSort] = useState<EncounterSort>(storedEncounterSort);
const [showArchivedEncounters, setShowArchivedEncounters] = useState(false);
const [noteBody, setNoteBody] = useState('');
@@ -173,6 +183,8 @@ export function CampaignHomePage() {
const [deleteConfirmName, setDeleteConfirmName] = useState('');
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
// An encounter picked to go after may have been archived or deleted since.
const encounterPlace = newEncounterPlace.startsWith(AFTER) && !liveEncounters.some((e) => AFTER + e.id === newEncounterPlace) ? 'BOTTOM' : newEncounterPlace;
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
const encountersPage = usePagination(shownEncounters, 10);
@@ -208,6 +220,9 @@ export function CampaignHomePage() {
const encounter = await createEncounter.mutateAsync({
name: newEncounterName || (newEncounterMode === 'QUEST' ? 'New Quest' : 'New Encounter'),
mode: newEncounterMode,
...(encounterPlace.startsWith(AFTER)
? { afterEncounterId: encounterPlace.slice(AFTER.length) }
: { position: encounterPlace === 'TOP' ? ('TOP' as const) : ('BOTTOM' as const) }),
});
navigate(`/encounters/${encounter.id}`);
} catch (err) {
@@ -501,6 +516,28 @@ export function CampaignHomePage() {
<option value="BATTLE">Battle</option>
<option value="QUEST">NPC quest</option>
</select>
{liveEncounters.length > 0 && (
<select
aria-label="Where the new encounter goes in the list"
title="Where it goes in the campaign's order. ▲ and ▼ move it afterwards."
value={encounterPlace}
onChange={(e) => {
setNewEncounterPlace(e.target.value);
if (!e.target.value.startsWith(AFTER)) localStorage.setItem(ENCOUNTER_PLACE_KEY, e.target.value);
}}
style={{ maxWidth: 220 }}
>
<option value="BOTTOM">At the bottom of the list</option>
<option value="TOP">At the top of the list</option>
<optgroup label="Straight after…">
{liveEncounters.map((e) => (
<option key={e.id} value={AFTER + e.id}>
After {e.name}
</option>
))}
</optgroup>
</select>
)}
<button type="submit" className="btn btn-primary" disabled={createEncounter.isPending}>
{newEncounterMode === 'QUEST' ? '📜 Start Quest' : '⚔ Start Encounter'}
</button>
@@ -750,74 +787,163 @@ export function CampaignHomePage() {
startingItemCount: Number(fd.get('startingItemCount')),
startingSpellCount: Number(fd.get('startingSpellCount')),
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
abilityScoreMethod: fd.get('abilityScoreMethod') as ScoreMethod,
showMonsterHp: fd.get('showMonsterHp') === 'on',
subclassesEnabled: fd.get('subclassesEnabled') === 'on',
multiclassEnabled: fd.get('multiclassEnabled') === 'on',
areaAttackAcPenalty: Number(fd.get('areaAttackAcPenalty')),
maxEquipped: Number(fd.get('maxEquipped')),
maxEquippedWeapons: Number(fd.get('maxEquippedWeapons')),
diceMode: fd.get('diceMode') as CampaignDiceMode,
rollVisibility: fd.get('rollVisibility') as RollVisibility,
hideMonsterRolls: fd.get('hideMonsterRolls') === 'on',
autoRollMonsters: fd.get('autoRollMonsters') === 'on',
autoRollNpcs: fd.get('autoRollNpcs') === 'on',
critRule: fd.get('critRule') as CritRule,
trackXp: fd.get('trackXp') === 'on',
hpOnLevelUp: fd.get('hpOnLevelUp') as HpMethod,
});
}}
>
<div className="grid grid-cols-3">
<div className="field">
<label>Starting gold</label>
<NumberInput name="startingGold" min={0} max={1000000} keepValue defaultValue={campaign.startingGold} />
<fieldset className="settings-group">
<legend>Characters</legend>
<div className="grid grid-cols-3">
<div className="field">
<label>Starting gold</label>
<NumberInput name="startingGold" min={0} max={1000000} keepValue defaultValue={campaign.startingGold} />
</div>
<div className="field">
<label>Starting items (weapons + items)</label>
<NumberInput name="startingItemCount" min={0} max={50} keepValue defaultValue={campaign.startingItemCount} />
</div>
<div className="field">
<label>Starting spells</label>
<NumberInput name="startingSpellCount" min={0} max={50} keepValue defaultValue={campaign.startingSpellCount} />
</div>
</div>
<div className="field">
<label>Starting items (weapons + items)</label>
<NumberInput name="startingItemCount" min={0} max={50} keepValue defaultValue={campaign.startingItemCount} />
<label>Ability scores are made by</label>
<select name="abilityScoreMethod" defaultValue={campaign.abilityScoreMethod}>
<option value="ROLL_4D6">Rolling 4d6 and dropping the lowest, six times</option>
<option value="STANDARD_ARRAY">The standard array: 15, 14, 13, 12, 10, 8</option>
<option value="POINT_BUY">Point buy: 27 points, scores 8 to 15</option>
</select>
</div>
<div className="field">
<label>Starting spells</label>
<NumberInput name="startingSpellCount" min={0} max={50} keepValue defaultValue={campaign.startingSpellCount} />
<label className="checkbox-row">
<input type="checkbox" name="skipMotivationRoll" defaultChecked={campaign.skipMotivationRoll} />
Skip the motivation step when building a character
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="subclassesEnabled" defaultChecked={campaign.subclassesEnabled} />
Characters choose a subclass (untick and every class follows one path, with nothing to choose)
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="multiclassEnabled" defaultChecked={campaign.multiclassEnabled} />
Multiclassing: on a level up a character may take a level in another class whose ability requirements they meet
</label>
<label className="flex-row flex-wrap gap-sm mt-sm">
Equipped at once
<NumberInput
name="maxEquipped"
min={1}
max={20}
keepValue
defaultValue={campaign.maxEquipped}
style={{ width: 70 }}
title="How many weapons and items a character can have equipped together. Only equipped gear can be used in an encounter or gives its effects."
/>
things, of which
<NumberInput
name="maxEquippedWeapons"
min={0}
max={20}
keepValue
defaultValue={campaign.maxEquippedWeapons}
style={{ width: 70 }}
title="How many of those can be weapons. A weapon for each hand, like a pair of daggers, is made as one weapon."
/>
<span className="combatant-sub">
can be weapons. Armor and shields are items and count. Lowering these takes nothing off anyone; they are checked when
something is put on.
</span>
</label>
</fieldset>
<fieldset className="settings-group">
<legend>Dice &amp; rolls</legend>
<div className="grid grid-cols-2">
<div className="field">
<label>Dice at this table</label>
<select name="diceMode" defaultValue={campaign.diceMode}>
<option value="BOTH">Both: digital, or type in what a real die shows</option>
<option value="DIGITAL">Digital only: the app rolls</option>
<option value="PHYSICAL">Physical only: real dice, results typed in</option>
</select>
</div>
<div className="field">
<label>Who sees a roll</label>
<select name="rollVisibility" defaultValue={campaign.rollVisibility}>
<option value="ALL">Everyone sees every roll</option>
<option value="OWN">Players see their own; the DM sees all</option>
</select>
</div>
</div>
</div>
<label className="checkbox-row">
<input type="checkbox" name="skipMotivationRoll" defaultChecked={campaign.skipMotivationRoll} />
Skip the motivation step when building a character
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
</label>
<label className="flex-row flex-wrap gap-sm mt-sm">
Area attack AC penalty
<NumberInput
name="areaAttackAcPenalty"
min={0}
max={10}
keepValue
defaultValue={campaign.areaAttackAcPenalty}
style={{ width: 70 }}
title="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 the penalty off."
/>
<span className="combatant-sub">added to each target's AC for every target beyond the first (0 for none)</span>
</label>
<label className="flex-row flex-wrap gap-sm mt-sm">
Equipped at once
<NumberInput
name="maxEquipped"
min={1}
max={20}
keepValue
defaultValue={campaign.maxEquipped}
style={{ width: 70 }}
title="How many weapons and items a character can have equipped together. Only equipped gear can be used in an encounter or gives its effects."
/>
things, of which
<NumberInput
name="maxEquippedWeapons"
min={0}
max={20}
keepValue
defaultValue={campaign.maxEquippedWeapons}
style={{ width: 70 }}
title="How many of those can be weapons. A weapon for each hand, like a pair of daggers, is made as one weapon."
/>
<span className="combatant-sub">
can be weapons. Armor and shields are items and count. Lowering these takes nothing off anyone; they are checked when
something is put on.
</span>
</label>
<label className="checkbox-row">
<input type="checkbox" name="hideMonsterRolls" defaultChecked={campaign.hideMonsterRolls} />
Keep my rolls for monsters and NPCs to myself until they are put into play
</label>
</fieldset>
<fieldset className="settings-group">
<legend>Combat</legend>
<label className="checkbox-row">
<input type="checkbox" name="autoRollMonsters" defaultChecked={campaign.autoRollMonsters} />
Auto-roll monster attacks: on a monster&apos;s turn its target, to-hit and damage are rolled for me to accept or change
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="autoRollNpcs" defaultChecked={campaign.autoRollNpcs} />
Auto-roll NPC attacks the same way (friendly NPCs attack monsters, hostile ones the party)
</label>
<label className="checkbox-row mt-sm">
<input type="checkbox" name="showMonsterHp" defaultChecked={campaign.showMonsterHp} />
Players see the exact hit points of monsters and NPCs in encounters (untick for a health bar only)
</label>
<div className="field mt-sm">
<label>A critical hit</label>
<select name="critRule" defaultValue={campaign.critRule}>
<option value="DOUBLE_DICE">Rolls the damage dice twice (the rule)</option>
<option value="MAX_PLUS_ROLL">Takes the dice at their maximum, plus one roll of them</option>
</select>
</div>
<label className="flex-row flex-wrap gap-sm mt-sm">
Area attack AC penalty
<NumberInput
name="areaAttackAcPenalty"
min={0}
max={10}
keepValue
defaultValue={campaign.areaAttackAcPenalty}
style={{ width: 70 }}
title="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 the penalty off."
/>
<span className="combatant-sub">added to each target&apos;s AC for every target beyond the first (0 for none)</span>
</label>
</fieldset>
<fieldset className="settings-group">
<legend>Progression</legend>
<label className="checkbox-row">
<input type="checkbox" name="trackXp" defaultChecked={campaign.trackXp} />
Track experience points (untick for milestone levelling: no XP, and you decide when the party levels)
</label>
<div className="field mt-sm">
<label>Hit points on a level up</label>
<select name="hpOnLevelUp" defaultValue={campaign.hpOnLevelUp}>
<option value="AVERAGE">The hit die&apos;s average (rounded up) + Constitution</option>
<option value="ROLL">Roll the hit die + Constitution</option>
</select>
</div>
</fieldset>
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
Save Settings
</button>
+364 -63
View File
@@ -6,12 +6,13 @@ import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { InfoTip } from '../components/InfoTip';
import { ABILITY_LABELS, CLASS_LABELS, SKILL_LABELS, SPECIES_LABELS, extractErrorMessage, fmtMod, spellLevelLabel } from '../lib/format';
import { SPECIES_ABILITY_BONUSES, abilityModifier, speciesBonusFor } from '../lib/speciesData';
import { SPECIES_ABILITY_BONUSES, SPECIES_FLEXIBLE_BONUS, abilityModifier, bonusChoicesValid, speciesBonusFor, type BonusChoices } from '../lib/speciesData';
import { CLASS_SPELLCASTING_ABILITY, CLASS_HIT_DIE, CLASS_SAVE_PROFICIENCIES } from '../lib/classData';
import { ABILITY_GUIDE, CLASS_GUIDE, SKILL_GUIDE, SPECIES_GUIDE, SPECIES_SPEED, STEP_GUIDE, STEP_NAMES, hitDieToughness } from '../lib/guide';
import { speciesIcon } from '../lib/speciesIcons';
import { ProgressionTimeline } from '../components/ProgressionTimeline';
import { armorClass, hpMax, passivePerception, proficiencyBonus, saveBonus, spellAttackBonus, spellSaveDC } from '../lib/previewCalc';
import type { Ability, BuildOptions, CharacterClass, DiceMode, NamedAbility, PickGroup, Picks, Species } from '../api/types';
import type { Ability, BuildOptions, CharacterClass, DiceMode, NamedAbility, PickGroup, Picks, ScoreMethod, Species, SubclassSummary } from '../api/types';
interface SpeciesChoices {
type: 'choice' | 'wildcard';
@@ -20,24 +21,31 @@ interface SpeciesChoices {
interface ClassChoices {
type: 'choice' | 'wildcard' | 'reroll';
options: string[];
canReroll?: boolean; // a 7: the Artificer, or one reroll
}
// A rolled score; the standard array and point buy come as scores alone, no dice.
interface AbilityRoll {
dice: number[];
dropped: number;
dropped: number | null;
total: number;
}
interface BuildState {
speciesRoll?: number;
speciesChoices?: SpeciesChoices;
species?: Species;
halfElfBonusAbilities?: Ability[];
halfElfBonusAbilities?: Ability[]; // a build begun before chosen bonuses were general
speciesBonusChoices?: BonusChoices;
classRoll?: number;
classChoices?: ClassChoices;
characterClass?: CharacterClass;
subclassRoll?: number;
subclassSuggestion?: string;
subclass?: string | null;
motivationRoll?: number;
motivationText?: string | null;
abilityRolls?: AbilityRoll[];
abilityAssignments?: Record<Ability, number>;
pointBuy?: Record<Ability, number>;
skillProficiencies?: { skill: string; proficient: boolean; expertise: boolean }[];
motivationChoice?: number;
talents?: string[];
@@ -47,12 +55,16 @@ interface BuildState {
}
const ABILITIES: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
const HALF_ELF_ELIGIBLE: Ability[] = ['STR', 'DEX', 'CON', 'INT', 'WIS'];
// Point buy, as the server checks it (game/buildFlow.js): every score starts
// at 8, and raising it to 15 at most spends from a budget of 27.
const POINT_BUY_BUDGET = 27;
const POINT_BUY_COSTS: Record<number, number> = { 8: 0, 9: 1, 10: 2, 11: 3, 12: 4, 13: 5, 14: 7, 15: 9 };
const ALL_SKILLS = Object.keys(SKILL_LABELS);
// The six steps, in order, matching STEP_NAMES.
// The steps, in order, matching STEP_NAMES.
const STEP_STAGES = [
'ROLLING_SPECIES',
'ROLLING_CLASS',
'CHOOSING_SUBCLASS',
'ROLLING_MOTIVATION',
'ASSIGNING_SCORES',
'ASSIGNING_PROFICIENCIES',
@@ -104,7 +116,8 @@ export function CharacterBuilderPage() {
const patchDiceMode = usePatchDiceMode(characterId);
const [formError, setFormError] = useState<string | null>(null);
const [halfElfTouched, setHalfElfPick] = useState<Ability[] | null>(null);
const [bonusTouched, setBonusPick] = useState<BonusChoices | null>(null);
const [pendingSubclass, setPendingSubclass] = useState<string | null | undefined>(undefined);
// Species and class are picked first and only sent to the server on "Next",
// so a player can click around and watch the preview before committing.
const [pendingSpecies, setPendingSpecies] = useState<Species | null>(null);
@@ -144,7 +157,8 @@ export function CharacterBuilderPage() {
// Until the player touches a step they have come back to, it shows what
// they chose last time.
const halfElfPick = halfElfTouched ?? bs.halfElfBonusAbilities ?? [];
const savedBonuses: BonusChoices = bs.speciesBonusChoices ?? Object.fromEntries((bs.halfElfBonusAbilities ?? []).map((a) => [a, 1]));
const bonusPick = bonusTouched ?? savedBonuses;
const assignments = assignmentsTouched ?? bs.abilityAssignments ?? {};
const motivationPick = pendingMotivation ?? bs.motivationChoice ?? null;
const talents = talentsTouched ?? bs.talents ?? [];
@@ -179,7 +193,8 @@ export function CharacterBuilderPage() {
}
}
const needsDiceModeChoice = stage === 'ROLLING_SPECIES' && !bs.speciesRoll && !diceModeConfirmed;
// Only asked when the campaign leaves the dice to each character.
const needsDiceModeChoice = character.diceModeChoosable && stage === 'ROLLING_SPECIES' && !bs.speciesRoll && !diceModeConfirmed;
// On its own step, the current pick is whatever was clicked, falling back to
// what was chosen before (when the player has come back to change it).
@@ -189,20 +204,25 @@ export function CharacterBuilderPage() {
const classPick = pendingClass ?? bs.characterClass ?? null;
const pickedSpecies = onSpeciesStep && speciesPick && bs.speciesChoices?.options.includes(speciesPick) ? speciesPick : null;
const pickedClass = onClassStep && classPick && bs.classChoices?.options.includes(classPick) ? classPick : null;
const speciesReady = Boolean(pickedSpecies) && (pickedSpecies !== 'HALF_ELF' || halfElfPick.length === 2);
const speciesReady = Boolean(pickedSpecies) && bonusChoicesValid(pickedSpecies, bonusPick);
// What the preview shows: everything already locked in, with the step being
// worked on replaced by whatever is currently picked there.
const previewBs: BuildState = {
...bs,
species: onSpeciesStep ? (pickedSpecies ?? undefined) : bs.species,
halfElfBonusAbilities: onSpeciesStep ? (pickedSpecies === 'HALF_ELF' ? halfElfPick : undefined) : bs.halfElfBonusAbilities,
speciesBonusChoices: onSpeciesStep ? bonusPick : savedBonuses,
characterClass: onClassStep ? (pickedClass ?? undefined) : bs.characterClass,
abilityAssignments: stage === 'ASSIGNING_SCORES' ? undefined : bs.abilityAssignments,
};
// Where Back leads from here, if anywhere.
const stepOrder = STEP_STAGES.filter((s) => s !== 'ROLLING_MOTIVATION' || !campaignData?.campaign.skipMotivationRoll);
// Where Back leads from here, if anywhere. A campaign can leave the
// subclass and the motivation out.
const stepOrder = STEP_STAGES.filter(
(s) =>
(s !== 'ROLLING_MOTIVATION' || !campaignData?.campaign.skipMotivationRoll) &&
(s !== 'CHOOSING_SUBCLASS' || campaignData?.campaign.subclassesEnabled !== false)
);
const stepIndex = stepOrder.indexOf(stage);
const backLabel =
stage === 'CHOOSING_EQUIPMENT' && bs.equipment
@@ -233,7 +253,7 @@ export function CharacterBuilderPage() {
</div>
</div>
<BuildProgress stage={stage} />
<BuildProgress stage={stage} steps={stepOrder} />
{formError && <ErrorBanner message={formError} />}
@@ -260,10 +280,10 @@ export function CharacterBuilderPage() {
<>
{stage === 'ROLLING_SPECIES' && (
<div className="card mt-md text-center">
<div className="card-title">Step 1 — Species</div>
<div className="card-title">Species</div>
<p className="step-intro">{STEP_GUIDE.SPECIES}</p>
{!bs.speciesRoll ? (
<RollControl sides={6} diceMode={diceMode} onRoll={safeRoll} rolling={roll.isPending} label="Roll a d6 to see which species you can choose between." />
<RollControl sides={8} diceMode={diceMode} onRoll={safeRoll} rolling={roll.isPending} label="Roll a d8 to see which family of species you can choose from." />
) : (
<>
<div className="roll-result animate">{bs.speciesRoll}</div>
@@ -273,7 +293,7 @@ export function CharacterBuilderPage() {
Wildcard! Pick any species <InfoTip term="WILDCARD" />
</>
) : (
'Your roll offers these two. Click one to try it on:'
'Your roll offers these. Click one to try it on:'
)}
</p>
<div className="choice-grid">
@@ -286,7 +306,7 @@ export function CharacterBuilderPage() {
title={`${speciesIcon(s)} ${SPECIES_LABELS[s]}`}
selected={pickedSpecies === s}
onSelect={() => {
if (pickedSpecies !== s) setHalfElfPick([]);
if (pickedSpecies !== s) setBonusPick({});
setPendingSpecies(s);
}}
>
@@ -294,46 +314,29 @@ export function CharacterBuilderPage() {
<span>{SPECIES_GUIDE[s].goodFor}</span>
<span className="choice-card-facts">
{bonuses.join(' · ')}
{s === 'HALF_ELF' && ' · +1 to two of your choice'} · Speed {SPECIES_SPEED[s]} ft
{SPECIES_FLEXIBLE_BONUS[s] && `${bonuses.length ? ' · ' : ''}${flexibleLabel(s)}`} · Speed {SPECIES_SPEED[s]} ft
</span>
{options && <span className="choice-card-facts">Grants: {options.speciesTraits[s].map((t) => t.name).join(', ')}</span>}
</ChoiceCard>
);
})}
</div>
{pickedSpecies === 'HALF_ELF' && (
<div className="card mt-md" style={{ textAlign: 'left' }}>
<p style={{ marginTop: 0 }}>
Half-Elves get +2 Charisma, plus +1 to two other abilities of your choice. Pick two:
</p>
<div className="flex-row flex-wrap">
{HALF_ELF_ELIGIBLE.map((a) => (
<label key={a} className="checkbox-row">
<input
type="checkbox"
checked={halfElfPick.includes(a)}
disabled={!halfElfPick.includes(a) && halfElfPick.length >= 2}
onChange={(e) =>
setHalfElfPick(e.target.checked ? [...halfElfPick, a] : halfElfPick.filter((x) => x !== a))
}
/>
{ABILITY_LABELS[a]} <InfoTip text={ABILITY_GUIDE[a].affects} label={`What ${ABILITY_LABELS[a]} affects`} />
</label>
))}
</div>
</div>
{pickedSpecies && SPECIES_FLEXIBLE_BONUS[pickedSpecies] && (
<SpeciesBonusPicker key={pickedSpecies} species={pickedSpecies} value={bonusPick} onChange={setBonusPick} />
)}
<StepFooter
hint={
!pickedSpecies
? 'Nothing is locked in until you press Next.'
: pickedSpecies === 'HALF_ELF' && halfElfPick.length !== 2
? 'Pick two abilities for the Half-Elf bonus.'
: !speciesReady
? `Choose where the ${SPECIES_LABELS[pickedSpecies]} bonuses go.`
: `${SPECIES_LABELS[pickedSpecies]} selected — you can still change your mind.`
}
disabled={!speciesReady || choose.isPending}
onNext={() =>
safeChoose(pickedSpecies === 'HALF_ELF' ? { species: pickedSpecies, halfElfBonusAbilities: halfElfPick } : { species: pickedSpecies })
safeChoose(
pickedSpecies && SPECIES_FLEXIBLE_BONUS[pickedSpecies] ? { species: pickedSpecies, bonusChoices: bonusPick } : { species: pickedSpecies }
)
}
/>
</>
@@ -343,7 +346,7 @@ export function CharacterBuilderPage() {
{stage === 'ROLLING_CLASS' && (
<div className="card mt-md text-center">
<div className="card-title">Step 2 — Class</div>
<div className="card-title">Class</div>
<p className="step-intro">{STEP_GUIDE.CLASS}</p>
{!bs.classRoll || bs.classChoices?.type === 'reroll' ? (
<>
@@ -367,7 +370,9 @@ export function CharacterBuilderPage() {
Wildcard! Pick any class <InfoTip term="WILDCARD" />
</>
) : (
'Your roll offers these two. Click one to try it on:'
bs.classChoices?.canReroll
? 'A 7: the Artificer is yours to take, or you may roll once more (and keep what that gives).'
: 'Your roll offers these. Click one to try it on:'
)}
</p>
<div className="choice-grid">
@@ -398,6 +403,11 @@ export function CharacterBuilderPage() {
);
})}
</div>
{bs.classChoices?.canReroll && (
<div className="mt-md">
<RollControl sides={8} diceMode={diceMode} onRoll={safeRoll} rolling={roll.isPending} label="Or reroll the d8 — the new roll stands." />
</div>
)}
<p className="subtitle" style={{ textAlign: 'left' }}>
Hit die <InfoTip term="HIT_DICE" /> · Best abilities <InfoTip term="KEY_ABILITY" /> · Spellcasting{' '}
<InfoTip term="SPELLCASTING" /> · Saves <InfoTip term="SAVE_PROFICIENCY" />
@@ -416,10 +426,27 @@ export function CharacterBuilderPage() {
</div>
)}
{stage === 'CHOOSING_SUBCLASS' && bs.characterClass && (
<SubclassStep
characterClass={bs.characterClass}
subclasses={options?.subclasses[bs.characterClass] ?? []}
subclassLevel={options?.subclassLevels[bs.characterClass] ?? 3}
roll={bs.subclassRoll}
suggestion={bs.subclassSuggestion}
picked={pendingSubclass !== undefined ? pendingSubclass : (bs.subclass ?? null)}
onPick={setPendingSubclass}
diceMode={diceMode}
onRoll={safeRoll}
rolling={roll.isPending}
submitting={choose.isPending}
onSubmit={(subclass) => safeChoose({ subclass })}
/>
)}
{stage === 'ROLLING_MOTIVATION' && (
<div className="card mt-md text-center">
<div className="card-title">
<span>Step 3 — Motivation</span>
<span>Motivation</span>
{options && (
<button className="btn" onClick={() => setPendingMotivation(randomOf(options.motivations.filter((m) => m.key !== motivationPick)).key)}>
🎲 Surprise me
@@ -452,6 +479,8 @@ export function CharacterBuilderPage() {
<AssignScoresStep
bs={bs}
diceMode={diceMode}
method={campaignData?.campaign.abilityScoreMethod ?? 'ROLL_4D6'}
onPointBuy={(pointBuy) => safeChoose({ pointBuy })}
assignments={assignments}
setAssignments={setAssignments}
onRoll={safeRoll}
@@ -464,7 +493,7 @@ export function CharacterBuilderPage() {
{stage === 'ASSIGNING_PROFICIENCIES' && (
<div className="card mt-md">
<div className="card-title">
Step 5 — Skill Proficiencies <InfoTip term="SKILL_BONUS" />
Skill Proficiencies <InfoTip term="SKILL_BONUS" />
</div>
<p className="step-intro">{STEP_GUIDE.SKILLS}</p>
<p className="subtitle">
@@ -572,7 +601,7 @@ export function CharacterBuilderPage() {
{stage === 'CHOOSING_BACKGROUND' &&
(options ? (
<PickGroupsStep
title="Step 7 — Background"
title="Background"
intro={STEP_GUIDE.BACKGROUND}
groups={options.background}
picks={backgroundPicks}
@@ -587,7 +616,7 @@ export function CharacterBuilderPage() {
{stage === 'CHOOSING_APPEARANCE' &&
(options ? (
<PickGroupsStep
title="Step 8 — Appearance"
title="Appearance"
intro={STEP_GUIDE.APPEARANCE}
groups={options.appearance}
picks={appearancePicks}
@@ -739,7 +768,7 @@ function TalentsStep({
return (
<div className="card mt-md">
<div className="card-title">
<span>Step 6 — Special Abilities</span>
<span>Special Abilities</span>
<button className="btn" onClick={() => setTalents(randomSample(options.talents, need).map((t) => t.key))}>
🎲 Surprise me
</button>
@@ -931,14 +960,13 @@ function RollControl({
);
}
function BuildProgress({ stage }: { stage: string }) {
const steps = STEP_STAGES;
function BuildProgress({ stage, steps }: { stage: string; steps: string[] }) {
const idx = steps.indexOf(stage);
return (
<ol className="build-steps" style={{ listStyle: 'none', padding: 0, margin: 0 }} aria-label="Build steps">
{steps.map((s, i) => (
<li key={s} className={`badge${i < idx ? ' is-done' : i === idx ? ' is-current' : ''}`} aria-current={i === idx ? 'step' : undefined}>
{i < idx ? '✓' : i + 1} {STEP_NAMES[i]}
{i < idx ? '✓' : i + 1} {STEP_NAMES[STEP_STAGES.indexOf(s)]}
</li>
))}
</ol>
@@ -948,6 +976,8 @@ function BuildProgress({ stage }: { stage: string }) {
function AssignScoresStep({
bs,
diceMode,
method,
onPointBuy,
assignments,
setAssignments,
onRoll,
@@ -957,6 +987,8 @@ function AssignScoresStep({
}: {
bs: BuildState;
diceMode: DiceMode;
method: ScoreMethod;
onPointBuy: (scores: Record<Ability, number>) => void;
assignments: Partial<Record<Ability, number>>;
setAssignments: (a: Partial<Record<Ability, number>>) => void;
onRoll: (body?: { abilitySets?: number[][] }) => void;
@@ -964,13 +996,28 @@ function AssignScoresStep({
rolling: boolean;
submitting: boolean;
}) {
if (method === 'POINT_BUY') {
return <PointBuyStep bs={bs} onSubmit={onPointBuy} submitting={submitting} />;
}
if (!bs.abilityRolls && method === 'STANDARD_ARRAY') {
return (
<div className="card mt-md text-center">
<div className="card-title">Ability Scores</div>
<p className="step-intro">{STEP_GUIDE.SCORES}</p>
<p>This campaign uses the standard array: the six scores 15, 14, 13, 12, 10 and 8, for you to place.</p>
<button className="btn btn-primary" onClick={() => onRoll()} disabled={rolling}>
Use the standard array
</button>
</div>
);
}
if (!bs.abilityRolls) {
if (diceMode === 'PHYSICAL') {
return <PhysicalAbilityRollForm onRoll={onRoll} rolling={rolling} />;
}
return (
<div className="card mt-md text-center">
<div className="card-title">Step 4 — Ability Scores</div>
<div className="card-title">Ability Scores</div>
<p className="step-intro">{STEP_GUIDE.SCORES}</p>
<p>
Roll 4d6 (drop the lowest) six times — one set per ability. <InfoTip term="ROLL_4D6" />
@@ -984,7 +1031,7 @@ function AssignScoresStep({
const usedIndexes = new Set(Object.values(assignments));
const species = bs.species ?? null;
const halfElfBonusAbilities = bs.halfElfBonusAbilities || [];
const bonusChoices = bs.speciesBonusChoices ?? {};
const complete = ABILITIES.every((a) => assignments[a] !== undefined) && usedIndexes.size === 6;
const keyAbilities = bs.characterClass ? CLASS_GUIDE[bs.characterClass].keyAbilities : [];
@@ -998,7 +1045,7 @@ function AssignScoresStep({
return (
<div className="card mt-md">
<div className="card-title">Step 4 — Assign Ability Scores</div>
<div className="card-title">Assign Ability Scores</div>
<p className="step-intro">{STEP_GUIDE.SCORES}</p>
<div className="flex-between flex-wrap">
<span className="subtitle">
@@ -1017,7 +1064,7 @@ function AssignScoresStep({
<div className="flex-row flex-wrap gap-sm mt-sm">
{bs.abilityRolls.map((r, i) => (
<span key={i} className="badge" style={usedIndexes.has(i) ? { opacity: 0.4 } : { borderColor: 'var(--accent)' }}>
{r.dice.join('+')} (drop {r.dropped}) = {r.total}
{r.dice.length > 0 ? `${r.dice.join('+')} (drop ${r.dropped}) = ${r.total}` : r.total}
</span>
))}
</div>
@@ -1025,7 +1072,7 @@ function AssignScoresStep({
{ABILITIES.map((ability) => {
const assignedIndex = assignments[ability];
const rolledTotal = assignedIndex !== undefined ? bs.abilityRolls![assignedIndex].total : null;
const bonus = speciesBonusFor(species, ability, halfElfBonusAbilities);
const bonus = speciesBonusFor(species, ability, bonusChoices);
const final = rolledTotal !== null ? rolledTotal + bonus : null;
return (
<div className={`stat-block${keyAbilities.includes(ability) ? ' is-key' : ''}`} key={ability}>
@@ -1076,6 +1123,255 @@ function AssignScoresStep({
);
}
// Point buy: each ability starts at 8 and is raised with points from the
// budget. The scores go straight to their abilities; nothing is rolled.
function PointBuyStep({ bs, onSubmit, submitting }: { bs: BuildState; onSubmit: (scores: Record<Ability, number>) => void; submitting: boolean }) {
const [scores, setScores] = useState<Record<Ability, number>>(() => {
const bought = bs.pointBuy;
return Object.fromEntries(ABILITIES.map((a) => [a, bought?.[a] ?? 8])) as Record<Ability, number>;
});
const spent = ABILITIES.reduce((sum, a) => sum + POINT_BUY_COSTS[scores[a]], 0);
const left = POINT_BUY_BUDGET - spent;
const species = bs.species ?? null;
const keyAbilities = bs.characterClass ? CLASS_GUIDE[bs.characterClass].keyAbilities : [];
const canRaise = (a: Ability) => scores[a] < 15 && POINT_BUY_COSTS[scores[a] + 1] - POINT_BUY_COSTS[scores[a]] <= left;
return (
<div className="card mt-md">
<div className="card-title">
<span>Buy Ability Scores</span>
<span className={`badge${left < 0 ? ' badge-hostile' : ''}`}>
{left} of {POINT_BUY_BUDGET} points left
</span>
</div>
<p className="step-intro">
Every score starts at 8. Raising one costs a point a step up to 13, and two a step for 14 and 15. Spend up to {POINT_BUY_BUDGET}.
</p>
<div className="grid grid-cols-3 mt-md">
{ABILITIES.map((ability) => {
const bonus = speciesBonusFor(species, ability, bs.speciesBonusChoices ?? {});
const final = scores[ability] + bonus;
return (
<div className={`stat-block${keyAbilities.includes(ability) ? ' is-key' : ''}`} key={ability}>
<span className="stat-label">{ABILITY_LABELS[ability]}</span>
<span className="stat-sub">{ABILITY_GUIDE[ability].short}</span>
{keyAbilities.includes(ability) && <span className="key-marker">★ Key for your class</span>}
<div className="flex-row gap-sm" style={{ justifyContent: 'center' }}>
<button
className="btn btn-sm"
aria-label={`Lower ${ABILITY_LABELS[ability]}`}
disabled={scores[ability] <= 8}
onClick={() => setScores({ ...scores, [ability]: scores[ability] - 1 })}
>
−
</button>
<span className="stat-value">{scores[ability]}</span>
<button
className="btn btn-sm"
aria-label={`Raise ${ABILITY_LABELS[ability]}`}
disabled={!canRaise(ability)}
onClick={() => setScores({ ...scores, [ability]: scores[ability] + 1 })}
>
+
</button>
</div>
<span className="stat-sub">
{scores[ability]} {bonus ? `+${bonus}` : ''} = {final} ({fmtMod(abilityModifier(final))})
</span>
</div>
);
})}
</div>
<StepFooter
hint={left > 0 ? `You still have ${left} point${left === 1 ? '' : 's'} to spend (or keep them).` : 'All points spent. Press Next.'}
disabled={left < 0 || submitting}
onNext={() => onSubmit(scores)}
/>
</div>
);
}
// "Choose +2/+1 or +1/+1/+1": a species' flexible bonuses in a few words.
function flexibleLabel(species: Species): string {
const flexible = SPECIES_FLEXIBLE_BONUS[species];
if (!flexible) return '';
return `Choose ${flexible.patterns.map((pattern) => pattern.map((n) => `+${n}`).join('/')).join(' or ')}`;
}
// Where a species' flexible bonuses go: one of its patterns (+2/+1, or
// +1/+1/+1), each amount to a different ability.
function SpeciesBonusPicker({ species, value, onChange }: { species: Species; value: BonusChoices; onChange: (value: BonusChoices) => void }) {
const flexible = SPECIES_FLEXIBLE_BONUS[species]!;
const amountsOf = (choices: BonusChoices) => Object.values(choices).filter(Boolean).sort((a, b) => (b ?? 0) - (a ?? 0)).join(',');
const matching = flexible.patterns.findIndex((pattern) => pattern.join(',') === amountsOf(value));
const [patternIndex, setPatternIndex] = useState(Math.max(0, matching));
const pattern = flexible.patterns[patternIndex];
const eligible = ABILITIES.filter((a) => !flexible.exclude?.includes(a));
// Which ability fills each slot of the pattern, read back from the value.
const slots: (Ability | '')[] = [];
const used = new Set<Ability>();
for (const amount of pattern) {
const found = ABILITIES.find((a) => value[a] === amount && !used.has(a));
if (found) used.add(found);
slots.push(found ?? '');
}
function setSlot(index: number, ability: Ability | '') {
const next = [...slots];
next[index] = ability;
const choices: BonusChoices = {};
next.forEach((a, i) => {
if (a) choices[a] = pattern[i];
});
onChange(choices);
}
return (
<div className="card mt-md" style={{ textAlign: 'left' }}>
<p style={{ marginTop: 0 }}>
{SPECIES_LABELS[species]} choose their own ability bonuses
{flexible.exclude?.length ? ` (besides ${flexible.exclude.map((a) => ABILITY_LABELS[a]).join(', ')})` : ''}.
</p>
{flexible.patterns.length > 1 && (
<div className="flex-row flex-wrap gap-sm">
{flexible.patterns.map((p, i) => (
<label key={i} className="checkbox-row">
<input
type="radio"
name="bonus-pattern"
checked={patternIndex === i}
onChange={() => {
setPatternIndex(i);
onChange({});
}}
/>
{p.map((n) => `+${n}`).join(' / ')}
</label>
))}
</div>
)}
<div className="flex-row flex-wrap gap-sm mt-sm">
{pattern.map((amount, i) => (
<label key={`${patternIndex}-${i}`} className="flex-row gap-sm">
+{amount} to
<select value={slots[i]} onChange={(e) => setSlot(i, e.target.value as Ability | '')} aria-label={`Ability for the +${amount}`}>
<option value="">—</option>
{eligible.map((a) => (
<option key={a} value={a} disabled={slots.includes(a) && slots[i] !== a}>
{ABILITY_LABELS[a]}
</option>
))}
</select>
</label>
))}
</div>
</div>
);
}
// The subclass step: a die with a face per subclass suggests one, and the
// player picks freely, or leaves it until the class chooses at its level.
function SubclassStep({
characterClass,
subclasses,
subclassLevel,
roll,
suggestion,
picked,
onPick,
diceMode,
onRoll,
rolling,
submitting,
onSubmit,
}: {
characterClass: CharacterClass;
subclasses: SubclassSummary[];
subclassLevel: number;
roll?: number;
suggestion?: string;
picked: string | null;
onPick: (key: string | null) => void;
diceMode: DiceMode;
onRoll: (body?: { value?: number }) => void;
rolling: boolean;
submitting: boolean;
onSubmit: (subclass: string | null) => void;
}) {
const [previewing, setPreviewing] = useState<string | null>(null);
const pickedName = subclasses.find((sub) => sub.key === picked)?.name;
return (
<div className="card mt-md">
<div className="card-title">Subclass</div>
<p className="step-intro">
Your subclass is the path you take within your class. Choose it now and its gifts arrive at level {subclassLevel}; or decide
later, when you get there. Every {CLASS_LABELS[characterClass]} path is here, from the 2014 books and the 2024 Player’s Handbook.
</p>
{subclasses.length > 0 && !roll && (
<div className="text-center">
<RollControl sides={subclasses.length} diceMode={diceMode} onRoll={onRoll} rolling={rolling} label={`Can’t decide? Roll a d${subclasses.length} for a suggestion.`} />
</div>
)}
{roll && suggestion && (
<p className="text-center">
<span className="roll-result animate">{roll}</span>
<br />
The dice suggest <strong>{subclasses.find((sub) => sub.key === suggestion)?.name}</strong>. You are free to pick any.
</p>
)}
<div className="choice-grid mt-md">
{subclasses.map((sub) => (
<ChoiceCard
key={sub.key}
title={sub.name}
tag={sub.key === suggestion ? '🎲 Suggested' : sub.edition === '2024' ? '2024 rules' : undefined}
selected={picked === sub.key}
onSelect={() => onPick(sub.key)}
>
<span className="choice-card-blurb">{sub.summary}</span>
<span className="choice-card-facts">{sub.source}</span>
<span
className="btn btn-ghost btn-sm"
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
setPreviewing(previewing === sub.key ? null : sub.key);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
setPreviewing(previewing === sub.key ? null : sub.key);
}
}}
>
{previewing === sub.key ? 'Hide the road' : 'Levels 1–20 →'}
</span>
</ChoiceCard>
))}
</div>
{previewing && (
<div className="card mt-md">
<div className="card-title">{subclasses.find((sub) => sub.key === previewing)?.name}: levels 1 to 20</div>
<ProgressionTimeline characterClass={characterClass} subclass={previewing} subclassName={subclasses.find((sub) => sub.key === previewing)?.name} />
</div>
)}
<div className="flex-between flex-wrap mt-md">
<button className="btn" onClick={() => onSubmit(null)} disabled={submitting}>
Decide at level {subclassLevel}
</button>
<StepFooter
hint={pickedName ? `${pickedName} selected — you can still change your mind.` : 'Pick a path, or decide later.'}
disabled={!picked || submitting}
onNext={() => onSubmit(picked)}
/>
</div>
</div>
);
}
function PhysicalAbilityRollForm({ onRoll, rolling }: { onRoll: (body?: { abilitySets?: number[][] }) => void; rolling: boolean }) {
const [sets, setSets] = useState<string[][]>(Array.from({ length: 6 }, () => ['', '', '', '']));
@@ -1087,7 +1383,7 @@ function PhysicalAbilityRollForm({ onRoll, rolling }: { onRoll: (body?: { abilit
return (
<div className="card mt-md">
<div className="card-title">Step 4 — Ability Scores (Physical Dice)</div>
<div className="card-title">Ability Scores (Physical Dice)</div>
<p>Roll 4d6 six times at the table, then enter each set's four results below (drop-the-lowest is applied automatically).</p>
{sets.map((set, i) => (
<div key={i} className="flex-row flex-wrap mt-sm">
@@ -1179,7 +1475,7 @@ function EquipmentStep({
<div className="card mt-md">
<div className="card-title">
<span>
Step 9 — Starting Equipment <InfoTip term="STARTING_EQUIPMENT" />
Starting Equipment <InfoTip term="STARTING_EQUIPMENT" />
</span>
</div>
<p className="step-intro">{STEP_GUIDE.EQUIPMENT}</p>
@@ -1260,12 +1556,12 @@ function BuildPreview({
talents: string[];
}) {
const species = bs.species ?? null;
const halfElfBonusAbilities = bs.halfElfBonusAbilities || [];
const bonusChoices = bs.speciesBonusChoices ?? {};
const characterClass = bs.characterClass ?? null;
const abilityFinals: Partial<Record<Ability, number>> = {};
for (const ability of ABILITIES) {
const bonus = speciesBonusFor(species, ability, halfElfBonusAbilities);
const bonus = speciesBonusFor(species, ability, bonusChoices);
const assignedIndex = assignments[ability] ?? bs.abilityAssignments?.[ability];
const rolledTotal = assignedIndex !== undefined ? bs.abilityRolls?.[assignedIndex]?.total : undefined;
if (rolledTotal !== undefined) {
@@ -1307,7 +1603,7 @@ function BuildPreview({
<p style={{ marginTop: 0 }}>
<strong>{SPECIES_LABELS[species]}</strong>
{Object.entries({ STR: 0, DEX: 0, CON: 0, INT: 0, WIS: 0, CHA: 0 })
.map(([a]) => [a as Ability, speciesBonusFor(species, a as Ability, halfElfBonusAbilities)] as const)
.map(([a]) => [a as Ability, speciesBonusFor(species, a as Ability, bonusChoices)] as const)
.filter(([, bonus]) => bonus !== 0)
.map(([a, bonus]) => (
<span key={a} className="preview-highlight" style={{ marginLeft: 6 }}>
@@ -1324,6 +1620,11 @@ function BuildPreview({
{saveProfs && saveProfs.length > 0 && (
<span style={{ color: 'var(--text-muted)' }}> · {saveProfs.map((a) => ABILITY_LABELS[a]).join('/')} saves</span>
)}
{bs.subclass && options && (
<span className="preview-highlight" style={{ display: 'block' }}>
Path: {options.subclasses[characterClass]?.find((sub) => sub.key === bs.subclass)?.name} (from level {options.subclassLevels[characterClass]})
</span>
)}
</p>
)}
+148 -7
View File
@@ -37,7 +37,11 @@ import { useRememberCampaign } from '../lib/lastCampaign';
import { HpBar } from '../components/HpBar';
import { DeathSaves } from '../components/DeathSaves';
import { SpellSlotTracker } from '../components/SpellSlots';
import { CharacterState } from '../components/CharacterState';
import { Rollable } from '../components/Rollable';
import { withBonus } from '../lib/diceRoller';
import { LevelUpModal } from '../components/LevelUpModal';
import { ProgressionTimeline } from '../components/ProgressionTimeline';
import { GearInForce } from '../components/CharacterPanel';
import { effectsSummary } from '../lib/gear';
import { InfoTip } from '../components/InfoTip';
@@ -46,6 +50,8 @@ import { usePagination } from '../hooks/usePagination';
import {
ABILITY_LABELS,
CLASS_LABELS,
classLabel,
xpLabel,
COINS,
COIN_FIELD,
COIN_LABELS,
@@ -246,6 +252,8 @@ export function CharacterSheetPage() {
useCampaignSocket(character?.campaignId);
const { data: campaignData } = useCampaign(character?.campaignId);
// Off for the campaign: no XP is kept, so none is shown.
const trackXp = campaignData?.campaign.trackXp ?? true;
const { data: campaignWeapons } = useCampaignWeapons(character?.campaignId);
const { data: campaignSpells } = useCampaignSpells(character?.campaignId);
const { data: campaignItems } = useCampaignItems(character?.campaignId);
@@ -424,8 +432,14 @@ export function CharacterSheetPage() {
<span aria-hidden="true">{speciesIcon(character.species)}</span> {character.name}
</h1>
<p className="subtitle">
{character.species && SPECIES_LABELS[character.species]} {character.characterClass && CLASS_LABELS[character.characterClass]} · Level{' '}
{character.species && SPECIES_LABELS[character.species]} {classLabel(character.characterClass, character.subclassName)} · Level{' '}
{character.level}
{trackXp && ` · ${xpLabel(character.xp, character.xpProgress)}`}
{trackXp && character.xpProgress.levelUpDue && (
<span className="badge badge-live" style={{ marginLeft: 6 }} title="They have the XP for the next level. Levelling up is the DM's to do.">
⬆ Level up due
</span>
)}
{character.motivation && ` · "${character.motivation}"`}
{character.currentLocation && ` · 📍 ${character.currentLocation.name}`}
</p>
@@ -554,6 +568,42 @@ export function CharacterSheetPage() {
onBlur={(e) => Number(e.target.value) !== character.level && patchCharacter.mutate({ level: Number(e.target.value) })}
/>
</label>
{character.subclassLevel != null && (
<label
className="flex-row gap-sm"
title="The path within the class. Changing it takes the old subclass's lines out of Special Abilities and writes the new one's, up to this level."
>
Subclass
<select
value={character.subclass ?? ''}
disabled={patchCharacter.isPending}
onChange={(e) => patchCharacter.mutate({ subclass: e.target.value || null }, { onError: (err) => setFormError(extractErrorMessage(err)) })}
style={{ width: 'auto' }}
>
<option value="">Not chosen</option>
{character.subclassOptions.map((option) => (
<option key={option.key} value={option.key} title={`${option.summary} (${option.source})`}>
{option.name}
{option.edition === '2024' ? ' (2024)' : ''}
</option>
))}
</select>
</label>
)}
{trackXp && (
<label className="flex-row gap-sm" title="Experience points. An encounter's XP is added here when you award it. Enough for the next level marks the level as due; it does not level the character up.">
XP
<NumberInput
key={`xp:${character.xp}`}
min={0}
max={10000000}
keepValue
defaultValue={character.xp}
style={{ width: 90 }}
onBlur={(e) => Number(e.target.value) !== character.xp && patchCharacter.mutate({ xp: Math.max(0, Math.floor(Number(e.target.value)) || 0) })}
/>
</label>
)}
<button
className="btn btn-primary"
onClick={() => setLevellingUp(true)}
@@ -612,6 +662,21 @@ export function CharacterSheetPage() {
<JumpNav />
<div data-jump="Condition" className="card mt-md">
<div className="card-title">Condition and counters</div>
<CharacterState
characterId={characterId}
name={character.name}
inspiration={character.inspiration}
exhaustion={character.exhaustion}
conditions={character.conditions}
resources={sheet.resources}
canUse={canEdit}
isDM={isDM}
onError={(err) => setFormError(extractErrorMessage(err))}
/>
</div>
<div data-jump="Abilities" className="card mt-md">
<div className="card-title">Ability Scores</div>
<div className="grid grid-cols-3">
@@ -621,7 +686,9 @@ export function CharacterSheetPage() {
{ABILITY_LABELS[ability]} <InfoTip term="ABILITY_MODIFIER" />
</span>
<span className="stat-value">{sheet.abilities[ability].final}</span>
<span className="stat-sub">{fmtMod(sheet.abilities[ability].modifier)}</span>
<Rollable label={`${character.name}: ${ABILITY_LABELS[ability]} check`} modifier={sheet.abilities[ability].modifier} className="stat-sub">
{fmtMod(sheet.abilities[ability].modifier)}
</Rollable>
</div>
))}
</div>
@@ -637,7 +704,9 @@ export function CharacterSheetPage() {
<span>
{ABILITY_LABELS[ability]} {sheet.saves[ability].proficient && <span className="badge">prof</span>}
</span>
<span className="mono">{fmtMod(sheet.saves[ability].bonus)}</span>
<Rollable label={`${character.name}: ${ABILITY_LABELS[ability]} save`} modifier={sheet.saves[ability].bonus} className="mono">
{fmtMod(sheet.saves[ability].bonus)}
</Rollable>
</div>
))}
</div>
@@ -656,7 +725,9 @@ export function CharacterSheetPage() {
<span className="badge">prof</span>
) : null}
</span>
<span className="mono">{fmtMod(detail.bonus)}</span>
<Rollable label={`${character.name}: ${SKILL_LABELS[skill]}`} modifier={detail.bonus} className="mono">
{fmtMod(detail.bonus)}
</Rollable>
</div>
))}
</div>
@@ -670,7 +741,10 @@ export function CharacterSheetPage() {
</div>
<p style={{ margin: 0 }}>
Save DC <strong>{sheet.spellcasting.saveDC}</strong> · Attack Bonus{' '}
<strong>{fmtMod(sheet.spellcasting.attackBonus)}</strong> <InfoTip term="SPELL_ATTACK_BONUS" />
<Rollable label={`${character.name}: spell attack`} modifier={sheet.spellcasting.attackBonus}>
<strong>{fmtMod(sheet.spellcasting.attackBonus)}</strong>
</Rollable>{' '}
<InfoTip term="SPELL_ATTACK_BONUS" />
</p>
{sheet.spellSlots ? (
<SpellSlotTracker
@@ -696,7 +770,15 @@ export function CharacterSheetPage() {
<div>
<strong>{cw.weapon.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
Lv {cw.level} · {cw.scaledDamageDice} {cw.weapon.damageType} · x{cw.quantity}
Lv {cw.level} ·{' '}
<Rollable label={`${character.name}: ${cw.weapon.name} attack`} modifier={cw.attackBonus}>
{fmtMod(cw.attackBonus)} to hit
</Rollable>{' '}
·{' '}
<Rollable label={`${character.name}: ${cw.weapon.name} damage`} dice={withBonus(cw.scaledDamageDice, cw.damageBonus)}>
{cw.scaledDamageDice} {fmtMod(cw.damageBonus)}
</Rollable>{' '}
{cw.weapon.damageType} · x{cw.quantity}
</span>
{!cw.usable && <RequiresLevel level={cw.level} />}
{effectsSummary(cw.weapon.effects, cw.weapon.effectNote) && (
@@ -761,7 +843,15 @@ export function CharacterSheetPage() {
<strong>{cs.spell.name}</strong>
<span style={{ color: 'var(--text-muted)', marginLeft: 6 }}>
{spellLevelLabel(cs.spell.level)}
{cs.spell.damageDice && ` · ${cs.spell.damageDice} damage`}
{cs.spell.damageDice && (
<>
{' · '}
<Rollable label={`${character.name}: ${cs.spell.name}`} dice={cs.spell.damageDice}>
{cs.spell.damageDice}
</Rollable>{' '}
damage
</>
)}
</span>
{!cs.usable && <RequiresLevel level={cs.spell.level} />}
{cs.spell.description && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{cs.spell.description}</div>}
@@ -1006,6 +1096,57 @@ export function CharacterSheetPage() {
{/* Composed by the character builder from the player's picks. Players
read them; only the DM can rewrite them (new abilities on level-up,
a scar earned in play). */}
{character.characterClass && character.buildStage === 'COMPLETE' && (
<div data-jump="Road ahead" className="card mt-md">
<div className="card-title">
<span>The Road Ahead</span>
<span className="badge">Level {character.level} of 20</span>
</div>
{character.subclassName ? (
<p style={{ marginTop: 0 }}>
Path: <strong>{character.subclassName}</strong>
{character.subclassOptions.find((o) => o.key === character.subclass) && (
<span className="combatant-sub"> · {character.subclassOptions.find((o) => o.key === character.subclass)!.source}</span>
)}
{character.subclassLevel != null && character.level < character.subclassLevel && (
<span className="combatant-sub"> · its gifts begin at level {character.subclassLevel}</span>
)}
</p>
) : (
character.subclassLevel != null && (
<p style={{ marginTop: 0 }} className="combatant-sub">
No path chosen yet: {CLASS_LABELS[character.characterClass]}s choose one at level {character.subclassLevel}.
</p>
)
)}
{character.subclassUpgrade && (
<div className="flex-row flex-wrap gap-sm mb-2">
<span className="combatant-sub">
The 2024 Player’s Handbook updates this path as <strong>{character.subclassUpgrade.name}</strong>.
</span>
{isDM && (
<button
className="btn btn-sm"
disabled={patchCharacter.isPending}
onClick={() =>
patchCharacter.mutate({ subclass: character.subclassUpgrade!.key }, { onError: (err) => setFormError(extractErrorMessage(err)) })
}
title="Swaps the old path's lines in Special Abilities for the new one's, up to this level."
>
Switch to the 2024 version
</button>
)}
</div>
)}
<ProgressionTimeline
characterClass={character.characterClass}
subclass={character.subclass}
subclassName={character.subclassName}
currentLevel={character.level}
/>
</div>
)}
{(
[
['specialAbilities', 'Special Abilities', 'One per line, e.g. “Reckless Attack — gained at level 2.”', 6],
File diff suppressed because it is too large Load Diff
+49 -3
View File
@@ -7,11 +7,14 @@ import { Spinner, ErrorBanner } from '../components/Spinner';
import { Pagination } from '../components/Paginated';
import { usePagination } from '../hooks/usePagination';
import { InfoTip } from '../components/InfoTip';
import { CLASS_LABELS, SPECIES_LABELS, formatPurse, slotsSummary } from '../lib/format';
import { SPECIES_LABELS, classLabel, formatPurse, slotsSummary, xpLabel } from '../lib/format';
import { RestModal } from '../components/RestModal';
import { HpBar } from '../components/HpBar';
import { LifeBadge } from '../components/DeathSaves';
import { ConditionChip, StateBadges } from '../components/CharacterState';
import { speciesIcon } from '../lib/speciesIcons';
import { openCharacterPanel } from '../lib/characterPanel';
import { GroupCheckModal } from '../components/GroupCheckModal';
// A DM's one-screen view of every character's stats at a glance; a player
// sees the same table, scoped to their own character(s) plus a read-only
@@ -26,11 +29,14 @@ export function PartyOverviewPage() {
const { data: characters, isLoading: charsLoading, error: charsError } = useCampaignCharacters(campaignId);
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(characters, 12);
const [resting, setResting] = useState(false);
const [checking, setChecking] = useState(false);
if (campaignLoading || charsLoading) return <Spinner label="Gathering the party…" />;
if (campaignError || charsError || !data) return <ErrorBanner message="Could not load the party overview." />;
const isDM = data.campaign.role === 'DM';
// Everyone alive and built, for a roll the DM calls for from the whole party.
const checkers = (characters ?? []).flatMap((c) => (c.checks && c.deathSaves.state !== 'DEAD' ? [{ id: c.id, name: c.name, ...c.checks }] : []));
return (
<div>
@@ -40,6 +46,11 @@ export function PartyOverviewPage() {
<p className="subtitle">{isDM ? 'Every character in this campaign, at a glance.' : 'Your party.'}</p>
</div>
<div className="flex-row">
{isDM && checkers.length > 0 && (
<button className="btn" onClick={() => setChecking(true)} title="Call for a check or a save from the party, and roll it for all of them at once">
🎲 Call for a roll…
</button>
)}
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
🛏 Rest…
@@ -68,6 +79,8 @@ export function PartyOverviewPage() {
<th>
AC <InfoTip term="AC" />
</th>
<th title="Passive Perception, Investigation and Insight: 10 + the skill's bonus, used without a roll">Passive P / Inv / Ins</th>
<th title="What a target rolls against to resist this character's spells">Spell DC</th>
<th>
Hit Dice <InfoTip term="HIT_DICE_LEFT" />
</th>
@@ -89,10 +102,38 @@ export function PartyOverviewPage() {
<span aria-hidden="true">{speciesIcon(c.species)}</span> {c.name}
</Link>
{isMine && <span className="badge badge-mine" style={{ marginLeft: 6 }}>★ Yours</span>}
{!inProgress && (c.inspiration || c.exhaustion > 0 || c.conditions.length > 0) && (
<span className="flex-row flex-wrap gap-sm" style={{ display: 'inline-flex', marginLeft: 6 }}>
<StateBadges inspiration={c.inspiration} exhaustion={c.exhaustion} />
{c.conditions.map((condition) => (
<ConditionChip key={condition} name={condition} />
))}
</span>
)}
{!inProgress && (
<button
className="btn btn-target"
style={{ marginLeft: 6 }}
title={`${c.name} at a glance, without leaving this page: saves, skills, attacks and special abilities`}
onClick={() => openCharacterPanel(c.id)}
>
📋
</button>
)}
</td>
<td data-label="Species">{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
<td data-label="Class">{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
<td data-label="Level">{c.level}</td>
<td data-label="Class">{c.characterClass ? classLabel(c.characterClass, c.subclassName) : inProgress ? 'Building…' : '—'}</td>
<td data-label="Level">
{c.level}
{!inProgress && data?.campaign.trackXp && (
<div className="combatant-sub mono" style={{ whiteSpace: 'nowrap' }}>
{xpLabel(c.xp, c.xpProgress)}
{c.xpProgress.levelUpDue && (
<span title="They have the XP for the next level"> ⬆</span>
)}
</div>
)}
</td>
<td data-label="HP">
{c.maxHp != null ? (
<div style={{ minWidth: 90 }}>
@@ -108,6 +149,10 @@ export function PartyOverviewPage() {
)}
</td>
<td data-label="AC">{c.armorClass ?? '—'}</td>
<td data-label="Passive P / Inv / Ins" className="mono">
{c.passives ? `${c.passives.perception} / ${c.passives.investigation} / ${c.passives.insight}` : '—'}
</td>
<td data-label="Spell DC" className="mono">{c.spellSaveDC ?? '—'}</td>
<td data-label="Hit Dice" className="mono">{c.hitDice ? `${c.hitDice.remaining}/${c.hitDice.count} d${c.hitDice.size}` : '—'}</td>
<td data-label="Spell slots" className="mono">{slotsSummary(c.spellSlots) || '—'}</td>
<td data-label="Coins" className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
@@ -120,6 +165,7 @@ export function PartyOverviewPage() {
</div>
)}
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
{isDM && checking && <GroupCheckModal characters={checkers} onClose={() => setChecking(false)} />}
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
</div>
);
+25 -107
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.14.0",
"version": "1.23.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.14.0",
"version": "1.23.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.14.0",
"version": "1.23.0",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.14.0",
"version": "1.23.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
@@ -1411,9 +1411,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1431,9 +1428,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1451,9 +1445,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1471,9 +1462,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1491,9 +1479,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1511,9 +1496,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1531,9 +1513,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1551,9 +1530,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1768,9 +1744,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1792,9 +1765,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1816,9 +1786,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1840,9 +1807,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1864,9 +1828,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1888,9 +1849,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2451,9 +2409,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2471,9 +2426,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2491,9 +2443,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2511,9 +2460,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2531,9 +2477,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2551,9 +2494,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2994,7 +2934,7 @@
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.3.0"
@@ -3061,6 +3001,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3077,6 +3018,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3093,6 +3035,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3109,6 +3052,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3125,6 +3069,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3141,6 +3086,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3157,6 +3103,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3173,6 +3120,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3189,6 +3137,7 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3205,6 +3154,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3221,6 +3171,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3237,6 +3188,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3253,6 +3205,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3269,6 +3222,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3285,6 +3239,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3301,6 +3256,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3317,6 +3273,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3333,6 +3290,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3349,6 +3307,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3365,6 +3324,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -3487,9 +3447,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3504,9 +3461,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3521,9 +3475,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3538,9 +3489,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3555,9 +3503,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3572,9 +3517,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3589,9 +3531,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3606,9 +3545,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3623,9 +3559,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -3640,9 +3573,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7200,9 +7130,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7224,9 +7151,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7248,9 +7172,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -7272,9 +7193,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.14.0",
"version": "1.23.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [