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>
This commit is contained in:
2026-10-10 17:36:27 +02:00
co-authored by Claude Opus 5.5
parent 61221f262c
commit 57fba68a48
50 changed files with 4706 additions and 592 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.14.1",
"version": "1.21.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;
+22 -1
View File
@@ -179,6 +179,9 @@ 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)
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 +263,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?
@@ -289,6 +295,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 +611,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 +702,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)
+15 -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,18 @@ 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}` : '', 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'],
+63
View File
@@ -5,6 +5,8 @@
const { SPECIES } = require('./data/species');
const { CLASSES } = require('./data/classes');
const { SKILLS } = require('./data/skills');
const { resourceState } = require('./classResources');
const { CR_XP, XP_THRESHOLDS, COUNT_MULTIPLIERS, LEVEL_XP } = require('./data/xp');
const { FULL_CASTER_SLOTS, HALF_CASTER_SLOTS, PACT_SLOTS, CASTER_KIND, MAX_SLOT_LEVEL } = require('./data/spellSlots');
const ABILITIES = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
@@ -55,6 +57,11 @@ function passivePerception(perceptionSkillBonus) {
return 10 + perceptionSkillBonus;
}
// Any skill has a passive score: what it comes to without a roll.
function passiveScore(skillBonus) {
return 10 + skillBonus;
}
function armorClass(dexMod, armorBonus = 0) {
return 10 + dexMod + armorBonus;
}
@@ -176,6 +183,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
@@ -307,6 +362,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 +375,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 +511,7 @@ module.exports = {
saveBonus,
skillBonus,
passivePerception,
passiveScore,
armorClass,
initiative,
hpMax,
@@ -470,6 +530,9 @@ module.exports = {
spellSlotState,
maxSpellLevel,
hitDiceState,
challengeXp,
encounterDifficulty,
xpProgress,
GEAR_STATS,
activeGear,
computeCharacterSheet,
+101
View File
@@ -0,0 +1,101 @@
// Class counters: the things a class can do a set number of times before a
// rest (Rage, Ki, Channel Divinity...). How many there are comes from class,
// subclass, level and ability modifiers; the character row keeps only how
// many are spent (`resourcesUsed`, { key: used }), as it does for spell slots.
// Pure: no DB calls.
//
// What a counter does is on the sheet in the special abilities; nothing here
// feeds another number.
const atLeastOne = (n) => Math.max(1, n);
// Each entry is offered to the classes (and, where set, the subclass) it
// names, from `from` level on. `max` gets { level, mods, profBonus }, where
// `mods` is the ability modifiers by ability. `rest` is the rest that gives
// them back: a SHORT one comes back on a long rest too.
const RESOURCES = [
{ key: 'RAGE', name: 'Rage', classes: ['BARBARIAN'], max: ({ level }) => (level >= 6 ? 4 : level >= 3 ? 3 : 2), rest: 'LONG' },
{ key: 'ZEALOUS_PRESENCE', name: 'Zealous Presence', classes: ['BARBARIAN'], subclass: 'ZEALOT', from: 10, max: () => 1, rest: 'LONG' },
// Font of Inspiration: from level 5 it comes back on a short rest.
{ key: 'BARDIC_INSPIRATION', name: 'Bardic Inspiration', classes: ['BARD'], max: ({ mods }) => atLeastOne(mods.CHA), rest: ({ level }) => (level >= 5 ? 'SHORT' : 'LONG') },
{ key: 'HEALING_HANDS', name: 'Healing Hands', classes: ['CLERIC'], max: () => 1, rest: 'SHORT' },
{ key: 'CHANNEL_DIVINITY', name: 'Channel Divinity', classes: ['CLERIC'], from: 2, max: ({ level }) => (level >= 6 ? 2 : 1), rest: 'SHORT' },
{ key: 'CHANNEL_DIVINITY', name: 'Channel Divinity', classes: ['PALADIN'], from: 3, max: () => 1, rest: 'SHORT' },
{ key: 'WARDING_FLARE', name: 'Warding Flare', classes: ['CLERIC'], subclass: 'LIGHT', from: 2, max: ({ mods }) => atLeastOne(mods.WIS), rest: 'LONG' },
{ key: 'WAR_PRIEST', name: 'War Priest', classes: ['CLERIC'], subclass: 'WAR', from: 2, max: ({ mods }) => atLeastOne(mods.WIS), rest: 'LONG' },
{ key: 'WILD_SHAPE', name: 'Wild Shape', classes: ['DRUID'], from: 2, max: () => 2, rest: 'SHORT' },
{ key: 'NATURAL_RECOVERY', name: 'Natural Recovery', classes: ['DRUID'], subclass: 'LAND', from: 2, max: () => 1, rest: 'LONG' },
{ key: 'STAR_MAP', name: 'Star Map (Guiding Bolt)', classes: ['DRUID'], subclass: 'STARS', from: 2, max: ({ profBonus }) => profBonus, rest: 'LONG' },
{ key: 'COSMIC_OMEN', name: 'Cosmic Omen', classes: ['DRUID'], subclass: 'STARS', from: 6, max: ({ profBonus }) => profBonus, rest: 'LONG' },
{ key: 'SECOND_WIND', name: 'Second Wind', classes: ['FIGHTER'], max: () => 1, rest: 'SHORT' },
{ key: 'ACTION_SURGE', name: 'Action Surge', classes: ['FIGHTER'], from: 2, max: () => 1, rest: 'SHORT' },
{ key: 'INDOMITABLE', name: 'Indomitable', classes: ['FIGHTER'], from: 9, max: () => 1, rest: 'LONG' },
{ key: 'SUPERIORITY_DICE', name: 'Superiority Dice', classes: ['FIGHTER'], subclass: 'BATTLE_MASTER', from: 3, max: ({ level }) => (level >= 7 ? 5 : 4), rest: 'SHORT' },
{ key: 'FIGHTING_SPIRIT', name: 'Fighting Spirit', classes: ['FIGHTER'], subclass: 'SAMURAI', from: 3, max: () => 3, rest: 'LONG' },
{ key: 'KI', name: 'Ki', classes: ['MONK'], from: 2, max: ({ level }) => level, rest: 'SHORT' },
{ key: 'WHOLENESS_OF_BODY', name: 'Wholeness of Body', classes: ['MONK'], subclass: 'OPEN_HAND', from: 6, max: () => 1, rest: 'LONG' },
{ key: 'LAY_ON_HANDS', name: 'Lay on Hands (HP)', classes: ['PALADIN'], max: ({ level }) => 5 * level, rest: 'LONG' },
{ key: 'DIVINE_SENSE', name: 'Divine Sense', classes: ['PALADIN'], max: ({ mods }) => atLeastOne(1 + mods.CHA), rest: 'LONG' },
{ key: 'WILD_TALENT', name: 'Wild Talent', classes: ['SORCERER'], max: () => 1, rest: 'LONG' },
{ key: 'SORCERY_POINTS', name: 'Sorcery Points', classes: ['SORCERER'], from: 2, max: ({ level }) => level, rest: 'LONG' },
{ key: 'TIDES_OF_CHAOS', name: 'Tides of Chaos', classes: ['SORCERER'], subclass: 'WILD_MAGIC', from: 3, max: () => 1, rest: 'LONG' },
{ key: 'FEY_PRESENCE', name: 'Fey Presence', classes: ['WARLOCK'], subclass: 'ARCHFEY', from: 3, max: () => 1, rest: 'SHORT' },
{ key: 'MISTY_ESCAPE', name: 'Misty Escape', classes: ['WARLOCK'], subclass: 'ARCHFEY', from: 6, max: () => 1, rest: 'SHORT' },
{ key: 'PATRONS_LUCK', name: 'Patron’s Luck', classes: ['WARLOCK'], subclass: 'FIEND', from: 6, max: () => 1, rest: 'SHORT' },
{ key: 'ENTROPIC_WARD', name: 'Entropic Ward', classes: ['WARLOCK'], subclass: 'GREAT_OLD_ONE', from: 6, max: () => 1, rest: 'SHORT' },
{ key: 'ARCANE_RECOVERY', name: 'Arcane Recovery', classes: ['WIZARD'], max: () => 1, rest: 'LONG' },
{ key: 'ILLUSORY_SELF', name: 'Illusory Self', classes: ['WIZARD'], subclass: 'ILLUSION', from: 10, max: () => 1, rest: 'SHORT' },
];
// The counters a character has, with how many of each are spent and left:
// [{ key, name, max, used, remaining, restoresOn }]. `used` is the stored
// tally, which may name counters the character no longer has or hold more
// than they now allow.
function resourceState({ characterClass, subclass = null, level = 1, resourcesUsed = {} }, abilities, profBonus) {
const mods = Object.fromEntries(Object.entries(abilities).map(([ability, detail]) => [ability, detail.modifier]));
const context = { level, mods, profBonus };
const tally = resourcesUsed && typeof resourcesUsed === 'object' ? resourcesUsed : {};
return RESOURCES.filter(
(resource) => resource.classes.includes(characterClass) && level >= (resource.from || 1) && (!resource.subclass || resource.subclass === subclass)
).map((resource) => {
const max = resource.max(context);
const used = Math.min(Math.max(Math.round(Number(tally[resource.key]) || 0), 0), max);
return {
key: resource.key,
name: resource.name,
max,
used,
remaining: max - used,
restoresOn: typeof resource.rest === 'function' ? resource.rest(context) : resource.rest,
};
});
}
// The tally to store, from a character's counters: only what is spent.
function resourceTally(resources) {
return Object.fromEntries(resources.filter((resource) => resource.used > 0).map((resource) => [resource.key, resource.used]));
}
// What a rest gives back: the tally afterwards, and the names of the counters
// that were refilled. A long rest refills every counter; a short rest those
// that restore on one.
function restResources(resources, restType) {
const refills = (resource) => resource.used > 0 && (restType === 'LONG' || resource.restoresOn === 'SHORT');
return {
resourcesUsed: resourceTally(resources.filter((resource) => !refills(resource))),
restored: resources.filter(refills).map((resource) => resource.name),
};
}
const MAX_EXHAUSTION = 6;
module.exports = { RESOURCES, resourceState, resourceTally, restResources, MAX_EXHAUSTION };
+60
View File
@@ -0,0 +1,60 @@
const { RESOURCES, resourceState, resourceTally, restResources } = require('./classResources');
const { computeCharacterSheet } = require('./calculations');
const { CLASSES } = require('./data/classes');
const { findSubclass } = require('./data/subclasses');
const of = (character) => computeCharacterSheet({ level: 1, ...character }).resources;
const named = (resources) => Object.fromEntries(resources.map((r) => [r.key, r.max]));
describe('class counters', () => {
test('every counter belongs to a real class and, where it names one, a real subclass', () => {
for (const resource of RESOURCES) {
for (const cls of resource.classes) {
expect(CLASSES[cls]).toBeDefined();
if (resource.subclass) expect(findSubclass(cls, resource.subclass)).not.toBeNull();
}
}
});
test('come from class and level', () => {
expect(named(of({ characterClass: 'BARBARIAN' }))).toEqual({ RAGE: 2 });
expect(named(of({ characterClass: 'BARBARIAN', level: 6 }))).toEqual({ RAGE: 4 });
expect(named(of({ characterClass: 'FIGHTER' }))).toEqual({ SECOND_WIND: 1 });
expect(named(of({ characterClass: 'FIGHTER', level: 9 }))).toEqual({ SECOND_WIND: 1, ACTION_SURGE: 1, INDOMITABLE: 1 });
expect(named(of({ characterClass: 'MONK' }))).toEqual({});
expect(named(of({ characterClass: 'MONK', level: 4 }))).toEqual({ KI: 4 });
expect(named(of({ characterClass: 'PALADIN', level: 3, chaScore: 10 }))).toEqual({ CHANNEL_DIVINITY: 1, LAY_ON_HANDS: 15, DIVINE_SENSE: 1 });
expect(of({ characterClass: 'ROGUE', level: 10 })).toEqual([]);
expect(of({ characterClass: null })).toEqual([]);
});
test('some follow an ability modifier, never below one', () => {
expect(named(of({ characterClass: 'BARD', chaScore: 16 })).BARDIC_INSPIRATION).toBe(3);
expect(named(of({ characterClass: 'BARD', chaScore: 6 })).BARDIC_INSPIRATION).toBe(1);
});
test('a subclass adds its own', () => {
expect(named(of({ characterClass: 'FIGHTER', level: 3 })).SUPERIORITY_DICE).toBeUndefined();
expect(named(of({ characterClass: 'FIGHTER', level: 3, subclass: 'BATTLE_MASTER' })).SUPERIORITY_DICE).toBe(4);
expect(named(of({ characterClass: 'FIGHTER', level: 7, subclass: 'BATTLE_MASTER' })).SUPERIORITY_DICE).toBe(5);
expect(named(of({ characterClass: 'DRUID', level: 2, subclass: 'LAND' }))).toEqual({ WILD_SHAPE: 2, NATURAL_RECOVERY: 1 });
});
test('a bard\'s inspiration comes back on a short rest from level 5', () => {
expect(of({ characterClass: 'BARD', level: 4 })[0].restoresOn).toBe('LONG');
expect(of({ characterClass: 'BARD', level: 5 })[0].restoresOn).toBe('SHORT');
});
test('the stored tally is squared up with what the character has', () => {
const resources = of({ characterClass: 'MONK', level: 4, resourcesUsed: { KI: 9, RAGE: 1 } });
expect(resources).toEqual([{ key: 'KI', name: 'Ki', max: 4, used: 4, remaining: 0, restoresOn: 'SHORT' }]);
expect(resourceTally(resources)).toEqual({ KI: 4 });
expect(resourceState({ characterClass: 'MONK', level: 4, resourcesUsed: null }, { WIS: { modifier: 0 } }, 2)[0].used).toBe(0);
});
test('a short rest refills what restores on one; a long rest everything', () => {
const resources = of({ characterClass: 'FIGHTER', level: 9, resourcesUsed: { SECOND_WIND: 1, INDOMITABLE: 1 } });
expect(restResources(resources, 'SHORT')).toEqual({ resourcesUsed: { INDOMITABLE: 1 }, restored: ['Second Wind'] });
expect(restResources(resources, 'LONG')).toEqual({ resourcesUsed: {}, restored: ['Second Wind', 'Indomitable'] });
});
});
+9 -46
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.
// 10. These are the features every member of the class gets; what a subclass
// adds is in subclasses.js. Level 1 features are in characterOptions.js and
// are written by the builder.
//
// Each entry becomes a line of the character's special abilities, "Name —
// text". An `upgrade` rewrites the line that already has that name (Sneak
@@ -55,17 +55,13 @@ 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.' }],
},
BARD: {
2: [
@@ -73,15 +69,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 }],
@@ -93,19 +86,16 @@ const LEVEL_FEATURES = {
},
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).' }],
@@ -113,24 +103,17 @@ const LEVEL_FEATURES = {
DRUID: {
2: [
wildShape('CR 1/4 or less (no flying or swimming)'),
{ name: 'Natural Recovery', text: 'Once per day on a short rest, recover spent spell slots totalling up to half your level (rounded up).' },
{ name: 'Bonus Cantrip', text: 'Learn one extra cantrip (the DM grants it).' },
],
4: [BOON, wildShape('CR 1/2 or less (it may swim, not fly)')],
6: [{ name: 'Land’s Stride', text: 'Ordinary difficult terrain and plants do not slow you; advantage on saves against magical plants.' }],
8: [BOON, wildShape('CR 1 or less (it may swim or fly)')],
10: [{ name: 'Nature’s Ward', text: 'Immune to poison and disease; you can’t be charmed or frightened by fey or elementals.' }],
},
FIGHTER: {
2: [{ name: 'Action Surge', text: 'Once per short rest, take one extra action on your turn.' }],
3: [{ name: 'Improved Critical', text: 'Your weapon attacks score a critical hit on a roll of 19 or 20.' }],
4: [BOON],
5: [EXTRA_ATTACK],
6: [BOON],
7: [{ name: 'Remarkable Athlete', text: 'Add half your proficiency bonus to Strength, Dexterity and Constitution checks you are not proficient in; long jump + Strength modifier feet.' }],
8: [BOON],
9: [{ name: 'Indomitable', text: 'Once per long rest, reroll a failed saving throw.' }],
10: [{ name: 'Additional Fighting Style', text: 'Choose a second option from your Fighting Style list. (If it is +1 AC in armor, the DM raises Extra AC by 1.)' }],
},
MONK: {
2: [
@@ -141,7 +124,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 +134,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.' }],
@@ -171,13 +152,10 @@ 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.' }],
},
@@ -187,7 +165,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,7 +173,6 @@ 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.' },
@@ -205,24 +181,19 @@ const LEVEL_FEATURES = {
},
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],
},
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.' }],
},
@@ -231,21 +202,13 @@ const LEVEL_FEATURES = {
3: [{ name: 'Pact Boon', text: 'Choose one with your DM: Pact of the Blade (summon a weapon of your choice as an action; it counts as magical), Pact of the Chain (gain a small familiar spirit: an imp, a sprite or a cat-shaped shadow), or Pact of the Tome (a book of three extra cantrips from any class, which the DM grants).' }],
4: [BOON],
5: [invocations('three')],
6: [{ name: 'Patron’s Luck', text: 'Once per short rest, add a d10 to an ability check or saving throw after seeing the roll.' }],
7: [invocations('four')],
8: [BOON],
9: [invocations('five')],
10: [{ name: 'Patron’s Resilience', text: 'After each rest, choose one damage type: you resist it until you choose another.' }],
},
WIZARD: {
2: [
{ name: 'Evocation Savant', text: 'School of Evocation. Copying evocation spells costs half the time and gold.' },
{ name: 'Sculpt Spells', text: 'When you cast an evocation spell that hits an area, choose 1 + the spell’s level creatures to automatically save and take no damage.' },
],
4: [BOON],
6: [{ name: 'Potent Cantrip', text: 'Creatures that save against your damaging cantrips still take half damage.' }],
8: [BOON],
10: [{ name: 'Empowered Evocation', text: 'Add your Intelligence modifier to the damage of your evocation spells.' }],
},
};
+501
View File
@@ -0,0 +1,501 @@
// Subclasses: the path a character takes within their class. Three per class.
// The first of each is the path every character of that class followed before
// subclasses could be chosen (the Champion fighter, the Life cleric...); its
// lines are word for word what the class tables used to write, so sheets
// written back then still match.
//
// `features` are by character level, in the same shape as LEVEL_FEATURES
// (levelFeatures.js): each becomes a line of the special abilities, and an
// `upgrade` rewrites the line that already has its name. Like the class
// tables they stop at level 10, and none of it feeds the calculation engine.
// The level at which a class chooses.
const SUBCLASS_LEVEL = {
BARBARIAN: 3,
BARD: 3,
CLERIC: 2,
DRUID: 2,
FIGHTER: 3,
MONK: 3,
PALADIN: 3,
RANGER: 3,
ROGUE: 3,
SORCERER: 3,
WARLOCK: 3,
WIZARD: 2,
};
const SUBCLASSES = {
BARBARIAN: [
{
key: 'BERSERKER',
name: 'Path of the Berserker',
summary: 'Rage as pure fury: extra attacks now, exhaustion after.',
features: {
3: [{ name: 'Frenzy', text: 'Path of the Berserker. While raging you may make one extra melee attack as a bonus action each turn; when the rage ends, gain one level of exhaustion.' }],
6: [{ name: 'Mindless Rage', text: 'You can’t be charmed or frightened while raging.' }],
10: [{ name: 'Intimidating Presence', text: 'Action: one creature within 30 ft makes a Wisdom save (DC 8 + proficiency bonus + Charisma modifier) or is frightened of you until the end of your next turn.' }],
},
},
{
key: 'TOTEM_WARRIOR',
name: 'Path of the Totem Warrior',
summary: 'A spirit animal walks with you and lends its strength.',
features: {
3: [
{ name: 'Spirit Seeker', text: 'Path of the Totem Warrior. You can speak with beasts and sense what they sense, as a ritual with your DM.' },
{ name: 'Totem Spirit', text: 'Choose one with your DM. Bear: while raging, all damage but psychic is halved. Eagle: while raging, opportunity attacks against you have disadvantage and you can Dash as a bonus action. Wolf: while raging, allies have advantage on melee attacks against enemies next to you.' },
],
6: [{ name: 'Aspect of the Beast', text: 'Choose one with your DM. Bear: carry twice as much, advantage on Strength checks to push, pull or lift. Eagle: see clearly up to a mile, no penalty in dim light. Wolf: track at a fast pace and move stealthily at a normal one.' }],
10: [{ name: 'Spirit Walker', text: 'As a ritual, ask the spirits of a place for what they know (the DM answers as a nature spirit would).' }],
},
},
{
key: 'ZEALOT',
name: 'Path of the Zealot',
summary: 'A god’s fury in your blows, and death has trouble keeping you.',
features: {
3: [
{ name: 'Divine Fury', text: 'Path of the Zealot. While raging, the first creature you hit each turn takes an extra 1d6 + half your level radiant or necrotic damage.' },
{ name: 'Warrior of the Gods', text: 'Spells that bring you back from the dead cost nothing to cast on you.' },
],
6: [{ name: 'Fanatical Focus', text: 'Once per rage, reroll a saving throw you just failed.' }],
10: [{ name: 'Zealous Presence', text: 'Once per long rest, bonus action: up to ten allies within 60 ft have advantage on attacks and saves until your next turn.' }],
},
},
],
BARD: [
{
key: 'LORE',
name: 'College of Lore',
summary: 'Knows a bit of everything, and a word to trip anyone up.',
features: {
3: [
{ name: 'Cutting Words', text: 'College of Lore. Reaction: spend a Bardic Inspiration to subtract the die from an enemy’s attack, check or damage roll.' },
{ name: 'Bonus Proficiencies', text: 'Treat three extra skills of your choice as proficient (add your proficiency bonus by hand).' },
],
6: [{ name: 'Additional Magical Secrets', text: 'Learn two spells from any class list (the DM grants them within the unlock schedule).' }],
},
},
{
key: 'VALOR',
name: 'College of Valor',
summary: 'A skald in the front line, inspiring by example.',
features: {
3: [
{ name: 'Combat Inspiration', text: 'College of Valor. A creature with your Bardic Inspiration may add the die to a weapon damage roll, or to its AC against one attack as a reaction.' },
{ name: 'Battle Training', text: 'You fight in medium armor and with a shield (the DM raises Extra AC to match what you wear).' },
],
6: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
},
},
{
key: 'SWORDS',
name: 'College of Swords',
summary: 'A blade dancer whose performance is the fight itself.',
features: {
3: [
{ name: 'Fighting Style', text: 'College of Swords. Choose one with your DM: +2 damage with a one-handed weapon and nothing in the other hand, or add your ability modifier to the damage of a second weapon (added by hand).' },
{ name: 'Blade Flourish', text: 'When you attack on your turn, +10 ft speed that turn. On a hit, spend a Bardic Inspiration and add the die to the damage, and choose: add it to your AC until your next turn, hit a second creature next to you for the die’s roll, or push the target 5 ft + the roll and follow it.' },
],
6: [{ name: 'Extra Attack', text: 'Attack twice when you take the Attack action.' }],
},
},
],
CLERIC: [
{
key: 'LIFE',
name: 'Life Domain',
summary: 'The healer: more from every cure, and healing in a burst.',
features: {
2: [{ name: 'Preserve Life', text: 'Channel Divinity. Action: share out healing of 5 × your level among creatures within 30 ft (up to half their max HP each).' }],
6: [{ name: 'Blessed Healer', text: 'When you heal someone else with a spell, you heal yourself 2 + the spell’s level.' }],
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 radiant damage.' }],
},
},
{
key: 'LIGHT',
name: 'Light Domain',
summary: 'Fire and radiance: blinds attackers and burns the dark away.',
features: {
2: [
{ name: 'Warding Flare', text: 'Light Domain. Reaction, a few times per day (your Wisdom modifier, at least once): a creature within 30 ft that attacks you has disadvantage on that attack.' },
{ name: 'Radiance of the Dawn', text: 'Channel Divinity. Action: magical darkness within 30 ft is dispelled, and each enemy there takes 2d10 + your level radiant damage (Constitution save for half).' },
],
6: [{ name: 'Improved Flare', text: 'Use Warding Flare when a creature attacks someone else within 30 ft of you.' }],
8: [{ name: 'Potent Spellcasting', text: 'Add your Wisdom modifier to the damage of your cleric cantrips.' }],
},
},
{
key: 'WAR',
name: 'War Domain',
summary: 'A soldier of the faith: extra strikes and blows that land.',
features: {
2: [
{ name: 'War Priest', text: 'War Domain. A few times per day (your Wisdom modifier, at least once), after you take the Attack action, make one weapon attack as a bonus action.' },
{ name: 'Guided Strike', text: 'Channel Divinity. When you make an attack roll, add +10 to it after seeing the roll.' },
],
6: [{ name: 'War God’s Blessing', text: 'Channel Divinity. Reaction: give +10 to an attack roll made by a creature within 30 ft.' }],
8: [{ name: 'Divine Strike', text: 'Once per turn, a weapon hit deals +1d8 damage of the weapon’s type.' }],
},
},
],
DRUID: [
{
key: 'LAND',
name: 'Circle of the Land',
summary: 'A keeper of old magic, at home in the wild places.',
features: {
2: [
{ name: 'Natural Recovery', text: 'Once per day on a short rest, recover spent spell slots totalling up to half your level (rounded up).' },
{ name: 'Bonus Cantrip', text: 'Learn one extra cantrip (the DM grants it).' },
],
6: [{ name: 'Land’s Stride', text: 'Ordinary difficult terrain and plants do not slow you; advantage on saves against magical plants.' }],
10: [{ name: 'Nature’s Ward', text: 'Immune to poison and disease; you can’t be charmed or frightened by fey or elementals.' }],
},
},
{
key: 'MOON',
name: 'Circle of the Moon',
summary: 'A shapeshifter who fights as the beast.',
features: {
2: [
{ name: 'Combat Wild Shape', text: 'Circle of the Moon. Use Wild Shape as a bonus action. While in beast form, bonus action: spend a spell slot to heal 1d8 per slot level.' },
{ name: 'Circle Forms', text: 'Your Wild Shape can be a beast of up to CR 1; from level 6, up to CR equal to a third of your level (rounded down).' },
],
6: [{ name: 'Primal Strike', text: 'Your attacks in beast form count as magical.' }],
10: [{ name: 'Elemental Wild Shape', text: 'Spend both uses of Wild Shape at once to become an air, earth, fire or water elemental.' }],
},
},
{
key: 'STARS',
name: 'Circle of Stars',
summary: 'Reads the night sky and wears its constellations.',
features: {
2: [
{ name: 'Star Map', text: 'Circle of Stars. You know the Guidance cantrip, and cast Guiding Bolt without a slot a number of times per day equal to your proficiency bonus (the DM grants both).' },
{ name: 'Starry Form', text: 'Bonus action: spend a Wild Shape use to take a starry form for 10 minutes. Choose Archer (bonus action: ranged spell attack, 60 ft, 1d8 + Wisdom modifier radiant), Chalice (when you cast a healing spell, another creature within 30 ft heals 1d8 + Wisdom modifier) or Dragon (treat a roll of 9 or lower as 10 on Intelligence and Wisdom checks and on concentration saves).' },
],
6: [{ name: 'Cosmic Omen', text: 'After a long rest roll a die. Even (Weal): reaction, add 1d6 to a creature’s attack, check or save. Odd (Woe): reaction, subtract 1d6. Usable a number of times per day equal to your proficiency bonus, on creatures within 30 ft.' }],
10: [{ name: 'Twinkling Constellations', text: 'Archer and Chalice use 2d8. In Dragon form you can fly 20 ft. At the start of each turn in Starry Form you may change constellation.' }],
},
},
],
FIGHTER: [
{
key: 'CHAMPION',
name: 'Champion',
summary: 'Simple and deadly: more critical hits, more athletic.',
features: {
3: [{ name: 'Improved Critical', text: 'Your weapon attacks score a critical hit on a roll of 19 or 20.' }],
7: [{ name: 'Remarkable Athlete', text: 'Add half your proficiency bonus to Strength, Dexterity and Constitution checks you are not proficient in; long jump + Strength modifier feet.' }],
10: [{ name: 'Additional Fighting Style', text: 'Choose a second option from your Fighting Style list. (If it is +1 AC in armor, the DM raises Extra AC by 1.)' }],
},
},
{
key: 'BATTLE_MASTER',
name: 'Battle Master',
summary: 'A tactician who turns each swing into a manoeuvre.',
features: {
3: [
{ name: 'Combat Superiority', text: 'Battle Master. Four superiority dice (d8), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier.' },
{ name: 'Manoeuvres', text: 'Choose three with your DM: Trip Attack (add the die to damage; Strength save or prone), Disarming Attack (add the die; Strength save or it drops what it holds), Pushing Attack (add the die; Strength save or pushed 15 ft), Precision Attack (add the die to an attack roll), Riposte (reaction when a creature misses you: attack it and add the die), Parry (reaction: reduce melee damage by the die + Dexterity modifier), Rally (bonus action: an ally gains the die + Charisma modifier Temp HP), Menacing Attack (add the die; Wisdom save or frightened until your next turn).' },
],
7: [
{ name: 'Know Your Enemy', text: 'After a minute watching a creature, the DM tells you whether it is your equal, better or worse in two of: Strength, Dexterity, Constitution, AC, current HP, level.' },
{ name: 'Combat Superiority', text: 'Battle Master. Five superiority dice (d8), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier.', upgrade: true },
{ name: 'Manoeuvres', text: 'Choose two more with your DM (five in all) from the list on your level 3 line.' },
],
10: [{ name: 'Combat Superiority', text: 'Battle Master. Five superiority dice (d10), back on a short rest. Spend one on a manoeuvre; where a save is called for the DC is 8 + proficiency bonus + Strength or Dexterity modifier. Choose two more manoeuvres with your DM (seven in all).', upgrade: true }],
},
},
{
key: 'SAMURAI',
name: 'Samurai',
summary: 'Unbreakable resolve: strikes with advantage and shrugs off blows.',
features: {
3: [
{ name: 'Fighting Spirit', text: 'Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 5 Temp HP.' },
{ name: 'Bonus Proficiency', text: 'Treat one of History, Insight, Performance or Persuasion as proficient (add your proficiency bonus by hand).' },
],
7: [{ name: 'Elegant Courtier', text: 'Add your Wisdom modifier to Persuasion checks, and treat Wisdom saves as proficient (both added by hand).' }],
10: [
{ name: 'Tireless Spirit', text: 'If you roll initiative with no uses of Fighting Spirit left, you regain one.' },
{ name: 'Fighting Spirit', text: 'Samurai. Three times per long rest, bonus action: advantage on all weapon attacks this turn and gain 10 Temp HP.', upgrade: true },
],
},
},
],
MONK: [
{
key: 'OPEN_HAND',
name: 'Way of the Open Hand',
summary: 'Pure martial arts: knocks down, pushes away, heals itself.',
features: {
3: [{ name: 'Open Hand Technique', text: 'Flurry hits can knock a target prone (Dexterity save), push it 15 ft (Strength save) or stop its reactions.' }],
6: [{ name: 'Wholeness of Body', text: 'Once per long rest, heal 3 × your level as an action.' }],
},
},
{
key: 'SHADOW',
name: 'Way of Shadow',
summary: 'A ninja: steps between shadows and strikes unseen.',
features: {
3: [{ name: 'Shadow Arts', text: 'Way of Shadow. 2 ki: cast Darkness, Darkvision, Pass without Trace or Silence without a slot. You can also make a small illusion at will.' }],
6: [{ name: 'Shadow Step', text: 'Bonus action, while in dim light or darkness: teleport up to 60 ft to another dim or dark spot you can see, and have advantage on your first melee attack this turn.' }],
},
},
{
key: 'FOUR_ELEMENTS',
name: 'Way of the Four Elements',
summary: 'Bends fire, water, earth and air with ki.',
features: {
3: [
{ name: 'Elemental Attunement', text: 'Way of the Four Elements. Action: a harmless trick of fire, water, earth or air within 30 ft (light or snuff a flame, chill or warm, shape a little earth or mist).' },
{ name: 'Elemental Disciplines', text: 'Choose one with your DM: Fangs of the Fire Snake (1 ki: your unarmed reach is 10 ft this turn and hits deal fire damage; +1 ki for +1d10 fire), Fist of Unbroken Air (2 ki: one creature within 30 ft takes 3d10 bludgeoning, is pushed 20 ft and knocked prone; Strength save for half and no push), Water Whip (2 ki: one creature within 30 ft takes 3d10 bludgeoning and is pulled 25 ft or knocked prone; Dexterity save for half), Shape the Flowing River (1 ki: reshape or freeze water or ice in a 30 ft cube).' },
],
6: [{ name: 'Elemental Disciplines', text: 'Choose one more with your DM (two in all). New at this level: Gong of the Summit (3 ki: cast Shatter), Clench of the North Wind (3 ki: cast Hold Person).' }],
},
},
],
PALADIN: [
{
key: 'DEVOTION',
name: 'Oath of Devotion',
summary: 'The shining knight: honesty, courage, a blessed blade.',
features: {
3: [
{ name: 'Sacred Weapon', text: 'Oath of Devotion, Channel Divinity. Add your Charisma modifier to attack rolls with your weapon for 1 minute; it sheds light.' },
{ name: 'Turn the Unholy', text: 'Channel Divinity. Fiends and undead within 30 ft make a Wisdom save or flee for 1 minute.' },
],
7: [{ name: 'Aura of Devotion', text: 'You and allies within 10 ft can’t be charmed.' }],
},
},
{
key: 'ANCIENTS',
name: 'Oath of the Ancients',
summary: 'A green knight who guards life, light and laughter.',
features: {
3: [
{ name: 'Nature’s Wrath', text: 'Oath of the Ancients, Channel Divinity. Spectral vines restrain a creature within 10 ft (Strength or Dexterity save; it repeats the save at the end of each of its turns).' },
{ name: 'Turn the Faithless', text: 'Channel Divinity. Fey and fiends within 30 ft make a Wisdom save or flee for 1 minute.' },
],
7: [{ name: 'Aura of Warding', text: 'You and allies within 10 ft take half damage from spells.' }],
},
},
{
key: 'VENGEANCE',
name: 'Oath of Vengeance',
summary: 'A relentless hunter of one sworn enemy at a time.',
features: {
3: [
{ name: 'Vow of Enmity', text: 'Oath of Vengeance, Channel Divinity. Bonus action: for 1 minute you have advantage on attack rolls against one creature within 10 ft.' },
{ name: 'Abjure Enemy', text: 'Channel Divinity. One creature within 60 ft makes a Wisdom save or is frightened and cannot move for 1 minute; on a success its speed is halved.' },
],
7: [{ name: 'Relentless Avenger', text: 'When you hit with an opportunity attack, move up to half your speed straight afterwards without provoking attacks.' }],
},
},
],
RANGER: [
{
key: 'HUNTER',
name: 'Hunter',
summary: 'Brings down the biggest thing in the room.',
features: {
3: [{ name: 'Colossus Slayer', text: 'Hunter’s Prey. Once per turn, +1d8 damage to a creature that is already below its max HP.' }],
7: [{ name: 'Escape the Horde', text: 'Defensive Tactics. Opportunity attacks against you have disadvantage.' }],
},
},
{
key: 'BEAST_MASTER',
name: 'Beast Master',
summary: 'Fights beside an animal companion.',
features: {
3: [{ name: 'Ranger’s Companion', text: 'Beast Master. A beast of CR 1/4 or lower fights beside you (the DM adds it to encounters as a monster). It acts on your turn: it can move freely, and attacks, Dashes, Disengages or Helps when you use your action to tell it to. Add your proficiency bonus to its AC, attacks and damage. If it dies, bond with another over 8 hours.' }],
7: [{ name: 'Exceptional Training', text: 'On a turn your companion does not attack, it can Dash, Disengage or Help as your bonus action. Its attacks count as magical.' }],
},
},
{
key: 'GLOOM_STALKER',
name: 'Gloom Stalker',
summary: 'An ambusher who owns the first round and the dark.',
features: {
3: [
{ name: 'Dread Ambusher', text: 'Gloom Stalker. Add your Wisdom modifier to initiative (ask your DM to count it). On your first turn of a fight: +10 ft speed and one extra weapon attack, which deals +1d8 damage on a hit.' },
{ name: 'Umbral Sight', text: 'You see in darkness to 60 ft, and creatures that rely on darkvision cannot see you in the dark.' },
],
7: [{ name: 'Iron Mind', text: 'Treat Wisdom saves as proficient (add your proficiency bonus by hand).' }],
},
},
],
ROGUE: [
{
key: 'THIEF',
name: 'Thief',
summary: 'Quick fingers, rooftops and anything that can be used.',
features: {
3: [
{ name: 'Fast Hands', text: 'Bonus action: Sleight of Hand, pick a lock, disarm a trap or Use an Object.' },
{ name: 'Second-Story Work', text: 'Climbing costs no extra movement; running jumps + Dexterity modifier feet.' },
],
9: [{ name: 'Supreme Sneak', text: 'Advantage on Stealth if you move no more than half your speed.' }],
},
},
{
key: 'ASSASSIN',
name: 'Assassin',
summary: 'Strikes first, and the first strike is the worst.',
features: {
3: [
{ name: 'Assassinate', text: 'Assassin. Advantage on attacks against any creature that has not yet taken a turn in this fight, and any hit on a surprised creature is a critical hit.' },
{ name: 'Tools of the Trade', text: 'You are practised with disguises and poisons: advantage on checks to make or use them.' },
],
9: [{ name: 'Infiltration Expertise', text: 'With a week and 25 gold you build a false identity with papers and a history; nobody doubts it without an obvious reason.' }],
},
},
{
key: 'SWASHBUCKLER',
name: 'Swashbuckler',
summary: 'A duellist: in, strike, out, and a smile.',
features: {
3: [
{ name: 'Fancy Footwork', text: 'Swashbuckler. A creature you made a melee attack against this turn cannot make opportunity attacks against you until the turn ends.' },
{ name: 'Rakish Audacity', text: 'Add your Charisma modifier to initiative (ask your DM to count it). You can Sneak Attack a creature with nobody else next to you or it, without needing advantage.' },
],
9: [{ name: 'Panache', text: 'Action: Persuasion against a creature’s Insight. A hostile one has disadvantage on attacks against anyone but you for 1 minute; any other is charmed by you for 1 minute.' }],
},
},
],
SORCERER: [
{
key: 'DRACONIC',
name: 'Draconic Bloodline',
summary: 'Dragon blood: tougher skin and one element at your call.',
features: {
3: [{ name: 'Dragon Ancestor', text: 'Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier (ask your DM about Extra AC).' }],
6: [{ name: 'Elemental Affinity', text: 'Draconic Bloodline. Add your Charisma modifier to one damage roll of your dragon’s element; spend 1 sorcery point to resist that element for 1 hour.' }],
},
},
{
key: 'WILD_MAGIC',
name: 'Wild Magic',
summary: 'Raw chaos: luck you can bend, and surprises you cannot.',
features: {
3: [
{ name: 'Wild Magic Surge', text: 'Wild Magic. After you cast a sorcerer spell of 1st level or higher, the DM may have you roll a d20: on a 1, something strange happens (the DM decides, or rolls on a table of their own).' },
{ name: 'Tides of Chaos', text: 'Once per long rest, gain advantage on one attack, check or save. The DM may give it back by calling for a Wild Magic Surge.' },
],
6: [{ name: 'Bend Luck', text: 'Reaction, 2 sorcery points: add or subtract 1d4 from another creature’s attack, check or save.' }],
},
},
{
key: 'STORM',
name: 'Storm Sorcery',
summary: 'Wind and lightning: never stands still for long.',
features: {
3: [
{ name: 'Tempestuous Magic', text: 'Storm Sorcery. Bonus action, just before or after casting a spell of 1st level or higher: fly 10 ft without provoking opportunity attacks.' },
{ name: 'Wind Speaker', text: 'You speak the tongue of air, water, earth and fire elementals.' },
],
6: [
{ name: 'Heart of the Storm', text: 'You resist lightning and thunder damage. When you start casting a lightning or thunder spell of 1st level or higher, creatures of your choice within 10 ft take half your level in lightning or thunder damage.' },
{ name: 'Storm Guide', text: 'Action: stop rain falling within 20 ft of you. Bonus action: choose which way the wind blows within 100 ft until your next turn.' },
],
},
},
],
WARLOCK: [
{
key: 'FIEND',
name: 'The Fiend',
summary: 'A pact with something from the hells: power fed by the fallen.',
features: {
3: [{ name: 'Dark One’s Blessing', text: 'The Fiend. When you bring a hostile creature to 0 HP, gain Temp HP equal to your Charisma modifier + your level (the DM adds it).' }],
6: [{ name: 'Patron’s Luck', text: 'Once per short rest, add a d10 to an ability check or saving throw after seeing the roll.' }],
10: [{ name: 'Patron’s Resilience', text: 'After each rest, choose one damage type: you resist it until you choose another.' }],
},
},
{
key: 'ARCHFEY',
name: 'The Archfey',
summary: 'A fey lord’s favourite: glamour, vanishing and beguilement.',
features: {
3: [{ name: 'Fey Presence', text: 'The Archfey. Once per short rest, action: each creature in a 10 ft cube from you makes a Wisdom save or is charmed or frightened (your choice) until the end of your next turn.' }],
6: [{ name: 'Misty Escape', text: 'Once per short rest, reaction when you take damage: turn invisible and teleport up to 60 ft. You stay invisible until your next turn, or until you attack or cast.' }],
10: [{ name: 'Beguiling Defenses', text: 'You can’t be charmed. When a creature tries, turn it back: it makes a Wisdom save or is charmed by you for 1 minute.' }],
},
},
{
key: 'GREAT_OLD_ONE',
name: 'The Great Old One',
summary: 'Something vast and unknowable whispers through you.',
features: {
3: [{ name: 'Awakened Mind', text: 'The Great Old One. Speak mind to mind with any creature you can see within 30 ft; it needs no shared language, only to know one.' }],
6: [{ name: 'Entropic Ward', text: 'Once per short rest, reaction: an attack against you has disadvantage. If it misses, your next attack against that creature has advantage.' }],
10: [{ name: 'Thought Shield', text: 'Nobody reads your thoughts unless you allow it. You resist psychic damage, and whoever deals it to you takes the same amount.' }],
},
},
],
WIZARD: [
{
key: 'EVOCATION',
name: 'School of Evocation',
summary: 'Blasting, with friends left standing in the middle of it.',
features: {
2: [
{ name: 'Evocation Savant', text: 'School of Evocation. Copying evocation spells costs half the time and gold.' },
{ name: 'Sculpt Spells', text: 'When you cast an evocation spell that hits an area, choose 1 + the spell’s level creatures to automatically save and take no damage.' },
],
6: [{ name: 'Potent Cantrip', text: 'Creatures that save against your damaging cantrips still take half damage.' }],
10: [{ name: 'Empowered Evocation', text: 'Add your Intelligence modifier to the damage of your evocation spells.' }],
},
},
{
key: 'ABJURATION',
name: 'School of Abjuration',
summary: 'Wards and counters: a shield of magic that takes the hits.',
features: {
2: [
{ name: 'Abjuration Savant', text: 'School of Abjuration. Copying abjuration spells costs half the time and gold.' },
{ name: 'Arcane Ward', text: 'When you cast an abjuration spell of 1st level or higher, a ward appears with HP of twice your level + your Intelligence modifier; it takes damage before you do (the DM tracks it as Temp HP). It lasts until a long rest; later abjuration spells restore twice their level to it.' },
],
6: [{ name: 'Projected Ward', text: 'Reaction: when a creature within 30 ft takes damage, your Arcane Ward takes it instead.' }],
10: [{ name: 'Improved Abjuration', text: 'Add your proficiency bonus to ability checks made as part of an abjuration spell (Counterspell, Dispel Magic).' }],
},
},
{
key: 'ILLUSION',
name: 'School of Illusion',
summary: 'Tricks of light and sound that hold up to a second look.',
features: {
2: [
{ name: 'Illusion Savant', text: 'School of Illusion. Copying illusion spells costs half the time and gold.' },
{ name: 'Improved Minor Illusion', text: 'You know Minor Illusion (the DM grants it), and it makes a sound and an image with one casting.' },
],
6: [{ name: 'Malleable Illusions', text: 'Action: change what an illusion of yours looks like while it lasts, if it lasts a minute or more.' }],
10: [{ name: 'Illusory Self', text: 'Once per short rest, reaction when a creature attacks you: a copy of you takes the blow and the attack misses.' }],
},
},
],
};
function subclassesOf(characterClass) {
return SUBCLASSES[characterClass] || [];
}
// One subclass of a class by its key; null when the class has no such subclass.
function findSubclass(characterClass, key) {
return subclassesOf(characterClass).find((subclass) => subclass.key === key) || null;
}
function subclassName(characterClass, key) {
const subclass = findSubclass(characterClass, key);
return subclass ? subclass.name : null;
}
// The path a class followed before subclasses could be chosen.
function defaultSubclass(characterClass) {
return subclassesOf(characterClass)[0] || null;
}
module.exports = { SUBCLASS_LEVEL, SUBCLASSES, subclassesOf, findSubclass, subclassName, defaultSubclass };
+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 };
+117 -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 with your DM: ${subclassesOf(characterClass).map((subclass) => subclass.name).join(', ')}.`,
});
// Everything a subclass has given by `level`, in the order it came.
function subclassFeaturesThrough(subclass, level) {
return Object.keys(subclass.features)
.map(Number)
.filter((at) => at <= level)
.sort((a, b) => a - b)
.flatMap((at) => subclass.features[at]);
}
// Writes a newly chosen subclass onto the sheet: everything it has given up
// to the character's level, in place of the line saying a choice is owed.
function withSubclass(specialAbilities, characterClass, level, subclassKey) {
const subclass = findSubclass(characterClass, subclassKey);
if (!subclass) throw new LevelUpError('That is not a subclass of this class.');
const lines = toLines(specialAbilities).filter((line) => lineName(line) !== SUBCLASS_OWED.toLowerCase());
return addFeatures(fromLines(lines), subclassFeaturesThrough(subclass, level));
}
// Takes a subclass's lines back off the sheet, found by their names. A line
// the class itself also writes under that name is left alone.
function withoutSubclass(specialAbilities, characterClass, subclassKey) {
const subclass = findSubclass(characterClass, subclassKey);
if (!subclass) return specialAbilities;
const classNames = new Set(
[...(CLASS_FEATURES[characterClass] || []), ...Object.values(LEVEL_FEATURES[characterClass] || {}).flat()]
.filter((feature) => feature.name)
.map((feature) => feature.name.toLowerCase())
);
const names = new Set(
Object.values(subclass.features)
.flat()
.map((feature) => feature.name.toLowerCase())
.filter((name) => !classNames.has(name))
);
return fromLines(toLines(specialAbilities).filter((line) => !names.has(lineName(line))));
}
// The DM changing (or setting, or clearing) a character's subclass after the
// fact: the old one's lines go, the new one's are written.
function switchSubclass(specialAbilities, characterClass, level, fromKey, toKey) {
const cleared = withoutSubclass(specialAbilities, characterClass, fromKey);
if (toKey) return withSubclass(cleared, characterClass, level, toKey);
return owesSubclass(characterClass, level) ? addFeatures(cleared, [subclassOwedFeature(characterClass)]) : cleared;
}
// The subclasses a character may pick from on reaching `level`, each with
// what it would write; null when there is no choice to make (too low a level,
// or one is chosen already).
function subclassChoice(characterClass, level, currentKey) {
if (!owesSubclass(characterClass, level) || findSubclass(characterClass, currentKey)) return null;
return {
level: SUBCLASS_LEVEL[characterClass],
options: subclassesOf(characterClass).map((subclass) => ({
key: subclass.key,
name: subclass.name,
summary: subclass.summary,
features: subclassFeaturesThrough(subclass, level).map(({ name, text }) => ({ name, text })),
})),
};
}
// How many times a Boon has been taken, read off its line ("Hardy — +10
// maximum HP…" is twice).
function boonTaken(specialAbilities, boon) {
@@ -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,4 @@ function levelUpPlan(character) {
};
}
module.exports = { LevelUpError, featuresAt, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
module.exports = { LevelUpError, featuresAt, owesSubclass, subclassChoice, subclassByDefault, switchSubclass, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
+111 -7
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, 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', () => {
@@ -61,7 +63,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 +72,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,7 +94,7 @@ describe('advance', () => {
});
test('with no Boon picked the line says one is owed', () => {
const { specialAbilities, armorBonus } = advance(null, 'WIZARD', 4);
const { specialAbilities, armorBonus } = advance(null, 'WIZARD', 4, undefined, { key: 'EVOCATION' });
expect(specialAbilities).toBe('Boon (level 4) — Choose one with your DM: Weapon Mastery, Arcane Focus, Toughened, Hardy.');
expect(armorBonus).toBe(0);
});
@@ -125,6 +127,108 @@ 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']);
expect(plan.subclass.options[0].features.map((f) => f.name)).toEqual(['Improved Critical']);
// A wizard chooses a level earlier.
expect(levelUpPlan({ ...fighter, characterClass: 'WIZARD', level: 1 }).subclass.level).toBe(2);
// Still owed later on: the options then carry everything missed so far.
const late = levelUpPlan({ ...fighter, level: 6 });
expect(late.subclass.options[0].features.map((f) => f.name)).toEqual(['Improved Critical', 'Remarkable Athlete']);
// With one chosen there is nothing to ask, and its features are in the list.
const chosen = levelUpPlan({ ...fighter, level: 6, subclass: 'CHAMPION' });
expect(chosen.subclass).toBeNull();
expect(chosen.features.map((f) => f.name)).toEqual(['Remarkable Athlete']);
});
});
describe('a campaign without subclasses', () => {
const fighter = { characterClass: 'FIGHTER', specialAbilities: null, weapons: [] };
test('asks for nothing: the class takes its first subclass at the level it would have chosen', () => {
const plan = levelUpPlan({ ...fighter, level: 2 }, { subclasses: false });
expect(plan.subclass).toBeNull();
expect(plan.subclassTaken).toBe('CHAMPION');
expect(plan.features.map((f) => f.name)).toEqual(['Improved Critical']);
// Before that level, and once it is taken, there is nothing to take.
expect(levelUpPlan({ ...fighter, level: 1 }, { subclasses: false }).subclassTaken).toBeNull();
const later = levelUpPlan({ ...fighter, level: 6, subclass: 'CHAMPION' }, { subclasses: false });
expect(later.subclassTaken).toBeNull();
expect(later.features.map((f) => f.name)).toEqual(['Remarkable Athlete']);
});
test('a character who joined above that level gets everything missed so far', () => {
const plan = levelUpPlan({ ...fighter, level: 6 }, { subclasses: false });
expect(plan.subclassTaken).toBe('CHAMPION');
expect(plan.features.map((f) => f.name)).toEqual(['Improved Critical', 'Remarkable Athlete']);
});
test('with subclasses on, the plan takes none by default', () => {
expect(levelUpPlan({ ...fighter, level: 2 }).subclassTaken).toBeNull();
});
});
describe('subclasses', () => {
test('every class has three, each with a summary and features from its choice level to 10', () => {
for (const cls of Object.keys(CLASSES)) {
expect(SUBCLASSES[cls]).toHaveLength(3);
for (const subclass of SUBCLASSES[cls]) {
expect(subclass.key && subclass.name && subclass.summary).toBeTruthy();
const levels = Object.keys(subclass.features).map(Number);
expect(Math.min(...levels)).toBe(SUBCLASS_LEVEL[cls]);
expect(Math.max(...levels)).toBeLessThanOrEqual(10);
for (const f of Object.values(subclass.features).flat()) {
expect(f.name && f.text).toBeTruthy();
expect(f.name).not.toMatch(/—|–|--/); // the dash is what ends a name on the sheet
}
}
expect(new Set(SUBCLASSES[cls].map((s) => s.key)).size).toBe(3);
}
});
test('a subclass adds its features to the class\'s at each level', () => {
expect(featuresAt('FIGHTER', 3).map((f) => f.name)).toEqual([]);
expect(featuresAt('FIGHTER', 3, 'CHAMPION').map((f) => f.name)).toEqual(['Improved Critical']);
expect(featuresAt('CLERIC', 8, 'WAR').map((f) => f.boon || f.name)).toEqual([true, 'Destroy Undead', 'Divine Strike']);
expect(featuresAt('FIGHTER', 3, 'LIFE')).toEqual([]); // another class's subclass counts for nothing
});
test('without one, from the level the class chooses at, a line says a subclass is owed', () => {
expect(advance(null, 'FIGHTER', 2).specialAbilities).toBe('Action Surge — Once per short rest, take one extra action on your turn.');
const owed = advance(null, 'FIGHTER', 3).specialAbilities;
expect(owed).toBe('Subclass — Choose one with your DM: Champion, Battle Master, Samurai.');
// Said once, however many levels go by.
expect(advance(owed, 'FIGHTER', 5).specialAbilities.split('\n').filter((l) => l.startsWith('Subclass — '))).toHaveLength(1);
});
test('picking one late writes everything it has given so far, and clears the line', () => {
const owed = advance(advance(null, 'FIGHTER', 3).specialAbilities, 'FIGHTER', 5).specialAbilities;
const { specialAbilities } = advance(owed, 'FIGHTER', 7, undefined, { key: 'BATTLE_MASTER', chosen: true });
const lines = specialAbilities.split('\n');
expect(lines.some((l) => l.startsWith('Subclass — '))).toBe(false);
// The level 7 upgrade has taken the place of the level 3 line.
expect(lines.filter((l) => l.startsWith('Combat Superiority — '))).toEqual([expect.stringContaining('Five superiority dice (d8)')]);
expect(lines.filter((l) => l.startsWith('Manoeuvres — '))).toHaveLength(2);
expect(lines.some((l) => l.startsWith('Know Your Enemy — '))).toBe(true);
expect(() => advance(owed, 'FIGHTER', 7, undefined, { key: 'LIFE', chosen: true })).toThrow(LevelUpError);
});
test('switching takes the old subclass\'s lines off and writes the new one\'s', () => {
const champion = advance(advance('Second Wind — heal.', 'FIGHTER', 3, undefined, { key: 'CHAMPION', chosen: true }).specialAbilities, 'FIGHTER', 5, undefined, CHAMPION).specialAbilities;
expect(champion.split('\n').map((l) => l.split(' — ')[0])).toEqual(['Second Wind', 'Improved Critical', 'Extra Attack']);
const samurai = switchSubclass(champion, 'FIGHTER', 5, 'CHAMPION', 'SAMURAI');
expect(samurai.split('\n').map((l) => l.split(' — ')[0])).toEqual(['Second Wind', 'Extra Attack', 'Fighting Spirit', 'Bonus Proficiency']);
// Cleared altogether, the choice is owed again.
expect(switchSubclass(samurai, 'FIGHTER', 5, 'SAMURAI', null).split('\n').map((l) => l.split(' — ')[0])).toEqual(['Second Wind', 'Extra Attack', 'Subclass']);
// A line the class also writes under the same name is not taken away.
const valor = advance('Extra Attack — mine.', 'BARD', 6, undefined, { key: 'VALOR', chosen: true }).specialAbilities;
expect(switchSubclass(valor, 'BARD', 6, 'VALOR', 'LORE')).not.toContain('Combat Inspiration');
});
});
+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 });
});
});
+373 -4
View File
@@ -1623,6 +1623,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', () => {
@@ -3004,8 +3036,16 @@ 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)).toEqual(['CHAMPION', 'BATTLE_MASTER', 'SAMURAI']);
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;
@@ -3052,9 +3092,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 }]);
@@ -3075,6 +3117,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 with your DM: Thief, Assassin, Swashbuckler.');
// Going down takes nothing away, and coming back up adds nothing twice.
await patch({ level: 1 });
@@ -3086,4 +3129,330 @@ 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]);
// Too early, not theirs, and not the player's to set.
expect((await patch(dmAuth, { subclass: 'THIEF' })).status).toBe(400);
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');
});
});
+23 -3
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,7 @@ function campaignSummary(campaign, role) {
areaAttackAcPenalty: campaign.areaAttackAcPenalty,
maxEquipped: campaign.maxEquipped,
maxEquippedWeapons: campaign.maxEquippedWeapons,
subclassesEnabled: campaign.subclassesEnabled,
ownerId: campaign.ownerId,
archivedAt: campaign.archivedAt,
role,
@@ -69,6 +71,8 @@ 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(),
});
// POST /campaigns — create, creator becomes DM/owner
@@ -213,7 +217,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 +232,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 +276,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
+155 -20
View File
@@ -12,12 +12,15 @@ 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, subclassesOf, findSubclass, subclassName } = require('../game/data/subclasses');
const {
rollDie,
rollAbilityScore,
@@ -52,10 +55,15 @@ async function loadFullCharacter(id) {
weapons: { include: { weapon: true } },
spells: { include: { spell: true } },
currentLocation: true,
campaign: { select: { subclassesEnabled: 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 +75,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,8 +85,14 @@ function serializeCharacter(character, isOwner, isDM = true) {
name: character.name,
species: character.species,
characterClass: character.characterClass,
subclass: subclasses ? character.subclass : null,
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
// What the class can choose from, and the level it chooses at.
subclassLevel: subclasses ? (SUBCLASS_LEVEL[character.characterClass] ?? null) : null,
subclassOptions: subclasses ? subclassesOf(character.characterClass).map(({ key, name, summary }) => ({ key, name, summary })) : [],
level: character.level,
xp: character.xp,
xpProgress: xpProgress(character.xp, character.level),
motivation: character.motivation,
halfElfBonusAbility1: character.halfElfBonusAbility1,
halfElfBonusAbility2: character.halfElfBonusAbility2,
@@ -91,6 +107,9 @@ 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,
@@ -117,11 +136,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,
};
@@ -163,8 +188,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 +203,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 +221,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 +240,37 @@ 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.' });
if (newLevel < SUBCLASS_LEVEL[characterClass]) {
return res.status(400).json({ error: `${req.character.name} chooses a subclass at level ${SUBCLASS_LEVEL[characterClass]}.` });
}
}
// The old subclass's lines come off the sheet and the new one's go on.
if (writesAbilities) parsed.data.specialAbilities = switchSubclass(abilities, characterClass, newLevel, req.character.subclass, picked);
}
// Damage is soaked by temp HP before it reaches real HP — unless this
@@ -401,6 +458,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 +539,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 +548,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 +558,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 +580,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 +602,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 +728,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,
@@ -1135,6 +1261,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 +1273,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,
+177 -24
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();
@@ -104,6 +106,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 +150,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 +186,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 +195,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 +215,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 +244,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 +279,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 +308,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 +419,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 +472,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 +516,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 +549,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 +656,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 +747,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 +768,51 @@ 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.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);
}
+1
View File
@@ -179,6 +179,7 @@ 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(),
}),
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));