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>
This commit is contained in:
2026-10-11 16:14:57 +02:00
co-authored by Claude Opus 5.5
parent 18d9e7301c
commit 86ef106969
27 changed files with 1098 additions and 78 deletions
+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 } },