Files
the-sunken-reach/backend/src/game/calculations.js
T
joshuaandClaude Opus 5.5 86ef106969 V1.24.0: multiclassing, by each class's own level
- With the campaign setting on, the level-up checklist asks which class gains
  the level; a new class needs both classes' ability requirements (2014 rules)
- HP, Hit Dice, features, Boons, subclasses and counters go by each class's
  own level; spell slots add up to a caster level, pact slots apart
- A first level in a new class writes its level-1 gifts and what it teaches
- Eldritch Knight and Arcane Trickster cast from level 3 with real slots
- The sheet shows each class's road to 20 and suggested multiclass routes
  (Hexadin, Sorlock, fighter dip...) with the character's progress and scores

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:14:57 +02:00

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');