- With the campaign setting on, the level-up checklist asks which class gains the level; a new class needs both classes' ability requirements (2014 rules) - HP, Hit Dice, features, Boons, subclasses and counters go by each class's own level; spell slots add up to a caster level, pact slots apart - A first level in a new class writes its level-1 gifts and what it teaches - Eldritch Knight and Arcane Trickster cast from level 3 with real slots - The sheet shows each class's road to 20 and suggested multiclass routes (Hexadin, Sorlock, fighter dip...) with the character's progress and scores Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
584 lines
23 KiB
JavaScript
584 lines
23 KiB
JavaScript
// D&D 5e rules calculation engine — the ONLY place these formulas may live.
|
|
// See /docs/GAME_RULES_REFERENCE.md for the spec this implements.
|
|
// Pure and dependency-free on purpose: no DB calls, no framework imports.
|
|
|
|
const { SPECIES } = require('./data/species');
|
|
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,
|
|
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'];
|
|
|
|
function abilityModifier(score) {
|
|
return Math.floor((score - 10) / 2);
|
|
}
|
|
|
|
function proficiencyBonus(level) {
|
|
return 2 + Math.floor((level - 1) / 4);
|
|
}
|
|
|
|
// A species' fixed bonus, plus whatever the player chose for its flexible
|
|
// bonuses ({ ability: amount }: a Half-Elf's +1/+1, a Goblin's +2/+1...).
|
|
// Choices count only for a species that has some to make.
|
|
function speciesAbilityBonus(ability, species, choices = {}) {
|
|
const speciesData = species ? SPECIES[species] : null;
|
|
if (!speciesData) return 0;
|
|
const chosen = speciesData.flexible ? Number((choices || {})[ability]) || 0 : 0;
|
|
return (speciesData.abilityBonuses[ability] || 0) + chosen;
|
|
}
|
|
|
|
function finalAbilityScore(rolledScore, ability, species, choices = {}) {
|
|
return rolledScore + speciesAbilityBonus(ability, species, choices);
|
|
}
|
|
|
|
function speciesSpeed(species) {
|
|
return species && SPECIES[species] ? SPECIES[species].speed : 30;
|
|
}
|
|
|
|
function effectiveSpeed(species, speedOverride) {
|
|
return speedOverride != null ? speedOverride : speciesSpeed(species);
|
|
}
|
|
|
|
function saveBonus(abilityMod, proficient, profBonus) {
|
|
return abilityMod + (proficient ? profBonus : 0);
|
|
}
|
|
|
|
// expertise (e.g. Rogue) doubles the proficiency portion of the bonus.
|
|
function skillBonus(abilityMod, proficient, profBonus, expertise = false) {
|
|
if (!proficient) return abilityMod;
|
|
return abilityMod + profBonus * (expertise ? 2 : 1);
|
|
}
|
|
|
|
function passivePerception(perceptionSkillBonus) {
|
|
return 10 + perceptionSkillBonus;
|
|
}
|
|
|
|
// Any skill has a passive score: what it comes to without a roll.
|
|
function passiveScore(skillBonus) {
|
|
return 10 + skillBonus;
|
|
}
|
|
|
|
function armorClass(dexMod, armorBonus = 0) {
|
|
return 10 + dexMod + armorBonus;
|
|
}
|
|
|
|
function initiative(dexMod) {
|
|
return dexMod;
|
|
}
|
|
|
|
// Level 1 always uses the maximum possible Hit Die roll; every level after
|
|
// that uses the fixed "average" value (floor(hitDieSize / 2) + 1). Do not
|
|
// regress this to a flat average-for-all-levels formula — that was a bug
|
|
// caught while building the reference Excel calculator.
|
|
function hpMax(level, hitDieSize, conMod) {
|
|
const base = hitDieSize + conMod;
|
|
if (level <= 1) return base;
|
|
const perLevelAverage = Math.floor(hitDieSize / 2) + 1;
|
|
return base + (level - 1) * (perLevelAverage + conMod);
|
|
}
|
|
|
|
function spellSaveDC(profBonus, spellAbilityMod) {
|
|
return 8 + profBonus + spellAbilityMod;
|
|
}
|
|
|
|
function spellAttackBonus(profBonus, spellAbilityMod) {
|
|
return profBonus + spellAbilityMod;
|
|
}
|
|
|
|
// attackAbility is "STR" | "DEX" | "FINESSE" (finesse = better of STR/DEX).
|
|
function weaponAbilityModifier(attackAbility, strMod, dexMod) {
|
|
if (attackAbility === 'STR') return strMod;
|
|
if (attackAbility === 'DEX') return dexMod;
|
|
if (attackAbility === 'FINESSE') return Math.max(strMod, dexMod);
|
|
throw new Error(`Unknown attackAbility: ${attackAbility}`);
|
|
}
|
|
|
|
function weaponAttackBonus({ attackAbility, strMod, dexMod, proficient, profBonus }) {
|
|
const abilityMod = weaponAbilityModifier(attackAbility, strMod, dexMod);
|
|
return abilityMod + (proficient ? profBonus : 0);
|
|
}
|
|
|
|
function weaponDamageBonus({ attackAbility, strMod, dexMod }) {
|
|
return weaponAbilityModifier(attackAbility, strMod, dexMod);
|
|
}
|
|
|
|
function getGoverningAbilityForSkill(skill) {
|
|
const data = SKILLS[skill];
|
|
if (!data) throw new Error(`Unknown skill: ${skill}`);
|
|
return data.ability;
|
|
}
|
|
|
|
// Saving throw proficiencies are fixed per class, not a player choice —
|
|
// use this at character-build finalize time to seed CharacterSaveProficiency.
|
|
function getClassSaveProficiencies(characterClass) {
|
|
const data = characterClass ? CLASSES[characterClass] : null;
|
|
return data ? data.saveProficiencies : [];
|
|
}
|
|
|
|
function getClassHitDieSize(characterClass) {
|
|
const data = characterClass ? CLASSES[characterClass] : null;
|
|
return data ? data.hitDie : 8;
|
|
}
|
|
|
|
// 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;
|
|
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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Spell slots and Hit Dice: what a character has to spend between rests.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MAX_LEVEL = 20;
|
|
|
|
function clampLevel(level) {
|
|
return Math.min(Math.max(Math.round(Number(level) || 1), 1), MAX_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, 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] || { FULL: FULL_CASTER_SLOTS, HALF: HALF_CASTER_SLOTS, THIRD: THIRD_CASTER_SLOTS }[kind];
|
|
return { kind, restoresOn: 'LONG', max: [...table[row]] };
|
|
}
|
|
|
|
// The slot tracker: for each slot level the character has, how many slots
|
|
// there are and how many are spent. `used` is the stored tally (1st-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 = [], 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) => {
|
|
const spent = Math.min(Math.max(Number((used || [])[i]) || 0, 0), max);
|
|
return { level: i + 1, max, used: spent, remaining: max - spent };
|
|
})
|
|
.filter((slot) => slot.max > 0);
|
|
return levels.length > 0 ? { kind: slots.kind, restoresOn: slots.restoresOn, levels } : null;
|
|
}
|
|
|
|
// The highest level of spell a class can cast at a level; 0 for none.
|
|
function maxSpellLevel(characterClass, level, subclass = null) {
|
|
const slots = spellSlotsFor(characterClass, level, subclass);
|
|
return slots ? slots.max.length : 0;
|
|
}
|
|
|
|
// One Hit Die per level, of the class's size. Spent ones come back on a long rest.
|
|
function hitDiceState(characterClass, level, used = 0) {
|
|
const count = clampLevel(level);
|
|
const spent = Math.min(Math.max(Number(used) || 0, 0), count);
|
|
return { size: getClassHitDieSize(characterClass), count, used: spent, remaining: count - spent };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Experience points and encounter difficulty (data/xp.js).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// The XP one monster of a challenge rating is worth. The rating is free text
|
|
// on a monster ("1/4", "5", "0.5"); null for anything that is not a rating.
|
|
function challengeXp(challengeRating) {
|
|
if (challengeRating == null) return null;
|
|
const text = String(challengeRating).trim().replace(/^cr\s*/i, '');
|
|
const fractions = { 0.125: '1/8', 0.25: '1/4', 0.5: '1/2' };
|
|
const key = fractions[text] || text;
|
|
return Object.prototype.hasOwnProperty.call(CR_XP, key) ? CR_XP[key] : null;
|
|
}
|
|
|
|
// How hard a fight is for a party. `partyLevels` is the level of each
|
|
// character in it; `challengeRatings` the rating of each monster. `xp` is
|
|
// what the monsters are worth; `adjustedXp` is that multiplied for their
|
|
// number, which is what is held against the party's thresholds. `rating` is
|
|
// TRIVIAL, EASY, MEDIUM, HARD or DEADLY, or null with no party or no rated
|
|
// monster to judge by. Monsters with no readable rating are counted in
|
|
// `unrated` and left out of everything else.
|
|
function encounterDifficulty(partyLevels, challengeRatings) {
|
|
const worth = challengeRatings.map(challengeXp);
|
|
const rated = worth.filter((xp) => xp !== null);
|
|
const xp = rated.reduce((sum, value) => sum + value, 0);
|
|
const multiplier = rated.length === 0 ? 1 : COUNT_MULTIPLIERS.find(([atLeast]) => rated.length >= atLeast)[1];
|
|
const adjustedXp = Math.floor(xp * multiplier);
|
|
|
|
let thresholds = null;
|
|
let rating = null;
|
|
if (partyLevels.length > 0) {
|
|
const [easy, medium, hard, deadly] = [0, 1, 2, 3].map((i) => partyLevels.reduce((sum, level) => sum + XP_THRESHOLDS[clampLevel(level) - 1][i], 0));
|
|
thresholds = { easy, medium, hard, deadly };
|
|
if (rated.length > 0) {
|
|
rating = adjustedXp >= deadly ? 'DEADLY' : adjustedXp >= hard ? 'HARD' : adjustedXp >= medium ? 'MEDIUM' : adjustedXp >= easy ? 'EASY' : 'TRIVIAL';
|
|
}
|
|
}
|
|
return { xp, adjustedXp, multiplier, rating, thresholds, monsters: rated.length, unrated: worth.length - rated.length };
|
|
}
|
|
|
|
// Where a character's XP puts them: what their next level takes, and whether
|
|
// they have it. Levelling stays the DM's decision; this only says it is due.
|
|
function xpProgress(xp, level) {
|
|
const at = clampLevel(level);
|
|
const nextLevelAt = at >= MAX_LEVEL ? null : LEVEL_XP[at];
|
|
return { nextLevelAt, levelUpDue: nextLevelAt !== null && xp >= nextLevelAt };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gear effects (house rule). A weapon or item in the campaign pools can carry
|
|
// effects: signed amounts added to one of the numbers on the sheet. They count
|
|
// while a character has the thing equipped and is high enough level to use it;
|
|
// holding two of something does not double it.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// What an effect can change. An ability effect changes the score itself, so
|
|
// everything worked out from that ability moves with it.
|
|
const GEAR_STATS = [
|
|
'AC',
|
|
'SPEED',
|
|
'INITIATIVE',
|
|
'MAX_HP',
|
|
'PASSIVE_PERCEPTION',
|
|
...ABILITIES,
|
|
'SAVES', // every saving throw
|
|
...ABILITIES.map((ability) => `SAVE_${ability}`),
|
|
'SKILLS', // every skill check
|
|
...Object.keys(SKILLS).map((skill) => `SKILL_${skill}`),
|
|
];
|
|
|
|
// The effects in force for a character, each with the name of what gives it:
|
|
// [{ stat, amount, source }]. Takes a character with `inventoryItems` (each
|
|
// with its item) and `weapons` (each with its weapon) loaded; without them
|
|
// there are none. `notes` are the things gear does that are not a number.
|
|
function activeGear(character) {
|
|
const level = character.level || 1;
|
|
const held = [
|
|
...(character.inventoryItems || []).map((ii) => ({ ...ii, entry: ii.item })),
|
|
...(character.weapons || []).map((cw) => ({ ...cw, entry: cw.weapon })),
|
|
].filter((h) => h.entry && h.equipped && canUseGear(level, h.level));
|
|
|
|
const effects = [];
|
|
const notes = [];
|
|
for (const { entry } of held) {
|
|
for (const effect of Array.isArray(entry.effects) ? entry.effects : []) {
|
|
if (GEAR_STATS.includes(effect.stat) && Number.isInteger(effect.amount) && effect.amount !== 0) {
|
|
effects.push({ stat: effect.stat, amount: effect.amount, source: entry.name });
|
|
}
|
|
}
|
|
if (entry.effectNote) notes.push({ source: entry.name, note: entry.effectNote });
|
|
}
|
|
return { effects, notes };
|
|
}
|
|
|
|
/**
|
|
* Assembles the full derived character sheet from raw stored data. This is
|
|
* the single function the API and quick-stats view should call rather than
|
|
* re-deriving any of the above by hand.
|
|
*
|
|
* @param {object} character - rolled scores, species/class, level, armorBonus, etc.
|
|
* With `inventoryItems` and `weapons` loaded (each with its pool entry),
|
|
* the effects of equipped gear are applied; see activeGear.
|
|
* @param {Array<{skill: string, proficient: boolean, expertise: boolean}>} skillProficiencies
|
|
* @param {Array<{ability: string, proficient: boolean}>} saveProficiencies
|
|
*/
|
|
function computeCharacterSheet(character, skillProficiencies = [], saveProficiencies = []) {
|
|
const {
|
|
level = 1,
|
|
species = null,
|
|
characterClass = null,
|
|
strScore = 10,
|
|
dexScore = 10,
|
|
conScore = 10,
|
|
intScore = 10,
|
|
wisScore = 10,
|
|
chaScore = 10,
|
|
speciesBonusChoices = {},
|
|
armorBonus = 0,
|
|
speedOverride = null,
|
|
spellSlotsUsed = [],
|
|
hitDiceUsed = 0,
|
|
} = character;
|
|
|
|
const rolled = { STR: strScore, DEX: dexScore, CON: conScore, INT: intScore, WIS: wisScore, CHA: chaScore };
|
|
|
|
const gear = activeGear(character);
|
|
// The total equipped gear adds to (or takes from) one number.
|
|
const fromGear = (...stats) => gear.effects.filter((e) => stats.includes(e.stat)).reduce((sum, e) => sum + e.amount, 0);
|
|
|
|
const abilities = {};
|
|
for (const ability of ABILITIES) {
|
|
// A score never drops below 1, whatever is weighing the character down.
|
|
const final = Math.max(1, finalAbilityScore(rolled[ability], ability, species, speciesBonusChoices) + fromGear(ability));
|
|
abilities[ability] = {
|
|
rolled: rolled[ability],
|
|
final,
|
|
modifier: abilityModifier(final),
|
|
};
|
|
}
|
|
|
|
const profBonus = proficiencyBonus(level);
|
|
|
|
const saves = {};
|
|
for (const ability of ABILITIES) {
|
|
const entry = saveProficiencies.find((s) => s.ability === ability);
|
|
const proficient = entry ? entry.proficient : false;
|
|
saves[ability] = { proficient, bonus: saveBonus(abilities[ability].modifier, proficient, profBonus) + fromGear('SAVES', `SAVE_${ability}`) };
|
|
}
|
|
|
|
const skills = {};
|
|
for (const skillKey of Object.keys(SKILLS)) {
|
|
const ability = getGoverningAbilityForSkill(skillKey);
|
|
const entry = skillProficiencies.find((s) => s.skill === skillKey);
|
|
const proficient = entry ? entry.proficient : false;
|
|
const expertise = entry ? Boolean(entry.expertise) : false;
|
|
skills[skillKey] = {
|
|
ability,
|
|
proficient,
|
|
expertise,
|
|
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise) + fromGear('SKILLS', `SKILL_${skillKey}`),
|
|
};
|
|
}
|
|
|
|
// 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 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,
|
|
proficiencyBonus: profBonus,
|
|
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, (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'),
|
|
passiveInvestigation: passiveScore(skills.INVESTIGATION.bonus),
|
|
passiveInsight: passiveScore(skills.INSIGHT.bonus),
|
|
// What equipped gear is doing to the numbers above, and what else it does.
|
|
gear,
|
|
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: 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: multi ? multiclass.multiclassResources(classes, character.resourcesUsed, abilities, profBonus) : resourceState(character, abilities, profBonus),
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Gear levels (house rule, not SRD). A held weapon or item has its own level,
|
|
// 1-9 (the same span as spell levels), and its dice climb a fixed ladder as it
|
|
// levels:
|
|
//
|
|
// 1d4 1d6 1d8 1d10 1d12 2d6 1d6+1d8 2d8 1d8+1d10 2d10 ...
|
|
//
|
|
// Each rung is the smallest possible step up in average damage: +1, or +0.5
|
|
// where the ladder has to add a die (1d12 -> 2d6, 2d12 -> 3d8, ...).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MAX_GEAR_LEVEL = 9;
|
|
const DIE_SIZES = [4, 6, 8, 10, 12];
|
|
// Rungs a piece of gear climbs per level: 1 = standard, 2 = powerful, 3 = legendary.
|
|
const MAX_DAMAGE_SCALING = 3;
|
|
|
|
function formatDice(sizes) {
|
|
const counts = new Map();
|
|
for (const size of sizes) counts.set(size, (counts.get(size) || 0) + 1);
|
|
return [...counts].map(([size, count]) => `${count}d${size}`).join('+');
|
|
}
|
|
|
|
// k dice whose sizes are as even as possible, `steps` die-size steps above k d4s.
|
|
function evenDice(k, steps) {
|
|
const sizes = [];
|
|
for (let i = 0; i < k; i++) {
|
|
const share = Math.floor(steps / k) + (i >= k - (steps % k) ? 1 : 0);
|
|
sizes.push(DIE_SIZES[share]);
|
|
}
|
|
return sizes;
|
|
}
|
|
|
|
function buildDamageLadder(rungs) {
|
|
const ladder = [];
|
|
let k = 1;
|
|
let steps = 0;
|
|
while (ladder.length < rungs) {
|
|
const maxSteps = k * (DIE_SIZES.length - 1);
|
|
if (steps > maxSteps) {
|
|
// Out of room at this many dice: add one, landing just above the last rung.
|
|
const previousAverage = ladder[ladder.length - 1].average;
|
|
k += 1;
|
|
steps = Math.floor(previousAverage - k * 2.5) + 1;
|
|
continue;
|
|
}
|
|
ladder.push({ dice: formatDice(evenDice(k, steps)), average: k * 2.5 + steps });
|
|
steps += 1;
|
|
}
|
|
return ladder;
|
|
}
|
|
|
|
// Far longer than max level at legendary scaling needs, so heavy base dice
|
|
// (a "10d6" item, say) still have room to climb.
|
|
const DAMAGE_LADDER = buildDamageLadder(120);
|
|
|
|
// Average roll of a dice expression like "1d8", "2d6", "1d6+1d8" or "2d4+2".
|
|
// Returns null for anything else (free text is allowed in the pool).
|
|
function diceAverage(expression) {
|
|
if (typeof expression !== 'string' || !expression.trim()) return null;
|
|
let total = 0;
|
|
for (const term of expression.replace(/\s+/g, '').toLowerCase().replace(/-/g, '+-').split('+')) {
|
|
if (!term) continue;
|
|
const die = /^(-?)(\d*)d(\d+)$/.exec(term);
|
|
if (die) {
|
|
total += (die[1] ? -1 : 1) * (Number(die[2]) || 1) * ((Number(die[3]) + 1) / 2);
|
|
} else if (/^-?\d+$/.test(term)) {
|
|
total += Number(term);
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return total;
|
|
}
|
|
|
|
function clampGearLevel(level) {
|
|
return Math.min(Math.max(Math.round(Number(level) || 1), 1), MAX_GEAR_LEVEL);
|
|
}
|
|
|
|
// Dice for a piece of gear at a given level. `minLevel` is the level the gear
|
|
// starts at (most gear: 1; a greatsword might only exist from level 3). At its
|
|
// starting level it is always the base dice exactly as the DM wrote them;
|
|
// above that it climbs `scaling` rungs per level, starting from the highest
|
|
// rung that doesn't beat the base (so "1d4+1" starts from 1d6, and a flat "1"
|
|
// starts below the ladder). Dice the ladder can't read (free text) never scale.
|
|
function scaledDamageDice(baseDice, level = 1, scaling = 1, minLevel = 1) {
|
|
const levelsGained = clampGearLevel(level) - clampGearLevel(minLevel);
|
|
if (levelsGained <= 0) return baseDice;
|
|
const baseAverage = diceAverage(baseDice);
|
|
if (baseAverage === null) return baseDice;
|
|
|
|
let baseRung = -1;
|
|
while (baseRung + 1 < DAMAGE_LADDER.length && DAMAGE_LADDER[baseRung + 1].average <= baseAverage) baseRung += 1;
|
|
const rungsPerLevel = Math.min(Math.max(Math.round(Number(scaling) || 1), 1), MAX_DAMAGE_SCALING);
|
|
const rung = Math.min(baseRung + levelsGained * rungsPerLevel, DAMAGE_LADDER.length - 1);
|
|
return DAMAGE_LADDER[rung].dice;
|
|
}
|
|
|
|
// A character can use gear and spells of their own level or lower. For
|
|
// weapons and items the required level is the held copy's level; for spells
|
|
// it is the classic spell level (cantrips, level 0, are open to everyone).
|
|
function canUseGear(characterLevel, requiredLevel) {
|
|
return characterLevel >= requiredLevel;
|
|
}
|
|
|
|
/**
|
|
* Attack/damage bonus for a specific weapon against a character's abilities —
|
|
* used by the quick-stats panel and weapon list. `gearLevel` is the level of
|
|
* the copy the character holds.
|
|
*/
|
|
function computeWeaponStats(weapon, abilities, profBonus, proficient = true, gearLevel = 1) {
|
|
const strMod = abilities.STR.modifier;
|
|
const dexMod = abilities.DEX.modifier;
|
|
return {
|
|
attackBonus: weaponAttackBonus({ attackAbility: weapon.attackAbility, strMod, dexMod, proficient, profBonus }),
|
|
damageBonus: weaponDamageBonus({ attackAbility: weapon.attackAbility, strMod, dexMod }),
|
|
damageDice: scaledDamageDice(weapon.damageDice, gearLevel, weapon.damageScaling, weapon.minLevel),
|
|
damageType: weapon.damageType,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
ABILITIES,
|
|
abilityModifier,
|
|
proficiencyBonus,
|
|
speciesAbilityBonus,
|
|
finalAbilityScore,
|
|
speciesSpeed,
|
|
effectiveSpeed,
|
|
saveBonus,
|
|
skillBonus,
|
|
passivePerception,
|
|
passiveScore,
|
|
armorClass,
|
|
initiative,
|
|
hpMax,
|
|
spellSaveDC,
|
|
spellAttackBonus,
|
|
weaponAbilityModifier,
|
|
weaponAttackBonus,
|
|
weaponDamageBonus,
|
|
getGoverningAbilityForSkill,
|
|
getClassSaveProficiencies,
|
|
getClassHitDieSize,
|
|
getClassSpellcastingAbility,
|
|
MAX_LEVEL,
|
|
MAX_SLOT_LEVEL,
|
|
spellSlotsFor,
|
|
slotStateOf,
|
|
casterKind,
|
|
spellSlotState,
|
|
maxSpellLevel,
|
|
hitDiceState,
|
|
challengeXp,
|
|
encounterDifficulty,
|
|
xpProgress,
|
|
GEAR_STATS,
|
|
activeGear,
|
|
computeCharacterSheet,
|
|
computeWeaponStats,
|
|
MAX_GEAR_LEVEL,
|
|
MAX_DAMAGE_SCALING,
|
|
DAMAGE_LADDER,
|
|
diceAverage,
|
|
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');
|