V1.30.0: class features that change a number are worked out on the sheet
- Unarmored Defense (barbarian, monk), Draconic Resilience, Fast Movement, Unarmored Movement, Aura of Protection, Diamond Soul, Slippery Mind and Jack of All Trades are counted, by level in the class that gives them - Armor and shields are items marked with what kind they are, so the sheet knows when a character is unarmored or in heavy armor; the library's and the seeds' are marked - The sheet lists the class features in force; a speed the DM set is final - A cantrip can be cast with every spell slot spent (tested end to end) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.29.0",
|
||||
"version": "1.30.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "src/index.js",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Armor and shields say what kind they are, so the sheet can tell when a
|
||||
-- character is unarmored (Unarmored Defense, a monk's speed) or in heavy
|
||||
-- armor (a barbarian's Fast Movement). Copies of the library's armor already
|
||||
-- in a campaign are marked by name; anything else the DM marks on the item.
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ArmorType" AS ENUM ('LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "items" ADD COLUMN "armorType" "ArmorType";
|
||||
|
||||
UPDATE "items" SET "armorType" = 'LIGHT' WHERE "name" IN ('Padded Armor', 'Leather Armor', 'Studded Leather');
|
||||
UPDATE "items" SET "armorType" = 'MEDIUM' WHERE "name" IN ('Hide Armor', 'Chain Shirt', 'Scale Mail', 'Breastplate', 'Half Plate');
|
||||
UPDATE "items" SET "armorType" = 'HEAVY' WHERE "name" IN ('Ring Mail', 'Chain Mail', 'Splint Armor', 'Plate Armor');
|
||||
UPDATE "items" SET "armorType" = 'SHIELD' WHERE "name" = 'Shield';
|
||||
@@ -597,6 +597,10 @@ model Item {
|
||||
// item with an AC effect.
|
||||
effects Json @default("[]")
|
||||
effectNote String?
|
||||
// Set on armor and shields. Its AC is still an effect; this says what kind
|
||||
// it is, so the sheet knows when a character is unarmored (Unarmored
|
||||
// Defense, a monk's speed) or in heavy armor (a barbarian's Fast Movement).
|
||||
armorType ArmorType?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
@@ -608,6 +612,13 @@ model Item {
|
||||
@@map("items")
|
||||
}
|
||||
|
||||
enum ArmorType {
|
||||
LIGHT
|
||||
MEDIUM
|
||||
HEAVY
|
||||
SHIELD
|
||||
}
|
||||
|
||||
// An Item from the campaign pool held by a character.
|
||||
model InventoryItem {
|
||||
id String @id @default(cuid())
|
||||
|
||||
@@ -187,8 +187,8 @@ const items = [
|
||||
{ name: 'Torches (bundle of 5)', description: 'Each burns for an hour: bright light for 20 feet, dim for 20 more.', isStartingOption: true },
|
||||
{ name: 'Healer’s Kit', description: 'Ten uses. Spend one to stabilise a creature at 0 hit points without a Medicine check.', isStartingOption: true },
|
||||
{ name: 'Thieves’ Tools', description: 'Picks, a small file and a mirror on a handle. Needed to pick a lock or disarm a trap.', isStartingOption: true },
|
||||
{ name: 'Shield', description: 'A wooden or steel shield carried on one arm.', isStartingOption: true, effects: [{ stat: 'AC', amount: 2 }], effectNote: 'Takes a hand: no two-handed weapon while it is up.' },
|
||||
{ name: 'Leather Armor', description: 'Boiled leather, light enough to sleep and sneak in.', isStartingOption: true, effects: [{ stat: 'AC', amount: 1 }] },
|
||||
{ name: 'Shield', armorType: 'SHIELD', description: 'A wooden or steel shield carried on one arm.', isStartingOption: true, effects: [{ stat: 'AC', amount: 2 }], effectNote: 'Takes a hand: no two-handed weapon while it is up.' },
|
||||
{ name: 'Leather Armor', armorType: 'LIGHT', description: 'Boiled leather, light enough to sleep and sneak in.', isStartingOption: true, effects: [{ stat: 'AC', amount: 1 }] },
|
||||
{ name: 'Spellcasting Focus', description: 'A wand, holy symbol, crystal or carved staff that your spells are cast through.', isStartingOption: true },
|
||||
{ name: 'Founding Fair Token', description: 'A stamped brass token from the Lantern Market. Good for one drink at any stall, or a small favour from whoever ran the game you won it at.', isStartingOption: true },
|
||||
|
||||
@@ -212,7 +212,7 @@ const items = [
|
||||
{ name: 'Seal-Stone Fragment', description: 'A shard of worked stone from the edge of the breach. Script runs along one face. It is warm, and holding it makes the bell script easier to look at.' },
|
||||
{ name: 'Seized House Documents', description: 'Ledgers and letters taken from the noble house after it was confronted. No value in coin; a great deal of value to anyone building a case.' },
|
||||
{ name: 'House Records', description: 'The fallen house’s private records: names, payments and meeting places for its contacts across Aldenfell.' },
|
||||
{ name: 'Masterwork Chain Mail', description: 'Close-linked mail from the Grey Dawn’s own armoury, fitted rather than issued.', minLevel: 5, effects: [{ stat: 'AC', amount: 7 }, { stat: 'SPEED', amount: -10 }], effectNote: 'Disadvantage on Stealth checks.' },
|
||||
{ name: 'Masterwork Chain Mail', armorType: 'HEAVY', description: 'Close-linked mail from the Grey Dawn’s own armoury, fitted rather than issued.', minLevel: 5, effects: [{ stat: 'AC', amount: 7 }, { stat: 'SPEED', amount: -10 }], effectNote: 'Disadvantage on Stealth checks.' },
|
||||
{ name: 'The Seal-Stone', description: 'The stone at the heart of the seal, whole again and quiet. It is heavier than it looks, and it is never quite cold.', minLevel: 7, effects: [{ stat: 'SAVE_WIS', amount: 2 }], effectNote: 'Grows warm near anything of the Hollow Below. Its other properties are the DM’s to decide: something tied to binding, warding or detecting.' },
|
||||
];
|
||||
|
||||
|
||||
@@ -878,6 +878,7 @@ const book = {
|
||||
},
|
||||
{
|
||||
"name": "Shield Ward",
|
||||
"armorType": "SHIELD",
|
||||
"description": "A silver shield charm worn on the forearm; it unfolds into a disc of pale light. Counts as a shield (+2 AC). Once per long rest, as a reaction when hit, gain +5 AC against that attack.",
|
||||
"damageDice": null,
|
||||
"minLevel": 7,
|
||||
@@ -1087,6 +1088,7 @@ const book = {
|
||||
},
|
||||
{
|
||||
"name": "Riot Shield",
|
||||
"armorType": "SHIELD",
|
||||
"description": "A tall clear plastic shield from Port Meridian.",
|
||||
"damageDice": null,
|
||||
"minLevel": 1,
|
||||
@@ -1102,6 +1104,7 @@ const book = {
|
||||
},
|
||||
{
|
||||
"name": "Motorcycle Leathers",
|
||||
"armorType": "LIGHT",
|
||||
"description": "Jacket, trousers, gloves and boots, all scuffed. Light armor for heroes who still want to look cool.",
|
||||
"damageDice": null,
|
||||
"minLevel": 1,
|
||||
@@ -1132,6 +1135,7 @@ const book = {
|
||||
},
|
||||
{
|
||||
"name": "Silverleaf Mail",
|
||||
"armorType": "MEDIUM",
|
||||
"description": "A chain shirt woven from leaves of living silver, cool and quiet, light as a summer shirt. A gift of the Silverbough Court.",
|
||||
"damageDice": null,
|
||||
"minLevel": 5,
|
||||
@@ -1147,6 +1151,7 @@ const book = {
|
||||
},
|
||||
{
|
||||
"name": "Cinderscale Armor",
|
||||
"armorType": "MEDIUM",
|
||||
"description": "Scale mail made from red dragon scales that Cindermaw shed as a wyrmling, still warm.",
|
||||
"damageDice": null,
|
||||
"minLevel": 7,
|
||||
@@ -1162,6 +1167,7 @@ const book = {
|
||||
},
|
||||
{
|
||||
"name": "Gauntlet Plate",
|
||||
"armorType": "HEAVY",
|
||||
"description": "The silver plate armor of a fallen knight of the Silver Gauntlet, polished in the chapel for a hundred years.",
|
||||
"damageDice": null,
|
||||
"minLevel": 8,
|
||||
|
||||
@@ -287,6 +287,10 @@ function activeGear(character) {
|
||||
|
||||
const effects = [];
|
||||
const notes = [];
|
||||
// The armor on their back (the heaviest, should there be two) and whether
|
||||
// a shield is on their arm: class features turn on what is worn.
|
||||
const kinds = held.map((h) => h.entry.armorType).filter(Boolean);
|
||||
const armor = { worn: ['HEAVY', 'MEDIUM', 'LIGHT'].find((kind) => kinds.includes(kind)) || null, shield: kinds.includes('SHIELD') };
|
||||
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) {
|
||||
@@ -295,7 +299,7 @@ function activeGear(character) {
|
||||
}
|
||||
if (entry.effectNote) notes.push({ source: entry.name, note: entry.effectNote });
|
||||
}
|
||||
return { effects, notes };
|
||||
return { effects, notes, armor };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,11 +351,22 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
|
||||
const profBonus = proficiencyBonus(level);
|
||||
|
||||
// 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;
|
||||
// What their class features do to the numbers below (classFeatures.js).
|
||||
const modifiers = Object.fromEntries(ABILITIES.map((ability) => [ability, abilities[ability].modifier]));
|
||||
const features = classFeatureEffects(classes, modifiers, profBonus, gear.armor);
|
||||
|
||||
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 proficient = (entry ? entry.proficient : false) || features.saveProficiencies.includes(ability);
|
||||
saves[ability] = {
|
||||
proficient,
|
||||
bonus: saveBonus(abilities[ability].modifier, proficient, profBonus) + features.saveBonus + fromGear('SAVES', `SAVE_${ability}`),
|
||||
};
|
||||
}
|
||||
|
||||
const skills = {};
|
||||
@@ -364,14 +379,10 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
ability,
|
||||
proficient,
|
||||
expertise,
|
||||
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise) + fromGear('SKILLS', `SKILL_${skillKey}`),
|
||||
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise) + (proficient ? 0 : features.halfProficiency) + 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) }))
|
||||
@@ -386,14 +397,17 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
|
||||
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')),
|
||||
// Unarmored Defense and the like replace the 10 + DEX that AC starts from.
|
||||
armorClass: (features.acBase !== null ? features.acBase + armorBonus : armorClass(abilities.DEX.modifier, armorBonus)) + fromGear('AC'),
|
||||
initiative: initiative(abilities.DEX.modifier) + features.halfProficiency + fromGear('INITIATIVE'),
|
||||
// A speed the DM has set is their last word: a class's extra speed is
|
||||
// added to the species' own speed only.
|
||||
speed: Math.max(0, effectiveSpeed(species, speedOverride) + (speedOverride != null ? 0 : features.speed) + fromGear('SPEED')),
|
||||
hp: {
|
||||
// hpRollAdjust: how far HP rolled on level-ups is from the average counted here.
|
||||
max: Math.max(
|
||||
1,
|
||||
(multi ? multiclass.multiclassHpMax(classes, abilities.CON.modifier) : hpMax(level, hitDieSize, abilities.CON.modifier)) + (Number(hpRollAdjust) || 0) + fromGear('MAX_HP')
|
||||
(multi ? multiclass.multiclassHpMax(classes, abilities.CON.modifier) : hpMax(level, hitDieSize, abilities.CON.modifier)) + (Number(hpRollAdjust) || 0) + features.hp + fromGear('MAX_HP')
|
||||
),
|
||||
},
|
||||
hitDice: multi ? multiclass.multiclassHitDice(classes, hitDiceUsed) : hitDiceState(characterClass, level, hitDiceUsed),
|
||||
@@ -404,6 +418,8 @@ 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,
|
||||
// The class features counted in the numbers above: [{ name, source, text }].
|
||||
features: speedOverride != null ? features.inForce.filter((f) => !['Fast Movement', 'Unarmored Movement'].includes(f.name)) : features.inForce,
|
||||
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) })),
|
||||
@@ -586,3 +602,4 @@ module.exports = {
|
||||
// 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');
|
||||
const { classFeatureEffects } = require('./classFeatures');
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// The class features that change a number on the sheet, worked out so nobody
|
||||
// has to add them by hand: Unarmored Defense, Draconic Resilience, Fast
|
||||
// Movement, Unarmored Movement, Aura of Protection, Diamond Soul, Slippery
|
||||
// Mind and Jack of All Trades. Everything else a class gives is a line of
|
||||
// text on the sheet (game/data/levelFeatures.js). Pure: no database.
|
||||
//
|
||||
// Each goes by the level in the class that gives it, so a multiclassed
|
||||
// character gets what each of their classes has earned (game/multiclass.js,
|
||||
// classLevels). Several depend on what is worn: armor and shields are items
|
||||
// marked with what kind they are (Item.armorType), and count while equipped.
|
||||
|
||||
const { SUBCLASS_LEVEL } = require('./data/subclasses');
|
||||
|
||||
// A monk's extra speed without armor or a shield, by monk level.
|
||||
function unarmoredMovement(monkLevel) {
|
||||
if (monkLevel < 2) return 0;
|
||||
if (monkLevel < 6) return 10;
|
||||
if (monkLevel < 10) return 15;
|
||||
if (monkLevel < 14) return 20;
|
||||
if (monkLevel < 18) return 25;
|
||||
return 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a character's class features do to the sheet's numbers.
|
||||
*
|
||||
* @param {Array<{characterClass: string, level: number, subclass: string|null}>} classes
|
||||
* @param {object} modifiers - ability modifiers by ability: { STR, DEX, CON, INT, WIS, CHA }
|
||||
* @param {number} profBonus
|
||||
* @param {{worn: 'LIGHT'|'MEDIUM'|'HEAVY'|null, shield: boolean}} armor - what is equipped
|
||||
* @returns {{
|
||||
* acBase: number|null, // what AC starts from in place of 10 + DEX, when that is better
|
||||
* speed: number, // added to walking speed
|
||||
* hp: number, // added to maximum HP
|
||||
* saveBonus: number, // added to every saving throw
|
||||
* saveProficiencies: string[],// saves the character is proficient in on top of their class's two
|
||||
* halfProficiency: number, // added to initiative and to skills they are not proficient in
|
||||
* inForce: Array<{name: string, source: string, text: string}>
|
||||
* }}
|
||||
*/
|
||||
function classFeatureEffects(classes, modifiers, profBonus, armor = { worn: null, shield: false }) {
|
||||
const levelIn = (characterClass) => {
|
||||
const entry = classes.find((c) => c.characterClass === characterClass);
|
||||
return entry ? entry.level : 0;
|
||||
};
|
||||
const unarmored = !armor.worn;
|
||||
const plain = 10 + modifiers.DEX;
|
||||
const inForce = [];
|
||||
const signed = (n) => (n >= 0 ? `+${n}` : `${n}`);
|
||||
|
||||
// Armor Class without armor: the best of the ways the character has of
|
||||
// working it out, and only when it beats 10 + DEX.
|
||||
const acWays = [];
|
||||
if (levelIn('BARBARIAN') >= 1 && unarmored) {
|
||||
acWays.push({ ac: plain + modifiers.CON, name: 'Unarmored Defense', source: 'Barbarian', text: `AC is 10 + Dexterity + Constitution (${signed(modifiers.CON)}) without armor; a shield still adds.` });
|
||||
}
|
||||
if (levelIn('MONK') >= 1 && unarmored && !armor.shield) {
|
||||
acWays.push({ ac: plain + modifiers.WIS, name: 'Unarmored Defense', source: 'Monk', text: `AC is 10 + Dexterity + Wisdom (${signed(modifiers.WIS)}) without armor or a shield.` });
|
||||
}
|
||||
const sorcerer = classes.find((c) => c.characterClass === 'SORCERER');
|
||||
const draconic = sorcerer && sorcerer.subclass === 'DRACONIC' && sorcerer.level >= SUBCLASS_LEVEL.SORCERER ? sorcerer : null;
|
||||
if (draconic && unarmored) {
|
||||
acWays.push({ ac: 13 + modifiers.DEX, name: 'Draconic Resilience', source: 'Draconic Bloodline', text: 'AC is 13 + Dexterity without armor.' });
|
||||
}
|
||||
const bestAc = acWays.sort((a, b) => b.ac - a.ac)[0];
|
||||
const acBase = bestAc && bestAc.ac > plain ? bestAc.ac : null;
|
||||
if (acBase !== null) inForce.push({ name: bestAc.name, source: bestAc.source, text: bestAc.text });
|
||||
|
||||
let hp = 0;
|
||||
if (draconic) {
|
||||
hp += draconic.level;
|
||||
inForce.push({ name: 'Draconic Resilience', source: 'Draconic Bloodline', text: `+1 maximum HP for each sorcerer level (+${draconic.level}).` });
|
||||
}
|
||||
|
||||
let speed = 0;
|
||||
if (levelIn('BARBARIAN') >= 5 && armor.worn !== 'HEAVY') {
|
||||
speed += 10;
|
||||
inForce.push({ name: 'Fast Movement', source: 'Barbarian', text: '+10 ft speed when not in heavy armor.' });
|
||||
}
|
||||
const monkSpeed = unarmored && !armor.shield ? unarmoredMovement(levelIn('MONK')) : 0;
|
||||
if (monkSpeed > 0) {
|
||||
speed += monkSpeed;
|
||||
inForce.push({ name: 'Unarmored Movement', source: 'Monk', text: `+${monkSpeed} ft speed without armor or a shield.` });
|
||||
}
|
||||
|
||||
let saveBonus = 0;
|
||||
if (levelIn('PALADIN') >= 6) {
|
||||
saveBonus = Math.max(1, modifiers.CHA);
|
||||
inForce.push({ name: 'Aura of Protection', source: 'Paladin', text: `+${saveBonus} (Charisma) to every saving throw while conscious. Allies in the aura add it by hand.` });
|
||||
}
|
||||
|
||||
const saveProficiencies = [];
|
||||
if (levelIn('MONK') >= 14) {
|
||||
saveProficiencies.push('STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA');
|
||||
inForce.push({ name: 'Diamond Soul', source: 'Monk', text: 'Proficient in every saving throw.' });
|
||||
}
|
||||
if (levelIn('ROGUE') >= 15) {
|
||||
saveProficiencies.push('WIS');
|
||||
inForce.push({ name: 'Slippery Mind', source: 'Rogue', text: 'Proficient in Wisdom saving throws.' });
|
||||
}
|
||||
|
||||
let halfProficiency = 0;
|
||||
if (levelIn('BARD') >= 2) {
|
||||
halfProficiency = Math.floor(profBonus / 2);
|
||||
inForce.push({ name: 'Jack of All Trades', source: 'Bard', text: `+${halfProficiency} (half proficiency) to initiative and to every skill you are not proficient in.` });
|
||||
}
|
||||
|
||||
return { acBase, speed, hp, saveBonus, saveProficiencies: [...new Set(saveProficiencies)], halfProficiency, inForce };
|
||||
}
|
||||
|
||||
module.exports = { classFeatureEffects, unarmoredMovement };
|
||||
@@ -0,0 +1,134 @@
|
||||
const { computeCharacterSheet } = require('./calculations');
|
||||
const { classFeatureEffects, unarmoredMovement } = require('./classFeatures');
|
||||
|
||||
// A human (+1 to everything) with STR 16, DEX 14 (+2), CON 14 (+2), INT 10,
|
||||
// WIS 16 (+3), CHA 16 (+3) once the species bonus is in.
|
||||
const scores = { species: 'HUMAN', strScore: 15, dexScore: 13, conScore: 13, intScore: 9, wisScore: 15, chaScore: 15, speciesBonusChoices: {} };
|
||||
const sheet = (character, saves = []) => computeCharacterSheet({ ...scores, level: 1, ...character }, [], saves);
|
||||
const wearing = (name, armorType, ac) => ({ equipped: true, level: 1, item: { name, armorType, effects: [{ stat: 'AC', amount: ac }] } });
|
||||
const names = (s) => s.features.map((f) => f.name);
|
||||
|
||||
describe('Armor Class without armor', () => {
|
||||
test('a barbarian adds Constitution, and keeps it behind a shield', () => {
|
||||
expect(sheet({ characterClass: 'BARBARIAN' }).armorClass).toBe(14); // 10 + 2 + 2
|
||||
const shielded = sheet({ characterClass: 'BARBARIAN', inventoryItems: [wearing('Shield', 'SHIELD', 2)] });
|
||||
expect(shielded.armorClass).toBe(16);
|
||||
expect(names(shielded)).toContain('Unarmored Defense');
|
||||
});
|
||||
|
||||
test('a monk adds Wisdom, but not with a shield', () => {
|
||||
expect(sheet({ characterClass: 'MONK' }).armorClass).toBe(15); // 10 + 2 + 3
|
||||
const shielded = sheet({ characterClass: 'MONK', inventoryItems: [wearing('Shield', 'SHIELD', 2)] });
|
||||
expect(shielded.armorClass).toBe(14); // 10 + 2, and the shield
|
||||
expect(names(shielded)).not.toContain('Unarmored Defense');
|
||||
});
|
||||
|
||||
test('armor on, it is the armor that counts', () => {
|
||||
const armored = sheet({ characterClass: 'BARBARIAN', inventoryItems: [wearing('Hide Armor', 'MEDIUM', 2)] });
|
||||
expect(armored.armorClass).toBe(14); // 10 + 2 + the hide's 2
|
||||
expect(names(armored)).toEqual([]);
|
||||
// Armor that is carried, not worn, changes nothing.
|
||||
const carried = sheet({ characterClass: 'BARBARIAN', inventoryItems: [{ ...wearing('Hide Armor', 'MEDIUM', 2), equipped: false }] });
|
||||
expect(carried.armorClass).toBe(14);
|
||||
expect(names(carried)).toContain('Unarmored Defense');
|
||||
});
|
||||
|
||||
test('it is left out when it would not help, and the DM’s extra AC still adds', () => {
|
||||
const frail = sheet({ characterClass: 'BARBARIAN', conScore: 7 }); // CON 8: -1
|
||||
expect(frail.armorClass).toBe(12);
|
||||
expect(names(frail)).toEqual([]);
|
||||
expect(sheet({ characterClass: 'BARBARIAN', armorBonus: 1 }).armorClass).toBe(15);
|
||||
});
|
||||
|
||||
test('a draconic sorcerer has 13 + Dexterity and a hit point a level, from the level the bloodline starts', () => {
|
||||
const before = sheet({ characterClass: 'SORCERER', subclass: 'DRACONIC', level: 2 });
|
||||
expect(before.armorClass).toBe(12);
|
||||
expect(before.hp.max).toBe(14); // 6 + 2, then 4 + 2
|
||||
const dragon = sheet({ characterClass: 'SORCERER', subclass: 'DRACONIC', level: 3 });
|
||||
expect(dragon.armorClass).toBe(15);
|
||||
expect(dragon.hp.max).toBe(23); // 20, and 3 more
|
||||
expect(sheet({ characterClass: 'SORCERER', subclass: 'WILD_MAGIC', level: 3 }).hp.max).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('speed', () => {
|
||||
test('a barbarian is 10 ft faster from level 5, out of heavy armor', () => {
|
||||
expect(sheet({ characterClass: 'BARBARIAN', level: 4 }).speed).toBe(30);
|
||||
expect(sheet({ characterClass: 'BARBARIAN', level: 5 }).speed).toBe(40);
|
||||
expect(sheet({ characterClass: 'BARBARIAN', level: 5, inventoryItems: [wearing('Hide Armor', 'MEDIUM', 2)] }).speed).toBe(40);
|
||||
expect(sheet({ characterClass: 'BARBARIAN', level: 5, inventoryItems: [wearing('Plate Armor', 'HEAVY', 8)] }).speed).toBe(30);
|
||||
});
|
||||
|
||||
test('a monk speeds up as they level, without armor or a shield', () => {
|
||||
expect([1, 2, 5, 6, 10, 14, 18, 20].map(unarmoredMovement)).toEqual([0, 10, 10, 15, 20, 25, 30, 30]);
|
||||
expect(sheet({ characterClass: 'MONK', level: 6 }).speed).toBe(45);
|
||||
expect(sheet({ characterClass: 'MONK', level: 6, inventoryItems: [wearing('Leather Armor', 'LIGHT', 1)] }).speed).toBe(30);
|
||||
expect(sheet({ characterClass: 'MONK', level: 6, inventoryItems: [wearing('Shield', 'SHIELD', 2)] }).speed).toBe(30);
|
||||
});
|
||||
|
||||
test('a speed the DM has set is the last word', () => {
|
||||
const set = sheet({ characterClass: 'BARBARIAN', level: 5, speedOverride: 40 });
|
||||
expect(set.speed).toBe(40);
|
||||
expect(names(set)).not.toContain('Fast Movement');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saving throws', () => {
|
||||
const classSaves = (...abilities) => abilities.map((ability) => ({ ability, proficient: true }));
|
||||
const bonuses = (s) => Object.fromEntries(Object.entries(s.saves).map(([ability, save]) => [ability, save.bonus]));
|
||||
|
||||
test('a paladin’s aura adds Charisma to every save from level 6, and at least 1', () => {
|
||||
const young = sheet({ characterClass: 'PALADIN', level: 5 }, classSaves('WIS', 'CHA'));
|
||||
expect(bonuses(young)).toEqual({ STR: 3, DEX: 2, CON: 2, INT: 0, WIS: 6, CHA: 6 });
|
||||
const aura = sheet({ characterClass: 'PALADIN', level: 6 }, classSaves('WIS', 'CHA'));
|
||||
expect(bonuses(aura)).toEqual({ STR: 6, DEX: 5, CON: 5, INT: 3, WIS: 9, CHA: 9 });
|
||||
const dour = sheet({ characterClass: 'PALADIN', level: 6, chaScore: 7 }, classSaves('WIS', 'CHA')); // CHA 8: -1
|
||||
expect(bonuses(dour).STR).toBe(4);
|
||||
});
|
||||
|
||||
test('a monk is proficient in every save from level 14', () => {
|
||||
const before = sheet({ characterClass: 'MONK', level: 13 }, classSaves('STR', 'DEX'));
|
||||
expect(before.saves.WIS).toEqual({ proficient: false, bonus: 3 });
|
||||
const soul = sheet({ characterClass: 'MONK', level: 14 }, classSaves('STR', 'DEX'));
|
||||
expect(Object.values(soul.saves).every((save) => save.proficient)).toBe(true);
|
||||
expect(bonuses(soul)).toEqual({ STR: 8, DEX: 7, CON: 7, INT: 5, WIS: 8, CHA: 8 }); // +5 proficiency
|
||||
});
|
||||
|
||||
test('a rogue is proficient in Wisdom saves from level 15', () => {
|
||||
expect(sheet({ characterClass: 'ROGUE', level: 14 }, classSaves('DEX', 'INT')).saves.WIS).toEqual({ proficient: false, bonus: 3 });
|
||||
expect(sheet({ characterClass: 'ROGUE', level: 15 }, classSaves('DEX', 'INT')).saves.WIS).toEqual({ proficient: true, bonus: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Jack of All Trades', () => {
|
||||
test('a bard adds half proficiency to initiative and to skills they are not proficient in', () => {
|
||||
const skills = [{ skill: 'PERFORMANCE', proficient: true, expertise: false }];
|
||||
const novice = computeCharacterSheet({ ...scores, characterClass: 'BARD', level: 1 }, skills, []);
|
||||
expect(novice.initiative).toBe(2);
|
||||
const bard = computeCharacterSheet({ ...scores, characterClass: 'BARD', level: 2 }, skills, []);
|
||||
expect(bard.initiative).toBe(3); // +2 Dexterity, +1
|
||||
expect(bard.skills.STEALTH.bonus).toBe(3);
|
||||
expect(bard.skills.PERFORMANCE.bonus).toBe(5); // +3 Charisma, +2: proficient, so no half
|
||||
expect(bard.passivePerception).toBe(14); // 10 + 3 Wisdom + 1
|
||||
expect(computeCharacterSheet({ ...scores, characterClass: 'BARD', level: 9 }, skills, []).initiative).toBe(4); // half of +4
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiclassed', () => {
|
||||
test('each class counts by its own level', () => {
|
||||
// Paladin 6 / Barbarian 1: the aura, and Unarmored Defense, but no Fast Movement.
|
||||
const mixed = sheet({ characterClass: 'PALADIN', level: 7, classLevels: [{ characterClass: 'BARBARIAN', level: 1 }] });
|
||||
expect(names(mixed).sort()).toEqual(['Aura of Protection', 'Unarmored Defense']);
|
||||
expect(mixed.armorClass).toBe(14);
|
||||
// Barbarian 1 / Monk 1: the better of the two ways, not both.
|
||||
const both = sheet({ characterClass: 'BARBARIAN', level: 2, classLevels: [{ characterClass: 'MONK', level: 1 }] });
|
||||
expect(both.armorClass).toBe(15);
|
||||
expect(both.features).toHaveLength(1);
|
||||
expect(both.features[0].source).toBe('Monk');
|
||||
});
|
||||
|
||||
test('nothing for a class with none of these', () => {
|
||||
const effects = classFeatureEffects([{ characterClass: 'FIGHTER', level: 20, subclass: null }], { STR: 3, DEX: 2, CON: 2, INT: 0, WIS: 3, CHA: 3 }, 6);
|
||||
expect(effects).toEqual({ acBase: null, speed: 0, hp: 0, saveBonus: 0, saveProficiencies: [], halfProficiency: 0, inForce: [] });
|
||||
});
|
||||
});
|
||||
@@ -266,7 +266,7 @@ const CLASS_FEATURES = {
|
||||
],
|
||||
BARBARIAN: [
|
||||
{ name: 'Rage', text: 'Twice per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved.' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor, add your Constitution modifier to your AC (ask your DM to set your armor bonus).' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor, add your Constitution modifier to your AC (counted on your sheet). A shield still adds.' },
|
||||
],
|
||||
ROGUE: [
|
||||
{ name: 'Sneak Attack', text: 'Once per turn, deal an extra 1d6 damage to a target you have advantage against, or that has one of your allies next to it.' },
|
||||
@@ -306,7 +306,7 @@ const CLASS_FEATURES = {
|
||||
],
|
||||
MONK: [
|
||||
{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d4 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor, add your Wisdom modifier to your AC (ask your DM to set your armor bonus).' },
|
||||
{ name: 'Unarmored Defense', text: 'Without armor or a shield, add your Wisdom modifier to your AC (counted on your sheet).' },
|
||||
],
|
||||
ARTIFICER: [
|
||||
{ name: 'Magical Tinkering', text: 'Touch a tiny object to give it a small magic: it shines, plays a recorded message, gives off a smell or sound, or shows a picture. You can keep as many going as your Intelligence modifier.' },
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// is in the note. A DM who wants it stricter edits the copy in their campaign.
|
||||
|
||||
const STEALTH = 'Disadvantage on Stealth checks.';
|
||||
const ARMOR_TYPE = { 'Light Armor': 'LIGHT', 'Medium Armor': 'MEDIUM', 'Heavy Armor': 'HEAVY', Shields: 'SHIELD' };
|
||||
const armor = (key, name, category, ac, description, note = null, more = []) => ({
|
||||
key,
|
||||
name,
|
||||
@@ -18,6 +19,8 @@ const armor = (key, name, category, ac, description, note = null, more = []) =>
|
||||
damageDice: null,
|
||||
effects: [{ stat: 'AC', amount: ac }, ...more],
|
||||
effectNote: note,
|
||||
// What kind it is: the sheet goes by this for Unarmored Defense and the like.
|
||||
armorType: ARMOR_TYPE[category],
|
||||
});
|
||||
const gear = (key, name, category, description, effects = [], effectNote = null, damageDice = null) => ({
|
||||
key,
|
||||
|
||||
@@ -57,7 +57,7 @@ const LEVEL_FEATURES = {
|
||||
],
|
||||
3: [rage('Three times', 2)],
|
||||
4: [BOON],
|
||||
5: [EXTRA_ATTACK, { name: 'Fast Movement', text: '+10 ft speed when not in heavy armor (ask your DM to raise your speed).' }],
|
||||
5: [EXTRA_ATTACK, { name: 'Fast Movement', text: '+10 ft speed when not in heavy armor (counted on your sheet).' }],
|
||||
6: [rage('Four times', 2)],
|
||||
7: [{ name: 'Feral Instinct', text: 'Advantage on initiative; you can act normally in a surprise round if you rage first.' }],
|
||||
8: [BOON],
|
||||
@@ -77,7 +77,7 @@ const LEVEL_FEATURES = {
|
||||
},
|
||||
BARD: {
|
||||
2: [
|
||||
{ name: 'Jack of All Trades', text: 'Add half your proficiency bonus (round down) to any check you are not proficient in.' },
|
||||
{ name: 'Jack of All Trades', text: 'Add half your proficiency bonus (round down) to any check you are not proficient in (counted on your sheet for skills and initiative).' },
|
||||
{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d6.' },
|
||||
],
|
||||
3: [
|
||||
@@ -169,7 +169,7 @@ const LEVEL_FEATURES = {
|
||||
{ name: 'Flurry of Blows', text: '1 ki: two unarmed strikes as a bonus action.' },
|
||||
{ name: 'Patient Defense', text: '1 ki: Dodge as a bonus action.' },
|
||||
{ name: 'Step of the Wind', text: '1 ki: Dash or Disengage as a bonus action, jump distance doubled.' },
|
||||
{ name: 'Unarmored Movement', text: '+10 ft speed without armor (ask your DM to raise your speed).' },
|
||||
{ name: 'Unarmored Movement', text: '+10 ft speed without armor (counted on your sheet).' },
|
||||
],
|
||||
3: [
|
||||
{ name: 'Deflect Missiles', text: 'Reaction: reduce ranged weapon damage by 1d10 + Dexterity modifier + your level.' },
|
||||
@@ -182,28 +182,28 @@ const LEVEL_FEATURES = {
|
||||
],
|
||||
6: [
|
||||
{ name: 'Ki-Empowered Strikes', text: 'Your unarmed strikes count as magical.' },
|
||||
{ name: 'Unarmored Movement', text: '+15 ft speed without armor (ask your DM to raise your speed).', upgrade: true },
|
||||
{ name: 'Unarmored Movement', text: '+15 ft speed without armor (counted on your sheet).', upgrade: true },
|
||||
],
|
||||
7: [EVASION, { name: 'Stillness of Mind', text: 'Action: end charm or fear on yourself.' }],
|
||||
8: [BOON],
|
||||
9: [{ name: 'Unarmored Movement', text: '+15 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true }],
|
||||
9: [{ name: 'Unarmored Movement', text: '+15 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true }],
|
||||
10: [
|
||||
{ name: 'Purity of Body', text: 'Immune to poison and disease.' },
|
||||
{ name: 'Unarmored Movement', text: '+20 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
|
||||
{ name: 'Unarmored Movement', text: '+20 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true },
|
||||
],
|
||||
11: [{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d8 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.', upgrade: true }],
|
||||
12: [BOON],
|
||||
13: [{ name: 'Tongue of the Sun and Moon', text: 'You understand every spoken language, and anyone who speaks one understands you.' }],
|
||||
14: [
|
||||
{ name: 'Diamond Soul', text: 'You are proficient in every saving throw (add the bonus by hand); 1 ki rerolls a failed save.' },
|
||||
{ name: 'Unarmored Movement', text: '+25 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
|
||||
{ name: 'Diamond Soul', text: 'You are proficient in every saving throw (counted on your sheet); 1 ki rerolls a failed save.' },
|
||||
{ name: 'Unarmored Movement', text: '+25 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true },
|
||||
],
|
||||
15: [{ name: 'Timeless Body', text: 'You need no food or water and suffer none of the frailty of old age.' }],
|
||||
16: [BOON],
|
||||
17: [{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d10 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.', upgrade: true }],
|
||||
18: [
|
||||
{ name: 'Empty Body', text: '4 ki: invisible for one minute, and all damage but force against you is halved. 8 ki: cast Astral Projection on yourself.' },
|
||||
{ name: 'Unarmored Movement', text: '+30 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
|
||||
{ name: 'Unarmored Movement', text: '+30 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true },
|
||||
],
|
||||
19: [BOON],
|
||||
20: [{ name: 'Perfect Self', text: 'When you roll initiative with no ki left, regain 4 ki.' }],
|
||||
@@ -219,7 +219,7 @@ const LEVEL_FEATURES = {
|
||||
],
|
||||
4: [BOON],
|
||||
5: [EXTRA_ATTACK],
|
||||
6: [{ name: 'Aura of Protection', text: 'You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious.' }],
|
||||
6: [{ name: 'Aura of Protection', text: 'You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet).' }],
|
||||
8: [BOON],
|
||||
10: [{ name: 'Aura of Courage', text: 'You and allies within 10 ft can’t be frightened.' }],
|
||||
11: [{ name: 'Improved Divine Smite', text: 'Every melee weapon hit deals an extra 1d8 radiant damage.' }],
|
||||
@@ -227,7 +227,7 @@ const LEVEL_FEATURES = {
|
||||
14: [{ name: 'Cleansing Touch', text: 'A few times per day (your Charisma modifier, at least once), action: end one spell on yourself or a willing creature you touch.' }],
|
||||
16: [BOON],
|
||||
18: [
|
||||
{ name: 'Aura of Protection', text: 'You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious.', upgrade: true },
|
||||
{ name: 'Aura of Protection', text: 'You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet).', upgrade: true },
|
||||
{ name: 'Aura of Courage', text: 'You and allies within 30 ft can’t be frightened.', upgrade: true },
|
||||
],
|
||||
19: [BOON],
|
||||
@@ -275,7 +275,7 @@ const LEVEL_FEATURES = {
|
||||
12: [BOON],
|
||||
13: [sneakAttack('7d6')],
|
||||
14: [{ name: 'Blindsense', text: 'If you can hear, you know where any hidden or invisible creature within 10 ft is.' }],
|
||||
15: [sneakAttack('8d6'), { name: 'Slippery Mind', text: 'You are proficient in Wisdom saves (add the bonus by hand).' }],
|
||||
15: [sneakAttack('8d6'), { name: 'Slippery Mind', text: 'You are proficient in Wisdom saves (counted on your sheet).' }],
|
||||
16: [BOON],
|
||||
17: [sneakAttack('9d6')],
|
||||
18: [{ name: 'Elusive', text: 'No attack against you has advantage while you are not incapacitated.' }],
|
||||
|
||||
@@ -9,7 +9,7 @@ const SORCERER = [
|
||||
edition: '2014',
|
||||
summary: 'Dragon blood: tougher skin and one element at your call.',
|
||||
features: {
|
||||
3: [{ name: 'Dragon Ancestor', text: 'Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier (ask your DM about Extra AC).' }],
|
||||
3: [{ name: 'Dragon Ancestor', text: 'Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier: without armor your AC is 13 + Dexterity, and you have 1 more maximum HP for each sorcerer level (both counted on your sheet).' }],
|
||||
6: [{ name: 'Elemental Affinity', text: 'Draconic Bloodline. Add your Charisma modifier to one damage roll of your dragon’s element; spend 1 sorcery point to resist that element for 1 hour.' }],
|
||||
14: [{ name: 'Dragon Wings', text: 'Bonus action: sprout dragon wings and fly at your speed until you dismiss them.' }],
|
||||
18: [{ name: 'Draconic Presence', text: 'Action, 5 sorcery points: for a minute, creatures within 60 ft make a Wisdom save or are charmed (awe) or frightened (fear) of you.' }],
|
||||
|
||||
@@ -2638,7 +2638,7 @@ describe('gear effects', () => {
|
||||
const quick = async (auth = playerAuth) => (await request(app).get(`${ch}/quick-stats`).set(auth)).body.quickStats;
|
||||
|
||||
const before = (await sheet()).sheet;
|
||||
expect(before.gear).toEqual({ effects: [], notes: [] });
|
||||
expect(before.gear).toEqual({ effects: [], notes: [], armor: { worn: null, shield: false } });
|
||||
|
||||
// Only the DM says what gear does, and only with numbers the sheet has.
|
||||
const mail = {
|
||||
@@ -4005,6 +4005,16 @@ describe('battle actions', () => {
|
||||
expect(dry.status).toBe(400);
|
||||
expect(dry.body.error).toMatch(/no spell slot of level 3 or higher left/);
|
||||
expect((await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: bolt.characterSpellId } })).status).toBe(201);
|
||||
|
||||
// Every slot she has spent, at every level: the cantrip is still hers to cast, and lands.
|
||||
const all = (await request(app).get(`/characters/${mageId}`).set(dmAuth)).body.character.sheet.spellSlots.levels;
|
||||
for (const slot of all) await request(app).post(`/characters/${mageId}/spell-slots`).set(dmAuth).send({ action: 'set', level: slot.level, used: slot.max });
|
||||
const empty = (await view(dmAuth)).participants.find((p) => p.displayName === 'Mira').characterCombatInfo.spellSlots.levels;
|
||||
expect(empty.every((l) => l.remaining === 0)).toBe(true);
|
||||
const again = await request(app).post(`${e}/actions`).set(dmAuth).send({ source: { type: 'spell', characterSpellId: bolt.characterSpellId } });
|
||||
expect(again.status).toBe(201);
|
||||
await request(app).patch(`${e}/actions/${again.body.actionId}`).set(dmAuth).send({ rolls: [{ participantId: one.id, d20: 1 }] });
|
||||
expect((await request(app).post(`${e}/actions/${again.body.actionId}/apply`).set(dmAuth).send({})).status).toBe(200);
|
||||
});
|
||||
|
||||
itIfDb('a weapon is changed as the battle begins or on your own turn after attacking, and not in between', async () => {
|
||||
@@ -4200,3 +4210,40 @@ describe('free roam', () => {
|
||||
expect((await request(app).get(quest).set(playerAuth)).body.encounter.places).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('class features on the sheet', () => {
|
||||
itIfDb('a barbarian’s Unarmored Defense and Fast Movement go by the armor that is equipped', async () => {
|
||||
const { dmAuth, playerAuth, campaign } = await setupCampaign('unarmored');
|
||||
const c = `/campaigns/${campaign.id}`;
|
||||
const id = await buildAs(campaign.id, playerAuth, 'Korg', 'BARBARIAN', 5);
|
||||
await prisma.character.update({ where: { id }, data: { species: 'HUMAN', dexScore: 13, conScore: 15, speciesBonusChoices: {}, armorBonus: 0, speedOverride: null } });
|
||||
const sheet = async () => (await request(app).get(`/characters/${id}`).set(playerAuth)).body.character.sheet;
|
||||
|
||||
// Dexterity 14 (+2), Constitution 16 (+3): 10 + 2 + 3. A human's 30 ft, and 10 more.
|
||||
let s = await sheet();
|
||||
expect(s).toMatchObject({ armorClass: 15, speed: 40 });
|
||||
expect(s.features.map((f) => f.name)).toEqual(['Unarmored Defense', 'Fast Movement']);
|
||||
|
||||
// The library's armor comes marked. Hide (+2): 10 + 2 + 2, and no Unarmored Defense.
|
||||
const hide = (await request(app).post(`${c}/items/from-library`).set(dmAuth).send({ key: 'hide-armor' })).body.item;
|
||||
expect(hide.armorType).toBe('MEDIUM');
|
||||
await request(app).post(`/characters/${id}/inventory`).set(dmAuth).send({ itemId: hide.id });
|
||||
expect((await sheet()).armorClass).toBe(15); // carried, not worn
|
||||
await request(app).patch(`/characters/${id}/inventory/${hide.id}`).set(playerAuth).send({ equipped: true });
|
||||
s = await sheet();
|
||||
expect(s).toMatchObject({ armorClass: 14, speed: 40 });
|
||||
expect(s.features.map((f) => f.name)).toEqual(['Fast Movement']);
|
||||
|
||||
// The DM says what kind of thing an item is. Heavy: the extra speed goes. Not armor: all of it is back, on top of the +2.
|
||||
expect((await request(app).patch(`${c}/items/${hide.id}`).set(playerAuth).send({ armorType: 'HEAVY' })).status).toBe(403);
|
||||
expect((await request(app).patch(`${c}/items/${hide.id}`).set(dmAuth).send({ armorType: 'PLATE' })).status).toBe(400);
|
||||
await request(app).patch(`${c}/items/${hide.id}`).set(dmAuth).send({ armorType: 'HEAVY' });
|
||||
expect(await sheet()).toMatchObject({ armorClass: 14, speed: 30 });
|
||||
await request(app).patch(`${c}/items/${hide.id}`).set(dmAuth).send({ armorType: null });
|
||||
expect(await sheet()).toMatchObject({ armorClass: 17, speed: 40 });
|
||||
|
||||
// A speed the DM sets is used as it is.
|
||||
await request(app).patch(`/characters/${id}`).set(dmAuth).send({ speedOverride: 35 });
|
||||
expect((await sheet()).speed).toBe(35);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -706,6 +706,8 @@ const itemSchema = z.object({
|
||||
damageScaling: z.number().int().min(1).max(MAX_DAMAGE_SCALING).optional(),
|
||||
minLevel: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
// Armor and shields: what kind it is. Null for anything else.
|
||||
armorType: z.enum(['LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD']).optional().nullable(),
|
||||
...gearEffects,
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ const POOLS = {
|
||||
},
|
||||
items: {
|
||||
library: ITEM_LIBRARY,
|
||||
row: z.strictObject({ name: text(120), description: optionalText(2000), damageDice: dice, ...gear }),
|
||||
row: z.strictObject({ name: text(120), description: optionalText(2000), damageDice: dice, armorType: z.enum(['LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD']).nullish(), ...gear }),
|
||||
},
|
||||
spells: {
|
||||
library: SPELL_LIBRARY,
|
||||
|
||||
Reference in New Issue
Block a user