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:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 })),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user