Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86ef106969 |
@@ -99,6 +99,12 @@ including a character sheet or an encounter) and **Account**.
|
||||
checklist at level 2 or 3, and shown beside the class everywhere; the DM can
|
||||
change it from the sheet, and switch a 2014 path to its 2024 update. Every
|
||||
sheet shows **the road ahead**: what each level from 1 to 20 gives.
|
||||
- **Multiclassing** (a campaign setting): the level-up checklist asks which
|
||||
class gains the level, checks both classes' ability requirements, and works
|
||||
out HP, Hit Dice, spell slots, counters and features by each class's own
|
||||
level. The sheet suggests well-known multiclass routes from where the
|
||||
character stands. Eldritch Knights and Arcane Tricksters now have their
|
||||
spell slots.
|
||||
- **43 species** (the 2014 core, the 2024 Aasimar and Goliath, and the species
|
||||
of Monsters of the Multiverse) and **13 classes**, the Artificer included. A campaign can do without them: untick "Characters choose a
|
||||
subclass" in its Settings and every class follows one path, unasked.
|
||||
|
||||
@@ -1,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 } },
|
||||
|
||||
+53
-4
@@ -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
|
||||
|
||||
@@ -211,6 +211,13 @@ section 7): a **long rest** gives every slot back; a **short rest** gives a
|
||||
warlock theirs. Levelling up with "⬆ Level Up…" gives a fresh set. Changing
|
||||
the Level field keeps what was spent, as far as the new level has those slots.
|
||||
|
||||
An **Artificer** casts from level 1 at half a full caster's pace, rounding up
|
||||
(2 slots at levels 1 and 2, 2nd-level slots at 5). An **Eldritch Knight** or
|
||||
**Arcane Trickster** casts from level 3 at a third of the pace, with
|
||||
Intelligence. A **multiclassed** character adds their casting classes up to a
|
||||
caster level on the full caster table, with a warlock's pact slots kept apart
|
||||
([CHARACTERS.md](CHARACTERS.md), "Multiclassing").
|
||||
|
||||
**Bard, Cleric, Druid, Sorcerer, Wizard** (back on a long rest):
|
||||
|
||||
| Character level | 1st | 2nd | 3rd | 4th | 5th |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
Item,
|
||||
LevelUpPlan,
|
||||
Location,
|
||||
MulticlassReference,
|
||||
Monster,
|
||||
Note,
|
||||
Npc,
|
||||
@@ -252,12 +253,14 @@ export function useCharacterState(characterId: string) {
|
||||
}
|
||||
|
||||
// What a character's next level brings. DM only.
|
||||
export function useLevelUpPlan(characterId: string, enabled: boolean) {
|
||||
// `toClass`: the plan for a level in that class (multiclassing); the first class when null.
|
||||
export function useLevelUpPlan(characterId: string, enabled: boolean, toClass: CharacterClass | null = null) {
|
||||
return useQuery({
|
||||
queryKey: ['character', characterId, 'level-up'],
|
||||
queryKey: ['character', characterId, 'level-up', toClass],
|
||||
enabled,
|
||||
gcTime: 0, // never open on the plan for a level the character has already reached
|
||||
queryFn: async () => (await apiClient.get<{ plan: LevelUpPlan }>(`/characters/${characterId}/level-up`)).data.plan,
|
||||
queryFn: async () =>
|
||||
(await apiClient.get<{ plan: LevelUpPlan }>(`/characters/${characterId}/level-up`, { params: toClass ? { class: toClass } : {} })).data.plan,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -265,7 +268,7 @@ export function useLevelUpPlan(characterId: string, enabled: boolean) {
|
||||
export function useLevelUp(characterId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: { subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[] }) =>
|
||||
mutationFn: async (data: { characterClass?: CharacterClass; subclass?: string; boon?: string; weaponId?: string | null; spellIds?: string[] }) =>
|
||||
(await apiClient.post<{ character: CharacterDetail }>(`/characters/${characterId}/level-up`, data)).data.character,
|
||||
onSuccess: (character) => {
|
||||
invalidateCharacter(qc, characterId);
|
||||
@@ -362,6 +365,16 @@ export function useBuildOptions() {
|
||||
});
|
||||
}
|
||||
|
||||
// Multiclassing requirements and the suggested routes.
|
||||
export function useMulticlassReference(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ['multiclass-reference'],
|
||||
enabled,
|
||||
staleTime: Infinity,
|
||||
queryFn: async () => (await apiClient.get<MulticlassReference>('/reference/multiclass')).data,
|
||||
});
|
||||
}
|
||||
|
||||
// What each level from 1 to 20 gives a class, with a subclass's lines when one is given.
|
||||
export function useProgression(characterClass: CharacterClass | null | undefined, subclass?: string | null) {
|
||||
return useQuery({
|
||||
|
||||
@@ -132,6 +132,21 @@ export interface SubclassSummary {
|
||||
replaces: string | null;
|
||||
}
|
||||
|
||||
// What each class asks of the scores to multiclass, and the suggested routes
|
||||
// (GET /reference/multiclass). An alternative is a list of abilities that must
|
||||
// all be at least prereqScore.
|
||||
export interface MulticlassReference {
|
||||
prereqScore: number;
|
||||
prereqs: Record<CharacterClass, { alternatives: Ability[][]; text: string }>;
|
||||
paths: {
|
||||
key: string;
|
||||
name: string;
|
||||
from: CharacterClass | null;
|
||||
steps: { class: CharacterClass; toLevel: number; subclass?: string }[];
|
||||
why: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// One level of a class's road from 1 to 20 (GET /reference/progression).
|
||||
export interface ProgressionLevel {
|
||||
level: number;
|
||||
@@ -216,7 +231,7 @@ export interface SpellSlotLevel {
|
||||
// A character's spell slots, from class and level. A warlock's (PACT) are all
|
||||
// of one slot level and come back on a short rest; everyone else's on a long one.
|
||||
export interface SpellSlots {
|
||||
kind: 'FULL' | 'HALF' | 'PACT';
|
||||
kind: 'FULL' | 'HALF' | 'THIRD' | 'PACT';
|
||||
restoresOn: 'SHORT' | 'LONG';
|
||||
levels: SpellSlotLevel[];
|
||||
}
|
||||
@@ -227,6 +242,16 @@ export interface HitDice {
|
||||
count: number;
|
||||
used: number;
|
||||
remaining: number;
|
||||
// A multiclassed character's dice by class (each class's own size).
|
||||
pools?: { characterClass: CharacterClass; size: number; count: number }[];
|
||||
}
|
||||
|
||||
// A class a character has levels in. One entry unless they have multiclassed.
|
||||
export interface ClassLevel {
|
||||
characterClass: CharacterClass;
|
||||
level: number;
|
||||
subclass?: string | null;
|
||||
subclassName: string | null;
|
||||
}
|
||||
|
||||
// A class counter (Rage, Ki, Channel Divinity...): how many uses there are,
|
||||
@@ -278,6 +303,7 @@ export interface CharacterSummary extends CharacterCondition {
|
||||
characterClass: CharacterClass | null;
|
||||
subclassName: string | null;
|
||||
level: number;
|
||||
classes: ClassLevel[];
|
||||
xp: number;
|
||||
xpProgress: XpProgress;
|
||||
buildStage: BuildStage;
|
||||
@@ -350,6 +376,10 @@ export interface CharacterSheet {
|
||||
passiveInvestigation: number;
|
||||
passiveInsight: number;
|
||||
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
|
||||
// A multiclassed caster's DC and attack for each class that casts.
|
||||
spellcastingByClass: { characterClass: CharacterClass; ability: Ability; saveDC: number; attackBonus: number }[];
|
||||
// A multiclassed warlock's pact slots, apart from the other slots; null otherwise.
|
||||
pactSlots: { count: number; slotLevel: number } | null;
|
||||
spellSlots: SpellSlots | null; // null for a character with no spell slots
|
||||
resources: ClassResource[]; // the class counters, with how many of each are left
|
||||
gear: ActiveGear;
|
||||
@@ -478,6 +508,7 @@ export interface CharacterDetail extends CharacterCondition {
|
||||
// The 2024 subclass that updates the one followed, if there is one.
|
||||
subclassUpgrade: SubclassSummary | null;
|
||||
level: number;
|
||||
classes: ClassLevel[];
|
||||
xp: number;
|
||||
xpProgress: XpProgress;
|
||||
motivation: string | null;
|
||||
@@ -883,6 +914,13 @@ export interface BoonOption {
|
||||
export interface LevelUpPlan {
|
||||
fromLevel: number;
|
||||
toLevel: number;
|
||||
// The class taking the level, its own level after it, and whether it is the
|
||||
// character's first level in that class (multiclassing).
|
||||
characterClass: CharacterClass;
|
||||
classLevel: number;
|
||||
joining: boolean;
|
||||
// With multiclassing on: every class the level could go to, and why not.
|
||||
classChoice: { options: { characterClass: CharacterClass; level: number; eligible: boolean; problems: string[] }[] } | null;
|
||||
maxHp: { from: number; to: number };
|
||||
// Set when a subclass has to be picked on the way to this level: the level
|
||||
// the class chooses at, and each option with everything it would write.
|
||||
@@ -898,7 +936,7 @@ export interface LevelUpPlan {
|
||||
spellSlots: SpellSlots | null; // the slots at the new level
|
||||
// Held weapons that can take the free upgrade of one level.
|
||||
weapons: { weaponId: string; name: string; equipped: boolean; level: number; damageDice: string; nextDamageDice: string }[];
|
||||
beyondTables: boolean; // past level 10, where the class tables stop
|
||||
beyondTables: boolean; // past level 20, where the class tables stop
|
||||
}
|
||||
|
||||
export type RestType = 'SHORT' | 'LONG';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useLevelUp, useLevelUpPlan } from '../api/queries';
|
||||
import { extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format';
|
||||
import { CLASS_LABELS, classLabel, extractErrorMessage, slotLevelLabel, slotsSummary, spellLevelLabel } from '../lib/format';
|
||||
import { Modal } from './Modal';
|
||||
import { Spinner, ErrorBanner } from './Spinner';
|
||||
import type { CharacterDetail, LevelUpPlan, Spell } from '../api/types';
|
||||
import type { CharacterClass, CharacterDetail, LevelUpPlan, Spell } from '../api/types';
|
||||
|
||||
// One numbered step of the checklist.
|
||||
function Step({ n, title, children }: { n: number; title: string; children: ReactNode }) {
|
||||
@@ -29,7 +29,9 @@ function allowance(spells: NonNullable<LevelUpPlan['spells']>): string {
|
||||
// where the level calls for one), new spells and fresh slots, and the free weapon upgrade. The
|
||||
// site does every step; the DM makes the choices and says yes.
|
||||
export function LevelUpModal({ character, campaignSpells, onClose }: { character: CharacterDetail; campaignSpells: Spell[]; onClose: () => void }) {
|
||||
const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true);
|
||||
// The class taking the level: the first one, unless another is picked (multiclassing).
|
||||
const [toClass, setToClass] = useState<CharacterClass | null>(null);
|
||||
const { data: plan, isLoading, error: planError } = useLevelUpPlan(character.id, true, toClass);
|
||||
const levelUp = useLevelUp(character.id);
|
||||
const [subclass, setSubclass] = useState('');
|
||||
const [boon, setBoon] = useState('');
|
||||
@@ -66,7 +68,9 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
async function handleLevelUp() {
|
||||
setError(null);
|
||||
try {
|
||||
await levelUp.mutateAsync({ subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
|
||||
await levelUp.mutateAsync({
|
||||
characterClass: plan!.characterClass,
|
||||
subclass: plan!.subclass ? subclass : undefined, boon: plan!.boon ? boon : undefined, weaponId: weaponId || null, spellIds: [...spellIds] });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(extractErrorMessage(err));
|
||||
@@ -78,8 +82,44 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
<Step n={1} title="Level">
|
||||
{plan.classChoice && (
|
||||
<div className="mb-2">
|
||||
<label className="flex-row flex-wrap gap-sm">
|
||||
Which class gains this level?
|
||||
<select
|
||||
value={plan.characterClass}
|
||||
onChange={(e) => {
|
||||
setToClass(e.target.value as CharacterClass);
|
||||
setSubclass('');
|
||||
setBoon('');
|
||||
setSpellIds(new Set());
|
||||
}}
|
||||
>
|
||||
{plan.classChoice.options.map((o) => (
|
||||
<option key={o.characterClass} value={o.characterClass} disabled={!o.eligible} title={o.problems.join(' ')}>
|
||||
{CLASS_LABELS[o.characterClass]}
|
||||
{o.level > 0 ? ` (level ${o.level} → ${o.level + 1})` : o.eligible ? ' (new class)' : ' (requirements not met)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{plan.joining && (
|
||||
<p className="combatant-sub">
|
||||
Multiclassing into {CLASS_LABELS[plan.characterClass]}: it starts at its own level 1, with what that class grants at level 1 (not its saves or
|
||||
skills) and what it teaches a newcomer.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p>
|
||||
Level <strong>{plan.fromLevel}</strong> → <strong>{plan.toLevel}</strong>. Proficiency bonus, saves, skills and spell DC follow by themselves.
|
||||
Level <strong>{plan.fromLevel}</strong> → <strong>{plan.toLevel}</strong>
|
||||
{(character.classes.length > 1 || plan.characterClass !== character.characterClass) && (
|
||||
<span className="combatant-sub">
|
||||
{' '}
|
||||
· {CLASS_LABELS[plan.characterClass]} {plan.classLevel - 1} → {plan.classLevel} (now {classLabel(character.characterClass, character.subclassName, character.classes)})
|
||||
</span>
|
||||
)}
|
||||
. Proficiency bonus, saves, skills and spell DC follow by themselves.
|
||||
</p>
|
||||
</Step>
|
||||
|
||||
@@ -120,7 +160,7 @@ export function LevelUpModal({ character, campaignSpells, onClose }: { character
|
||||
)}
|
||||
{plan.features.length === 0 && !plan.boon && !plan.subclass && (
|
||||
<p className="combatant-sub">
|
||||
{plan.beyondTables ? 'The class tables stop at level 10. Add anything new to the special abilities by hand.' : 'No new feature at this level.'}
|
||||
{plan.beyondTables ? 'The class tables stop at level 20. Add anything new to the special abilities by hand.' : 'No new feature at this level.'}
|
||||
</p>
|
||||
)}
|
||||
{plan.features.map((feature) => (
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useMulticlassReference } from '../api/queries';
|
||||
import { CLASS_LABELS } from '../lib/format';
|
||||
import { Spinner } from './Spinner';
|
||||
import type { Ability, CharacterClass, ClassLevel, MulticlassReference } from '../api/types';
|
||||
|
||||
// Whether final scores meet a class's multiclassing requirement.
|
||||
function meets(reference: MulticlassReference, characterClass: CharacterClass, scores: Record<Ability, number>): boolean {
|
||||
return reference.prereqs[characterClass].alternatives.some((all) => all.every((a) => scores[a] >= reference.prereqScore));
|
||||
}
|
||||
|
||||
// Suggested multiclass routes from where a character stands: each route's
|
||||
// steps, how far along the character is, and whether their scores meet every
|
||||
// class the route takes them through (multiclassing needs the requirement of
|
||||
// the class joined and of each class left).
|
||||
export function MulticlassRoutes({ classes, scores }: { classes: ClassLevel[]; scores: Record<Ability, number> }) {
|
||||
const { data: reference, isLoading } = useMulticlassReference(true);
|
||||
if (isLoading || !reference) return <Spinner label="Reading the routes…" />;
|
||||
|
||||
const levelIn = (cls: CharacterClass) => classes.find((c) => c.characterClass === cls)?.level ?? 0;
|
||||
const have = new Set(classes.map((c) => c.characterClass));
|
||||
const paths = reference.paths.filter((p) => !p.from || have.has(p.from));
|
||||
const yours = classes.map((c) => c.characterClass);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="combatant-sub" style={{ marginTop: 0 }}>
|
||||
Multiclassing is on. A level can go to another class when {classes.length > 1 ? 'every class you have' : `your ${CLASS_LABELS[yours[0]]}`} and the new class’s
|
||||
requirements are met: {yours.map((c) => `${CLASS_LABELS[c]} needs ${reference.prereqs[c].text}`).join('; ')}.
|
||||
</p>
|
||||
<div className="choice-grid">
|
||||
{paths.map((path) => {
|
||||
const classesOnRoute = [...new Set(path.steps.map((s) => s.class))];
|
||||
const blocked = classesOnRoute.filter((cls) => !meets(reference, cls, scores));
|
||||
return (
|
||||
<div key={path.key} className="choice-card" style={{ cursor: 'default' }}>
|
||||
<span className="choice-card-title">
|
||||
{path.name}
|
||||
{blocked.length === 0 ? <span className="badge badge-friendly">Open to you</span> : <span className="badge badge-hostile">Needs scores</span>}
|
||||
</span>
|
||||
<span className="choice-card-blurb">{path.why}</span>
|
||||
<ol style={{ margin: 0, paddingLeft: '1.2rem' }}>
|
||||
{path.steps.map((step, i) => {
|
||||
const done = levelIn(step.class) >= step.toLevel;
|
||||
return (
|
||||
<li key={i} style={done ? { opacity: 0.6 } : undefined}>
|
||||
{done ? '✓ ' : ''}
|
||||
{CLASS_LABELS[step.class]} to level {step.toLevel}
|
||||
{step.subclass ? ` (${step.subclass.toLowerCase().replace(/_/g, ' ')})` : ''}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{blocked.length > 0 && (
|
||||
<span className="choice-card-facts">{blocked.map((cls) => `${CLASS_LABELS[cls]}: ${reference.prereqs[cls].text}`).join(' · ')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ import { Rollable } from '../components/Rollable';
|
||||
import { withBonus } from '../lib/diceRoller';
|
||||
import { LevelUpModal } from '../components/LevelUpModal';
|
||||
import { ProgressionTimeline } from '../components/ProgressionTimeline';
|
||||
import { MulticlassRoutes } from '../components/MulticlassRoutes';
|
||||
import { GearInForce } from '../components/CharacterPanel';
|
||||
import { effectsSummary } from '../lib/gear';
|
||||
import { InfoTip } from '../components/InfoTip';
|
||||
@@ -65,6 +66,7 @@ import {
|
||||
formatPurse,
|
||||
purseWorthInGold,
|
||||
spellLevelLabel,
|
||||
slotLevelLabel,
|
||||
} from '../lib/format';
|
||||
import { speciesIcon } from '../lib/speciesIcons';
|
||||
import { hpMax as previewHpMax, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
|
||||
@@ -432,7 +434,7 @@ export function CharacterSheetPage() {
|
||||
<span aria-hidden="true">{speciesIcon(character.species)}</span> {character.name}
|
||||
</h1>
|
||||
<p className="subtitle">
|
||||
{character.species && SPECIES_LABELS[character.species]} {classLabel(character.characterClass, character.subclassName)} · Level{' '}
|
||||
{character.species && SPECIES_LABELS[character.species]} {classLabel(character.characterClass, character.subclassName, character.classes)} · Level{' '}
|
||||
{character.level}
|
||||
{trackXp && ` · ${xpLabel(character.xp, character.xpProgress)}`}
|
||||
{trackXp && character.xpProgress.levelUpDue && (
|
||||
@@ -746,6 +748,20 @@ export function CharacterSheetPage() {
|
||||
</Rollable>{' '}
|
||||
<InfoTip term="SPELL_ATTACK_BONUS" />
|
||||
</p>
|
||||
{sheet.spellcastingByClass.length > 1 && (
|
||||
<p className="combatant-sub" style={{ margin: '0.2rem 0 0' }}>
|
||||
By class:{' '}
|
||||
{sheet.spellcastingByClass
|
||||
.map((c) => `${CLASS_LABELS[c.characterClass]} (${ABILITY_LABELS[c.ability]}) DC ${c.saveDC}, ${fmtMod(c.attackBonus)}`)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
{sheet.pactSlots && (
|
||||
<p className="combatant-sub" style={{ margin: '0.2rem 0 0' }}>
|
||||
Pact Magic: {sheet.pactSlots.count} slot{sheet.pactSlots.count === 1 ? '' : 's'} of {slotLevelLabel(sheet.pactSlots.slotLevel)} level, back on a short rest.
|
||||
Keep count of these at the table; the tracker below is the other classes’ slots.
|
||||
</p>
|
||||
)}
|
||||
{sheet.spellSlots ? (
|
||||
<SpellSlotTracker
|
||||
characterId={characterId}
|
||||
@@ -1138,12 +1154,38 @@ export function CharacterSheetPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{character.classes.length > 1 && (
|
||||
<p className="section-label" style={{ margin: '0.5rem 0 0.3rem' }}>
|
||||
{CLASS_LABELS[character.characterClass]} {character.classes[0].level}
|
||||
{character.subclassName ? ` (${character.subclassName})` : ''}
|
||||
</p>
|
||||
)}
|
||||
<ProgressionTimeline
|
||||
characterClass={character.characterClass}
|
||||
subclass={character.subclass}
|
||||
subclassName={character.subclassName}
|
||||
currentLevel={character.level}
|
||||
currentLevel={character.classes[0]?.level ?? character.level}
|
||||
/>
|
||||
{character.classes.slice(1).map((c) => (
|
||||
<div key={c.characterClass} className="mt-md">
|
||||
<p className="section-label" style={{ margin: '0 0 0.3rem' }}>
|
||||
{CLASS_LABELS[c.characterClass]} {c.level}
|
||||
{c.subclassName ? ` (${c.subclassName})` : ''}
|
||||
</p>
|
||||
<ProgressionTimeline characterClass={c.characterClass} subclass={c.subclass} subclassName={c.subclassName} currentLevel={c.level} />
|
||||
</div>
|
||||
))}
|
||||
{campaignData?.campaign.multiclassEnabled && (
|
||||
<details className="mt-md">
|
||||
<summary className="section-label" style={{ cursor: 'pointer' }}>
|
||||
Multiclass routes from here
|
||||
</summary>
|
||||
<MulticlassRoutes
|
||||
classes={character.classes}
|
||||
scores={Object.fromEntries(Object.entries(sheet.abilities).map(([a, d]) => [a, d.final])) as Record<Ability, number>}
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ export function PartyOverviewPage() {
|
||||
)}
|
||||
</td>
|
||||
<td data-label="Species">{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
|
||||
<td data-label="Class">{c.characterClass ? classLabel(c.characterClass, c.subclassName) : inProgress ? 'Building…' : '—'}</td>
|
||||
<td data-label="Class">{c.characterClass ? classLabel(c.characterClass, c.subclassName, c.classes) : inProgress ? 'Building…' : '—'}</td>
|
||||
<td data-label="Level">
|
||||
{c.level}
|
||||
{!inProgress && data?.campaign.trackXp && (
|
||||
|
||||
Generated
+4
-4
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dnd-campaign-manager",
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
]
|
||||
},
|
||||
"backend": {
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.0",
|
||||
"@prisma/client": "^7.10.0",
|
||||
@@ -33,7 +33,7 @@
|
||||
}
|
||||
},
|
||||
"frontend": {
|
||||
"version": "1.23.0",
|
||||
"version": "1.24.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"axios": "^1.20.0",
|
||||
|
||||
+1
-1
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user