Compare commits

...
2 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 c3f2b0dfa7 V1.25.0: the DM approves a level, each player takes their own
- "Level up..." beside Rest approves a level for any of the party at once;
  with XP tracked, those who have the XP start ticked
- Each player is told with a notice and picks for themselves in the checklist:
  class (multiclass), subclass, Boon, spells within the level's allowance,
  the weapon upgrade; a waiting level shows on the sheet and campaign page
- The DM sees who still has picks to make, and can finish or take back any
- Hit points on a level up can be rolled (campaign setting): by the site, or
  at the table and typed in with physical dice
- Toasts: notices that slide in over any page

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:14:57 +02:00
35 changed files with 1866 additions and 130 deletions
+13 -3
View File
@@ -99,6 +99,12 @@ including a character sheet or an encounter) and **Account**.
checklist at level 2 or 3, and shown beside the class everywhere; the DM can
change it from the sheet, and switch a 2014 path to its 2024 update. Every
sheet shows **the road ahead**: what each level from 1 to 20 gives.
- **Multiclassing** (a campaign setting): the level-up checklist asks which
class gains the level, checks both classes' ability requirements, and works
out HP, Hit Dice, spell slots, counters and features by each class's own
level. The sheet suggests well-known multiclass routes from where the
character stands. Eldritch Knights and Arcane Tricksters now have their
spell slots.
- **43 species** (the 2014 core, the 2024 Aasimar and Goliath, and the species
of Monsters of the Multiverse) and **13 classes**, the Artificer included. A campaign can do without them: untick "Characters choose a
subclass" in its Settings and every class follows one path, unasked.
@@ -108,9 +114,13 @@ including a character sheet or an encounter) and **Account**.
who sees a roll. **Combat**: auto-rolled monster and NPC attacks, the
critical hit rule, monster HP, the area penalty. **Progression**: XP
tracking (off for milestone levelling) and HP on a level up.
- **⬆ Level Up…** on a sheet (DM) takes the character up a level with the
whole checklist: HP, the class's new special abilities, a Boon, spells and
the free weapon upgrade.
- **⬆ Level up…** (DM, beside Rest) approves a level for any of the party at
once. Each player gets a notice, sees what the level gives and makes their
own picks in the checklist: HP (the average, or a roll where the campaign
rolls), the class's new special abilities, a subclass, a Boon, spells and
the free weapon upgrade. A level waiting shows on the sheet and the
campaign page until it is taken; the DM sees who still has picks to make,
and can finish any of them.
- The character sheet is long, so it starts with a row of **section links**
that jump straight to that part of the page.
- On a small screen every table (weapons, items, spells, monsters, the party
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.23.0",
"version": "1.25.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -0,0 +1,19 @@
-- Multiclassing: levels a character takes in classes besides their first.
-- CreateTable
CREATE TABLE "character_class_levels" (
"id" TEXT NOT NULL,
"characterId" TEXT NOT NULL,
"characterClass" "CharacterClass" NOT NULL,
"level" INTEGER NOT NULL DEFAULT 1,
"subclass" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "character_class_levels_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "character_class_levels_characterId_characterClass_key" ON "character_class_levels"("characterId", "characterClass");
-- AddForeignKey
ALTER TABLE "character_class_levels" ADD CONSTRAINT "character_class_levels_characterId_fkey" FOREIGN KEY ("characterId") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,5 @@
-- Level-ups the DM approves for players to take, and rolled HP on a level up.
-- AlterTable
ALTER TABLE "characters" ADD COLUMN "hpRollAdjust" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "levelUpsPending" INTEGER NOT NULL DEFAULT 0;
+26
View File
@@ -357,6 +357,13 @@ model Character {
// until the class reaches the level it chooses at.
subclass String?
level Int @default(1)
// Levels the DM has approved that the character has not yet taken: each is
// one level-up checklist waiting for its picks. The player resolves them
// (or the DM does), one at a time.
levelUpsPending Int @default(0)
// Where the campaign rolls HP on a level up: how far the rolls so far are
// from the average they replace, in all (a 2 on a d8 is -3). Added to max HP.
hpRollAdjust Int @default(0)
xp Int @default(0)
motivation String?
@@ -436,12 +443,31 @@ model Character {
notes Note[]
encounterEntries EncounterParticipant[]
locationVisits CharacterLocationVisit[]
// Levels in classes other than characterClass, for a multiclassed character
// (game/multiclass.js). The first class's own level is `level` less these.
classLevels CharacterClassLevel[]
@@index([campaignId])
@@index([userId])
@@map("characters")
}
// A class a character has taken levels in besides their first (characterClass),
// with its own level and subclass. A single-class character has none.
model CharacterClassLevel {
id String @id @default(cuid())
characterId String
characterClass CharacterClass
level Int @default(1)
subclass String?
createdAt DateTime @default(now())
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
@@unique([characterId, characterClass])
@@map("character_class_levels")
}
model CharacterSkillProficiency {
id String @id @default(cuid())
characterId String
+67 -22
View File
@@ -7,7 +7,16 @@ 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, CLASS_SLOT_TABLES, MAX_SLOT_LEVEL } = require('./data/spellSlots');
const {
FULL_CASTER_SLOTS,
HALF_CASTER_SLOTS,
THIRD_CASTER_SLOTS,
PACT_SLOTS,
CASTER_KIND,
CLASS_SLOT_TABLES,
SUBCLASS_CASTER_KIND,
MAX_SLOT_LEVEL,
} = require('./data/spellSlots');
const ABILITIES = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'];
@@ -122,9 +131,18 @@ function getClassHitDieSize(characterClass) {
return data ? data.hitDie : 8;
}
function getClassSpellcastingAbility(characterClass) {
// The ability a class casts with; a subclass that casts in a class that does
// not (Eldritch Knight, Arcane Trickster) uses Intelligence.
function getClassSpellcastingAbility(characterClass, subclass = null) {
const data = characterClass ? CLASSES[characterClass] : null;
return data ? data.spellcastingAbility : null;
if (data && data.spellcastingAbility) return data.spellcastingAbility;
return SUBCLASS_CASTER_KIND[subclass] ? 'INT' : null;
}
// How a class casts (FULL, HALF, THIRD or PACT), counting a casting subclass;
// null for none.
function casterKind(characterClass, subclass = null) {
return CASTER_KIND[characterClass] || SUBCLASS_CASTER_KIND[subclass] || null;
}
// ---------------------------------------------------------------------------
@@ -140,15 +158,15 @@ function clampLevel(level) {
// How many spell slots a class has at a level, by slot level (1st first), and
// which rest gives them back. Null for a class that never has any. A warlock's
// slots are all of one slot level, so the lower ones are 0.
function spellSlotsFor(characterClass, level) {
const kind = CASTER_KIND[characterClass];
function spellSlotsFor(characterClass, level, subclass = null) {
const kind = casterKind(characterClass, subclass);
if (!kind) return null;
const row = clampLevel(level) - 1;
if (kind === 'PACT') {
const [count, slotLevel] = PACT_SLOTS[row];
return { kind, restoresOn: 'SHORT', max: Array.from({ length: slotLevel }, (_, i) => (i === slotLevel - 1 ? count : 0)) };
}
const table = CLASS_SLOT_TABLES[characterClass] || (kind === 'FULL' ? FULL_CASTER_SLOTS : HALF_CASTER_SLOTS);
const table = CLASS_SLOT_TABLES[characterClass] || { FULL: FULL_CASTER_SLOTS, HALF: HALF_CASTER_SLOTS, THIRD: THIRD_CASTER_SLOTS }[kind];
return { kind, restoresOn: 'LONG', max: [...table[row]] };
}
@@ -157,8 +175,12 @@ function spellSlotsFor(characterClass, level) {
// first), which may be shorter than the class's row or hold more than it now
// allows. Null when there are no slots to track (not a caster, or a level 1
// paladin).
function spellSlotState(characterClass, level, used = []) {
const slots = spellSlotsFor(characterClass, level);
function spellSlotState(characterClass, level, used = [], subclass = null) {
return slotStateOf(spellSlotsFor(characterClass, level, subclass), used);
}
// The slot tracker for a set of slots (spellSlotsFor's shape).
function slotStateOf(slots, used = []) {
if (!slots) return null;
const levels = slots.max
.map((max, i) => {
@@ -170,8 +192,8 @@ function spellSlotState(characterClass, level, used = []) {
}
// The highest level of spell a class can cast at a level; 0 for none.
function maxSpellLevel(characterClass, level) {
const slots = spellSlotsFor(characterClass, level);
function maxSpellLevel(characterClass, level, subclass = null) {
const slots = spellSlotsFor(characterClass, level, subclass);
return slots ? slots.max.length : 0;
}
@@ -303,6 +325,7 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
speedOverride = null,
spellSlotsUsed = [],
hitDiceUsed = 0,
hpRollAdjust = 0,
} = character;
const rolled = { STR: strScore, DEX: dexScore, CON: conScore, INT: intScore, WIS: wisScore, CHA: chaScore };
@@ -345,8 +368,20 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
};
}
// Every class the character has levels in, the first first. One entry for
// most characters; a multiclassed one adds up per class (multiclass.js).
const classes = multiclass.classLevels(character);
const multi = classes.length > 1;
const hitDieSize = getClassHitDieSize(characterClass);
const spellcastingAbility = getClassSpellcastingAbility(characterClass);
const casters = classes
.map((entry) => ({ ...entry, ability: getClassSpellcastingAbility(entry.characterClass, entry.subclass) }))
.filter((entry) => entry.ability && (casterKind(entry.characterClass, entry.subclass) !== 'THIRD' || entry.level >= 3));
const casting = (ability) => ({
ability,
saveDC: spellSaveDC(profBonus, abilities[ability].modifier),
attackBonus: spellAttackBonus(profBonus, abilities[ability].modifier),
});
const spellcastingAbility = casters.length > 0 ? casters[0].ability : null;
return {
abilities,
@@ -354,8 +389,14 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
armorClass: armorClass(abilities.DEX.modifier, armorBonus) + fromGear('AC'),
initiative: initiative(abilities.DEX.modifier) + fromGear('INITIATIVE'),
speed: Math.max(0, effectiveSpeed(species, speedOverride) + fromGear('SPEED')),
hp: { max: Math.max(1, hpMax(level, hitDieSize, abilities.CON.modifier) + fromGear('MAX_HP')) },
hitDice: hitDiceState(characterClass, level, hitDiceUsed),
hp: {
// hpRollAdjust: how far HP rolled on level-ups is from the average counted here.
max: Math.max(
1,
(multi ? multiclass.multiclassHpMax(classes, abilities.CON.modifier) : hpMax(level, hitDieSize, abilities.CON.modifier)) + (Number(hpRollAdjust) || 0) + fromGear('MAX_HP')
),
},
hitDice: multi ? multiclass.multiclassHitDice(classes, hitDiceUsed) : hitDiceState(characterClass, level, hitDiceUsed),
saves,
skills,
passivePerception: passivePerception(skills.PERCEPTION.bonus) + fromGear('PASSIVE_PERCEPTION'),
@@ -363,17 +404,15 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
passiveInsight: passiveScore(skills.INSIGHT.bonus),
// What equipped gear is doing to the numbers above, and what else it does.
gear,
spellcasting: spellcastingAbility
? {
ability: spellcastingAbility,
saveDC: spellSaveDC(profBonus, abilities[spellcastingAbility].modifier),
attackBonus: spellAttackBonus(profBonus, abilities[spellcastingAbility].modifier),
}
: null,
spellcasting: spellcastingAbility ? casting(spellcastingAbility) : null,
// A multiclassed caster's DC and attack for each class that casts.
spellcastingByClass: casters.map((entry) => ({ characterClass: entry.characterClass, ...casting(entry.ability) })),
// The slot tracker; null for a character with no spell slots.
spellSlots: spellSlotState(characterClass, level, spellSlotsUsed),
spellSlots: multi ? slotStateOf(multiclass.multiclassSpellSlots(classes), spellSlotsUsed) : spellSlotState(characterClass, level, spellSlotsUsed, character.subclass),
// A multiclassed warlock's pact slots, apart from the others; null otherwise.
pactSlots: multi ? multiclass.pactSlotsOf(classes) : null,
// The class counters (Rage, Ki...), with how many of each are left.
resources: resourceState(character, abilities, profBonus),
resources: multi ? multiclass.multiclassResources(classes, character.resourcesUsed, abilities, profBonus) : resourceState(character, abilities, profBonus),
};
}
@@ -524,6 +563,8 @@ module.exports = {
MAX_LEVEL,
MAX_SLOT_LEVEL,
spellSlotsFor,
slotStateOf,
casterKind,
spellSlotState,
maxSpellLevel,
hitDiceState,
@@ -541,3 +582,7 @@ module.exports = {
scaledDamageDice,
canUseGear,
};
// multiclass.js and this file use each other, so it is required here, at
// the end, and its functions are looked up when called.
const multiclass = require('./multiclass');
+116
View File
@@ -0,0 +1,116 @@
// Suggested multiclass routes: well-known ways to combine classes, for a
// player choosing where their next level goes. Each route starts from a class
// (`from`) and lists its `steps` in order: take `class` up to `toLevel` (that
// class's own level). `subclass` is the subclass the route assumes, if any,
// and `why` what it buys. The requirements are checked against the character
// when shown (game/multiclass.js); none of this is enforced.
const MULTICLASS_PATHS = [
{
key: 'HEXADIN',
name: 'Hexadin',
from: 'PALADIN',
steps: [{ class: 'PALADIN', toLevel: 2 }, { class: 'WARLOCK', toLevel: 1, subclass: 'HEXBLADE' }, { class: 'PALADIN', toLevel: 18 }],
why: 'Hexblade lets you swing with Charisma, so one ability powers attacks, smites and auras.',
},
{
key: 'SORCADIN',
name: 'Sorcadin',
from: 'PALADIN',
steps: [{ class: 'PALADIN', toLevel: 6 }, { class: 'SORCERER', toLevel: 14 }],
why: 'Aura of Protection, then a deep pool of spell slots to pour into Divine Smite.',
},
{
key: 'SORLOCK',
name: 'Sorlock',
from: 'SORCERER',
steps: [{ class: 'SORCERER', toLevel: 2 }, { class: 'WARLOCK', toLevel: 2 }, { class: 'SORCERER', toLevel: 18 }],
why: 'Agonizing Eldritch Blast, quickened with Metamagic, and pact slots that come back on a short rest.',
},
{
key: 'GLOOM_ASSASSIN',
name: 'Gloom Assassin',
from: 'RANGER',
steps: [{ class: 'RANGER', toLevel: 5, subclass: 'GLOOM_STALKER' }, { class: 'ROGUE', toLevel: 3, subclass: 'ASSASSIN' }, { class: 'RANGER', toLevel: 12 }],
why: 'An enormous first round: extra attacks, advantage and automatic critical hits on the surprised.',
},
{
key: 'FIGHTER_DIP',
name: 'Fighter dip for anyone who swings',
from: null,
steps: [{ class: 'FIGHTER', toLevel: 2 }],
why: 'Two levels give a fighting style, Second Wind and Action Surge: one huge turn per rest.',
},
{
key: 'CLERIC_DIP',
name: 'Cleric dip for armor and healing',
from: 'WIZARD',
steps: [{ class: 'CLERIC', toLevel: 1 }, { class: 'WIZARD', toLevel: 19 }],
why: 'Armor and shields, healing words and a domain’s gifts on a wizard, for one level.',
},
{
key: 'BLADESINGER_FIGHTER',
name: 'Fighting wizard',
from: 'FIGHTER',
steps: [{ class: 'FIGHTER', toLevel: 2 }, { class: 'WIZARD', toLevel: 18, subclass: 'BLADESINGING' }],
why: 'Starts in armor with Action Surge, then grows into a Bladesinger with real spells.',
},
{
key: 'ROGUE_FIGHTER',
name: 'Skirmisher',
from: 'ROGUE',
steps: [{ class: 'ROGUE', toLevel: 1 }, { class: 'FIGHTER', toLevel: 2 }, { class: 'ROGUE', toLevel: 17 }],
why: 'Action Surge for a second Sneak Attack chance in a turn, and better armor.',
},
{
key: 'MONK_CLERIC',
name: 'Wisdom warrior',
from: 'MONK',
steps: [{ class: 'MONK', toLevel: 5 }, { class: 'CLERIC', toLevel: 2 }, { class: 'MONK', toLevel: 13 }],
why: 'Stunning Strike and Extra Attack first, then Wisdom-powered healing and Channel Divinity.',
},
{
key: 'BARBARIAN_FIGHTER',
name: 'Raging champion',
from: 'BARBARIAN',
steps: [{ class: 'BARBARIAN', toLevel: 5 }, { class: 'FIGHTER', toLevel: 3, subclass: 'CHAMPION' }, { class: 'BARBARIAN', toLevel: 12 }],
why: 'Reckless Attack with critical hits on 19–20: advantage makes those crits come often.',
},
{
key: 'DRUID_CLERIC',
name: 'Nature priest',
from: 'DRUID',
steps: [{ class: 'CLERIC', toLevel: 1, subclass: 'LIFE' }, { class: 'DRUID', toLevel: 19 }],
why: 'One Life Domain level makes every druid healing spell heal more, and adds heavy armor.',
},
{
key: 'BARD_WARLOCK',
name: 'Eldritch bard',
from: 'BARD',
steps: [{ class: 'BARD', toLevel: 6 }, { class: 'WARLOCK', toLevel: 2 }, { class: 'BARD', toLevel: 12 }],
why: 'Eldritch Blast with Agonizing Blast as a dependable attack for a support caster.',
},
{
key: 'ARTIFICER_WIZARD',
name: 'Arcane engineer',
from: 'ARTIFICER',
steps: [{ class: 'ARTIFICER', toLevel: 2 }, { class: 'WIZARD', toLevel: 18 }],
why: 'Infusions and a tool focus early, then a wizard’s full spell list, all on Intelligence.',
},
{
key: 'RANGER_DRUID',
name: 'Wild warden',
from: 'RANGER',
steps: [{ class: 'RANGER', toLevel: 5 }, { class: 'DRUID', toLevel: 2 }, { class: 'RANGER', toLevel: 13 }],
why: 'Extra Attack and a ranger’s toolkit, plus Wild Shape for scouting and a second health bar.',
},
{
key: 'WARLOCK_PALADIN',
name: 'Blade pact knight',
from: 'WARLOCK',
steps: [{ class: 'WARLOCK', toLevel: 3, subclass: 'HEXBLADE' }, { class: 'PALADIN', toLevel: 2 }, { class: 'WARLOCK', toLevel: 15 }],
why: 'A pact weapon swung with Charisma, and short-rest slots to burn on Divine Smite.',
},
];
module.exports = { MULTICLASS_PATHS };
+39 -1
View File
@@ -74,6 +74,31 @@ const ARTIFICER_SLOTS = [
[4, 3, 3, 3, 2],
];
// Eldritch Knight, Arcane Trickster: a third of a full caster's pace, from
// level 3 (the subclass is what casts, not the class).
const THIRD_CASTER_SLOTS = [
[],
[],
[2],
[3],
[3],
[3],
[4, 2],
[4, 2],
[4, 2],
[4, 3],
[4, 3],
[4, 3],
[4, 3, 2],
[4, 3, 2],
[4, 3, 2],
[4, 3, 3],
[4, 3, 3],
[4, 3, 3],
[4, 3, 3, 1],
[4, 3, 3, 1],
];
// Warlock: a few slots, all of one slot level. [how many, which level].
const PACT_SLOTS = [
[1, 1],
@@ -115,6 +140,19 @@ const CASTER_KIND = {
// A class whose slots follow its own table rather than its kind's.
const CLASS_SLOT_TABLES = { ARTIFICER: ARTIFICER_SLOTS };
// Subclasses that cast in a class that otherwise does not, with Intelligence.
const SUBCLASS_CASTER_KIND = { ELDRITCH_KNIGHT: 'THIRD', ARCANE_TRICKSTER: 'THIRD' };
const MAX_SLOT_LEVEL = 9;
module.exports = { FULL_CASTER_SLOTS, HALF_CASTER_SLOTS, ARTIFICER_SLOTS, PACT_SLOTS, CASTER_KIND, CLASS_SLOT_TABLES, MAX_SLOT_LEVEL };
module.exports = {
FULL_CASTER_SLOTS,
HALF_CASTER_SLOTS,
ARTIFICER_SLOTS,
THIRD_CASTER_SLOTS,
PACT_SLOTS,
CASTER_KIND,
CLASS_SLOT_TABLES,
SUBCLASS_CASTER_KIND,
MAX_SLOT_LEVEL,
};
+42 -16
View File
@@ -7,6 +7,7 @@ const { SUBCLASS_LEVEL, subclassesOf, findSubclass, defaultSubclass } = require(
const { CLASS_FEATURES } = require('./data/characterOptions');
const { CASTER_KIND } = require('./data/spellSlots');
const { getClassSpellcastingAbility, maxSpellLevel, scaledDamageDice, MAX_GEAR_LEVEL } = require('./calculations');
const { classLevels, classOptions, joiningFeatures } = require('./multiclass');
// A choice that cannot be made: the message is for whoever made it.
class LevelUpError extends Error {}
@@ -211,25 +212,42 @@ function subclassByDefault(characterClass, level, currentKey) {
}
// The checklist for a character's next level. Takes the character with its
// weapons loaded (each with its pool entry). `subclasses` is the campaign's
// setting: off, there is no subclass to pick, and the class's first one is
// taken without asking (`subclassTaken`), its features listed with the rest.
function levelUpPlan(character, { subclasses = true } = {}) {
// weapons and class levels loaded (each weapon with its pool entry).
// `subclasses` is the campaign's setting: off, there is no subclass to pick,
// and the class's first one is taken without asking (`subclassTaken`), its
// features listed with the rest. `multiclass` (the campaign's setting) lists
// the classes the level could go to (`classChoice`, by the final ability
// `scores`), and `toClass` is the one it goes to: the character's first class
// unless another is named. Features, Boons, spells and the subclass all go by
// that class's own level.
function levelUpPlan(character, { subclasses = true, multiclass = false, toClass = null, scores = {} } = {}) {
const toLevel = character.level + 1;
const 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) : []),
];
const characterClass = toClass || character.characterClass;
const entry = classLevels(character).find((e) => e.characterClass === characterClass) || { characterClass, level: 0, subclass: null };
const classLevel = entry.level + 1;
const joining = entry.level === 0;
const taken = subclasses || joining ? null : subclassByDefault(characterClass, classLevel, entry.subclass);
const entries = joining
? joiningFeatures(characterClass)
: [
...featuresAt(characterClass, classLevel, taken ? null : entry.subclass),
...(taken ? subclassFeaturesThrough(findSubclass(characterClass, taken), classLevel) : []),
];
return {
fromLevel: character.level,
toLevel,
// The class taking the level, and its own level once it has.
characterClass,
classLevel,
// True when this is the character's first level in this class.
joining,
classChoice: multiclass ? { options: classOptions(character, scores) } : null,
// Set when a subclass has to be picked on the way to this level.
subclass: subclasses ? subclassChoice(character.characterClass, toLevel, character.subclass) : null,
subclass: subclasses ? subclassChoice(characterClass, classLevel, entry.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),
features: entries.filter((e) => !e.boon).map(({ name, text, upgrade }) => ({ name, text, upgrade: Boolean(upgrade) })),
boon: entries.some((e) => e.boon) ? boonOptions(character.specialAbilities) : null,
spells: spellsGainedAt(characterClass, classLevel),
// The free upgrade: one weapon of standard growth goes up a level, never
// past its holder.
weapons: (character.weapons || [])
@@ -242,11 +260,19 @@ function levelUpPlan(character, { subclasses = true } = {}) {
damageDice: scaledDamageDice(cw.weapon.damageDice, cw.level, cw.weapon.damageScaling, cw.weapon.minLevel),
nextDamageDice: scaledDamageDice(cw.weapon.damageDice, cw.level + 1, cw.weapon.damageScaling, cw.weapon.minLevel),
})),
// The class tables stop at level 10; past it only the numbers move.
beyondTables: toLevel > MAX_FEATURE_LEVEL,
// The class tables stop at level 20; past it only the numbers move.
beyondTables: classLevel > MAX_FEATURE_LEVEL,
};
}
// What taking a planned level does to a character's special abilities (see
// advance): a first level in a new class writes what that class grants at
// level 1 and teaches; any other goes through advance at the class's own level.
function advanceIn(specialAbilities, plan, boonKey, { key = null, chosen = false } = {}) {
if (plan.joining) return { specialAbilities: addFeatures(specialAbilities, joiningFeatures(plan.characterClass)), armorBonus: 0, tempHp: 0 };
return advance(specialAbilities, plan.characterClass, plan.classLevel, boonKey, { key, chosen });
}
// The road from level 1 to 20 for a class (and subclass): what every level
// gives, as the progression timeline shows it. Level 1 is what the builder
// writes; a level that grants a Boon says so. `fromSubclass` marks the
@@ -274,4 +300,4 @@ function progression(characterClass, subclassKey = null) {
return levels;
}
module.exports = { LevelUpError, progression, featuresAt, owesSubclass, subclassChoice, subclassByDefault, switchSubclass, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
module.exports = { LevelUpError, progression, advanceIn, featuresAt, owesSubclass, subclassChoice, subclassByDefault, switchSubclass, addFeatures, boonTaken, boonOptions, hardyTempHp, advance, spellsGainedAt, levelUpPlan };
+227
View File
@@ -0,0 +1,227 @@
// Multiclassing (the 2014 Player’s Handbook rules): a character takes a level
// in another class instead of the one they have. Their levels add up to their
// character level; hit dice, hit points, class features and counters go by
// each class's own level, and spell slots by the levels of every class that
// casts. Pure: no DB calls.
//
// A character's first class is Character.characterClass (with its subclass);
// the others are CharacterClassLevel rows. The first class's own level is the
// character level less the others'.
const { CLASSES } = require('./data/classes');
const { FULL_CASTER_SLOTS, PACT_SLOTS } = require('./data/spellSlots');
const { CLASS_FEATURES } = require('./data/characterOptions');
const { resourceState } = require('./classResources');
const calculations = require('./calculations');
// What a class asks of the scores before anyone can take a level in it (or
// leave it for another). Each entry is a list of alternatives; every ability
// in an alternative must be 13 or more.
const MULTICLASS_PREREQS = {
ARTIFICER: [['INT']],
BARBARIAN: [['STR']],
BARD: [['CHA']],
CLERIC: [['WIS']],
DRUID: [['WIS']],
FIGHTER: [['STR'], ['DEX']],
MONK: [['DEX', 'WIS']],
PALADIN: [['STR', 'CHA']],
RANGER: [['DEX', 'WIS']],
ROGUE: [['DEX']],
SORCERER: [['CHA']],
WARLOCK: [['CHA']],
WIZARD: [['INT']],
};
const PREREQ_SCORE = 13;
const ABILITY_NAMES = { STR: 'Strength', DEX: 'Dexterity', CON: 'Constitution', INT: 'Intelligence', WIS: 'Wisdom', CHA: 'Charisma' };
// "Strength 13 or Dexterity 13", "Dexterity 13 and Wisdom 13".
function prereqText(characterClass) {
return (MULTICLASS_PREREQS[characterClass] || [])
.map((all) => all.map((ability) => `${ABILITY_NAMES[ability]} ${PREREQ_SCORE}`).join(' and '))
.join(' or ');
}
// Whether final scores ({ STR: 14, ... }) meet a class's requirement.
function meetsPrereq(characterClass, scores) {
return (MULTICLASS_PREREQS[characterClass] || []).some((all) => all.every((ability) => (scores[ability] || 0) >= PREREQ_SCORE));
}
// What a class teaches someone who comes to it from another (the 2014
// multiclassing table), as a line for the special abilities.
const MULTICLASS_PROFICIENCIES = {
ARTIFICER: 'light and medium armor, shields, thieves’ tools and tinker’s tools',
BARBARIAN: 'shields, simple and martial weapons',
BARD: 'light armor, one skill of your choice and one musical instrument',
CLERIC: 'light and medium armor and shields',
DRUID: 'light and medium armor and shields (no metal)',
FIGHTER: 'light and medium armor, shields, simple and martial weapons',
MONK: 'simple weapons and shortswords',
PALADIN: 'light and medium armor, shields, simple and martial weapons',
RANGER: 'light and medium armor, shields, simple and martial weapons, and one skill from the ranger’s list',
ROGUE: 'light armor, one skill from the rogue’s list, and thieves’ tools',
SORCERER: 'nothing new: a sorcerer’s magic is in the blood',
WARLOCK: 'light armor and simple weapons',
WIZARD: 'nothing new: a wizard’s training is the spellbook',
};
// The lines a character's first level in another class writes: what the class
// grants at level 1 (multiclassing does not bring its saves or skills), and
// what it teaches.
function joiningFeatures(characterClass) {
return [
...(CLASS_FEATURES[characterClass] || []),
{ name: `${CLASSES[characterClass].name} Training`, text: `Multiclassing into ${CLASSES[characterClass].name} teaches ${MULTICLASS_PROFICIENCIES[characterClass]}.` },
];
}
// Every class a character has levels in, the first first:
// [{ characterClass, level, subclass, first }]. Takes a character with its
// classLevels (an empty list or none: a single class).
function classLevels(character) {
const others = [...(character.classLevels || [])]
.filter((row) => row.level > 0 && row.characterClass !== character.characterClass)
.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0));
const elsewhere = others.reduce((sum, row) => sum + row.level, 0);
return [
{ characterClass: character.characterClass, level: Math.max(1, (character.level || 1) - elsewhere), subclass: character.subclass || null, first: true },
...others.map((row) => ({ characterClass: row.characterClass, level: row.level, subclass: row.subclass || null, first: false })),
];
}
// The classes a character could put their next level in: those they have
// (always), and those whose requirement they and every class they have meet.
// `scores` are the final ability scores.
function classOptions(character, scores) {
const have = classLevels(character);
const leaving = have.filter((entry) => !meetsPrereq(entry.characterClass, scores));
return Object.keys(CLASSES).map((characterClass) => {
const current = have.find((entry) => entry.characterClass === characterClass);
if (current) return { characterClass, level: current.level, eligible: true, problems: [] };
const problems = [];
if (!meetsPrereq(characterClass, scores)) problems.push(`${CLASSES[characterClass].name} needs ${prereqText(characterClass)}.`);
for (const entry of leaving) problems.push(`Leaving ${CLASSES[entry.characterClass].name} for another class needs ${prereqText(entry.characterClass)}.`);
return { characterClass, level: 0, eligible: problems.length === 0, problems };
});
}
// The character as it would be with one more level in `characterClass`: the
// level and the class rows moved on. For working out the sheet afterwards.
function withLevelIn(character, characterClass, subclass = undefined) {
const next = { ...character, level: character.level + 1 };
if (characterClass === character.characterClass) {
if (subclass !== undefined) next.subclass = subclass;
return next;
}
const rows = [...(character.classLevels || [])];
const at = rows.findIndex((row) => row.characterClass === characterClass);
if (at >= 0) rows[at] = { ...rows[at], level: rows[at].level + 1, ...(subclass !== undefined ? { subclass } : {}) };
else rows.push({ characterClass, level: 1, subclass: subclass ?? null, createdAt: new Date() });
next.classLevels = rows;
return next;
}
// Hit points: the first class's die in full at level 1, then each level the
// average of its own class's die (rounded up), plus Constitution every level.
function multiclassHpMax(classes, conMod) {
let total = 0;
classes.forEach((entry, i) => {
const die = calculations.getClassHitDieSize(entry.characterClass);
const average = Math.floor(die / 2) + 1;
const levels = entry.level;
total += i === 0 ? die + conMod + (levels - 1) * (average + conMod) : levels * (average + conMod);
});
return total;
}
// Hit Dice: one per level, of each class's own size. Spent ones are counted
// together; `size` is the first class's, the one rests roll.
function multiclassHitDice(classes, used = 0) {
const count = classes.reduce((sum, entry) => sum + entry.level, 0);
const spent = Math.min(Math.max(Number(used) || 0, 0), count);
return {
size: calculations.getClassHitDieSize(classes[0].characterClass),
count,
used: spent,
remaining: count - spent,
pools: classes.map((entry) => ({ characterClass: entry.characterClass, size: calculations.getClassHitDieSize(entry.characterClass), count: entry.level })),
};
}
// How many levels a class adds to the shared caster level: all of a full
// caster's, half a half caster's (an artificer's rounded up), a third of a
// casting subclass's, and none of a warlock's (pact slots stand apart).
function casterLevelOf(entry) {
const kind = calculations.casterKind(entry.characterClass, entry.subclass);
if (kind === 'FULL') return entry.level;
if (kind === 'HALF') return entry.characterClass === 'ARTIFICER' ? Math.ceil(entry.level / 2) : Math.floor(entry.level / 2);
if (kind === 'THIRD') return Math.floor(entry.level / 3);
return 0;
}
// The spell slots of a multiclassed character (pact slots aside): a single
// casting class keeps its own table; two or more add up to a caster level on
// the full caster table. A character whose only casting is a warlock's has
// those pact slots. Null for none.
function multiclassSpellSlots(classes) {
const casters = classes.filter((entry) => {
const kind = calculations.casterKind(entry.characterClass, entry.subclass);
return kind && kind !== 'PACT';
});
if (casters.length === 0) {
const warlock = classes.find((entry) => calculations.casterKind(entry.characterClass) === 'PACT');
return warlock ? calculations.spellSlotsFor('WARLOCK', warlock.level) : null;
}
if (casters.length === 1) return calculations.spellSlotsFor(casters[0].characterClass, casters[0].level, casters[0].subclass);
const casterLevel = casters.reduce((sum, entry) => sum + casterLevelOf(entry), 0);
if (casterLevel < 1) return null;
return { kind: 'FULL', restoresOn: 'LONG', max: [...FULL_CASTER_SLOTS[Math.min(casterLevel, FULL_CASTER_SLOTS.length) - 1]] };
}
// A multiclassed warlock's pact slots, kept apart from the others' (they come
// back on a short rest): { count, slotLevel }, or null when the warlock's are
// the only slots (they are then the sheet's spell slots) or there is none.
function pactSlotsOf(classes) {
const warlock = classes.find((entry) => calculations.casterKind(entry.characterClass) === 'PACT');
if (!warlock) return null;
const others = classes.some((entry) => {
const kind = calculations.casterKind(entry.characterClass, entry.subclass);
return kind && kind !== 'PACT';
});
if (!others) return null;
const [count, slotLevel] = PACT_SLOTS[Math.min(warlock.level, PACT_SLOTS.length) - 1];
return { count, slotLevel };
}
// The class counters of every class, each by its own class level. Where two
// classes give the same counter (Channel Divinity), the larger one counts.
function multiclassResources(classes, resourcesUsed, abilities, profBonus) {
const byKey = new Map();
for (const entry of classes) {
for (const resource of resourceState({ ...entry, resourcesUsed }, abilities, profBonus)) {
const had = byKey.get(resource.key);
if (!had || resource.max > had.max) byKey.set(resource.key, resource);
}
}
return [...byKey.values()];
}
// Added to (not replacing) the exports object: calculations.js holds it from
// before this file has finished loading.
Object.assign(module.exports, {
MULTICLASS_PREREQS,
PREREQ_SCORE,
prereqText,
meetsPrereq,
joiningFeatures,
classLevels,
classOptions,
withLevelIn,
multiclassHpMax,
multiclassHitDice,
casterLevelOf,
multiclassSpellSlots,
pactSlotsOf,
multiclassResources,
});
+146
View File
@@ -0,0 +1,146 @@
const {
prereqText,
meetsPrereq,
classLevels,
classOptions,
withLevelIn,
multiclassHpMax,
multiclassSpellSlots,
pactSlotsOf,
casterLevelOf,
} = require('./multiclass');
const { computeCharacterSheet, spellSlotsFor } = require('./calculations');
const { levelUpPlan, advanceIn } = require('./levelUp');
const { MULTICLASS_PATHS } = require('./data/multiclassPaths');
const { findSubclass } = require('./data/subclasses');
const scores = (over = {}) => ({ STR: 10, DEX: 10, CON: 10, INT: 10, WIS: 10, CHA: 10, ...over });
describe('requirements', () => {
test('say what a class needs, with either-or where there is a choice', () => {
expect(prereqText('FIGHTER')).toBe('Strength 13 or Dexterity 13');
expect(prereqText('MONK')).toBe('Dexterity 13 and Wisdom 13');
});
test('are met by final scores of 13 or more', () => {
expect(meetsPrereq('FIGHTER', scores({ DEX: 13 }))).toBe(true);
expect(meetsPrereq('PALADIN', scores({ STR: 15, CHA: 12 }))).toBe(false);
});
test('apply to the class left as well as the one joined', () => {
const wizard = { characterClass: 'WIZARD', level: 3, classLevels: [] };
const options = classOptions(wizard, scores({ CHA: 14 })); // INT only 10: can't leave wizard
const warlock = options.find((o) => o.characterClass === 'WARLOCK');
expect(warlock.eligible).toBe(false);
expect(warlock.problems.join(' ')).toMatch(/Leaving Wizard/);
expect(options.find((o) => o.characterClass === 'WIZARD')).toMatchObject({ eligible: true, level: 3 });
expect(classOptions(wizard, scores({ INT: 15, CHA: 14 })).find((o) => o.characterClass === 'WARLOCK').eligible).toBe(true);
});
});
describe('class levels', () => {
test('the first class has the levels the others leave', () => {
const character = { characterClass: 'PALADIN', subclass: 'DEVOTION', level: 5, classLevels: [{ characterClass: 'WARLOCK', level: 2, subclass: null }] };
expect(classLevels(character)).toEqual([
{ characterClass: 'PALADIN', level: 3, subclass: 'DEVOTION', first: true },
{ characterClass: 'WARLOCK', level: 2, subclass: null, first: false },
]);
expect(classLevels({ characterClass: 'BARD', level: 4 })).toHaveLength(1);
});
test('a level in a class moves the character and that class on', () => {
const character = { characterClass: 'FIGHTER', level: 2, classLevels: [] };
const next = withLevelIn(character, 'ROGUE');
expect(next.level).toBe(3);
expect(classLevels(next).map((e) => [e.characterClass, e.level])).toEqual([['FIGHTER', 2], ['ROGUE', 1]]);
expect(classLevels(withLevelIn(next, 'FIGHTER')).map((e) => e.level)).toEqual([3, 1]);
});
});
describe('hit points', () => {
test('the first class’s die in full, then each level’s own class average', () => {
// Fighter 1 (10) + Wizard 2 (4 + 4), Constitution +1 at each of 3 levels.
expect(multiclassHpMax([{ characterClass: 'FIGHTER', level: 1 }, { characterClass: 'WIZARD', level: 2 }], 1)).toBe(10 + 8 + 3);
});
});
describe('spell slots', () => {
test('one casting class keeps its own table', () => {
expect(multiclassSpellSlots([{ characterClass: 'FIGHTER', level: 5 }, { characterClass: 'WIZARD', level: 3 }]).max).toEqual([4, 2]);
});
test('two casting classes add up to a caster level on the full table', () => {
// Paladin 6 (3) + Sorcerer 4 (4) = caster level 7.
expect(multiclassSpellSlots([{ characterClass: 'PALADIN', level: 6 }, { characterClass: 'SORCERER', level: 4 }]).max).toEqual([4, 3, 3, 1]);
expect(casterLevelOf({ characterClass: 'ARTIFICER', level: 3 })).toBe(2); // rounds up
expect(casterLevelOf({ characterClass: 'FIGHTER', level: 6, subclass: 'ELDRITCH_KNIGHT' })).toBe(2);
});
test('a warlock’s pact slots stand apart', () => {
const classes = [{ characterClass: 'SORCERER', level: 5 }, { characterClass: 'WARLOCK', level: 3 }];
expect(multiclassSpellSlots(classes).max).toEqual([4, 3, 2]);
expect(pactSlotsOf(classes)).toEqual({ count: 2, slotLevel: 2 });
expect(multiclassSpellSlots([{ characterClass: 'FIGHTER', level: 4 }, { characterClass: 'WARLOCK', level: 2 }]).kind).toBe('PACT');
});
test('the Eldritch Knight and Arcane Trickster cast from level 3', () => {
expect(spellSlotsFor('FIGHTER', 3, 'ELDRITCH_KNIGHT').max).toEqual([2]);
expect(spellSlotsFor('ROGUE', 7, 'ARCANE_TRICKSTER').max).toEqual([4, 2]);
expect(spellSlotsFor('FIGHTER', 3, 'CHAMPION')).toBeNull();
});
});
describe('the sheet', () => {
const base = { strScore: 16, dexScore: 12, conScore: 14, intScore: 10, wisScore: 10, chaScore: 14, species: 'HUMAN' };
test('a multiclassed character adds up hit dice, hit points, slots and counters by class', () => {
const sheet = computeCharacterSheet({ ...base, characterClass: 'PALADIN', level: 5, classLevels: [{ characterClass: 'SORCERER', level: 2 }] });
expect(sheet.hitDice).toMatchObject({ count: 5, pools: [{ characterClass: 'PALADIN', size: 10, count: 3 }, { characterClass: 'SORCERER', size: 6, count: 2 }] });
expect(sheet.hp.max).toBe(10 + 2 * 6 + 2 * 4 + 5 * 2); // CON 15 (14 + 1): +2
expect(sheet.spellSlots.levels.map((l) => l.max)).toEqual([4, 2]); // caster level 1 + 2 = 3
expect(sheet.spellcastingByClass.map((c) => c.characterClass)).toEqual(['PALADIN', 'SORCERER']);
expect(sheet.resources.find((r) => r.key === 'SORCERY_POINTS').max).toBe(2);
expect(sheet.resources.find((r) => r.key === 'LAY_ON_HANDS').max).toBe(15); // 5 × paladin level 3
});
test('a single class is worked out as before', () => {
const sheet = computeCharacterSheet({ ...base, characterClass: 'FIGHTER', level: 3, subclass: 'ELDRITCH_KNIGHT' });
expect(sheet.hitDice.pools).toBeUndefined();
expect(sheet.spellcasting.ability).toBe('INT');
expect(sheet.spellSlots.levels).toEqual([{ level: 1, max: 2, used: 0, remaining: 2 }]);
});
});
describe('levelling up into another class', () => {
const fighter = { characterClass: 'FIGHTER', level: 4, specialAbilities: null, weapons: [], classLevels: [] };
test('plans the new class at its own level 1, with what it grants and teaches', () => {
const plan = levelUpPlan(fighter, { multiclass: true, toClass: 'ROGUE', scores: scores({ STR: 15, DEX: 14 }) });
expect(plan).toMatchObject({ characterClass: 'ROGUE', classLevel: 1, joining: true, toLevel: 5, boon: null, subclass: null });
expect(plan.features.map((f) => f.name)).toEqual(['Sneak Attack', 'Thieves’ Cant', 'Rogue Training']);
expect(plan.classChoice.options.find((o) => o.characterClass === 'ROGUE').eligible).toBe(true);
const { specialAbilities } = advanceIn(null, plan, undefined);
expect(specialAbilities).toMatch(/^Rogue Training — /m);
});
test('a class’s Boons and subclass go by its own level', () => {
const multi = { ...fighter, level: 5, classLevels: [{ characterClass: 'ROGUE', level: 2 }] };
const plan = levelUpPlan(multi, { multiclass: true, toClass: 'ROGUE' });
expect(plan).toMatchObject({ classLevel: 3, joining: false });
expect(plan.subclass.options.map((o) => o.key)).toContain('ARCANE_TRICKSTER');
expect(levelUpPlan(multi, { multiclass: true }).characterClass).toBe('FIGHTER');
});
test('without the setting there is no class to choose', () => {
expect(levelUpPlan(fighter).classChoice).toBeNull();
});
});
test('every suggested route names real classes and subclasses', () => {
for (const path of MULTICLASS_PATHS) {
expect(path.steps.length).toBeGreaterThan(0);
for (const step of path.steps) {
if (step.subclass) expect(findSubclass(step.class, step.subclass)).not.toBeNull();
}
}
});
+144
View File
@@ -3644,3 +3644,147 @@ describe('reference', () => {
expect((await request(app).get('/reference/progression?class=BARBARIAN')).status).toBe(401);
});
});
describe('multiclassing', () => {
itIfDb('takes a level in another class only where the campaign allows it and the scores meet it', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('multiclass');
const id = await buildAs(campaign.id, playerAuth, 'Duo', 'FIGHTER', 3);
await prisma.character.update({ where: { id }, data: { strScore: 15, dexScore: 14, intScore: 8 } });
const plan = (cls) => request(app).get(`/characters/${id}/level-up${cls ? `?class=${cls}` : ''}`).set(dmAuth);
const levelUp = (body) => request(app).post(`/characters/${id}/level-up`).set(dmAuth).send(body);
expect((await plan('ROGUE')).status).toBe(400); // the campaign doesn't allow it
expect((await plan()).body.plan.classChoice).toBeNull();
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ multiclassEnabled: true });
const wizard = await plan('WIZARD');
expect(wizard.status).toBe(400);
expect(wizard.body.error).toMatch(/Intelligence 13/);
const rogue = (await plan('ROGUE')).body.plan;
expect(rogue).toMatchObject({ characterClass: 'ROGUE', classLevel: 1, joining: true, toLevel: 4 });
expect(rogue.classChoice.options.find((o) => o.characterClass === 'ROGUE').eligible).toBe(true);
const res = await levelUp({ characterClass: 'ROGUE' });
expect(res.status).toBe(200);
const character = res.body.character;
expect(character.level).toBe(4);
expect(character.classes).toEqual([
{ characterClass: 'FIGHTER', level: 3, subclass: null, subclassName: null },
{ characterClass: 'ROGUE', level: 1, subclass: null, subclassName: null },
]);
expect(character.specialAbilities).toMatch(/Rogue Training — /);
expect(character.sheet.hitDice.pools.map((p) => p.size)).toEqual([10, 8]);
// The rogue's own level 3 asks for its subclass; the fighter's level goes on as before.
await levelUp({ characterClass: 'ROGUE' });
const third = (await plan('ROGUE')).body.plan;
expect(third).toMatchObject({ classLevel: 3, joining: false });
expect((await levelUp({ characterClass: 'ROGUE' })).status).toBe(400); // subclass needed
const tricked = (await levelUp({ characterClass: 'ROGUE', subclass: 'ARCANE_TRICKSTER' })).body.character;
expect(tricked.classes[1]).toMatchObject({ characterClass: 'ROGUE', level: 3, subclass: 'ARCANE_TRICKSTER' });
expect(tricked.sheet.spellSlots.levels[0].max).toBe(2); // a third caster at rogue level 3
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters.find((c) => c.id === id);
expect(listed.classes.map((c) => `${c.characterClass} ${c.level}`)).toEqual(['FIGHTER 3', 'ROGUE 3']);
});
itIfDb('the reference lists each class’s requirement and the suggested routes', async () => {
const { playerAuth } = await setupCampaign('multiref');
const res = await request(app).get('/reference/multiclass').set(playerAuth);
expect(res.body.prereqs.MONK.text).toBe('Dexterity 13 and Wisdom 13');
expect(res.body.paths.map((p) => p.key)).toContain('SORLOCK');
});
});
describe('approved level-ups', () => {
itIfDb('the DM approves for many at once; each player then takes their own level, once', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('approve');
const ariaId = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
const boId = await buildAs(campaign.id, dmAuth, 'Bo', 'WIZARD', 1);
const approve = (auth, body) => request(app).post(`/campaigns/${campaign.id}/level-up`).set(auth).send(body);
const levelUp = (id, auth, body = {}) => request(app).post(`/characters/${id}/level-up`).set(auth).send(body);
// Not the player's to take until it is approved, and not theirs to approve.
expect((await request(app).get(`/characters/${ariaId}/level-up`).set(playerAuth)).status).toBe(403);
expect((await levelUp(ariaId, playerAuth)).status).toBe(403);
expect((await approve(playerAuth, { characterIds: [ariaId] })).status).toBe(403);
const approved = await approve(dmAuth, { characterIds: [ariaId, boId] });
expect(approved.status).toBe(200);
expect(approved.body.results.map((r) => [r.name, r.levelUpsPending, r.skipped])).toEqual([['Aria', 1, null], ['Bo', 1, null]]);
const listed = (await request(app).get(`/campaigns/${campaign.id}/characters`).set(playerAuth)).body.characters;
expect(listed.find((c) => c.id === ariaId).levelUpsPending).toBe(1);
// The player sees the plan and takes the level; a second try has nothing approved.
const plan = await request(app).get(`/characters/${ariaId}/level-up`).set(playerAuth);
expect(plan.status).toBe(200);
expect(plan.body.plan).toMatchObject({ toLevel: 2, hp: { method: 'AVERAGE', die: 10, average: 6 } });
const taken = await levelUp(ariaId, playerAuth);
expect(taken.status).toBe(200);
expect(taken.body.character).toMatchObject({ level: 2, levelUpsPending: 0 });
expect(taken.body.hpRolled).toBeNull();
expect((await levelUp(ariaId, playerAuth)).status).toBe(403);
// Bo is the DM's own character; another player cannot take that level.
expect((await levelUp(boId, playerAuth)).status).toBe(403);
// An approval can be taken back, and stops at the top level.
const revoked = await approve(dmAuth, { characterIds: [boId, ariaId], revoke: true });
expect(revoked.body.results.find((r) => r.name === 'Bo')).toMatchObject({ levelUpsPending: 0, skipped: null });
expect(revoked.body.results.find((r) => r.name === 'Aria').skipped).toMatch(/no level up waiting/);
await prisma.character.update({ where: { id: boId }, data: { level: 20 } });
expect((await approve(dmAuth, { characterIds: [boId] })).body.results[0].skipped).toMatch(/level 20/);
});
itIfDb('an approved level is no longer shown as due', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('approvedue');
const id = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
await request(app).patch(`/characters/${id}`).set(dmAuth).send({ xp: 300 });
const read = async () => (await request(app).get(`/characters/${id}`).set(playerAuth)).body.character;
expect((await read()).xpProgress.levelUpDue).toBe(true);
await request(app).post(`/campaigns/${campaign.id}/level-up`).set(dmAuth).send({ characterIds: [id] });
expect(await read()).toMatchObject({ levelUpsPending: 1, xpProgress: { levelUpDue: false } });
});
itIfDb('where the campaign rolls hit points, the die is rolled here or typed in from the table', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('hproll');
const id = await buildAs(campaign.id, playerAuth, 'Aria', 'FIGHTER', 1);
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ hpOnLevelUp: 'ROLL', diceMode: 'PHYSICAL' });
const before = (await request(app).get(`/characters/${id}`).set(dmAuth)).body.character.sheet.hp.max;
const levelUp = (body) => request(app).post(`/characters/${id}/level-up`).set(dmAuth).send(body);
expect((await request(app).get(`/characters/${id}/level-up`).set(dmAuth)).body.plan.hp).toEqual({ method: 'ROLL', die: 10, average: 6, physical: true });
expect((await levelUp({})).status).toBe(400); // the roll has to be entered
expect((await levelUp({ hpRoll: 11 })).status).toBe(400); // not on a d10
const low = await levelUp({ hpRoll: 2 });
expect(low.body.hpRolled).toBe(2);
// +2 from the die and +2 Constitution (CON 14), where the average would have given 6 + 2.
expect(low.body.character.sheet.hp.max).toBe(before + 2 + 2);
// Digital dice: the server rolls, and the sheet follows whatever came up.
await request(app).patch(`/campaigns/${campaign.id}`).set(dmAuth).send({ diceMode: 'DIGITAL' });
const rolled = await levelUp({ hpRoll: 10, subclass: 'CHAMPION' }); // a typed value is ignored
expect(rolled.body.hpRolled).toBeGreaterThanOrEqual(1);
expect(rolled.body.hpRolled).toBeLessThanOrEqual(10);
expect(rolled.body.character.sheet.hp.max).toBe(before + 4 + rolled.body.hpRolled + 2);
});
});
describe('a player picking spells on a level up', () => {
itIfDb('is offered the campaign spells they can cast, and held to the level’s allowance', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('playerspells');
const id = await buildAs(campaign.id, playerAuth, 'Mira', 'WIZARD', 1);
const spell = async (name, level) => (await request(app).post(`/campaigns/${campaign.id}/spells`).set(dmAuth).send({ name, level })).body.spell.id;
const [a, b, c, high] = [await spell('Alarm', 1), await spell('Shield', 1), await spell('Sleep', 1), await spell('Fireball', 3)];
await request(app).post(`/campaigns/${campaign.id}/level-up`).set(dmAuth).send({ characterIds: [id] });
const plan = (await request(app).get(`/characters/${id}/level-up`).set(playerAuth)).body.plan;
expect(plan.spells).toMatchObject({ newSpells: 2, maxSpellLevel: 1 });
expect(plan.spellOptions.map((s) => s.name)).toEqual(['Alarm', 'Shield', 'Sleep']); // not Fireball: too high
const levelUp = (spellIds) => request(app).post(`/characters/${id}/level-up`).set(playerAuth).send({ subclass: 'EVOCATION', spellIds });
expect((await levelUp([a, b, c])).status).toBe(400); // one too many
expect((await levelUp([a, high])).status).toBe(400); // out of reach
const done = await levelUp([a, b]);
expect(done.status).toBe(200);
expect(done.body.character.spells.map((s) => s.spell.name).sort()).toEqual(['Alarm', 'Shield']);
});
});
+66 -1
View File
@@ -13,6 +13,7 @@ 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 { classLevels } = require('../game/multiclass');
const { findSeed, normalizeCode, CODE_LENGTH } = require('../seeds');
const { createCampaignFromSeed } = require('../seeds/createCampaign');
@@ -254,8 +255,16 @@ function characterSummary(character, subclasses = true) {
characterClass: character.characterClass,
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
level: character.level,
levelUpsPending: character.levelUpsPending,
// Every class it has levels in; one entry unless it has multiclassed.
classes: classLevels(character).map((entry) => ({
characterClass: entry.characterClass,
level: entry.level,
subclassName: subclasses ? subclassName(entry.characterClass, entry.subclass) : null,
})),
xp: character.xp,
xpProgress: xpProgress(character.xp, character.level),
// A level already approved is no longer due.
xpProgress: xpProgress(character.xp, Math.min(MAX_LEVEL, character.level + character.levelUpsPending)),
buildStage: character.buildStage,
currentHp: character.currentHp,
tempHp: character.tempHp,
@@ -362,6 +371,62 @@ router.post('/:id/characters', requireCampaignMember(), async (req, res, next) =
}
});
// ---------------------------------------------------------------------------
// Level-ups — the DM approves a level for any of the party at once. Each
// approval is a checklist waiting (Character.levelUpsPending): the
// character's player is told and makes their own picks (POST
// /characters/:id/level-up), so five players levelling together do not queue
// for the DM. A character with no player is the DM's to finish.
// ---------------------------------------------------------------------------
const approveLevelUpSchema = z.object({
characterIds: z.array(z.string()).min(1).max(200),
// Takes an approval back (one not yet used) instead of giving one.
revoke: z.boolean().default(false),
});
router.post('/:id/level-up', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = approveLevelUpSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const { revoke } = parsed.data;
const ids = [...new Set(parsed.data.characterIds)];
const characters = await prisma.character.findMany({
where: { id: { in: ids }, campaignId: req.campaign.id, buildStage: 'COMPLETE' },
orderBy: { createdAt: 'asc' },
});
if (characters.length !== ids.length) return res.status(400).json({ error: 'Pick finished characters from this campaign.' });
const results = characters.map((character) => {
const who = { characterId: character.id, name: character.name, userId: character.userId, level: character.level };
if (revoke) {
return character.levelUpsPending > 0
? { ...who, levelUpsPending: character.levelUpsPending - 1, skipped: null }
: { ...who, levelUpsPending: 0, skipped: `${character.name} has no level up waiting.` };
}
if (character.level + character.levelUpsPending >= MAX_LEVEL) {
return { ...who, levelUpsPending: character.levelUpsPending, skipped: `${character.name} is already at (or approved up to) level ${MAX_LEVEL}.` };
}
return { ...who, levelUpsPending: character.levelUpsPending + 1, skipped: null };
});
const changed = results.filter((result) => !result.skipped);
await prisma.$transaction(
changed.map((result) => prisma.character.update({ where: { id: result.characterId }, data: { levelUpsPending: result.levelUpsPending } }))
);
const io = req.app.get('io');
for (const { characterId, userId } of changed) {
io.to(`campaign:${req.campaign.id}`).emit('character:updated', { characterId });
// Tells the character's player there is a level to take (ids only).
if (!revoke) io.to(`campaign:${req.campaign.id}`).emit('character:level-up-granted', { characterId, userId });
}
res.json({ revoke, results });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Rests — the DM rests any of the party at once (utils/rest.js)
// ---------------------------------------------------------------------------
+130 -16
View File
@@ -8,6 +8,7 @@ const {
computeCharacterSheet,
computeWeaponStats,
getClassSaveProficiencies,
getClassHitDieSize,
getClassSpellcastingAbility,
scaledDamageDice,
canUseGear,
@@ -18,7 +19,9 @@ const {
MAX_SLOT_LEVEL,
ABILITIES,
} = require('../game/calculations');
const { LevelUpError, advance, levelUpPlan, switchSubclass, subclassByDefault } = require('../game/levelUp');
const { LevelUpError, advance, advanceIn, levelUpPlan, switchSubclass, subclassByDefault } = require('../game/levelUp');
const { classLevels, withLevelIn } = require('../game/multiclass');
const { CLASSES } = require('../game/data/classes');
const { resourceTally, MAX_EXHAUSTION } = require('../game/classResources');
const { SUBCLASS_LEVEL, SUBCLASSES, subclassesOf, findSubclass, subclassName, newerVersionOf, subclassSummary } = require('../game/data/subclasses');
const {
@@ -62,7 +65,8 @@ async function loadFullCharacter(id) {
weapons: { include: { weapon: true } },
spells: { include: { spell: true } },
currentLocation: true,
campaign: { select: { subclassesEnabled: true, diceMode: true } },
classLevels: true,
campaign: { select: { subclassesEnabled: true, multiclassEnabled: true, diceMode: true, hpOnLevelUp: true } },
},
});
}
@@ -100,8 +104,19 @@ function serializeCharacter(character, isOwner, isDM = true) {
// The 2024 subclass that updates the one the character follows, if any.
subclassUpgrade: subclasses && newerVersionOf(character.characterClass, character.subclass) ? subclassSummary(newerVersionOf(character.characterClass, character.subclass)) : null,
level: character.level,
// Levels the DM has approved that are still to be taken (their picks made).
levelUpsPending: character.levelUpsPending,
// Every class the character has levels in, the first first; one entry
// unless they have multiclassed.
classes: classLevels(character).map((entry) => ({
characterClass: entry.characterClass,
level: entry.level,
subclass: subclasses ? entry.subclass : null,
subclassName: subclasses ? subclassName(entry.characterClass, entry.subclass) : null,
})),
xp: character.xp,
xpProgress: xpProgress(character.xp, character.level),
// A level already approved is no longer due.
xpProgress: xpProgress(character.xp, Math.min(MAX_LEVEL, character.level + character.levelUpsPending)),
motivation: character.motivation,
speciesBonusChoices: character.speciesBonusChoices || {},
rolledScores: {
@@ -538,33 +553,91 @@ router.post('/:id/resources', requireCharacterAccess({ write: true }), async (re
// and the free weapon upgrade.
// ---------------------------------------------------------------------------
// Answers the request and returns false when this character cannot be levelled up.
// Answers the request and returns false when this character cannot be levelled
// up. The DM always can; the character's own player can once the DM has
// approved a level for them (levelUpsPending).
function canLevelUp(req, res, character) {
let refusal = null;
if (!req.isDM) refusal = [403, 'Only the DM can level a character up.'];
else if (character.buildStage !== 'COMPLETE') refusal = [400, 'Character build is not finished yet.'];
if (!req.isDM && !(req.isOwner && character.levelUpsPending > 0)) {
refusal = [403, req.isOwner ? 'Your DM has not approved a level up yet.' : 'Only the DM can level a character up.'];
} else if (character.buildStage !== 'COMPLETE') refusal = [400, 'Character build is not finished yet.'];
else if (character.level >= MAX_LEVEL) refusal = [400, `${character.name} is already level ${MAX_LEVEL}, the highest there is.`];
if (refusal) res.status(refusal[0]).json({ error: refusal[1] });
return !refusal;
}
// The plan for a character's next level in `toClass` (their first class when
// not given). Answers the request and returns null when the class can't take
// it: another class needs the campaign to allow multiclassing, and the
// character to meet both classes' requirements.
function planFor(req, res, character, toClass) {
const sheet = sheetOf(character);
const scores = Object.fromEntries(Object.entries(sheet.abilities).map(([ability, detail]) => [ability, detail.final]));
const multiclass = Boolean(character.campaign && character.campaign.multiclassEnabled);
if (toClass && !CLASSES[toClass]) {
res.status(400).json({ error: 'That is not a class.' });
return null;
}
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character), multiclass, toClass, scores });
if (plan.characterClass !== character.characterClass && !classLevels(character).some((e) => e.characterClass === plan.characterClass)) {
if (!multiclass) {
res.status(400).json({ error: 'This campaign does not allow multiclassing.' });
return null;
}
const option = plan.classChoice.options.find((o) => o.characterClass === plan.characterClass);
if (!option.eligible) {
res.status(400).json({ error: option.problems.join(' ') });
return null;
}
}
return { plan, sheet };
}
// How the level's hit points are come by: the campaign's method, the die of
// the class taking the level, the average it would count, and whether the
// die is rolled at the table (physical dice) and typed in.
function hpGain(character, plan) {
const die = getClassHitDieSize(plan.characterClass);
return {
method: (character.campaign && character.campaign.hpOnLevelUp) || 'AVERAGE',
die,
average: Math.floor(die / 2) + 1,
physical: effectiveDiceMode(character.campaign, character) === 'PHYSICAL',
};
}
// What the next level brings, for the DM to look over before saying yes.
// `?class=` asks about a level in another class (multiclassing).
router.get('/:id/level-up', requireCharacterAccess(), async (req, res, next) => {
try {
const character = await loadFullCharacter(req.character.id);
if (!canLevelUp(req, res, character)) return;
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character) });
const after = sheetOf({ ...character, level: plan.toLevel, spellSlotsUsed: [] });
res.json({ plan: { ...plan, maxHp: { from: sheetOf(character).hp.max, to: after.hp.max }, spellSlots: after.spellSlots } });
const planned = planFor(req, res, character, typeof req.query.class === 'string' ? req.query.class : null);
if (!planned) return;
const { plan, sheet } = planned;
const after = sheetOf({ ...withLevelIn(character, plan.characterClass), spellSlotsUsed: [] });
// The spells that could be granted now: the campaign's, of a level the
// class can cast, that the character does not know. Sent with the plan
// because a player otherwise sees only the spells they hold.
const spellOptions = plan.spells
? await prisma.spell.findMany({
where: { campaignId: character.campaignId, level: { lte: plan.spells.maxSpellLevel }, id: { notIn: character.spells.map((held) => held.spellId) } },
select: { id: true, name: true, level: true, school: true, description: true },
orderBy: [{ level: 'asc' }, { name: 'asc' }],
})
: [];
res.json({ plan: { ...plan, maxHp: { from: sheet.hp.max, to: after.hp.max }, hp: hpGain(character, plan), spellSlots: after.spellSlots, spellOptions } });
} catch (err) {
next(err);
}
});
const levelUpSchema = z.object({
characterClass: z.enum(Object.keys(CLASSES)).optional(), // the class taking the level; the first one when left out
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
hpRoll: z.number().int().min(1).max(12).optional(), // the hit die rolled at the table, where HP is rolled with physical dice
spellIds: z.array(z.string()).max(50).default([]), // spells newly granted
});
@@ -576,7 +649,9 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
const character = await loadFullCharacter(req.character.id);
if (!canLevelUp(req, res, character)) return;
const plan = levelUpPlan(character, { subclasses: usesSubclasses(character) });
const planned = planFor(req, res, character, parsed.data.characterClass || null);
if (!planned) return;
const { plan } = planned;
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)) {
@@ -591,18 +666,42 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
if (new Set(spellIds).size !== spellIds.length || spellsFound !== spellIds.length) {
return res.status(400).json({ error: "Pick the new spells from this campaign's spell list, each once." });
}
// The DM grants what they like; a player picks within what the level allows.
if (!req.isDM && spellIds.length > 0) {
const allowed = plan.spells ? plan.spells.newSpells + plan.spells.newCantrips : 0;
const inReach = plan.spells ? await prisma.spell.count({ where: { id: { in: spellIds }, level: { lte: plan.spells.maxSpellLevel } } }) : 0;
if (spellIds.length > allowed || inReach !== spellIds.length) {
return res.status(400).json({ error: allowed > 0 ? `This level allows ${allowed} new spell${allowed === 1 ? '' : 's'}, of a level you can cast.` : 'This level grants no new spell.' });
}
}
// The subclass picked, or the one taken for them where the campaign has no choosing.
const newSubclass = subclass || plan.subclassTaken || null;
const first = plan.characterClass === character.characterClass;
const heldSubclass = (classLevels(character).find((e) => e.characterClass === plan.characterClass) || {}).subclass || null;
let gained;
try {
// The subclass picked, or the one taken for them where the campaign has no choosing.
const newSubclass = subclass || plan.subclassTaken;
gained = advance(character.specialAbilities, character.characterClass, plan.toLevel, boon, newSubclass ? { key: newSubclass, chosen: true } : { key: character.subclass });
gained = advanceIn(character.specialAbilities, plan, boon, newSubclass ? { key: newSubclass, chosen: true } : { key: heldSubclass });
} catch (err) {
if (!(err instanceof LevelUpError)) throw err;
return res.status(400).json({ error: err.message });
}
const armorBonus = clamp(character.armorBonus + gained.armorBonus, -30, 30);
const maxHp = sheetOf({ ...character, level: plan.toLevel, armorBonus }).hp.max;
// Rolled hit points: the die is rolled here, or typed in from the table.
const hp = hpGain(character, plan);
let hpRolled = null;
if (hp.method === 'ROLL') {
if (hp.physical) {
if (!validateManualRoll(hp.die, parsed.data.hpRoll)) {
return res.status(400).json({ error: `Roll a d${hp.die} for hit points and enter what it shows (1 to ${hp.die}).` });
}
hpRolled = parsed.data.hpRoll;
} else {
hpRolled = rollDie(hp.die);
}
}
const hpRollAdjust = character.hpRollAdjust + (hpRolled == null ? 0 : hpRolled - hp.average);
const maxHp = sheetOf({ ...withLevelIn(character, plan.characterClass, newSubclass || undefined), armorBonus, hpRollAdjust }).hp.max;
await prisma.$transaction(async (tx) => {
if (weaponId) {
@@ -612,11 +711,22 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
});
}
await tx.characterSpell.createMany({ data: spellIds.map((spellId) => ({ characterId: character.id, spellId })), skipDuplicates: true });
// A level in another class goes on that class's row.
if (!first) {
await tx.characterClassLevel.upsert({
where: { characterId_characterClass: { characterId: character.id, characterClass: plan.characterClass } },
create: { characterId: character.id, characterClass: plan.characterClass, level: 1, subclass: newSubclass },
update: { level: plan.classLevel, ...(newSubclass ? { subclass: newSubclass } : {}) },
});
}
await tx.character.update({
where: { id: character.id },
data: {
level: plan.toLevel,
...(subclass || plan.subclassTaken ? { subclass: subclass || plan.subclassTaken } : {}),
hpRollAdjust,
// An approved level is now taken.
levelUpsPending: Math.max(0, character.levelUpsPending - 1),
...(first && newSubclass ? { subclass: newSubclass } : {}),
specialAbilities: gained.specialAbilities,
armorBonus,
tempHp: Math.max(character.tempHp, gained.tempHp),
@@ -629,7 +739,8 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
const full = await loadFullCharacter(character.id);
emitCharacterUpdated(req);
res.json({ character: serializeCharacter(full, req.isOwner) });
// hpRolled: what the hit die showed, where HP is rolled (null otherwise).
res.json({ character: serializeCharacter(full, req.isOwner, req.isDM), hpRolled });
} catch (err) {
next(err);
}
@@ -768,6 +879,9 @@ router.post('/:id/copy', requireCharacterAccess(), async (req, res, next) => {
privateNotes: req.isOwner ? source.privateNotes : null,
diceMode: source.diceMode,
buildStage: 'COMPLETE',
classLevels: {
create: (source.classLevels || []).map(({ characterClass, level, subclass }) => ({ characterClass, level, subclass })),
},
skillProficiencies: {
create: source.skillProficiencies.map(({ skill, proficient, expertise }) => ({ skill, proficient, expertise })),
},
+12
View File
@@ -6,6 +6,8 @@ const { requireAuth } = require('../middleware/auth');
const { CLASSES } = require('../game/data/classes');
const { findSubclass } = require('../game/data/subclasses');
const { progression } = require('../game/levelUp');
const { MULTICLASS_PREREQS, PREREQ_SCORE, prereqText } = require('../game/multiclass');
const { MULTICLASS_PATHS } = require('../game/data/multiclassPaths');
const router = express.Router();
@@ -23,4 +25,14 @@ router.get('/progression', (req, res) => {
res.json({ characterClass, subclass: subclass || null, levels: progression(characterClass, subclass) });
});
// GET /reference/multiclass — what each class asks of the ability scores to
// multiclass into or out of it, and the suggested routes (data/multiclassPaths.js).
router.get('/multiclass', (req, res) => {
res.json({
prereqScore: PREREQ_SCORE,
prereqs: Object.fromEntries(Object.keys(CLASSES).map((cls) => [cls, { alternatives: MULTICLASS_PREREQS[cls], text: prereqText(cls) }])),
paths: MULTICLASS_PATHS,
});
});
module.exports = router;
+2
View File
@@ -5,6 +5,8 @@
const { computeCharacterSheet } = require('../game/calculations');
const SHEET_INCLUDE = {
// A multiclassed character's other classes (game/multiclass.js).
classLevels: true,
skillProficiencies: true,
saveProficiencies: true,
inventoryItems: { include: { item: true } },
+100 -14
View File
@@ -362,8 +362,10 @@ Ability scores cannot be changed after the build is finalized.
## 6. Level and XP
- Every character starts at **level 1**. The range is 1 to 20.
- Only the DM can change level or XP. There are two ways to change a level,
both on the sheet: the **⬆ Level Up…** button, and the Level field.
- Only the DM decides that a character levels, and only the DM changes XP. A
level is taken in one of three ways: the DM **approves** it and the player
takes it themselves (below), the DM levels the character directly with
**⬆ Level up now…** on the sheet, or the DM types into the Level field.
- **XP** (experience points) is shown under the character's name on the sheet
as "37 / 300 XP": what they have, and what their next level takes. The party
overview shows the same under each level.
@@ -372,7 +374,7 @@ Ability scores cannot be changed after the build is finalized.
The DM can also type a character's XP into the **XP** field beside Level.
- **XP does not level a character up.** When a character has the XP for
their next level the sheet shows "⬆ Level up due" (the party overview shows
⬆), and the DM levels them with "⬆ Level Up…" when the table is ready. A DM
⬆), and the DM approves the level when the table is ready. A DM
who levels by milestones can turn **Track experience points** off in the
campaign settings. That hides XP everywhere, closes the award panel, and
leaves levelling entirely to the DM.
@@ -385,16 +387,50 @@ Ability scores cannot be changed after the build is finalized.
| 4 | 2,700 | 9 | 48,000 | 14 | 140,000 | 19 | 305,000 |
| 5 | 6,500 | 10 | 64,000 | 15 | 165,000 | 20 | 355,000 |
### Approving a level, and who takes it
So that five players levelling together do not queue for the DM, the DM
**approves** and each player **takes** their own level:
1. The DM presses **⬆ Level up…** on the campaign page or the party overview
(beside 🛏 Rest…). A pop-out lists the party; with XP tracked, those who have
the XP for their next level start ticked ("Everyone", "Those with the XP"
and "Nobody" tick in one go). **Approve** gives each ticked character one
level to take. One character can also be approved from their sheet
(**⬆ Approve a level up**).
2. Each approved character's player is told at once, wherever in the campaign
they are: a notice slides in ("⬆ Aria can level up!") with a button
straight to the checklist. If they are not online, it waits: their sheet
and their row on the campaign page show "⬆ Level up ready" until it is taken.
3. The player opens the checklist, sees what the level gives, makes the picks
that are theirs (the class for a multiclass, subclass, Boon, new spells,
the weapon upgrade, the hit point roll) and confirms. The level is theirs
then, healed to full, as if the DM had done it.
Things to know:
- An approval is one level. Approving twice lets a character take two, one
checklist at a time. "Take one back" in the pop-out undoes an approval that
has not been used.
- The DM sees who still has picks to make: a banner on the party overview,
and a badge on each character. The DM can open the checklist and finish it
for anyone ("Open the checklist" on the sheet).
- A character with no player (an ally the DM runs) is the DM's to finish.
- A player may pick as many new spells as the level allows, of a level they
can cast; the DM, doing it for them, is not held to that.
- Once approved, a level no longer shows as "due".
### Level Up: the checklist
"⬆ Level Up…" takes a character up **one level** and does everything that
goes with it. It opens a pop-out that lists the five steps and asks for the
choices; nothing changes until the DM presses "Level up to N".
The checklist takes a character up **one level** and does everything that
goes with it. It opens as a pop-out that lists the steps and asks for the
choices; nothing changes until "Level up to N" is pressed. It is the same
whether a player takes an approved level or the DM uses **⬆ Level up now…**.
| Step | What the site does | What the DM chooses |
| Step | What the site does | What is chosen |
|---|---|---|
| 1. Level | Raises the level by one. Proficiency bonus, saves, skills and spell DC follow | Nothing |
| 2. Hit points | Works out the new max HP and heals to it (a dead character stays dead). One more Hit Die | Nothing |
| 1. Level | Raises the level by one. Proficiency bonus, saves, skills and spell DC follow | With multiclassing on: which class gains the level |
| 2. Hit points | Works out the new max HP and heals to it (a dead character stays dead). One more Hit Die. Where the campaign **rolls** hit points, the class's die is rolled by the site, or rolled at the table and typed in (physical dice); the roll takes the place of the average for good | The die roll, with physical dice |
| 3. Special abilities | Writes the class's and the subclass's new features for that level into Special Abilities ([section 21](#21-what-each-class-gains-as-it-levels)) | The **subclass**, at the level the class chooses one; the **Boon**, at a level that grants one |
| 4. Spells | Gives a caster a fresh set of spell slots for the new level, and grants the spells ticked | Which spells to grant, from the campaign's spell list |
| 5. Gear | Raises one weapon by one gear level | Which weapon, or none |
@@ -499,6 +535,57 @@ the DM's to keep.
whose gear level is below the character's new level (the equipped one is
offered first). Powerful and legendary weapons never get it.
### Multiclassing
With **Multiclassing** on in the campaign settings, the level-up checklist
starts by asking **which class gains this level**: one the character has, or
a new one. A new class is open only when the character's final ability
scores meet its requirement *and* the requirement of every class they already
have (the 2014 rules):
| Class | Needs | Class | Needs |
|---|---|---|---|
| Artificer | Intelligence 13 | Paladin | Strength 13 and Charisma 13 |
| Barbarian | Strength 13 | Ranger | Dexterity 13 and Wisdom 13 |
| Bard | Charisma 13 | Rogue | Dexterity 13 |
| Cleric | Wisdom 13 | Sorcerer | Charisma 13 |
| Druid | Wisdom 13 | Warlock | Charisma 13 |
| Fighter | Strength 13 or Dexterity 13 | Wizard | Intelligence 13 |
| Monk | Dexterity 13 and Wisdom 13 | | |
Everything then goes by **each class's own level**:
- A first level in a new class writes what that class grants at level 1
(not its saving throws or skills) and a "… Training" line with what it
teaches a newcomer. The class's later features, Boons and subclass come at
its own levels: a fighter 5 / rogue 3 chooses a roguish archetype now.
- **Hit points**: the first class's die in full at level 1, then each level the
average of that level's class die, plus Constitution every level. Hit Dice
are counted per class.
- **Spell slots**: one casting class keeps its own table. Two or more add up a
caster level (all of a full caster's levels, half a paladin's or ranger's,
half an artificer's rounded up, a third of an Eldritch Knight's or Arcane
Trickster's) and use the full caster table. A warlock's pact slots stand
apart: the sheet says how many, back on a short rest.
- **Spellcasting**: each casting class has its own DC and attack bonus, listed
on the sheet.
- **Counters** (Rage, Ki, Channel Divinity...) go by their class's level; two
classes giving the same counter keep the larger.
The first class is the character's `characterClass`; each other class is a
row of its own (`CharacterClassLevel`: class, level, subclass). Everywhere a
class is named, a multiclassed character reads "Fighter 5 (Champion) / Rogue 3".
**Routes.** The sheet's "The Road Ahead" card shows the road from 1 to 20 for
each class the character has, and, with multiclassing on, **Multiclass routes
from here**: well-known combinations (the Hexadin, the Sorlock, a two-level
fighter dip…) with each step, how far along the character is, and whether
their scores meet every class on the way. They are suggestions; nothing is
enforced.
**Eldritch Knight and Arcane Trickster** cast with Intelligence from level 3,
with a third caster's slots, whether or not the character has multiclassed.
### The Level field
Typing a number into Level sets it outright: for a character who joins late,
@@ -1187,7 +1274,8 @@ Another Campaign" card on its sheet.
| Give inspiration, or take it away | No | Yes | No |
| Spend inspiration | Yes | Yes | No |
| Set exhaustion; give or remove a condition | No | Yes | No |
| Level a character up (the checklist), pick a subclass or a Boon | No | Yes | No |
| Approve a level up, level a character directly, change the Level field | No | Yes | No |
| Take an approved level (the checklist): pick the subclass, Boon, spells, weapon | Yes, once approved | Yes | No |
| Set or change the subclass on the sheet (in a campaign that uses subclasses) | No | Yes | No |
| Switch subclasses on or off for the campaign | No | Yes | No |
| Edit special abilities, background, appearance (after the build) | No | Yes | No |
@@ -1222,10 +1310,8 @@ calculations for them.
- The effects of special abilities: species traits and class features are
written on the sheet for the DM to apply, but nothing is calculated from them
- The spell slots of the spellcasting subclasses of non-casters (Eldritch
Knight, Arcane Trickster): their lines say what they can do, and the DM
grants their spells
- Multiclassing
- A multiclassed warlock's pact slots are counted at the table: the sheet
says how many there are, and the tracker keeps the other classes' slots
- Spell preparation limits and concentration
- Ability score increases and feats on level-up (a Boon is given in their
place, and Weapon Mastery and Arcane Focus are added by the player, not by
+7
View File
@@ -211,6 +211,13 @@ section 7): a **long rest** gives every slot back; a **short rest** gives a
warlock theirs. Levelling up with "⬆ Level Up…" gives a fresh set. Changing
the Level field keeps what was spent, as far as the new level has those slots.
An **Artificer** casts from level 1 at half a full caster's pace, rounding up
(2 slots at levels 1 and 2, 2nd-level slots at 5). An **Eldritch Knight** or
**Arcane Trickster** casts from level 3 at a third of the pace, with
Intelligence. A **multiclassed** character adds their casting classes up to a
caster level on the full caster table, with a warlock's pact slots kept apart
([CHARACTERS.md](CHARACTERS.md), "Multiclassing").
**Bard, Cleric, Druid, Sorcerer, Wizard** (back on a long rest):
| Character level | 1st | 2nd | 3rd | 4th | 5th |
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.23.0",
"version": "1.25.0",
"type": "module",
"scripts": {
"dev": "vite",
+34 -6
View File
@@ -16,8 +16,10 @@ import type {
GearEffect,
GoldTransaction,
Item,
LevelUpApproval,
LevelUpPlan,
Location,
MulticlassReference,
Monster,
Note,
Npc,
@@ -252,12 +254,14 @@ export function useCharacterState(characterId: string) {
}
// What a character's next level brings. DM only.
export function useLevelUpPlan(characterId: string, enabled: boolean) {
// `toClass`: the plan for a level in that class (multiclassing); the first class when null.
export function useLevelUpPlan(characterId: string, enabled: boolean, toClass: CharacterClass | null = null) {
return useQuery({
queryKey: ['character', characterId, 'level-up'],
queryKey: ['character', characterId, 'level-up', toClass],
enabled,
gcTime: 0, // never open on the plan for a level the character has already reached
queryFn: async () => (await apiClient.get<{ plan: LevelUpPlan }>(`/characters/${characterId}/level-up`)).data.plan,
queryFn: async () =>
(await apiClient.get<{ plan: LevelUpPlan }>(`/characters/${characterId}/level-up`, { params: toClass ? { class: toClass } : {} })).data.plan,
});
}
@@ -265,9 +269,9 @@ export function useLevelUpPlan(characterId: string, enabled: boolean) {
export function useLevelUp(characterId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[] }) =>
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/level-up`, data)).data.character,
onSuccess: (character) => {
mutationFn: async (data: { characterClass?: CharacterClass; subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[]; hpRoll?: number }) =>
(await apiClient.post<{ character: CharacterDetail; hpRolled: number | null }>(`/characters/${characterId}/level-up`, data)).data,
onSuccess: ({ character }) => {
invalidateCharacter(qc, characterId);
qc.invalidateQueries({ queryKey: ['encounter'] }); // a character combatant shows the sheet's HP
qc.invalidateQueries({ queryKey: ['campaign', character.campaignId, 'characters'] });
@@ -291,6 +295,20 @@ export function useRest(campaignId: string) {
});
}
// The DM approves a level for any of the party at once (or takes an approval
// back); each character's player then takes theirs.
export function useApproveLevelUps(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: { characterIds: string[]; revoke?: boolean }) =>
(await apiClient.post<{ revoke: boolean; results: LevelUpApproval[] }>(`/campaigns/${campaignId}/level-up`, data)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
},
});
}
export function useDeleteCharacter(campaignId: string) {
const qc = useQueryClient();
return useMutation({
@@ -362,6 +380,16 @@ export function useBuildOptions() {
});
}
// Multiclassing requirements and the suggested routes.
export function useMulticlassReference(enabled: boolean) {
return useQuery({
queryKey: ['multiclass-reference'],
enabled,
staleTime: Infinity,
queryFn: async () => (await apiClient.get<MulticlassReference>('/reference/multiclass')).data,
});
}
// What each level from 1 to 20 gives a class, with a subclass's lines when one is given.
export function useProgression(characterClass: CharacterClass | null | undefined, subclass?: string | null) {
return useQuery({
+58 -2
View File
@@ -132,6 +132,21 @@ export interface SubclassSummary {
replaces: string | null;
}
// What each class asks of the scores to multiclass, and the suggested routes
// (GET /reference/multiclass). An alternative is a list of abilities that must
// all be at least prereqScore.
export interface MulticlassReference {
prereqScore: number;
prereqs: Record<CharacterClass, { alternatives: Ability[][]; text: string }>;
paths: {
key: string;
name: string;
from: CharacterClass | null;
steps: { class: CharacterClass; toLevel: number; subclass?: string }[];
why: string;
}[];
}
// One level of a class's road from 1 to 20 (GET /reference/progression).
export interface ProgressionLevel {
level: number;
@@ -216,7 +231,7 @@ export interface SpellSlotLevel {
// A character's spell slots, from class and level. A warlock's (PACT) are all
// of one slot level and come back on a short rest; everyone else's on a long one.
export interface SpellSlots {
kind: 'FULL' | 'HALF' | 'PACT';
kind: 'FULL' | 'HALF' | 'THIRD' | 'PACT';
restoresOn: 'SHORT' | 'LONG';
levels: SpellSlotLevel[];
}
@@ -227,6 +242,16 @@ export interface HitDice {
count: number;
used: number;
remaining: number;
// A multiclassed character's dice by class (each class's own size).
pools?: { characterClass: CharacterClass; size: number; count: number }[];
}
// A class a character has levels in. One entry unless they have multiclassed.
export interface ClassLevel {
characterClass: CharacterClass;
level: number;
subclass?: string | null;
subclassName: string | null;
}
// A class counter (Rage, Ki, Channel Divinity...): how many uses there are,
@@ -278,6 +303,8 @@ export interface CharacterSummary extends CharacterCondition {
characterClass: CharacterClass | null;
subclassName: string | null;
level: number;
levelUpsPending: number; // levels the DM has approved, still to be taken
classes: ClassLevel[];
xp: number;
xpProgress: XpProgress;
buildStage: BuildStage;
@@ -350,6 +377,10 @@ export interface CharacterSheet {
passiveInvestigation: number;
passiveInsight: number;
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
// A multiclassed caster's DC and attack for each class that casts.
spellcastingByClass: { characterClass: CharacterClass; ability: Ability; saveDC: number; attackBonus: number }[];
// A multiclassed warlock's pact slots, apart from the other slots; null otherwise.
pactSlots: { count: number; slotLevel: number } | null;
spellSlots: SpellSlots | null; // null for a character with no spell slots
resources: ClassResource[]; // the class counters, with how many of each are left
gear: ActiveGear;
@@ -478,6 +509,8 @@ export interface CharacterDetail extends CharacterCondition {
// The 2024 subclass that updates the one followed, if there is one.
subclassUpgrade: SubclassSummary | null;
level: number;
levelUpsPending: number; // levels the DM has approved, still to be taken
classes: ClassLevel[];
xp: number;
xpProgress: XpProgress;
motivation: string | null;
@@ -883,7 +916,17 @@ export interface BoonOption {
export interface LevelUpPlan {
fromLevel: number;
toLevel: number;
// The class taking the level, its own level after it, and whether it is the
// character's first level in that class (multiclassing).
characterClass: CharacterClass;
classLevel: number;
joining: boolean;
// With multiclassing on: every class the level could go to, and why not.
classChoice: { options: { characterClass: CharacterClass; level: number; eligible: boolean; problems: string[] }[] } | null;
maxHp: { from: number; to: number };
// How the level's hit points are come by: the average, or a roll of the
// class's die (typed in from the table when `physical`).
hp: { method: HpMethod; die: number; average: number; physical: boolean };
// Set when a subclass has to be picked on the way to this level: the level
// the class chooses at, and each option with everything it would write.
subclass: { level: number; options: { key: string; name: string; summary: string; features: { name: string; text: string }[] }[] } | null;
@@ -896,9 +939,22 @@ export interface LevelUpPlan {
// What the book lets a caster be granted now; null for a class with no spells.
spells: { maxSpellLevel: number; unlocksSpellLevel: number | null; newSpells: number; newCantrips: number } | null;
spellSlots: SpellSlots | null; // the slots at the new level
// The campaign's spells that could be granted now: of a level the class can
// cast, and not yet known.
spellOptions: { id: string; name: string; level: number; school: string | null; description: string | null }[];
// Held weapons that can take the free upgrade of one level.
weapons: { weaponId: string; name: string; equipped: boolean; level: number; damageDice: string; nextDamageDice: string }[];
beyondTables: boolean; // past level 10, where the class tables stop
beyondTables: boolean; // past level 20, where the class tables stop
}
// What approving (or taking back) a level did for one character (POST /campaigns/:id/level-up).
export interface LevelUpApproval {
characterId: string;
name: string;
userId: string | null;
level: number;
levelUpsPending: number;
skipped: string | null; // why nothing changed for them
}
export type RestType = 'SHORT' | 'LONG';
+2
View File
@@ -4,6 +4,7 @@ import { useAuth } from '../auth/useAuth';
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
import { DiceRoller } from './DiceRoller';
import { CharacterPanel } from './CharacterPanel';
import { Toasts } from './Toasts';
export function Layout() {
const { user, logout } = useAuth();
@@ -70,6 +71,7 @@ export function Layout() {
)}
<DiceRoller />
{user && <CharacterPanel />}
<Toasts />
</div>
);
}
@@ -0,0 +1,135 @@
import { useState } from 'react';
import { useApproveLevelUps } from '../api/queries';
import { classLabel, extractErrorMessage } from '../lib/format';
import { Modal } from './Modal';
import { ErrorBanner } from './Spinner';
import type { CharacterSummary, LevelUpApproval } from '../api/types';
const MAX_LEVEL = 20;
// The DM's level-up button: approve a level for any of the party at once.
// Each approval is a checklist waiting for its character: their player is
// told and makes the picks themselves, so nobody queues for the DM. A
// character without a player is the DM's to finish, from its sheet.
// `trackXp`: with XP kept, those who have the XP for their next level start
// ticked; without it (milestones) the DM ticks who levels.
export function LevelUpBulkModal({
campaignId,
characters,
trackXp,
onClose,
}: {
campaignId: string;
characters: CharacterSummary[];
trackXp: boolean;
onClose: () => void;
}) {
const approve = useApproveLevelUps(campaignId);
const party = characters.filter((c) => c.buildStage === 'COMPLETE');
const canLevel = (c: CharacterSummary) => c.level + c.levelUpsPending < MAX_LEVEL;
const [picked, setPicked] = useState<Set<string>>(() => new Set(party.filter((c) => trackXp && c.xpProgress.levelUpDue && canLevel(c)).map((c) => c.id)));
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState<LevelUpApproval[] | null>(null);
function toggle(id: string) {
setPicked((current) => {
const next = new Set(current);
if (!next.delete(id)) next.add(id);
return next;
});
}
async function send(characterIds: string[], revoke = false) {
setError(null);
try {
const { results } = await approve.mutateAsync({ characterIds, revoke });
if (!revoke) setDone(results);
} catch (err) {
setError(extractErrorMessage(err));
}
}
if (done) {
return (
<Modal title="Level ups approved" subtitle="Each player makes their own picks" onClose={onClose}>
{done.map((result) => (
<div key={result.characterId} className="reward-row">
<strong>{result.name}</strong>
{result.skipped ? (
<div className="combatant-sub">{result.skipped}</div>
) : (
<div className="combatant-sub">
Level {result.level} → {result.level + 1} is theirs to take.{' '}
{result.userId ? 'Their player has been told and chooses what they gain.' : 'No player: finish it from the sheet.'}
</div>
)}
</div>
))}
<div className="modal-foot">
<button className="btn btn-primary" onClick={onClose}>
Done
</button>
</div>
</Modal>
);
}
return (
<Modal title="Level up" subtitle="Approve a level for the party; each player then picks what they gain" wide onClose={onClose}>
{error && <ErrorBanner message={error} />}
<div className="flex-row flex-wrap gap-sm">
<button className="btn" onClick={() => setPicked(new Set(party.filter(canLevel).map((c) => c.id)))}>
Everyone
</button>
{trackXp && (
<button className="btn" onClick={() => setPicked(new Set(party.filter((c) => c.xpProgress.levelUpDue && canLevel(c)).map((c) => c.id)))}>
Those with the XP
</button>
)}
<button className="btn btn-ghost" onClick={() => setPicked(new Set())}>
Nobody
</button>
</div>
<div className="mt-md">
{party.length === 0 && <p className="combatant-sub">No finished characters yet.</p>}
{party.map((c) => (
<label key={c.id} className="checkbox-row level-choice" style={canLevel(c) ? undefined : { opacity: 0.55 }}>
<input type="checkbox" checked={picked.has(c.id)} disabled={!canLevel(c)} onChange={() => toggle(c.id)} />
<span style={{ flex: 1 }}>
<strong>{c.name}</strong>{' '}
<span className="combatant-sub">
{classLabel(c.characterClass, c.subclassName, c.classes)} · Level {c.level}
</span>
{trackXp && c.xpProgress.levelUpDue && <span className="badge badge-live" style={{ marginLeft: 6 }}>has the XP</span>}
{c.levelUpsPending > 0 && (
<span className="badge" style={{ marginLeft: 6 }}>
{c.levelUpsPending} waiting on their picks
</span>
)}
{!c.userId && <span className="badge" style={{ marginLeft: 6 }}>no player: yours to finish</span>}
</span>
{c.levelUpsPending > 0 && (
<button
type="button"
className="btn btn-ghost btn-sm"
title="Takes back one approved level that has not been taken yet"
disabled={approve.isPending}
onClick={(e) => {
e.preventDefault();
send([c.id], true);
}}
>
Take one back
</button>
)}
</label>
))}
</div>
<div className="modal-foot">
<button className="btn btn-primary" disabled={picked.size === 0 || approve.isPending} onClick={() => send([...picked])}>
{approve.isPending ? 'Approving…' : `Approve a level for ${picked.size} character${picked.size === 1 ? '' : 's'}`}
</button>
</div>
</Modal>
);
}
+105 -21
View File
@@ -1,9 +1,11 @@
import { useState, type ReactNode } from 'react';
import { useLevelUp, useLevelUpPlan } from '../api/queries';
import { extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format';
import { CLASS_LABELS, classLabel, extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format';
import { Modal } from './Modal';
import { NumberInput } from './NumberInput';
import { Spinner, ErrorBanner } from './Spinner';
import type { CharacterDetail, LevelUpPlan, Spell } from '../api/types';
import { showToast } from '../lib/toasts';
import type { CharacterClass, CharacterDetail, LevelUpPlan } from '../api/types';
// One numbered step of the checklist.
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
@@ -27,15 +29,20 @@ function allowance(spells: NonNullable<LevelUpPlan['spells']>): string {
// The levelling-up checklist for one character, one level at a time: the
// level, HP, the class's new special abilities (with a subclass or a Boon
// where the level calls for one), new spells and fresh slots, and the free weapon upgrade. The
// site does every step; the DM makes the choices and says yes.
export function LevelUpModal({ character, campaignSpells, onClose }: { character: CharacterDetail; campaignSpells: Spell[]; onClose: () => void }) {
const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true);
// site does every step; whoever is levelling makes the choices and says yes:
// the DM, or the character's own player once the DM has approved the level.
// A player picks spells within what the level allows; the DM grants freely.
export function LevelUpModal({ character, isDM, onClose }: { character: CharacterDetail; isDM: boolean; onClose: () => void }) {
// The class taking the level: the first one, unless another is picked (multiclassing).
const [toClass, setToClass] = useState<CharacterClass | null>(null);
const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true, toClass);
const levelUp = useLevelUp(character.id);
const [subclass, setSubclass] = useState('');
const [boon, setBoon] = useState('');
// undefined until picked: the equipped weapon is offered first.
const [weaponChoice, setWeaponChoice] = useState<string | undefined>(undefined);
const [spellIds, setSpellIds] = useState<Set<string>>(new Set());
const [hpRoll, setHpRoll] = useState('');
const [error, setError] = useState<string | null>(null);
if (isLoading || !plan) {
@@ -47,13 +54,16 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
}
const weaponId = weaponChoice ?? plan.weapons.find((w) => w.equipped)?.weaponId ?? plan.weapons[0]?.weaponId ?? '';
const known = new Set(character.spells.map((s) => s.spellId));
const grantable = plan.spells
? campaignSpells.filter((s) => !known.has(s.id) && s.level <= plan.spells!.maxSpellLevel).sort((a, b) => a.level - b.level || a.name.localeCompare(b.name))
: [];
const grantable = plan.spellOptions;
// A player picks up to the level's allowance; the DM is not held to it.
const spellAllowance = plan.spells ? plan.spells.newSpells + plan.spells.newCantrips : 0;
const spellsFull = !isDM && spellIds.size >= spellAllowance;
const needsHpRoll = plan.hp.method === 'ROLL' && plan.hp.physical;
const hpRollValid = Number.isInteger(Number(hpRoll)) && Number(hpRoll) >= 1 && Number(hpRoll) <= plan.hp.die;
const pickedBoon = plan.boon?.find((b) => b.key === boon);
const pickedSubclass = plan.subclass?.options.find((option) => option.key === subclass);
const owed = plan.subclass && !subclass ? 'Choose a subclass first' : plan.boon && !boon ? 'Choose a Boon first' : null;
const owed =
plan.subclass && !subclass ? 'Choose a subclass first' : plan.boon && !boon ? 'Choose a Boon first' : needsHpRoll && !hpRollValid ? `Roll your d${plan.hp.die} for hit points` : null;
function toggleSpell(id: string) {
setSpellIds((current) => {
@@ -66,7 +76,14 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
async function handleLevelUp() {
setError(null);
try {
await levelUp.mutateAsync({ subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
const { hpRolled } = await levelUp.mutateAsync({
hpRoll: needsHpRoll ? Number(hpRoll) : undefined,
characterClass: plan!.characterClass,
subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
showToast({
tone: 'good',
text: `${character.name} is level ${plan!.toLevel}!${hpRolled != null ? ` The d${plan!.hp.die} for hit points came up ${hpRolled}.` : ''}`,
});
onClose();
} catch (err) {
setError(extractErrorMessage(err));
@@ -78,15 +95,74 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
{error && <ErrorBanner message={error} />}
<Step n={1} title="Level">
{plan.classChoice && (
<div className="mb-2">
<label className="flex-row flex-wrap gap-sm">
Which class gains this level?
<select
value={plan.characterClass}
onChange={(e) => {
setToClass(e.target.value as CharacterClass);
setSubclass('');
setBoon('');
setSpellIds(new Set());
}}
>
{plan.classChoice.options.map((o) => (
<option key={o.characterClass} value={o.characterClass} disabled={!o.eligible} title={o.problems.join(' ')}>
{CLASS_LABELS[o.characterClass]}
{o.level > 0 ? ` (level ${o.level} → ${o.level + 1})` : o.eligible ? ' (new class)' : ' (requirements not met)'}
</option>
))}
</select>
</label>
{plan.joining && (
<p className="combatant-sub">
Multiclassing into {CLASS_LABELS[plan.characterClass]}: it starts at its own level 1, with what that class grants at level 1 (not its saves or
skills) and what it teaches a newcomer.
</p>
)}
</div>
)}
<p>
Level <strong>{plan.fromLevel}</strong> → <strong>{plan.toLevel}</strong>. Proficiency bonus, saves, skills and spell DC follow by themselves.
Level <strong>{plan.fromLevel}</strong> → <strong>{plan.toLevel}</strong>
{(character.classes.length > 1 || plan.characterClass !== character.characterClass) && (
<span className="combatant-sub">
{' '}
· {CLASS_LABELS[plan.characterClass]} {plan.classLevel - 1} → {plan.classLevel} (now {classLabel(character.characterClass, character.subclassName, character.classes)})
</span>
)}
. Proficiency bonus, saves, skills and spell DC follow by themselves.
</p>
</Step>
<Step n={2} title="Hit points">
<p>
Max HP <strong>{plan.maxHp.from}</strong> → <strong>{plan.maxHp.to}</strong>, and healed to full. One more Hit Die.
</p>
{plan.hp.method === 'AVERAGE' ? (
<p>
Max HP <strong>{plan.maxHp.from}</strong> → <strong>{plan.maxHp.to}</strong>, and healed to full. One more Hit Die (d{plan.hp.die}).
</p>
) : needsHpRoll ? (
<div>
<p style={{ marginBottom: '0.3rem' }}>
This campaign rolls for hit points. Roll a <strong>d{plan.hp.die}</strong> at the table and enter what it shows; your Constitution
modifier is added for you. Healed to full, and one more Hit Die.
</p>
<label className="flex-row gap-sm">
I rolled
<NumberInput min={1} max={plan.hp.die} placeholder={`1–${plan.hp.die}`} value={hpRoll} onChange={(e) => setHpRoll(e.target.value)} style={{ width: 80 }} />
{hpRollValid && (
<span className="combatant-sub">
Max HP {plan.maxHp.from} → {plan.maxHp.to - plan.hp.average + Number(hpRoll)}
</span>
)}
</label>
</div>
) : (
<p>
This campaign rolls for hit points: a <strong>d{plan.hp.die}</strong> is rolled when you confirm (between {plan.maxHp.to - plan.hp.average + 1} and{' '}
{plan.maxHp.to - plan.hp.average + plan.hp.die} max HP), plus healing to full and one more Hit Die.
</p>
)}
</Step>
<Step n={3} title="Special abilities">
@@ -120,7 +196,7 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
)}
{plan.features.length === 0 && !plan.boon && !plan.subclass && (
<p className="combatant-sub">
{plan.beyondTables ? 'The class tables stop at level 10. Add anything new to the special abilities by hand.' : 'No new feature at this level.'}
{plan.beyondTables ? 'The class tables stop at level 20. Add anything new to the special abilities by hand.' : 'No new feature at this level.'}
</p>
)}
{plan.features.map((feature) => (
@@ -150,7 +226,9 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
</div>
)}
{(plan.features.length > 0 || plan.boon || plan.subclass) && (
<p className="combatant-sub">Written into Special Abilities for you. Where a line says "choose", edit it there once the player has.</p>
<p className="combatant-sub">
Written into Special Abilities for you. {isDM ? 'Where a line says "choose", edit it there once the player has.' : 'Where a line says "choose", agree it with your DM.'}
</p>
)}
</Step>
@@ -176,16 +254,22 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
<div className="level-spells">
{grantable.map((spell) => (
<label key={spell.id} className="checkbox-row level-choice">
<input type="checkbox" checked={spellIds.has(spell.id)} onChange={() => toggleSpell(spell.id)} />
<span>
{spell.name} <span className="combatant-sub">{spellLevelLabel(spell.level)}</span>
<input type="checkbox" checked={spellIds.has(spell.id)} disabled={spellsFull && !spellIds.has(spell.id)} onChange={() => toggleSpell(spell.id)} />
<span title={spell.description ?? undefined}>
{spell.name}{' '}
<span className="combatant-sub">
{spellLevelLabel(spell.level)}
{spell.school ? ` · ${spell.school}` : ''}
</span>
</span>
</label>
))}
</div>
)}
<p className="combatant-sub">
{spellIds.size > 0 && `${spellIds.size} picked. `}A player may also swap one known spell for another: take the old one away on the sheet.
{isDM
? `${spellIds.size > 0 ? `${spellIds.size} picked. ` : ''}A player may also swap one known spell for another: take the old one away on the sheet.`
: `${spellIds.size} of ${spellAllowance} picked. Hover a spell to read what it does.`}
</p>
</>
)}
@@ -0,0 +1,62 @@
import { useMulticlassReference } from '../api/queries';
import { CLASS_LABELS } from '../lib/format';
import { Spinner } from './Spinner';
import type { Ability, CharacterClass, ClassLevel, MulticlassReference } from '../api/types';
// Whether final scores meet a class's multiclassing requirement.
function meets(reference: MulticlassReference, characterClass: CharacterClass, scores: Record<Ability, number>): boolean {
return reference.prereqs[characterClass].alternatives.some((all) => all.every((a) => scores[a] >= reference.prereqScore));
}
// Suggested multiclass routes from where a character stands: each route's
// steps, how far along the character is, and whether their scores meet every
// class the route takes them through (multiclassing needs the requirement of
// the class joined and of each class left).
export function MulticlassRoutes({ classes, scores }: { classes: ClassLevel[]; scores: Record<Ability, number> }) {
const { data: reference, isLoading } = useMulticlassReference(true);
if (isLoading || !reference) return <Spinner label="Reading the routes…" />;
const levelIn = (cls: CharacterClass) => classes.find((c) => c.characterClass === cls)?.level ?? 0;
const have = new Set(classes.map((c) => c.characterClass));
const paths = reference.paths.filter((p) => !p.from || have.has(p.from));
const yours = classes.map((c) => c.characterClass);
return (
<div>
<p className="combatant-sub" style={{ marginTop: 0 }}>
Multiclassing is on. A level can go to another class when {classes.length > 1 ? 'every class you have' : `your ${CLASS_LABELS[yours[0]]}`} and the new class’s
requirements are met: {yours.map((c) => `${CLASS_LABELS[c]} needs ${reference.prereqs[c].text}`).join('; ')}.
</p>
<div className="choice-grid">
{paths.map((path) => {
const classesOnRoute = [...new Set(path.steps.map((s) => s.class))];
const blocked = classesOnRoute.filter((cls) => !meets(reference, cls, scores));
return (
<div key={path.key} className="choice-card" style={{ cursor: 'default' }}>
<span className="choice-card-title">
{path.name}
{blocked.length === 0 ? <span className="badge badge-friendly">Open to you</span> : <span className="badge badge-hostile">Needs scores</span>}
</span>
<span className="choice-card-blurb">{path.why}</span>
<ol style={{ margin: 0, paddingLeft: '1.2rem' }}>
{path.steps.map((step, i) => {
const done = levelIn(step.class) >= step.toLevel;
return (
<li key={i} style={done ? { opacity: 0.6 } : undefined}>
{done ? '✓ ' : ''}
{CLASS_LABELS[step.class]} to level {step.toLevel}
{step.subclass ? ` (${step.subclass.toLowerCase().replace(/_/g, ' ')})` : ''}
</li>
);
})}
</ol>
{blocked.length > 0 && (
<span className="choice-card-facts">{blocked.map((cls) => `${CLASS_LABELS[cls]}: ${reference.prereqs[cls].text}`).join(' · ')}</span>
)}
</div>
);
})}
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { dismissToast, subscribeToasts, type Toast } from '../lib/toasts';
// The stack of toasts (lib/toasts.ts), mounted once in the layout.
export function Toasts() {
const [toasts, setToasts] = useState<Toast[]>([]);
useEffect(() => subscribeToasts(setToasts), []);
if (toasts.length === 0) return null;
return (
<div className="toasts" role="status" aria-live="polite">
{toasts.map((toast) => (
<div key={toast.id} className={`toast${toast.tone ? ` toast-${toast.tone}` : ''}`}>
<span className="toast-text">{toast.text}</span>
{toast.action && (
<Link to={toast.action.to} className="btn btn-primary btn-sm" onClick={() => dismissToast(toast.id)}>
{toast.action.label}
</Link>
)}
<button className="btn btn-ghost btn-sm" aria-label="Dismiss" onClick={() => dismissToast(toast.id)}>
✕
</button>
</div>
))}
</div>
);
}
+14 -2
View File
@@ -3,6 +3,8 @@ import { useQueryClient, type QueryKey } from '@tanstack/react-query';
import { io, type Socket } from 'socket.io-client';
import { API_URL } from '../api/client';
import { useAuth } from '../auth/useAuth';
import { showToast } from '../lib/toasts';
import type { CharacterDetail } from '../api/types';
let sharedSocket: Socket | null = null;
@@ -24,8 +26,9 @@ const FLUSH_MS = 50;
// sync with server-pushed events (HP/condition changes, encounter rounds,
// new characters) so every open tab in the campaign updates live.
export function useCampaignSocket(campaignId: string | undefined) {
const { token } = useAuth();
const { token, user } = useAuth();
const qc = useQueryClient();
const userId = user?.id;
useEffect(() => {
if (!campaignId || !token) return;
@@ -56,6 +59,13 @@ export function useCampaignSocket(campaignId: string | undefined) {
// A character arriving somewhere new adds it to the places their player can see.
invalidate(['campaign', campaignId, 'locations']);
};
// The DM approved a level for a character: its player is told, wherever
// in the campaign they are, with a way straight to the checklist.
const onLevelUpGranted = ({ characterId, userId: ownerId }: { characterId?: string; userId?: string | null } = {}) => {
if (!characterId || !ownerId || ownerId !== userId) return;
const name = qc.getQueryData<CharacterDetail>(['character', characterId])?.name ?? 'Your character';
showToast({ text: `⬆ ${name} can level up! See what you gain and make your picks.`, tone: 'good', action: { label: 'Level up', to: `/characters/${characterId}?levelup=1` } });
};
const onCharacterCreated = () => {
invalidate(['campaign', campaignId, 'characters']);
};
@@ -74,6 +84,7 @@ export function useCampaignSocket(campaignId: string | undefined) {
socket.on('character:updated', onCharacterUpdated);
socket.on('character:created', onCharacterCreated);
socket.on('character:level-up-granted', onLevelUpGranted);
socket.on('encounter:participant-updated', onParticipantUpdated);
socket.on('encounter:round-advanced', onRoundAdvanced);
socket.on('campaign:deleted', onCampaignDeleted);
@@ -86,9 +97,10 @@ export function useCampaignSocket(campaignId: string | undefined) {
socket.emit('campaign:leave', campaignId);
socket.off('character:updated', onCharacterUpdated);
socket.off('character:created', onCharacterCreated);
socket.off('character:level-up-granted', onLevelUpGranted);
socket.off('encounter:participant-updated', onParticipantUpdated);
socket.off('encounter:round-advanced', onRoundAdvanced);
socket.off('campaign:deleted', onCampaignDeleted);
};
}, [campaignId, token, qc]);
}, [campaignId, token, qc, userId]);
}
+59
View File
@@ -403,6 +403,65 @@ input[type='radio'] {
font-size: 0.9rem;
}
/* Toasts: notices that slide in over any page (components/Toasts.tsx). */
.toasts {
position: fixed;
top: 4.2rem;
right: 1rem;
z-index: 1200;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: min(26rem, calc(100vw - 2rem));
}
.toast {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.8rem;
background: var(--bg-card);
border: 1px solid var(--accent);
border-radius: var(--radius);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
animation: toastIn 0.25s ease-out;
}
.toast-good {
border-color: var(--accent-strong);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45), 0 0 14px rgba(201, 162, 39, 0.3);
}
.toast-text {
flex: 1;
font-size: 0.92rem;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translateX(1.5rem);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation: none;
}
}
/* A notice across the top of a card or page that something is waiting. */
.notice-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
margin-bottom: 0.9rem;
border: 1px solid var(--accent-strong);
border-radius: var(--radius);
background: linear-gradient(160deg, var(--bg-card-hover) 0%, var(--bg-inset) 100%);
}
/* A group of campaign settings under its own heading. */
.settings-group {
border: 1px solid var(--border);
+6 -2
View File
@@ -97,9 +97,13 @@ export function xpLabel(xp: number, progress: { nextLevelAt: number | null }): s
return progress.nextLevelAt == null ? `${xp.toLocaleString()} XP` : `${xp.toLocaleString()} / ${progress.nextLevelAt.toLocaleString()} XP`;
}
// "Fighter" or, with a subclass chosen, "Fighter (Champion)".
export function classLabel(characterClass: string | null, subclassName?: string | null): string {
// "Fighter" or, with a subclass chosen, "Fighter (Champion)". A multiclassed
// character (`classes` with more than one) reads "Fighter 3 (Champion) / Rogue 2".
export function classLabel(characterClass: string | null, subclassName?: string | null, classes?: { characterClass: string; level: number; subclassName: string | null }[]): string {
if (!characterClass) return '';
if (classes && classes.length > 1) {
return classes.map((c) => `${CLASS_LABELS[c.characterClass] ?? c.characterClass} ${c.level}${c.subclassName ? ` (${c.subclassName})` : ''}`).join(' / ');
}
const label = CLASS_LABELS[characterClass] ?? characterClass;
return subclassName ? `${label} (${subclassName})` : label;
}
+45
View File
@@ -0,0 +1,45 @@
// Toasts: short notices that slide in over any page ("Aria can level up").
// A tiny store of its own, so anything can raise one (a socket event, a
// mutation) without being inside a particular component. <Toasts />
// (components/Toasts.tsx), mounted once in the layout, shows them.
export interface Toast {
id: number;
text: string;
// Where "tone" is 'good' the toast is a piece of good news; default is neutral.
tone?: 'good' | 'warn';
// A link to follow up on it: its label and where it goes.
action?: { label: string; to: string };
}
type Listener = (toasts: Toast[]) => void;
let toasts: Toast[] = [];
let nextId = 1;
const listeners = new Set<Listener>();
const publish = () => listeners.forEach((listener) => listener(toasts));
// How long a toast stays before it goes by itself.
const TOAST_MS = 12000;
export function showToast(toast: Omit<Toast, 'id'>): number {
const id = nextId++;
toasts = [...toasts, { ...toast, id }].slice(-5);
publish();
setTimeout(() => dismissToast(id), TOAST_MS);
return id;
}
export function dismissToast(id: number) {
if (!toasts.some((toast) => toast.id === id)) return;
toasts = toasts.filter((toast) => toast.id !== id);
publish();
}
export function subscribeToasts(listener: Listener): () => void {
listeners.add(listener);
listener(toasts);
return () => {
listeners.delete(listener);
};
}
+38 -8
View File
@@ -32,13 +32,14 @@ import { NumberInput } from '../components/NumberInput';
import { SectionNav, type Section } from '../components/SectionNav';
import { LifeBadge } from '../components/DeathSaves';
import { RestModal } from '../components/RestModal';
import { LevelUpBulkModal } from '../components/LevelUpBulkModal';
import { Pagination } from '../components/Paginated';
import { usePagination } from '../hooks/usePagination';
import { CampaignSearch, type SearchGroup } from '../components/campaign/CampaignSearch';
import { EntryDetail } from '../components/campaign/EntryDetail';
import { ItemsSection, MonstersSection, NpcsSection, SpellsSection, WeaponsSection } from '../components/campaign/PoolSections';
import { effectsSummary } from '../lib/gear';
import { extractErrorMessage, spellLevelLabel } from '../lib/format';
import { SPECIES_LABELS, classLabel, extractErrorMessage, spellLevelLabel } from '../lib/format';
import { matchesSearch, type EntryKind, type EntryRef } from '../lib/campaignEntries';
import { speciesIcon } from '../lib/speciesIcons';
import type { CampaignDiceMode, CharacterSummary, CritRule, Encounter, EncounterMode, HpMethod, RollVisibility, ScoreMethod } from '../api/types';
@@ -86,7 +87,8 @@ function sortEncounters(encounters: Encounter[], sort: EncounterSort): Encounter
return [...encounters].sort(compare[sort]);
}
function CharacterRow({ character, isDM, onDelete }: { character: CharacterSummary; isDM: boolean; onDelete: () => void }) {
// `mine`: the viewer's own character, who gets the way into a level up that is waiting.
function CharacterRow({ character, isDM, mine = false, onDelete }: { character: CharacterSummary; isDM: boolean; mine?: boolean; onDelete: () => void }) {
const inProgress = character.buildStage !== 'COMPLETE';
return (
<div className="card flex-between flex-wrap">
@@ -97,10 +99,15 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
{character.name}
</Link>
{inProgress && <span className="badge">Building…</span>}
{character.levelUpsPending > 0 && (isDM || mine) && (
<span className="badge badge-live" title={mine ? 'Your DM has approved a level: open the sheet to choose what you gain' : 'Approved, and waiting for the picks to be made'}>
⬆ Level up {mine ? 'ready' : 'waiting on picks'}
</span>
)}
</div>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '0.9rem' }}>
{character.species ? `${character.species} ` : ''}
{character.characterClass || (inProgress ? 'Not yet built' : '')}{character.subclassName && ` (${character.subclassName})`} · Level {character.level}
{character.species ? `${SPECIES_LABELS[character.species] ?? character.species} ` : ''}
{character.characterClass ? classLabel(character.characterClass, character.subclassName, character.classes) : inProgress ? 'Not yet built' : ''} · Level {character.level}
{character.currentLocationName && ` · 📍 ${character.currentLocationName}`}
</p>
</div>
@@ -110,6 +117,11 @@ function CharacterRow({ character, isDM, onDelete }: { character: CharacterSumma
<LifeBadge deathSaves={character.deathSaves} /> HP {character.currentHp}/{character.maxHp} · AC {character.armorClass}
</div>
)}
{mine && character.levelUpsPending > 0 && !inProgress && (
<Link to={`/characters/${character.id}?levelup=1`} className="btn btn-primary">
⬆ Level up
</Link>
)}
<Link to={inProgress ? `/characters/${character.id}/build` : `/characters/${character.id}`} className="btn">
{inProgress ? 'Continue Build' : 'Open Sheet'}
</Link>
@@ -180,6 +192,7 @@ export function CampaignHomePage() {
const [showSettings, setShowSettings] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [resting, setResting] = useState(false);
const [levelling, setLevelling] = useState(false);
const [deleteConfirmName, setDeleteConfirmName] = useState('');
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
@@ -440,7 +453,7 @@ export function CampaignHomePage() {
<div className="section-block">
<h2 style={{ fontSize: '1.1rem' }}>Your Character{myCharacters.length > 1 ? 's' : ''}</h2>
{myCharacters.map((c) => (
<CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} />
<CharacterRow key={c.id} character={c} isDM={false} mine onDelete={() => {}} />
))}
</div>
)}
@@ -450,9 +463,23 @@ export function CampaignHomePage() {
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
🛏 Rest…
</button>
<div className="flex-row flex-wrap">
<button
className="btn"
onClick={() => setLevelling(true)}
title="Approve a level for any of the party at once: each player is told, and picks what they gain themselves"
>
⬆ Level up…
{campaign.trackXp && characters.some((c) => c.xpProgress.levelUpDue) && (
<span className="badge badge-live" style={{ marginLeft: 6 }}>
{characters.filter((c) => c.xpProgress.levelUpDue).length} due
</span>
)}
</button>
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
🛏 Rest…
</button>
</div>
)}
</div>
<form onSubmit={handleCreateCharacter} className="flex-row flex-wrap mt-sm" style={{ marginBottom: '0.75rem' }}>
@@ -1010,6 +1037,9 @@ export function CampaignHomePage() {
)}
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
{isDM && levelling && (
<LevelUpBulkModal campaignId={campaignId} characters={characters ?? []} trackXp={campaign.trackXp} onClose={() => setLevelling(false)} />
)}
{isDM && opened && (
<EntryDetail
+89 -8
View File
@@ -1,5 +1,5 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import {
useCharacter,
usePatchCharacter,
@@ -27,6 +27,7 @@ import {
useCampaigns,
useCopyCharacter,
useCampaignLocations,
useApproveLevelUps,
} from '../api/queries';
import { useAuth } from '../auth/useAuth';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
@@ -42,6 +43,7 @@ import { Rollable } from '../components/Rollable';
import { withBonus } from '../lib/diceRoller';
import { LevelUpModal } from '../components/LevelUpModal';
import { ProgressionTimeline } from '../components/ProgressionTimeline';
import { MulticlassRoutes } from '../components/MulticlassRoutes';
import { GearInForce } from '../components/CharacterPanel';
import { effectsSummary } from '../lib/gear';
import { InfoTip } from '../components/InfoTip';
@@ -65,6 +67,7 @@ import {
formatPurse,
purseWorthInGold,
spellLevelLabel,
slotLevelLabel,
} from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
@@ -249,6 +252,7 @@ export function CharacterSheetPage() {
const { data: character, isLoading, error } = useCharacter(characterId);
useRememberCampaign(character?.campaignId);
const patchCharacter = usePatchCharacter(characterId);
const approveLevelUp = useApproveLevelUps(character?.campaignId ?? '');
useCampaignSocket(character?.campaignId);
const { data: campaignData } = useCampaign(character?.campaignId);
@@ -290,7 +294,9 @@ export function CharacterSheetPage() {
const [pickedSpellId, setPickedSpellId] = useState('');
const [copyTargetId, setCopyTargetId] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const [levellingUp, setLevellingUp] = useState(false);
// ?levelup=1 (the link in the "can level up" notice) opens the checklist straight away.
const [searchParams, setSearchParams] = useSearchParams();
const [levellingUp, setLevellingUp] = useState(searchParams.get('levelup') === '1');
const weaponsPage = usePagination(character?.weapons, 8);
const spellsPage = usePagination(character?.spells, 8);
@@ -426,13 +432,29 @@ export function CharacterSheetPage() {
return (
<div>
{character.levelUpsPending > 0 && canEdit && (
<div className="notice-banner" role="status">
<span style={{ flex: 1 }}>
<strong>⬆ {isOwner ? 'You can level up!' : `${character.name} has a level up waiting.`}</strong>{' '}
{isOwner
? `Level ${character.level} → ${character.level + 1}: see what you gain and make your picks.`
: character.userId
? 'Their player has been told; you can also finish it for them.'
: 'No player: it is yours to finish.'}
{character.levelUpsPending > 1 && ` (${character.levelUpsPending} levels approved.)`}
</span>
<button className="btn btn-primary" onClick={() => setLevellingUp(true)}>
{isOwner ? 'Choose what you gain' : 'Open the checklist'}
</button>
</div>
)}
<div className="page-header">
<div>
<h1>
<span aria-hidden="true">{speciesIcon(character.species)}</span> {character.name}
</h1>
<p className="subtitle">
{character.species && SPECIES_LABELS[character.species]} {classLabel(character.characterClass, character.subclassName)} · Level{' '}
{character.species && SPECIES_LABELS[character.species]} {classLabel(character.characterClass, character.subclassName, character.classes)} · Level{' '}
{character.level}
{trackXp && ` · ${xpLabel(character.xp, character.xpProgress)}`}
{trackXp && character.xpProgress.levelUpDue && (
@@ -604,13 +626,23 @@ export function CharacterSheetPage() {
/>
</label>
)}
{character.userId && character.userId !== user?.id && (
<button
className="btn btn-primary"
onClick={() => approveLevelUp.mutate({ characterIds: [character.id] }, { onError: (err) => setFormError(extractErrorMessage(err)) })}
disabled={character.level + character.levelUpsPending >= 20 || approveLevelUp.isPending}
title="Approves one level: their player is told, sees what they gain and makes the picks themselves"
>
⬆ Approve a level up{character.levelUpsPending > 0 ? ` (${character.levelUpsPending} waiting)` : ''}
</button>
)}
<button
className="btn btn-primary"
className="btn"
onClick={() => setLevellingUp(true)}
disabled={character.level >= 20}
title="Go up one level with the levelling-up checklist: HP, new special abilities, spells and the free weapon upgrade"
title="Go up one level yourself with the levelling-up checklist: HP, new special abilities, spells and the free weapon upgrade"
>
⬆ Level Up…
⬆ Level up now…
</button>
<label className="flex-row gap-sm" title="Hit Dice this character still has to spend on a short rest. The Rest button on the campaign page keeps count for you.">
Hit Dice left
@@ -746,6 +778,20 @@ export function CharacterSheetPage() {
</Rollable>{' '}
<InfoTip term="SPELL_ATTACK_BONUS" />
</p>
{sheet.spellcastingByClass.length > 1 && (
<p className="combatant-sub" style={{ margin: '0.2rem 0 0' }}>
By class:{' '}
{sheet.spellcastingByClass
.map((c) => `${CLASS_LABELS[c.characterClass]} (${ABILITY_LABELS[c.ability]}) DC ${c.saveDC}, ${fmtMod(c.attackBonus)}`)
.join(' · ')}
</p>
)}
{sheet.pactSlots && (
<p className="combatant-sub" style={{ margin: '0.2rem 0 0' }}>
Pact Magic: {sheet.pactSlots.count} slot{sheet.pactSlots.count === 1 ? '' : 's'} of {slotLevelLabel(sheet.pactSlots.slotLevel)} level, back on a short rest.
Keep count of these at the table; the tracker below is the other classes’ slots.
</p>
)}
{sheet.spellSlots ? (
<SpellSlotTracker
characterId={characterId}
@@ -1138,12 +1184,38 @@ export function CharacterSheetPage() {
)}
</div>
)}
{character.classes.length > 1 && (
<p className="section-label" style={{ margin: '0.5rem 0 0.3rem' }}>
{CLASS_LABELS[character.characterClass]} {character.classes[0].level}
{character.subclassName ? ` (${character.subclassName})` : ''}
</p>
)}
<ProgressionTimeline
characterClass={character.characterClass}
subclass={character.subclass}
subclassName={character.subclassName}
currentLevel={character.level}
currentLevel={character.classes[0]?.level ?? character.level}
/>
{character.classes.slice(1).map((c) => (
<div key={c.characterClass} className="mt-md">
<p className="section-label" style={{ margin: '0 0 0.3rem' }}>
{CLASS_LABELS[c.characterClass]} {c.level}
{c.subclassName ? ` (${c.subclassName})` : ''}
</p>
<ProgressionTimeline characterClass={c.characterClass} subclass={c.subclass} subclassName={c.subclassName} currentLevel={c.level} />
</div>
))}
{campaignData?.campaign.multiclassEnabled && (
<details className="mt-md">
<summary className="section-label" style={{ cursor: 'pointer' }}>
Multiclass routes from here
</summary>
<MulticlassRoutes
classes={character.classes}
scores={Object.fromEntries(Object.entries(sheet.abilities).map(([a, d]) => [a, d.final])) as Record<Ability, number>}
/>
</details>
)}
</div>
)}
@@ -1246,7 +1318,16 @@ export function CharacterSheetPage() {
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} onJump={notesPage.setPage} />
</div>
{isDM && levellingUp && <LevelUpModal character={character} campaignSpells={campaignSpells ?? []} onClose={() => setLevellingUp(false)} />}
{(isDM || (isOwner && character.levelUpsPending > 0)) && levellingUp && (
<LevelUpModal
character={character}
isDM={isDM}
onClose={() => {
setLevellingUp(false);
if (searchParams.has('levelup')) setSearchParams({}, { replace: true });
}}
/>
)}
</div>
);
+24 -1
View File
@@ -9,6 +9,7 @@ import { usePagination } from '../hooks/usePagination';
import { InfoTip } from '../components/InfoTip';
import { SPECIES_LABELS, classLabel, formatPurse, slotsSummary, xpLabel } from '../lib/format';
import { RestModal } from '../components/RestModal';
import { LevelUpBulkModal } from '../components/LevelUpBulkModal';
import { HpBar } from '../components/HpBar';
import { LifeBadge } from '../components/DeathSaves';
import { ConditionChip, StateBadges } from '../components/CharacterState';
@@ -29,6 +30,7 @@ export function PartyOverviewPage() {
const { data: characters, isLoading: charsLoading, error: charsError } = useCampaignCharacters(campaignId);
const { pageItems, page, pageCount, prevPage, nextPage, setPage } = usePagination(characters, 12);
const [resting, setResting] = useState(false);
const [levelling, setLevelling] = useState(false);
const [checking, setChecking] = useState(false);
if (campaignLoading || charsLoading) return <Spinner label="Gathering the party…" />;
@@ -51,6 +53,11 @@ export function PartyOverviewPage() {
🎲 Call for a roll…
</button>
)}
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
<button className="btn" onClick={() => setLevelling(true)} title="Approve a level for any of the party at once: each player is told, and picks what they gain themselves">
⬆ Level up…
</button>
)}
{isDM && characters?.some((c) => c.buildStage === 'COMPLETE') && (
<button className="btn" onClick={() => setResting(true)} title="A short or long rest for any of the party: Hit Dice, HP and spell slots">
🛏 Rest…
@@ -64,6 +71,18 @@ export function PartyOverviewPage() {
{characters?.length === 0 && <div className="empty-state">No characters yet.</div>}
{isDM && characters?.some((c) => c.levelUpsPending > 0) && (
<div className="notice-banner" role="status">
<span style={{ flex: 1 }}>
<strong>⬆ Level ups waiting on picks:</strong>{' '}
{characters
.filter((c) => c.levelUpsPending > 0)
.map((c) => `${c.name}${c.userId ? '' : ' (no player: yours to finish)'}`)
.join(', ')}
</span>
</div>
)}
{characters && characters.length > 0 && (
<div className="table-wrap">
<table className="data-table">
@@ -122,9 +141,10 @@ export function PartyOverviewPage() {
)}
</td>
<td data-label="Species">{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
<td data-label="Class">{c.characterClass ? classLabel(c.characterClass, c.subclassName) : inProgress ? 'Building…' : '—'}</td>
<td data-label="Class">{c.characterClass ? classLabel(c.characterClass, c.subclassName, c.classes) : inProgress ? 'Building…' : '—'}</td>
<td data-label="Level">
{c.level}
{c.levelUpsPending > 0 && <span title="A level up is approved and waiting for its picks"> ⬆</span>}
{!inProgress && data?.campaign.trackXp && (
<div className="combatant-sub mono" style={{ whiteSpace: 'nowrap' }}>
{xpLabel(c.xp, c.xpProgress)}
@@ -167,6 +187,9 @@ export function PartyOverviewPage() {
<Pagination page={page} pageCount={pageCount} onPrev={prevPage} onNext={nextPage} onJump={setPage} />
{isDM && checking && <GroupCheckModal characters={checkers} onClose={() => setChecking(false)} />}
{isDM && resting && <RestModal campaignId={campaignId} characters={characters ?? []} onClose={() => setResting(false)} />}
{isDM && levelling && (
<LevelUpBulkModal campaignId={campaignId} characters={characters ?? []} trackXp={data?.campaign.trackXp ?? true} onClose={() => setLevelling(false)} />
)}
</div>
);
}
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.23.0",
"version": "1.25.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.23.0",
"version": "1.25.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.23.0",
"version": "1.25.0",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.23.0",
"version": "1.25.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.23.0",
"version": "1.25.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [