Compare commits

...
1 Commits
Author SHA1 Message Date
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
27 changed files with 1098 additions and 78 deletions
+6
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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.23.0",
"version": "1.24.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;
+19
View File
@@ -436,12 +436,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
+62 -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;
}
@@ -345,8 +367,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 +388,10 @@ 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: {
max: Math.max(1, (multi ? multiclass.multiclassHpMax(classes, abilities.CON.modifier) : hpMax(level, hitDieSize, abilities.CON.modifier)) + 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 +399,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 +558,8 @@ module.exports = {
MAX_LEVEL,
MAX_SLOT_LEVEL,
spellSlotsFor,
slotStateOf,
casterKind,
spellSlotState,
maxSpellLevel,
hitDiceState,
@@ -541,3 +577,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();
}
}
});
@@ -3644,3 +3644,53 @@ 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');
});
});
+7
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,6 +255,12 @@ function characterSummary(character, subclasses = true) {
characterClass: character.characterClass,
subclassName: subclasses ? subclassName(character.characterClass, character.subclass) : null,
level: character.level,
// 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),
buildStage: character.buildStage,
+68 -11
View File
@@ -18,7 +18,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 +64,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 } },
},
});
}
@@ -100,6 +103,14 @@ 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,
// 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),
motivation: character.motivation,
@@ -548,20 +559,51 @@ function canLevelUp(req, res, character) {
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 };
}
// 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: [] });
res.json({ plan: { ...plan, maxHp: { from: sheet.hp.max, to: after.hp.max }, spellSlots: after.spellSlots } });
} 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
@@ -576,7 +618,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)) {
@@ -592,17 +636,19 @@ router.post('/:id/level-up', requireCharacterAccess({ write: true }), async (req
return res.status(400).json({ error: "Pick the new spells from this campaign's spell list, each once." });
}
// 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;
const maxHp = sheetOf({ ...withLevelIn(character, plan.characterClass, newSubclass || undefined), armorBonus }).hp.max;
await prisma.$transaction(async (tx) => {
if (weaponId) {
@@ -612,11 +658,19 @@ 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 } : {}),
...(first && newSubclass ? { subclass: newSubclass } : {}),
specialAbilities: gained.specialAbilities,
armorBonus,
tempHp: Math.max(character.tempHp, gained.tempHp),
@@ -768,6 +822,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 } },
+53 -4
View File
@@ -499,6 +499,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,
@@ -1222,10 +1273,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.24.0",
"type": "module",
"scripts": {
"dev": "vite",
+17 -4
View File
@@ -18,6 +18,7 @@ import type {
Item,
LevelUpPlan,
Location,
MulticlassReference,
Monster,
Note,
Npc,
@@ -252,12 +253,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,7 +268,7 @@ 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[] }) =>
mutationFn: async (data: { characterClass?: CharacterClass; subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[] }) =>
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/level-up`, data)).data.character,
onSuccess: (character) => {
invalidateCharacter(qc, characterId);
@@ -362,6 +365,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({
+40 -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,7 @@ export interface CharacterSummary extends CharacterCondition {
characterClass: CharacterClass | null;
subclassName: string | null;
level: number;
classes: ClassLevel[];
xp: number;
xpProgress: XpProgress;
buildStage: BuildStage;
@@ -350,6 +376,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 +508,7 @@ export interface CharacterDetail extends CharacterCondition {
// The 2024 subclass that updates the one followed, if there is one.
subclassUpgrade: SubclassSummary | null;
level: number;
classes: ClassLevel[];
xp: number;
xpProgress: XpProgress;
motivation: string | null;
@@ -883,6 +914,13 @@ 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 };
// 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.
@@ -898,7 +936,7 @@ export interface LevelUpPlan {
spellSlots: SpellSlots | null; // the slots at the new level
// Held weapons that can take the free upgrade of one level.
weapons: { weaponId: string; name: string; equipped: boolean; level: number; damageDice: string; nextDamageDice: string }[];
beyondTables: boolean; // past level 10, where the class tables stop
beyondTables: boolean; // past level 20, where the class tables stop
}
export type RestType = 'SHORT' | 'LONG';
+46 -6
View File
@@ -1,9 +1,9 @@
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 { Spinner, ErrorBanner } from './Spinner';
import type { CharacterDetail, LevelUpPlan, Spell } from '../api/types';
import type { CharacterClass, CharacterDetail, LevelUpPlan, Spell } from '../api/types';
// One numbered step of the checklist.
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
@@ -29,7 +29,9 @@ function allowance(spells: NonNullable<LevelUpPlan['spells']>): string {
// 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);
// 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('');
@@ -66,7 +68,9 @@ 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] });
await levelUp.mutateAsync({
characterClass: plan!.characterClass,
subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
onClose();
} catch (err) {
setError(extractErrorMessage(err));
@@ -78,8 +82,44 @@ 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>
@@ -120,7 +160,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) => (
@@ -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>
);
}
+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;
}
+44 -2
View File
@@ -42,6 +42,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 +66,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';
@@ -432,7 +434,7 @@ export function CharacterSheetPage() {
<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 && (
@@ -746,6 +748,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 +1154,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>
)}
+1 -1
View File
@@ -122,7 +122,7 @@ 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}
{!inProgress && data?.campaign.trackXp && (
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.23.0",
"version": "1.24.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.23.0",
"version": "1.24.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.23.0",
"version": "1.24.0",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.23.0",
"version": "1.24.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.24.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [