diff --git a/README.md b/README.md index c03f3b4..adbcc3a 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,12 @@ including a character sheet or an encounter) and **Account**. checklist at level 2 or 3, and shown beside the class everywhere; the DM can change it from the sheet, and switch a 2014 path to its 2024 update. Every sheet shows **the road ahead**: what each level from 1 to 20 gives. +- **Multiclassing** (a campaign setting): the level-up checklist asks which + class gains the level, checks both classes' ability requirements, and works + out HP, Hit Dice, spell slots, counters and features by each class's own + level. The sheet suggests well-known multiclass routes from where the + character stands. Eldritch Knights and Arcane Tricksters now have their + spell slots. - **43 species** (the 2014 core, the 2024 Aasimar and Goliath, and the species of Monsters of the Multiverse) and **13 classes**, the Artificer included. A campaign can do without them: untick "Characters choose a subclass" in its Settings and every class follows one path, unasked. diff --git a/backend/package.json b/backend/package.json index 7e283a7..321390b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.23.0", + "version": "1.24.0", "private": true, "type": "commonjs", "main": "src/index.js", diff --git a/backend/prisma/migrations/20261011140420_multiclassing/migration.sql b/backend/prisma/migrations/20261011140420_multiclassing/migration.sql new file mode 100644 index 0000000..c1c59d8 --- /dev/null +++ b/backend/prisma/migrations/20261011140420_multiclassing/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 5902ce3..99c2a8b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/game/calculations.js b/backend/src/game/calculations.js index 458abe2..fffcfdb 100644 --- a/backend/src/game/calculations.js +++ b/backend/src/game/calculations.js @@ -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'); diff --git a/backend/src/game/data/multiclassPaths.js b/backend/src/game/data/multiclassPaths.js new file mode 100644 index 0000000..1557061 --- /dev/null +++ b/backend/src/game/data/multiclassPaths.js @@ -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 }; diff --git a/backend/src/game/data/spellSlots.js b/backend/src/game/data/spellSlots.js index c54c42b..6cb7482 100644 --- a/backend/src/game/data/spellSlots.js +++ b/backend/src/game/data/spellSlots.js @@ -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, +}; diff --git a/backend/src/game/levelUp.js b/backend/src/game/levelUp.js index fb61844..a204d92 100644 --- a/backend/src/game/levelUp.js +++ b/backend/src/game/levelUp.js @@ -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 }; diff --git a/backend/src/game/multiclass.js b/backend/src/game/multiclass.js new file mode 100644 index 0000000..4ae46bc --- /dev/null +++ b/backend/src/game/multiclass.js @@ -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, +}); diff --git a/backend/src/game/multiclass.test.js b/backend/src/game/multiclass.test.js new file mode 100644 index 0000000..a63f0a7 --- /dev/null +++ b/backend/src/game/multiclass.test.js @@ -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(); + } + } +}); diff --git a/backend/src/routes/api.integration.test.js b/backend/src/routes/api.integration.test.js index 5446651..149c6c4 100644 --- a/backend/src/routes/api.integration.test.js +++ b/backend/src/routes/api.integration.test.js @@ -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'); + }); +}); diff --git a/backend/src/routes/campaigns.routes.js b/backend/src/routes/campaigns.routes.js index dc3ec03..83c5529 100644 --- a/backend/src/routes/campaigns.routes.js +++ b/backend/src/routes/campaigns.routes.js @@ -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, diff --git a/backend/src/routes/characters.routes.js b/backend/src/routes/characters.routes.js index 75e0e1f..8c71440 100644 --- a/backend/src/routes/characters.routes.js +++ b/backend/src/routes/characters.routes.js @@ -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 })), }, diff --git a/backend/src/routes/reference.routes.js b/backend/src/routes/reference.routes.js index 13aa0ce..641fd84 100644 --- a/backend/src/routes/reference.routes.js +++ b/backend/src/routes/reference.routes.js @@ -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; diff --git a/backend/src/utils/sheet.js b/backend/src/utils/sheet.js index 21f8c26..351ad21 100644 --- a/backend/src/utils/sheet.js +++ b/backend/src/utils/sheet.js @@ -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 } }, diff --git a/docs/CHARACTERS.md b/docs/CHARACTERS.md index 3314dc3..4e31cb8 100644 --- a/docs/CHARACTERS.md +++ b/docs/CHARACTERS.md @@ -499,6 +499,57 @@ the DM's to keep. whose gear level is below the character's new level (the equipped one is offered first). Powerful and legendary weapons never get it. +### Multiclassing + +With **Multiclassing** on in the campaign settings, the level-up checklist +starts by asking **which class gains this level**: one the character has, or +a new one. A new class is open only when the character's final ability +scores meet its requirement *and* the requirement of every class they already +have (the 2014 rules): + +| Class | Needs | Class | Needs | +|---|---|---|---| +| Artificer | Intelligence 13 | Paladin | Strength 13 and Charisma 13 | +| Barbarian | Strength 13 | Ranger | Dexterity 13 and Wisdom 13 | +| Bard | Charisma 13 | Rogue | Dexterity 13 | +| Cleric | Wisdom 13 | Sorcerer | Charisma 13 | +| Druid | Wisdom 13 | Warlock | Charisma 13 | +| Fighter | Strength 13 or Dexterity 13 | Wizard | Intelligence 13 | +| Monk | Dexterity 13 and Wisdom 13 | | | + +Everything then goes by **each class's own level**: + +- A first level in a new class writes what that class grants at level 1 + (not its saving throws or skills) and a "… Training" line with what it + teaches a newcomer. The class's later features, Boons and subclass come at + its own levels: a fighter 5 / rogue 3 chooses a roguish archetype now. +- **Hit points**: the first class's die in full at level 1, then each level the + average of that level's class die, plus Constitution every level. Hit Dice + are counted per class. +- **Spell slots**: one casting class keeps its own table. Two or more add up a + caster level (all of a full caster's levels, half a paladin's or ranger's, + half an artificer's rounded up, a third of an Eldritch Knight's or Arcane + Trickster's) and use the full caster table. A warlock's pact slots stand + apart: the sheet says how many, back on a short rest. +- **Spellcasting**: each casting class has its own DC and attack bonus, listed + on the sheet. +- **Counters** (Rage, Ki, Channel Divinity...) go by their class's level; two + classes giving the same counter keep the larger. + +The first class is the character's `characterClass`; each other class is a +row of its own (`CharacterClassLevel`: class, level, subclass). Everywhere a +class is named, a multiclassed character reads "Fighter 5 (Champion) / Rogue 3". + +**Routes.** The sheet's "The Road Ahead" card shows the road from 1 to 20 for +each class the character has, and, with multiclassing on, **Multiclass routes +from here**: well-known combinations (the Hexadin, the Sorlock, a two-level +fighter dip…) with each step, how far along the character is, and whether +their scores meet every class on the way. They are suggestions; nothing is +enforced. + +**Eldritch Knight and Arcane Trickster** cast with Intelligence from level 3, +with a third caster's slots, whether or not the character has multiclassed. + ### The Level field Typing a number into Level sets it outright: for a character who joins late, @@ -1222,10 +1273,8 @@ calculations for them. - The effects of special abilities: species traits and class features are written on the sheet for the DM to apply, but nothing is calculated from them -- The spell slots of the spellcasting subclasses of non-casters (Eldritch - Knight, Arcane Trickster): their lines say what they can do, and the DM - grants their spells -- Multiclassing +- A multiclassed warlock's pact slots are counted at the table: the sheet + says how many there are, and the tracker keeps the other classes' slots - Spell preparation limits and concentration - Ability score increases and feats on level-up (a Boon is given in their place, and Weapon Mastery and Arcane Focus are added by the player, not by diff --git a/docs/SPELLS.md b/docs/SPELLS.md index 0aff841..02f4cdb 100644 --- a/docs/SPELLS.md +++ b/docs/SPELLS.md @@ -211,6 +211,13 @@ section 7): a **long rest** gives every slot back; a **short rest** gives a warlock theirs. Levelling up with "⬆ Level Up…" gives a fresh set. Changing the Level field keeps what was spent, as far as the new level has those slots. +An **Artificer** casts from level 1 at half a full caster's pace, rounding up +(2 slots at levels 1 and 2, 2nd-level slots at 5). An **Eldritch Knight** or +**Arcane Trickster** casts from level 3 at a third of the pace, with +Intelligence. A **multiclassed** character adds their casting classes up to a +caster level on the full caster table, with a warlock's pact slots kept apart +([CHARACTERS.md](CHARACTERS.md), "Multiclassing"). + **Bard, Cleric, Druid, Sorcerer, Wizard** (back on a long rest): | Character level | 1st | 2nd | 3rd | 4th | 5th | diff --git a/frontend/package.json b/frontend/package.json index 57e51d8..d5a8613 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.23.0", + "version": "1.24.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/queries.ts b/frontend/src/api/queries.ts index 346fa62..3eeb71f 100644 --- a/frontend/src/api/queries.ts +++ b/frontend/src/api/queries.ts @@ -18,6 +18,7 @@ import type { Item, LevelUpPlan, Location, + MulticlassReference, Monster, Note, Npc, @@ -252,12 +253,14 @@ export function useCharacterState(characterId: string) { } // What a character's next level brings. DM only. -export function useLevelUpPlan(characterId: string, enabled: boolean) { +// `toClass`: the plan for a level in that class (multiclassing); the first class when null. +export function useLevelUpPlan(characterId: string, enabled: boolean, toClass: CharacterClass | null = null) { return useQuery({ - queryKey: ['character', characterId, 'level-up'], + queryKey: ['character', characterId, 'level-up', toClass], enabled, gcTime: 0, // never open on the plan for a level the character has already reached - queryFn: async () => (await apiClient.get<{ plan: LevelUpPlan }>(`/characters/${characterId}/level-up`)).data.plan, + queryFn: async () => + (await apiClient.get<{ plan: LevelUpPlan }>(`/characters/${characterId}/level-up`, { params: toClass ? { class: toClass } : {} })).data.plan, }); } @@ -265,7 +268,7 @@ export function useLevelUpPlan(characterId: string, enabled: boolean) { export function useLevelUp(characterId: string) { const qc = useQueryClient(); return useMutation({ - mutationFn: async (data: { subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[] }) => + mutationFn: async (data: { characterClass?: CharacterClass; subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[] }) => (await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/level-up`, data)).data.character, onSuccess: (character) => { invalidateCharacter(qc, characterId); @@ -362,6 +365,16 @@ export function useBuildOptions() { }); } +// Multiclassing requirements and the suggested routes. +export function useMulticlassReference(enabled: boolean) { + return useQuery({ + queryKey: ['multiclass-reference'], + enabled, + staleTime: Infinity, + queryFn: async () => (await apiClient.get('/reference/multiclass')).data, + }); +} + // What each level from 1 to 20 gives a class, with a subclass's lines when one is given. export function useProgression(characterClass: CharacterClass | null | undefined, subclass?: string | null) { return useQuery({ diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 7a6afeb..b297b2a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -132,6 +132,21 @@ export interface SubclassSummary { replaces: string | null; } +// What each class asks of the scores to multiclass, and the suggested routes +// (GET /reference/multiclass). An alternative is a list of abilities that must +// all be at least prereqScore. +export interface MulticlassReference { + prereqScore: number; + prereqs: Record; + paths: { + key: string; + name: string; + from: CharacterClass | null; + steps: { class: CharacterClass; toLevel: number; subclass?: string }[]; + why: string; + }[]; +} + // One level of a class's road from 1 to 20 (GET /reference/progression). export interface ProgressionLevel { level: number; @@ -216,7 +231,7 @@ export interface SpellSlotLevel { // A character's spell slots, from class and level. A warlock's (PACT) are all // of one slot level and come back on a short rest; everyone else's on a long one. export interface SpellSlots { - kind: 'FULL' | 'HALF' | 'PACT'; + kind: 'FULL' | 'HALF' | 'THIRD' | 'PACT'; restoresOn: 'SHORT' | 'LONG'; levels: SpellSlotLevel[]; } @@ -227,6 +242,16 @@ export interface HitDice { count: number; used: number; remaining: number; + // A multiclassed character's dice by class (each class's own size). + pools?: { characterClass: CharacterClass; size: number; count: number }[]; +} + +// A class a character has levels in. One entry unless they have multiclassed. +export interface ClassLevel { + characterClass: CharacterClass; + level: number; + subclass?: string | null; + subclassName: string | null; } // A class counter (Rage, Ki, Channel Divinity...): how many uses there are, @@ -278,6 +303,7 @@ export interface CharacterSummary extends CharacterCondition { characterClass: CharacterClass | null; subclassName: string | null; level: number; + classes: ClassLevel[]; xp: number; xpProgress: XpProgress; buildStage: BuildStage; @@ -350,6 +376,10 @@ export interface CharacterSheet { passiveInvestigation: number; passiveInsight: number; spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null; + // A multiclassed caster's DC and attack for each class that casts. + spellcastingByClass: { characterClass: CharacterClass; ability: Ability; saveDC: number; attackBonus: number }[]; + // A multiclassed warlock's pact slots, apart from the other slots; null otherwise. + pactSlots: { count: number; slotLevel: number } | null; spellSlots: SpellSlots | null; // null for a character with no spell slots resources: ClassResource[]; // the class counters, with how many of each are left gear: ActiveGear; @@ -478,6 +508,7 @@ export interface CharacterDetail extends CharacterCondition { // The 2024 subclass that updates the one followed, if there is one. subclassUpgrade: SubclassSummary | null; level: number; + classes: ClassLevel[]; xp: number; xpProgress: XpProgress; motivation: string | null; @@ -883,6 +914,13 @@ export interface BoonOption { export interface LevelUpPlan { fromLevel: number; toLevel: number; + // The class taking the level, its own level after it, and whether it is the + // character's first level in that class (multiclassing). + characterClass: CharacterClass; + classLevel: number; + joining: boolean; + // With multiclassing on: every class the level could go to, and why not. + classChoice: { options: { characterClass: CharacterClass; level: number; eligible: boolean; problems: string[] }[] } | null; maxHp: { from: number; to: number }; // Set when a subclass has to be picked on the way to this level: the level // the class chooses at, and each option with everything it would write. @@ -898,7 +936,7 @@ export interface LevelUpPlan { spellSlots: SpellSlots | null; // the slots at the new level // Held weapons that can take the free upgrade of one level. weapons: { weaponId: string; name: string; equipped: boolean; level: number; damageDice: string; nextDamageDice: string }[]; - beyondTables: boolean; // past level 10, where the class tables stop + beyondTables: boolean; // past level 20, where the class tables stop } export type RestType = 'SHORT' | 'LONG'; diff --git a/frontend/src/components/LevelUpModal.tsx b/frontend/src/components/LevelUpModal.tsx index 3c21053..dc9d9ea 100644 --- a/frontend/src/components/LevelUpModal.tsx +++ b/frontend/src/components/LevelUpModal.tsx @@ -1,9 +1,9 @@ import { useState, type ReactNode } from 'react'; import { useLevelUp, useLevelUpPlan } from '../api/queries'; -import { extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format'; +import { CLASS_LABELS, classLabel, extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format'; import { Modal } from './Modal'; import { Spinner, ErrorBanner } from './Spinner'; -import type { CharacterDetail, LevelUpPlan, Spell } from '../api/types'; +import type { CharacterClass, CharacterDetail, LevelUpPlan, Spell } from '../api/types'; // One numbered step of the checklist. function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) { @@ -29,7 +29,9 @@ function allowance(spells: NonNullable): string { // where the level calls for one), new spells and fresh slots, and the free weapon upgrade. The // site does every step; the DM makes the choices and says yes. export function LevelUpModal({ character, campaignSpells, onClose }: { character: CharacterDetail; campaignSpells: Spell[]; onClose: () => void }) { - const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true); + // The class taking the level: the first one, unless another is picked (multiclassing). + const [toClass, setToClass] = useState(null); + const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true, toClass); const levelUp = useLevelUp(character.id); const [subclass, setSubclass] = useState(''); const [boon, setBoon] = useState(''); @@ -66,7 +68,9 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character async function handleLevelUp() { setError(null); try { - await levelUp.mutateAsync({ subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] }); + await levelUp.mutateAsync({ + characterClass: plan!.characterClass, + subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] }); onClose(); } catch (err) { setError(extractErrorMessage(err)); @@ -78,8 +82,44 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character {error && } + {plan.classChoice && ( +
+ + {plan.joining && ( +

+ Multiclassing into {CLASS_LABELS[plan.characterClass]}: it starts at its own level 1, with what that class grants at level 1 (not its saves or + skills) and what it teaches a newcomer. +

+ )} +
+ )}

- Level {plan.fromLevel} → {plan.toLevel}. Proficiency bonus, saves, skills and spell DC follow by themselves. + Level {plan.fromLevel} → {plan.toLevel} + {(character.classes.length > 1 || plan.characterClass !== character.characterClass) && ( + + {' '} + · {CLASS_LABELS[plan.characterClass]} {plan.classLevel - 1} → {plan.classLevel} (now {classLabel(character.characterClass, character.subclassName, character.classes)}) + + )} + . Proficiency bonus, saves, skills and spell DC follow by themselves.

@@ -120,7 +160,7 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character )} {plan.features.length === 0 && !plan.boon && !plan.subclass && (

- {plan.beyondTables ? 'The class tables stop at level 10. Add anything new to the special abilities by hand.' : 'No new feature at this level.'} + {plan.beyondTables ? 'The class tables stop at level 20. Add anything new to the special abilities by hand.' : 'No new feature at this level.'}

)} {plan.features.map((feature) => ( diff --git a/frontend/src/components/MulticlassRoutes.tsx b/frontend/src/components/MulticlassRoutes.tsx new file mode 100644 index 0000000..37159a5 --- /dev/null +++ b/frontend/src/components/MulticlassRoutes.tsx @@ -0,0 +1,62 @@ +import { useMulticlassReference } from '../api/queries'; +import { CLASS_LABELS } from '../lib/format'; +import { Spinner } from './Spinner'; +import type { Ability, CharacterClass, ClassLevel, MulticlassReference } from '../api/types'; + +// Whether final scores meet a class's multiclassing requirement. +function meets(reference: MulticlassReference, characterClass: CharacterClass, scores: Record): boolean { + return reference.prereqs[characterClass].alternatives.some((all) => all.every((a) => scores[a] >= reference.prereqScore)); +} + +// Suggested multiclass routes from where a character stands: each route's +// steps, how far along the character is, and whether their scores meet every +// class the route takes them through (multiclassing needs the requirement of +// the class joined and of each class left). +export function MulticlassRoutes({ classes, scores }: { classes: ClassLevel[]; scores: Record }) { + const { data: reference, isLoading } = useMulticlassReference(true); + if (isLoading || !reference) return ; + + const levelIn = (cls: CharacterClass) => classes.find((c) => c.characterClass === cls)?.level ?? 0; + const have = new Set(classes.map((c) => c.characterClass)); + const paths = reference.paths.filter((p) => !p.from || have.has(p.from)); + const yours = classes.map((c) => c.characterClass); + + return ( +
+

+ Multiclassing is on. A level can go to another class when {classes.length > 1 ? 'every class you have' : `your ${CLASS_LABELS[yours[0]]}`} and the new class’s + requirements are met: {yours.map((c) => `${CLASS_LABELS[c]} needs ${reference.prereqs[c].text}`).join('; ')}. +

+
+ {paths.map((path) => { + const classesOnRoute = [...new Set(path.steps.map((s) => s.class))]; + const blocked = classesOnRoute.filter((cls) => !meets(reference, cls, scores)); + return ( +
+ + {path.name} + {blocked.length === 0 ? Open to you : Needs scores} + + {path.why} +
    + {path.steps.map((step, i) => { + const done = levelIn(step.class) >= step.toLevel; + return ( +
  1. + {done ? '✓ ' : ''} + {CLASS_LABELS[step.class]} to level {step.toLevel} + {step.subclass ? ` (${step.subclass.toLowerCase().replace(/_/g, ' ')})` : ''} +
  2. + ); + })} +
+ {blocked.length > 0 && ( + {blocked.map((cls) => `${CLASS_LABELS[cls]}: ${reference.prereqs[cls].text}`).join(' · ')} + )} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 9ff387d..b6c5c99 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -97,9 +97,13 @@ export function xpLabel(xp: number, progress: { nextLevelAt: number | null }): s return progress.nextLevelAt == null ? `${xp.toLocaleString()} XP` : `${xp.toLocaleString()} / ${progress.nextLevelAt.toLocaleString()} XP`; } -// "Fighter" or, with a subclass chosen, "Fighter (Champion)". -export function classLabel(characterClass: string | null, subclassName?: string | null): string { +// "Fighter" or, with a subclass chosen, "Fighter (Champion)". A multiclassed +// character (`classes` with more than one) reads "Fighter 3 (Champion) / Rogue 2". +export function classLabel(characterClass: string | null, subclassName?: string | null, classes?: { characterClass: string; level: number; subclassName: string | null }[]): string { if (!characterClass) return ''; + if (classes && classes.length > 1) { + return classes.map((c) => `${CLASS_LABELS[c.characterClass] ?? c.characterClass} ${c.level}${c.subclassName ? ` (${c.subclassName})` : ''}`).join(' / '); + } const label = CLASS_LABELS[characterClass] ?? characterClass; return subclassName ? `${label} (${subclassName})` : label; } diff --git a/frontend/src/pages/CharacterSheetPage.tsx b/frontend/src/pages/CharacterSheetPage.tsx index 05d20f3..980ff46 100644 --- a/frontend/src/pages/CharacterSheetPage.tsx +++ b/frontend/src/pages/CharacterSheetPage.tsx @@ -42,6 +42,7 @@ import { Rollable } from '../components/Rollable'; import { withBonus } from '../lib/diceRoller'; import { LevelUpModal } from '../components/LevelUpModal'; import { ProgressionTimeline } from '../components/ProgressionTimeline'; +import { MulticlassRoutes } from '../components/MulticlassRoutes'; import { GearInForce } from '../components/CharacterPanel'; import { effectsSummary } from '../lib/gear'; import { InfoTip } from '../components/InfoTip'; @@ -65,6 +66,7 @@ import { formatPurse, purseWorthInGold, spellLevelLabel, + slotLevelLabel, } from '../lib/format'; import { speciesIcon } from '../lib/speciesIcons'; import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc'; @@ -432,7 +434,7 @@ export function CharacterSheetPage() { {character.name}

- {character.species && SPECIES_LABELS[character.species]} {classLabel(character.characterClass, character.subclassName)} · Level{' '} + {character.species && SPECIES_LABELS[character.species]} {classLabel(character.characterClass, character.subclassName, character.classes)} · Level{' '} {character.level} {trackXp && ` · ${xpLabel(character.xp, character.xpProgress)}`} {trackXp && character.xpProgress.levelUpDue && ( @@ -746,6 +748,20 @@ export function CharacterSheetPage() { {' '}

+ {sheet.spellcastingByClass.length > 1 && ( +

+ By class:{' '} + {sheet.spellcastingByClass + .map((c) => `${CLASS_LABELS[c.characterClass]} (${ABILITY_LABELS[c.ability]}) DC ${c.saveDC}, ${fmtMod(c.attackBonus)}`) + .join(' · ')} +

+ )} + {sheet.pactSlots && ( +

+ Pact Magic: {sheet.pactSlots.count} slot{sheet.pactSlots.count === 1 ? '' : 's'} of {slotLevelLabel(sheet.pactSlots.slotLevel)} level, back on a short rest. + Keep count of these at the table; the tracker below is the other classes’ slots. +

+ )} {sheet.spellSlots ? ( )} + {character.classes.length > 1 && ( +

+ {CLASS_LABELS[character.characterClass]} {character.classes[0].level} + {character.subclassName ? ` (${character.subclassName})` : ''} +

+ )} + {character.classes.slice(1).map((c) => ( +
+

+ {CLASS_LABELS[c.characterClass]} {c.level} + {c.subclassName ? ` (${c.subclassName})` : ''} +

+ +
+ ))} + {campaignData?.campaign.multiclassEnabled && ( +
+ + Multiclass routes from here + + [a, d.final])) as Record} + /> +
+ )} )} diff --git a/frontend/src/pages/PartyOverviewPage.tsx b/frontend/src/pages/PartyOverviewPage.tsx index a6a1fcf..82476c2 100644 --- a/frontend/src/pages/PartyOverviewPage.tsx +++ b/frontend/src/pages/PartyOverviewPage.tsx @@ -122,7 +122,7 @@ export function PartyOverviewPage() { )} {c.species ? SPECIES_LABELS[c.species] : '—'} - {c.characterClass ? classLabel(c.characterClass, c.subclassName) : inProgress ? 'Building…' : '—'} + {c.characterClass ? classLabel(c.characterClass, c.subclassName, c.classes) : inProgress ? 'Building…' : '—'} {c.level} {!inProgress && data?.campaign.trackXp && ( diff --git a/package-lock.json b/package-lock.json index bb093c6..cd4b5da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "dnd-campaign-manager", - "version": "1.23.0", + "version": "1.24.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dnd-campaign-manager", - "version": "1.23.0", + "version": "1.24.0", "workspaces": [ "backend", "frontend" ] }, "backend": { - "version": "1.23.0", + "version": "1.24.0", "dependencies": { "@prisma/adapter-pg": "^7.10.0", "@prisma/client": "^7.10.0", @@ -33,7 +33,7 @@ } }, "frontend": { - "version": "1.23.0", + "version": "1.24.0", "dependencies": { "@tanstack/react-query": "^5.103.2", "axios": "^1.20.0", diff --git a/package.json b/package.json index 8ccadd1..35f65cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dnd-campaign-manager", - "version": "1.23.0", + "version": "1.24.0", "private": true, "description": "D&D 5e campaign manager — monorepo root", "workspaces": [