Compare commits

...
3 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 4bd301182b V1.32.0: battles that keep to the fight
- The scene description shows during a battle only between rounds (while
  the first in the order is on turn), and always before and after it
- Combatant details start closed in a battle: the spotlight has whoever is
  on turn, or on a narrow screen their own card opens
- While a battle is under way, Add to the scene, Experience and Rewards
  fold to their titles
- Button labels wrap inside their buttons instead of spilling out
- Sticky turn order and side column follow the measured top bar height
- Phone: quick stats no longer sits on Log out, incoming rolls clear the
  bottom bar, the damage bar no longer scrolls on its own
- Docs updated; all packages at version 1.32.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:19:48 +02:00
joshuaandClaude Opus 5.5 cbda10668b V1.31.0: armor holds Dexterity back
- All of a Dexterity modifier counts toward AC in light armor, at most +2
  in medium armor, none in heavy armor, going by the equipped item's kind
- The gear card says when the armor is holding something back
- Library and seed armor notes and docs brought in line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:24:28 +02:00
joshuaandClaude Opus 5.5 16b5ac05ca 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>
2026-10-12 11:13:09 +02:00
35 changed files with 766 additions and 139 deletions
+13
View File
@@ -99,6 +99,19 @@ including a character sheet or an encounter) and **Account**.
checklist at level 2 or 3, and shown beside the class everywhere; the DM can
change it from the sheet, and switch a 2014 path to its 2024 update. Every
sheet shows **the road ahead**: what each level from 1 to 20 gives.
- **Class features that change a number are worked out**: Unarmored Defense
(barbarian, monk), Draconic Resilience, Fast Movement, Unarmored Movement,
Aura of Protection, Diamond Soul, Slippery Mind and Jack of All Trades are
counted on the sheet, by what is equipped (armor and shields are marked
items) and by level in the class that gives them.
- **Armor holds Dexterity back** as in the rules: all of it counts toward AC
in light armor, at most +2 in medium, none in heavy.
- **Battles that keep to the fight.** While a battle is under way the scene
description shows only between rounds, cards' details start closed (the
spotlight, or on a phone the card on turn, has them), and the DM's setup,
Experience and Rewards panels fold to their titles. Button labels wrap
inside their buttons, and on a phone quick stats no longer sits on Log out
nor incoming rolls on the bottom bar.
- **Stricter battles.** A spell with no slot of its level or higher left
cannot be cast (cantrips never need one), and a player changes the weapon
in hand only as the battle begins or on their own turn after attacking.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.29.0",
"version": "1.32.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';
+11
View File
@@ -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())
+3 -3
View File
@@ -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.' },
];
File diff suppressed because one or more lines are too long
+49 -12
View File
@@ -73,6 +73,15 @@ function armorClass(dexMod, armorBonus = 0) {
return 10 + dexMod + armorBonus;
}
// How much of a Dexterity modifier counts toward AC in the armor worn: all
// of it in light armor or none, at most +2 in medium armor, and none of it
// (a penalty included) in heavy armor.
function armorDexterity(dexMod, worn = null) {
if (worn === 'HEAVY') return 0;
if (worn === 'MEDIUM') return Math.min(dexMod, 2);
return dexMod;
}
function initiative(dexMod) {
return dexMod;
}
@@ -287,6 +296,12 @@ 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 worn = ['HEAVY', 'MEDIUM', 'LIGHT'].find((kind) => kinds.includes(kind)) || null;
const suit = worn ? held.find((h) => h.entry.armorType === worn) : null;
const armor = { worn, name: suit ? suit.entry.name : 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 +310,7 @@ function activeGear(character) {
}
if (entry.effectNote) notes.push({ source: entry.name, note: entry.effectNote });
}
return { effects, notes };
return { effects, notes, armor };
}
/**
@@ -347,11 +362,30 @@ 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);
// Medium and heavy armor hold Dexterity back; said on the gear card when it bites.
const acDex = armorDexterity(abilities.DEX.modifier, gear.armor.worn);
if (acDex !== abilities.DEX.modifier) {
gear.notes.push({
source: gear.armor.name,
note: gear.armor.worn === 'HEAVY' ? 'Heavy armor: Dexterity does not count toward AC.' : 'Medium armor: at most +2 from Dexterity counts toward AC.',
});
}
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 +398,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 +416,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(acDex, 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 +437,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) })),
@@ -549,6 +584,7 @@ module.exports = {
passivePerception,
passiveScore,
armorClass,
armorDexterity,
initiative,
hpMax,
spellSaveDC,
@@ -586,3 +622,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');
+111
View File
@@ -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 };
+168
View File
@@ -0,0 +1,168 @@
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: [] });
});
});
describe('Dexterity in armor', () => {
const { armorDexterity } = require('./calculations');
// Dexterity 18 (+4), and a clumsy one at 8 (-1).
const nimble = (inventoryItems) => sheet({ characterClass: 'FIGHTER', dexScore: 17, inventoryItems });
const clumsy = (inventoryItems) => sheet({ characterClass: 'FIGHTER', dexScore: 7, inventoryItems });
test('all of it in light armor or none, at most +2 in medium, none in heavy', () => {
expect([null, 'LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD'].map((worn) => armorDexterity(4, worn))).toEqual([4, 4, 2, 0, 4]);
expect([null, 'LIGHT', 'MEDIUM', 'HEAVY'].map((worn) => armorDexterity(-1, worn))).toEqual([-1, -1, -1, 0]);
});
test('the sheet’s AC follows, and the gear card says why', () => {
expect(nimble([]).armorClass).toBe(14);
expect(nimble([wearing('Leather Armor', 'LIGHT', 1)]).armorClass).toBe(15);
const scale = nimble([wearing('Scale Mail', 'MEDIUM', 4)]);
expect(scale.armorClass).toBe(16); // 10 + 2 of the 4 + 4
expect(scale.gear.notes).toEqual([{ source: 'Scale Mail', note: 'Medium armor: at most +2 from Dexterity counts toward AC.' }]);
const plate = nimble([wearing('Plate Armor', 'HEAVY', 8), wearing('Shield', 'SHIELD', 2)]);
expect(plate.armorClass).toBe(20); // 18, and the shield
expect(plate.gear.notes[0]).toEqual({ source: 'Plate Armor', note: 'Heavy armor: Dexterity does not count toward AC.' });
// Heavy armor hides a Dexterity penalty too; medium does not.
expect(clumsy([wearing('Plate Armor', 'HEAVY', 8)]).armorClass).toBe(18);
expect(clumsy([wearing('Scale Mail', 'MEDIUM', 4)]).armorClass).toBe(13);
// Nothing to say when the armor holds nothing back, and only Dexterity's part in AC changes.
expect(sheet({ characterClass: 'FIGHTER', inventoryItems: [wearing('Scale Mail', 'MEDIUM', 4)] }).gear.notes).toEqual([]);
expect(plate.initiative).toBe(4);
expect(plate.saves.DEX.bonus).toBe(4);
});
test('an item with +AC that is not marked as armor holds nothing back', () => {
expect(nimble([wearing('Ring of Protection', null, 1)]).armorClass).toBe(15);
});
});
+2 -2
View File
@@ -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.' },
+18 -14
View File
@@ -3,13 +3,15 @@
// matches the `Item` Prisma model minus `id`/`campaignId`, plus a stable `key`
// for "add to campaign" lookups and a `category` for the library picker.
//
// Armor here is a flat bonus to AC on top of 10 + Dexterity, because that is
// how gear effects work (game/calculations.js, GEAR_STATS): the standard
// rules' "replaces 10 + DEX" and "DEX bonus capped at 2" are not modelled.
// Each suit's bonus is its standard AC less 10, and what the bonus cannot say
// is in the note. A DM who wants it stricter edits the copy in their campaign.
// Armor's AC is a bonus on top of 10 + Dexterity, because that is how gear
// effects work (game/calculations.js, GEAR_STATS): each suit's bonus is its
// standard AC less 10. What kind of armor it is (armorType, from the category)
// tells the sheet how much Dexterity counts in it: all of it in light armor,
// at most +2 in medium, none in heavy (calculations.js, armorDexterity).
// What neither can say (a Strength requirement, Stealth) is in the note.
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 +20,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,
@@ -36,17 +40,17 @@ const ITEM_LIBRARY = [
armor('studded-leather', 'Studded Leather', 'Light Armor', 2, 'Tough leather set with close rivets and spikes.'),
// Medium armor
armor('hide-armor', 'Hide Armor', 'Medium Armor', 2, 'Thick furs and pelts.', 'Standard rules: counts at most +2 from Dexterity.'),
armor('chain-shirt', 'Chain Shirt', 'Medium Armor', 3, 'Interlocking rings worn between layers of clothing.', 'Standard rules: counts at most +2 from Dexterity.'),
armor('scale-mail', 'Scale Mail', 'Medium Armor', 4, 'A coat and leggings of leather covered in overlapping metal scales.', `${STEALTH} Standard rules: counts at most +2 from Dexterity.`),
armor('breastplate', 'Breastplate', 'Medium Armor', 4, 'A fitted metal chest piece worn with supple leather.', 'Standard rules: counts at most +2 from Dexterity.'),
armor('half-plate', 'Half Plate', 'Medium Armor', 5, 'Shaped metal plates covering most of the body.', `${STEALTH} Standard rules: counts at most +2 from Dexterity.`),
armor('hide-armor', 'Hide Armor', 'Medium Armor', 2, 'Thick furs and pelts.', 'Medium armor: at most +2 from Dexterity counts (worked out on the sheet).'),
armor('chain-shirt', 'Chain Shirt', 'Medium Armor', 3, 'Interlocking rings worn between layers of clothing.', 'Medium armor: at most +2 from Dexterity counts (worked out on the sheet).'),
armor('scale-mail', 'Scale Mail', 'Medium Armor', 4, 'A coat and leggings of leather covered in overlapping metal scales.', `${STEALTH} Medium armor: at most +2 from Dexterity counts (worked out on the sheet).`),
armor('breastplate', 'Breastplate', 'Medium Armor', 4, 'A fitted metal chest piece worn with supple leather.', 'Medium armor: at most +2 from Dexterity counts (worked out on the sheet).'),
armor('half-plate', 'Half Plate', 'Medium Armor', 5, 'Shaped metal plates covering most of the body.', `${STEALTH} Medium armor: at most +2 from Dexterity counts (worked out on the sheet).`),
// Heavy armor: slow, loud, and in the standard rules it ignores Dexterity.
armor('ring-mail', 'Ring Mail', 'Heavy Armor', 4, 'Leather armor with heavy rings sewn into it.', `${STEALTH} Standard rules: adds no Dexterity.`),
armor('chain-mail', 'Chain Mail', 'Heavy Armor', 6, 'Interlocking metal rings over a layer of quilted fabric, with gauntlets.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 13.`, [{ stat: 'SPEED', amount: -10 }]),
armor('splint-armor', 'Splint Armor', 'Heavy Armor', 7, 'Narrow vertical strips of metal riveted to a leather backing.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
armor('plate-armor', 'Plate Armor', 'Heavy Armor', 8, 'Shaped, interlocking metal plates covering the whole body.', `${STEALTH} Standard rules: adds no Dexterity, and needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
armor('ring-mail', 'Ring Mail', 'Heavy Armor', 4, 'Leather armor with heavy rings sewn into it.', `${STEALTH} Heavy armor: Dexterity does not count (worked out on the sheet).`),
armor('chain-mail', 'Chain Mail', 'Heavy Armor', 6, 'Interlocking metal rings over a layer of quilted fabric, with gauntlets.', `${STEALTH} Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 13.`, [{ stat: 'SPEED', amount: -10 }]),
armor('splint-armor', 'Splint Armor', 'Heavy Armor', 7, 'Narrow vertical strips of metal riveted to a leather backing.', `${STEALTH} Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
armor('plate-armor', 'Plate Armor', 'Heavy Armor', 8, 'Shaped, interlocking metal plates covering the whole body.', `${STEALTH} Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 15.`, [{ stat: 'SPEED', amount: -10 }]),
// Shields
armor('shield', 'Shield', 'Shields', 2, 'Wood or metal, carried in one hand.'),
+12 -12
View File
@@ -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.' }],
+1 -1
View File
@@ -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.' }],
+49 -1
View File
@@ -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, name: 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,41 @@ 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, and Dexterity
// no longer counts (10 + the armor's 2). 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: 12, 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);
});
});
+2
View File
@@ -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,
});
+1 -1
View File
@@ -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,
+74 -20
View File
@@ -147,8 +147,10 @@ this order:
Eye, Silver Tongue, Field Medic, Tough as Nails, …).
These are table rules written in plain words for the DM to apply. The app
does not calculate anything from them: Rage does not add damage by itself, and
Unarmored Defense does not change AC unless the DM sets the extra AC.
does not calculate anything from most of them: Rage does not add damage by
itself. The exception is the handful that change a number on the sheet
(Unarmored Defense, Fast Movement and the rest): those are worked out for
you, as section 8 ("Class features that are counted") sets out.
**Background** is five lines, one per question:
@@ -814,9 +816,9 @@ themselves.
| Stat | Formula |
|---|---|
| Armor Class | 10 + DEX modifier + equipped gear + the DM's extra AC |
| Initiative | DEX modifier + equipped gear |
| Speed | Species speed, unless a speed override is set, + equipped gear |
| Armor Class | 10 + DEX modifier (as much of it as the armor worn lets through; or what a class feature gives without armor, below) + equipped gear + the DM's extra AC |
| Initiative | DEX modifier + a bard's Jack of All Trades + equipped gear |
| Speed | Species speed + a class's extra speed, unless a speed override is set, + equipped gear |
| Passive Perception | 10 + Perception skill bonus + equipped gear |
| Passive Investigation, Passive Insight | 10 + that skill's bonus (gear that raises the skill raises it too) |
| Hit dice | One per level, of the class's die size (shown only; not spent or tracked) |
@@ -832,6 +834,57 @@ item is doing what. An effect on an ability score changes the score itself,
so everything in sections 5 to 10 that is built on that ability moves too.
The full rules are in [ITEMS.md](ITEMS.md), section 5.
**Dexterity in armor.** How much of the DEX modifier counts toward AC goes by
the armor that is equipped (items are marked light, medium or heavy:
[ITEMS.md](ITEMS.md), section 5):
| Armor worn | DEX modifier counted toward AC |
|---|---|
| None, or light | All of it |
| Medium | At most +2 (a penalty counts in full) |
| Heavy | None of it, a penalty included |
Only AC is affected: initiative, Dexterity saves and skills use the full
modifier. When the armor is holding something back, the "From Equipped Gear"
card says so. A shield changes nothing here, and neither does an item with
+AC that is not marked as armor.
### Class features that are counted
These class features change a number, so the sheet works them out. Nobody
adds them by hand, and the "From Class Features" card on the sheet lists the
ones in force and what each is doing.
| Feature | From | What the sheet does |
|---|---|---|
| Unarmored Defense | Barbarian 1 | AC starts from 10 + DEX + CON while no armor is equipped. A shield still adds |
| Unarmored Defense | Monk 1 | AC starts from 10 + DEX + WIS while no armor and no shield is equipped |
| Draconic Resilience | Sorcerer (Draconic Bloodline) 3 | AC starts from 13 + DEX while no armor is equipped, and maximum HP is 1 higher for each sorcerer level |
| Fast Movement | Barbarian 5 | +10 ft speed unless heavy armor is equipped |
| Unarmored Movement | Monk 2 | +10 ft speed (15 at monk level 6, 20 at 10, 25 at 14, 30 at 18) while no armor and no shield is equipped |
| Aura of Protection | Paladin 6 | Charisma modifier (at least +1) added to every one of the paladin's own saving throws. Allies in the aura add it by hand |
| Diamond Soul | Monk 14 | Proficient in every saving throw |
| Slippery Mind | Rogue 15 | Proficient in Wisdom saving throws |
| Jack of All Trades | Bard 2 | Half the proficiency bonus (rounded down) added to initiative and to every skill the bard is not proficient in (and so to the passive scores) |
- **By class level.** A multiclassed character gets each feature at the level
it comes in the class that gives it: a Paladin 6 / Barbarian 1 has the aura
and Unarmored Defense, but not Fast Movement.
- **One way of working out AC.** With more than one (a barbarian who is also a
monk), the best is used, never both. It is left out when plain 10 + DEX is
better (a barbarian with a Constitution penalty). Gear and the DM's Extra AC
add on top either way.
- **What is worn** is read from equipped items marked as **armor** (light,
medium or heavy) or as a **shield** ([ITEMS.md](ITEMS.md), section 5). The
library's armor and shield are marked already. An item that only gives +AC
and is not marked (a ring) is not armor and stops nothing.
- **A speed override wins.** When the DM has set a speed for a character it is
used as it is, and a class's extra speed is not added to it.
- **If the DM used to add these by hand** (Constitution put into Extra AC for
a barbarian, say), take that out, or it now counts twice.
Every other class feature is still a line of text for the table to apply.
**Extra AC** is a number only the DM can set on the sheet, added to AC on top
of gear, for anything an item does not cover. It can be negative. (It used to
be called "armor bonus" and stood in for all armor; characters keep whatever
@@ -1343,7 +1396,8 @@ calculations for them.
rest and lists conditions, but applies none of their effects
- Counters for species traits, talents and Boons, and uses the special
abilities give "a few times per day" beyond the ones in section 7
- Armor rules beyond a flat bonus to AC (no "replaces 10 + DEX", no cap on DEX)
- Armor proficiency, a Strength score needed for heavy armor, and
disadvantage on Stealth (these are notes on the armor)
- Weapon proficiency (everyone is proficient with everything)
- Shared rolls: a roll made from the sheet is seen only by whoever clicked,
and is not recorded
@@ -1426,12 +1480,12 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
| Class | Abilities |
|---|---|
| Artificer | **Magical Tinkering** — 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.<br>**Spellcasting** — You cast artificer spells through tools: thieves’ tools, artisan’s tools or an infused item serve as your focus. |
| Barbarian | **Rage** — Twice per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved.<br>**Unarmored Defense** — Without armor, add your Constitution modifier to your AC (ask your DM to set your armor bonus). |
| Barbarian | **Rage** — Twice per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved.<br>**Unarmored Defense** — Without armor, add your Constitution modifier to your AC (counted on your sheet). A shield still adds. |
| Bard | **Bardic Inspiration** — A few times per day, as a bonus action, give an ally a d6 to add to one attack, check or save.<br>**Jack of Many Tales** — You have heard a story about nearly everything: ask your DM for one useful rumour per session. |
| Cleric | **Divine Domain** — Choose with your DM what your god stands for (life, light, war, knowledge…). It shapes the favours you can ask.<br>**Healing Hands** — Once per rest, touch a creature to restore 1d8 + your Wisdom modifier hit points. |
| Druid | **Druidic** — You know the secret language of druids and can leave hidden messages in it.<br>**Nature’s Voice** — You can calm and make simple requests of ordinary animals. |
| Fighter | **Second Wind** — Once per rest, as a bonus action, recover 1d10 + your level hit points.<br>**Fighting Style** — Choose one with your DM: +2 to ranged attacks, +1 AC in armor, or +2 damage with a one-handed weapon. |
| Monk | **Martial Arts** — Your unarmed strikes deal 1d4 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.<br>**Unarmored Defense** — Without armor, add your Wisdom modifier to your AC (ask your DM to set your armor bonus). |
| Monk | **Martial Arts** — Your unarmed strikes deal 1d4 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.<br>**Unarmored Defense** — Without armor or a shield, add your Wisdom modifier to your AC (counted on your sheet). |
| Paladin | **Divine Sense** — A few times per day, sense any celestial, fiend or undead within 60 feet.<br>**Lay on Hands** — A pool of healing equal to 5 × your level per day, spent by touch however you like. |
| Ranger | **Favored Enemy** — Choose a kind of creature with your DM. Advantage on checks to track them and to recall what you know about them.<br>**Natural Explorer** — In the wild you cannot get lost, you find twice as much food, and difficult terrain does not slow your group. |
| Rogue | **Sneak Attack** — 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.<br>**Thieves’ Cant** — You know the secret slang and signs of the criminal underworld. |
@@ -1706,7 +1760,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
| 2 | **Reckless Attack**: On your first attack each turn you may attack with advantage; attacks against you have advantage until your next turn.<br>**Danger Sense**: Advantage on Dexterity saves against effects you can see (traps, breath, spells). |
| 3 | **Rage** (replaces): Three times per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved. |
| 4 | **Boon** (choose one) |
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Fast Movement**: +10 ft speed when not in heavy armor (ask your DM to raise your speed). |
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Fast Movement**: +10 ft speed when not in heavy armor (counted on your sheet). |
| 6 | **Rage** (replaces): Four times per day, for one minute: +2 melee damage, advantage on Strength checks and saves, and physical damage against you is halved. |
| 7 | **Feral Instinct**: Advantage on initiative; you can act normally in a surprise round if you rage first. |
| 8 | **Boon** (choose one) |
@@ -1776,7 +1830,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
| Level | Written into Special Abilities |
|---|---|
| 2 | **Jack of All Trades**: Add half your proficiency bonus (round down) to any check you are not proficient in.<br>**Song of Rest**: During a short rest, allies who spend Hit Dice heal an extra 1d6. |
| 2 | **Jack of All Trades**: Add half your proficiency bonus (round down) to any check you are not proficient in (counted on your sheet for skills and initiative).<br>**Song of Rest**: During a short rest, allies who spend Hit Dice heal an extra 1d6. |
| 3 | **Expertise**: Double proficiency on two proficient skills (add by hand). |
| 4 | **Boon** (choose one) |
| 5 | **Bardic Inspiration** (replaces): A few times per day, as a bonus action, give an ally a d8 to add to one attack, check or save.<br>**Font of Inspiration**: Regain Bardic Inspiration on a short rest.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
@@ -2059,23 +2113,23 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
| Level | Written into Special Abilities |
|---|---|
| 2 | **Ki**: Points equal to your level; they come back on a short rest. Ki save DC is 8 + proficiency bonus + Wisdom modifier.<br>**Flurry of Blows**: 1 ki: two unarmed strikes as a bonus action.<br>**Patient Defense**: 1 ki: Dodge as a bonus action.<br>**Step of the Wind**: 1 ki: Dash or Disengage as a bonus action, jump distance doubled.<br>**Unarmored Movement**: +10 ft speed without armor (ask your DM to raise your speed). |
| 2 | **Ki**: Points equal to your level; they come back on a short rest. Ki save DC is 8 + proficiency bonus + Wisdom modifier.<br>**Flurry of Blows**: 1 ki: two unarmed strikes as a bonus action.<br>**Patient Defense**: 1 ki: Dodge as a bonus action.<br>**Step of the Wind**: 1 ki: Dash or Disengage as a bonus action, jump distance doubled.<br>**Unarmored Movement**: +10 ft speed without armor (counted on your sheet). |
| 3 | **Deflect Missiles**: Reaction: reduce ranged weapon damage by 1d10 + Dexterity modifier + your level. |
| 4 | **Boon** (choose one)<br>**Slow Fall**: Reaction: reduce falling damage by 5 × your level. |
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Stunning Strike**: 1 ki after a melee hit: the target makes a Constitution save or is stunned until your next turn.<br>**Martial Arts** (replaces): Your unarmed strikes deal 1d6 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action. |
| 6 | **Ki-Empowered Strikes**: Your unarmed strikes count as magical.<br>**Unarmored Movement** (replaces): +15 ft speed without armor (ask your DM to raise your speed). |
| 6 | **Ki-Empowered Strikes**: Your unarmed strikes count as magical.<br>**Unarmored Movement** (replaces): +15 ft speed without armor (counted on your sheet). |
| 7 | **Evasion**: On a Dexterity save for half damage, take none on a success and half on a failure.<br>**Stillness of Mind**: Action: end charm or fear on yourself. |
| 8 | **Boon** (choose one) |
| 9 | **Unarmored Movement** (replaces): +15 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
| 10 | **Purity of Body**: Immune to poison and disease.<br>**Unarmored Movement** (replaces): +20 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
| 9 | **Unarmored Movement** (replaces): +15 ft speed without armor (counted on your sheet). You can run along walls and across water during your move. |
| 10 | **Purity of Body**: Immune to poison and disease.<br>**Unarmored Movement** (replaces): +20 ft speed without armor (counted on your sheet). You can run along walls and across water during your move. |
| 11 | **Martial Arts** (replaces): Your unarmed strikes deal 1d8 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action. |
| 12 | **Boon** (choose one) |
| 13 | **Tongue of the Sun and Moon**: You understand every spoken language, and anyone who speaks one understands you. |
| 14 | **Diamond Soul**: You are proficient in every saving throw (add the bonus by hand); 1 ki rerolls a failed save.<br>**Unarmored Movement** (replaces): +25 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
| 14 | **Diamond Soul**: You are proficient in every saving throw (counted on your sheet); 1 ki rerolls a failed save.<br>**Unarmored Movement** (replaces): +25 ft speed without armor (counted on your sheet). You can run along walls and across water during your move. |
| 15 | **Timeless Body**: You need no food or water and suffer none of the frailty of old age. |
| 16 | **Boon** (choose one) |
| 17 | **Martial Arts** (replaces): Your unarmed strikes deal 1d10 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action. |
| 18 | **Empty Body**: 4 ki: invisible for one minute, and all damage but force against you is halved. 8 ki: cast Astral Projection on yourself.<br>**Unarmored Movement** (replaces): +30 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move. |
| 18 | **Empty Body**: 4 ki: invisible for one minute, and all damage but force against you is halved. 8 ki: cast Astral Projection on yourself.<br>**Unarmored Movement** (replaces): +30 ft speed without armor (counted on your sheet). You can run along walls and across water during your move. |
| 19 | **Boon** (choose one) |
| 20 | **Perfect Self**: When you roll initiative with no ki left, regain 4 ki. |
@@ -2136,7 +2190,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
| 3 | **Divine Health**: Immune to disease. |
| 4 | **Boon** (choose one) |
| 5 | **Extra Attack**: Attack twice when you take the Attack action.<br>**Cantrips**: Double dice. Roll twice the damage dice the site shows for a damaging cantrip (Eldritch Blast becomes two beams, each with its own attack roll). |
| 6 | **Aura of Protection**: You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious. |
| 6 | **Aura of Protection**: You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet). |
| 7 | No new feature |
| 8 | **Boon** (choose one) |
| 9 | No new feature |
@@ -2148,7 +2202,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
| 15 | No new feature |
| 16 | **Boon** (choose one) |
| 17 | No new feature |
| 18 | **Aura of Protection** (replaces): You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious.<br>**Aura of Courage** (replaces): You and allies within 30 ft can’t be frightened. |
| 18 | **Aura of Protection** (replaces): You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet).<br>**Aura of Courage** (replaces): You and allies within 30 ft can’t be frightened. |
| 19 | **Boon** (choose one) |
| 20 | No new feature |
@@ -2271,7 +2325,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
| 12 | **Boon** (choose one) |
| 13 | **Sneak Attack** (replaces): Once per turn, deal an extra 7d6 damage to a target you have advantage against, or that has one of your allies next to it. |
| 14 | **Blindsense**: If you can hear, you know where any hidden or invisible creature within 10 ft is. |
| 15 | **Sneak Attack** (replaces): Once per turn, deal an extra 8d6 damage to a target you have advantage against, or that has one of your allies next to it.<br>**Slippery Mind**: You are proficient in Wisdom saves (add the bonus by hand). |
| 15 | **Sneak Attack** (replaces): Once per turn, deal an extra 8d6 damage to a target you have advantage against, or that has one of your allies next to it.<br>**Slippery Mind**: You are proficient in Wisdom saves (counted on your sheet). |
| 16 | **Boon** (choose one) |
| 17 | **Sneak Attack** (replaces): Once per turn, deal an extra 9d6 damage to a target you have advantage against, or that has one of your allies next to it. |
| 18 | **Elusive**: No attack against you has advantage while you are not incapacitated. |
@@ -2347,7 +2401,7 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
| Subclass | Level | Written into Special Abilities |
|---|---|---|
| **Draconic Bloodline**<br>Dragon blood: tougher skin and one element at your call.<br>_Player’s Handbook_ | 3 | **Dragon Ancestor**: 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). |
| **Draconic Bloodline**<br>Dragon blood: tougher skin and one element at your call.<br>_Player’s Handbook_ | 3 | **Dragon Ancestor**: 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 | **Elemental Affinity**: 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 | **Dragon Wings**: Bonus action: sprout dragon wings and fly at your speed until you dismiss them. |
| — | 18 | **Draconic Presence**: Action, 5 sorcery points: for a minute, creatures within 60 ft make a Wisdom save or are charmed (awe) or frightened (fear) of you. |
+16 -1
View File
@@ -155,6 +155,16 @@ characters, and characters keep their current HP.
Both save when the DM clicks out of the box.
In a battle under way the Scene box is only there between rounds: while the
first in the turn order is on turn. For the rest of the round it is out of
the way, for the DM and the players alike. Before the battle begins and once
it is over it is always there.
**Folded panels.** While a battle is in progress, "Add to the scene",
Experience and Rewards on the DM's side column start folded to their title
(click the title to open one), so the fight is what fills the screen. They
open again when the battle ends, or before it begins.
## 5. Who is in the scene
The DM adds people from the "Add to the scene" panel.
@@ -240,7 +250,12 @@ Each has a card. The card's left edge is coloured by kind, and it shows:
- **NPCs:** role, attitude and description; for the DM also AC, attack,
damage and DM notes.
Details start open for the DM and closed for players. Weapons and spells
In a battle every card's details start closed, so the list stays short: on a
wide screen the spotlight shows whoever is on turn in full, and on a narrower
one the card of whoever is on turn opens its details by itself (and closes
them again when the turn passes). In a quest or free roam the details start
open for the DM and closed for players. "▸ Show details" / "▾ Hide details"
on a card overrides this for that card. Weapons and spells
above a character's level are left out, because the character cannot use
them.
+13
View File
@@ -66,6 +66,19 @@ passivePerception = 10 + perceptionSkillBonus
armorClass = 10 + dexModifier + armorBonus
```
`dexModifier` here is as much of it as the equipped armor lets through
(`armorDexterity`): all of it with none or light armor, at most +2 in medium
armor, none in heavy armor. Armor is an item marked with its kind
(`Item.armorType`); its own AC is a gear effect.
Without armor, a class feature can replace the `10 + dexModifier` part:
Unarmored Defense (barbarian: `10 + DEX + CON`; monk, also without a shield:
`10 + DEX + WIS`) and Draconic Resilience (`13 + DEX`). The best one is used,
and only when it beats `10 + dexModifier`. These, and the other class features
that change a sheet number (Fast Movement, Unarmored Movement, Aura of
Protection, Diamond Soul, Slippery Mind, Jack of All Trades), live in
`backend/src/game/classFeatures.js`; CHARACTERS.md section 8 lists them.
`armorBonus` is a flat number the player enters representing whatever armor/shield
they have equipped (this matches the workbook's "Armor Bonus (from armor worn)"
input cell). Do not try to model armor types as a lookup table unless you want to
+28 -15
View File
@@ -168,6 +168,19 @@ Armor, shields, rings, cloaks and the like are ordinary items that have
**effects**. An effect is a whole number, positive for a bonus and negative
for a penalty, on one of the sheet's numbers:
**Armor and shields are marked as such.** An item's pop-out has an **Armor**
menu: not armor (the default), light, medium, heavy, or shield. Its AC is
still an effect (+AC, its usual AC less 10); the mark says what kind of thing
it is. The sheet counts **all** of a character's Dexterity modifier toward AC
in light armor, **at most +2** in medium armor and **none** in heavy armor
([CHARACTERS.md](CHARACTERS.md), section 8), so plate is +8 and comes to AC
18 whoever wears it. Some class features depend on what is worn too: a barbarian's and a monk's Unarmored
Defense and a draconic sorcerer's scales stop while armor is equipped, a
monk's also stop behind a shield, and a barbarian's extra speed stops in
heavy armor ([CHARACTERS.md](CHARACTERS.md), section 8). The library's armor
and shield come marked; mark your own. A ring or cloak that gives +AC is not
armor: leave it unmarked.
| It can change | Examples |
|---|---|
| AC | Chain mail +6, a shield +2, a cursed cloak −1 |
@@ -244,8 +257,8 @@ and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
## 9. What is not modelled
- Armor rules beyond a flat bonus: armor does not replace 10 + DEX, cap the
DEX bonus or need a Strength score (set the AC effect to match)
- A Strength score needed for heavy armor, armor proficiency and Stealth
disadvantage: these are notes on the armor for the table to apply
- Effects on attack rolls, damage, spell save DC or proficiency bonus
- Effects that depend on something (only at night, only against undead), or
that grow with the item's level
@@ -265,10 +278,10 @@ and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
Everything "📚 From Library" offers. "While equipped" is what the item does
to the sheet of whoever has it on.
Armor is a flat bonus to AC on top of 10 + Dexterity: each suit's bonus is its
standard AC less 10. What a flat bonus cannot say (heavy armor ignoring
Dexterity, a Strength requirement, disadvantage on Stealth) is in the note,
for the DM to apply or to build into the numbers of their own copy.
Armor is a bonus to AC on top of 10 + Dexterity: each suit's bonus is its
standard AC less 10, and it is marked light, medium or heavy, which sets how
much Dexterity counts in it (section 5). What that cannot say (a Strength
requirement, disadvantage on Stealth) is in the note, for the DM to apply.
This list is written from the app's own data by `npm run docs:lists` (in `/backend`). Re-run it after changing the data; do not edit the list by hand.
@@ -285,20 +298,20 @@ This list is written from the app's own data by `npm run docs:lists` (in `/backe
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Hide Armor | +2 AC | Standard rules: counts at most +2 from Dexterity. | — | Thick furs and pelts. |
| Chain Shirt | +3 AC | Standard rules: counts at most +2 from Dexterity. | — | Interlocking rings worn between layers of clothing. |
| Scale Mail | +4 AC | Disadvantage on Stealth checks. Standard rules: counts at most +2 from Dexterity. | — | A coat and leggings of leather covered in overlapping metal scales. |
| Breastplate | +4 AC | Standard rules: counts at most +2 from Dexterity. | — | A fitted metal chest piece worn with supple leather. |
| Half Plate | +5 AC | Disadvantage on Stealth checks. Standard rules: counts at most +2 from Dexterity. | — | Shaped metal plates covering most of the body. |
| Hide Armor | +2 AC | Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | Thick furs and pelts. |
| Chain Shirt | +3 AC | Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | Interlocking rings worn between layers of clothing. |
| Scale Mail | +4 AC | Disadvantage on Stealth checks. Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | A coat and leggings of leather covered in overlapping metal scales. |
| Breastplate | +4 AC | Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | A fitted metal chest piece worn with supple leather. |
| Half Plate | +5 AC | Disadvantage on Stealth checks. Medium armor: at most +2 from Dexterity counts (worked out on the sheet). | — | Shaped metal plates covering most of the body. |
### Heavy Armor (4)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Ring Mail | +4 AC | Disadvantage on Stealth checks. Standard rules: adds no Dexterity. | — | Leather armor with heavy rings sewn into it. |
| Chain Mail | +6 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 13. | — | Interlocking metal rings over a layer of quilted fabric, with gauntlets. |
| Splint Armor | +7 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 15. | — | Narrow vertical strips of metal riveted to a leather backing. |
| Plate Armor | +8 AC, −10 Speed | Disadvantage on Stealth checks. Standard rules: adds no Dexterity, and needs Strength 15. | — | Shaped, interlocking metal plates covering the whole body. |
| Ring Mail | +4 AC | Disadvantage on Stealth checks. Heavy armor: Dexterity does not count (worked out on the sheet). | — | Leather armor with heavy rings sewn into it. |
| Chain Mail | +6 AC, −10 Speed | Disadvantage on Stealth checks. Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 13. | — | Interlocking metal rings over a layer of quilted fabric, with gauntlets. |
| Splint Armor | +7 AC, −10 Speed | Disadvantage on Stealth checks. Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 15. | — | Narrow vertical strips of metal riveted to a leather backing. |
| Plate Armor | +8 AC, −10 Speed | Disadvantage on Stealth checks. Heavy armor: Dexterity does not count (worked out on the sheet). Standard rules: needs Strength 15. | — | Shaped, interlocking metal plates covering the whole body. |
### Shields (2)
+2 -1
View File
@@ -184,7 +184,8 @@ Casting a spell of 1st level or higher this way spends a slot when the DM
applies it: one of the spell's own level, or, when those are gone, the lowest
slot above it that is left. **With no slot of its level or higher left the
spell cannot be declared**: its button is off and marked "no slot left".
**Cantrips never take a slot** and can be cast every turn. A spell above what
**Cantrips never take a slot** and can be cast every turn, with every slot
the caster has spent or with none to begin with. A spell above what
the character's level allows is not offered at all (section on levels), and a
weapon has to be equipped and within the character's level to attack with.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.29.0",
"version": "1.32.0",
"type": "module",
"scripts": {
"dev": "vite",
+2
View File
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tansta
import { apiClient } from './client';
import type {
Ability,
ArmorType,
ActionPatch,
ActionSource,
BuildOptions,
@@ -652,6 +653,7 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) {
saveAbility?: Ability | null;
halfOnSave?: boolean;
locationId?: string | null;
armorType?: ArmorType | null; // an item: what kind of armor it is, if any
}) =>
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
onSuccess: () => {
+13
View File
@@ -384,6 +384,7 @@ export interface CharacterSheet {
spellSlots: SpellSlots | null; // null for a character with no spell slots
resources: ClassResource[]; // the class counters, with how many of each are left
gear: ActiveGear;
features: FeatureInForce[]; // class features already counted in the numbers above
}
export interface Weapon {
@@ -461,6 +462,18 @@ export interface Item {
isStartingOption: boolean;
effects: GearEffect[]; // armor is an item with an AC effect
effectNote: string | null;
armorType: ArmorType | null; // set on armor and shields: what kind it is
}
// What kind of armor an item is. The sheet goes by what is equipped for the
// class features that depend on it (Unarmored Defense, a monk's speed...).
export type ArmorType = 'LIGHT' | 'MEDIUM' | 'HEAVY' | 'SHIELD';
// A class feature counted in the sheet's numbers, in a line.
export interface FeatureInForce {
name: string;
source: string; // the class or subclass that gives it
text: string;
}
export interface InventoryItem {
+1 -1
View File
@@ -16,7 +16,7 @@ const STAT_HELP: Record<string, string> = {
SKILL_BONUS: 'Skill Bonus — added when you roll to use a skill. The governing ability modifier, plus your proficiency bonus if you are proficient (doubled if you have expertise).',
EXPERTISE: 'Expertise — doubles your proficiency bonus for this skill, on top of being proficient.',
ABILITY_MODIFIER: 'Ability Modifier — the number added to rolls that use this ability. floor((score − 10) / 2).',
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item.',
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item, and medium or heavy armor holds back how much Dexterity counts. Unarmored Defense (barbarian, monk) and a draconic sorcerer’s scales are worked out for you: do not add them here.',
SPEED: 'Speed — how far you can move on your turn, in feet. Set by your species, unless overridden.',
SPELL_SAVE_DC: 'Spell Save DC — the difficulty a target must beat to resist your spell. 8 + proficiency bonus + your spellcasting ability modifier.',
SPELL_ATTACK_BONUS: 'Spell Attack Bonus — added when you make a spell attack roll. Proficiency bonus + your spellcasting ability modifier.',
+15 -2
View File
@@ -1,4 +1,4 @@
import { Suspense } from 'react';
import { Suspense, useEffect, useRef } from 'react';
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
import { useAuth } from '../auth/useAuth';
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
@@ -15,6 +15,19 @@ export function Layout() {
useRememberCampaign(onCampaign?.params.campaignId);
const campaignId = useLastCampaignId();
// How tall the top bar really is, as --topbar-h, so that what sticks under
// it (the turn order in a battle) sits right below it on any screen.
const topbar = useRef<HTMLElement>(null);
useEffect(() => {
const bar = topbar.current;
if (!bar) return;
const measure = () => document.documentElement.style.setProperty('--topbar-h', `${bar.offsetHeight}px`);
measure();
const observer = new ResizeObserver(measure);
observer.observe(bar);
return () => observer.disconnect();
}, []);
// The same destinations twice: along the top bar on a wide screen, and as a
// bar along the bottom, in reach of a thumb, on a phone.
const destinations = [
@@ -39,7 +52,7 @@ export function Layout() {
return (
<div className="app-shell">
<header className="topbar">
<header className="topbar" ref={topbar}>
<Link to="/campaigns" className="topbar-brand">
<span className="die" aria-hidden>
⚄
@@ -9,7 +9,7 @@ import { extractErrorMessage, fmtMod, spellLevelLabel, spellRequirementLabel } f
import { abilityLine, rateLabel, type EntryKind, type EntryRef } from '../../lib/campaignEntries';
import { Field, MonsterForm, ScalingSelect, SpellDamageSelect, SpellLandingSelect, StartLevelSelect } from './parts';
import { landingValue } from '../../lib/campaignEntries';
import type { GearEffect, Item, Monster, Npc, Spell, Weapon } from '../../api/types';
import type { GearEffect, Item, Monster, Npc, Spell, Weapon, ArmorType } from '../../api/types';
const KIND_NOUNS: Record<EntryKind, string> = { weapon: 'weapon', item: 'item', spell: 'spell', monster: 'monster', npc: 'NPC' };
const ATTACK_ABILITY_LABELS: Record<Weapon['attackAbility'], string> = { STR: 'STR', DEX: 'DEX', FINESSE: 'Finesse (the better of STR and DEX)' };
@@ -90,6 +90,29 @@ function GearDetail({ campaignId, pool, entry, added, onClose }: { added: ReactN
Starting option: a player may pick it while building a character
</label>
{!weapon && 'armorType' in entry && (
<div className="mt-md">
<Field label="Armor">
{(id) => (
<select id={id} value={entry.armorType ?? ''} disabled={patch.isPending} onChange={(e) => patch.mutate({ id: entry.id, armorType: (e.target.value || null) as ArmorType | null }, { onError })}>
<option value="">Not armor</option>
<option value="LIGHT">Light armor</option>
<option value="MEDIUM">Medium armor</option>
<option value="HEAVY">Heavy armor</option>
<option value="SHIELD">Shield</option>
</select>
)}
</Field>
<p className="combatant-sub mt-sm">
Its AC is still set below, as +AC (its usual AC less 10). This says what kind it is. The sheet counts all of a character’s
Dexterity toward AC in light armor, at most +2 in medium armor and none in heavy armor. It also decides the class features
that depend on what is worn: a barbarian’s and a
monk’s Unarmored Defense and a draconic sorcerer’s scales stop while armor is equipped, a monk’s also behind a shield, and a
barbarian’s extra speed stops in heavy armor.
</p>
</div>
)}
<div className="section-label mt-md">While equipped</div>
<p className="combatant-sub" style={{ marginTop: 0 }}>
These count for a character only while it is equipped and they are high enough level to use it. Armor is an item with
@@ -259,6 +259,7 @@ export function ItemsSection({ campaignId, entries, onOpen, formRequested, onFor
isStartingOption: fd.get('isStartingOption') === 'on',
effects: [],
effectNote: null,
armorType: null,
});
close();
onOpen({ kind: 'item', id: item.id, isNew: true });
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useState } from 'react';
// Whether a CSS media query matches right now, kept up to date as the window
// is resized: useMediaQuery('(min-width: 960px)').
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const list = window.matchMedia(query);
const onChange = () => setMatches(list.matches);
onChange();
list.addEventListener('change', onChange);
return () => list.removeEventListener('change', onChange);
}, [query]);
return matches;
}
+28 -28
View File
@@ -25,6 +25,8 @@
--radius: 8px;
--font-display: 'Cinzel', Georgia, serif;
--font-body: 'Crimson Pro', Georgia, 'Times New Roman', serif;
/* The top bar's height, measured by Layout.tsx; this is only until then. */
--topbar-h: 3.6rem;
}
@media (prefers-color-scheme: light) {
@@ -262,7 +264,12 @@ textarea {
color: var(--text);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
/* A label longer than the room there is wraps inside the button rather
than running out of it (or under the button beside it). */
max-width: 100%;
line-height: 1.25;
text-align: center;
overflow-wrap: break-word;
}
.btn:hover:not(:disabled) {
border-color: var(--accent);
@@ -883,7 +890,7 @@ input[type='radio'] {
/* Who acts when, as one line of names: this round, a flag, the next round. */
.initiative-track {
position: sticky;
top: 3.6rem;
top: var(--topbar-h);
z-index: 25;
display: flex;
align-items: center;
@@ -2176,16 +2183,26 @@ textarea {
.fab-dice:hover {
transform: none;
}
.dice-incoming {
left: 0.85rem;
bottom: calc(var(--bottom-nav-height) + 0.6rem);
width: calc(100vw - 1.7rem);
}
.dice-panel {
left: 0.85rem;
bottom: calc(var(--bottom-nav-height) + 0.6rem);
width: calc(100vw - 1.7rem);
max-height: calc(100dvh - var(--bottom-nav-height) - 5rem);
}
/* Log out has a set width on a phone, so quick stats can sit just left of it. */
.topbar-user .btn {
width: 5.2rem;
padding: 0.45rem 0.3rem;
}
.fab-quickstats {
top: 0.3rem;
top: calc((var(--topbar-h) - 40px) / 2);
bottom: auto;
right: 6.4rem;
right: calc(0.85rem + 5.2rem + 0.5rem);
z-index: 31;
width: 40px;
height: 40px;
@@ -2194,7 +2211,7 @@ textarea {
}
/* The brand makes room for it. */
.app-shell:has(.fab-quickstats) .topbar-brand {
max-width: calc(100vw - 11rem);
max-width: calc(100vw - 0.85rem - 6.55rem - 40px - 1.25rem);
}
.drawer {
width: 100vw;
@@ -2237,7 +2254,6 @@ textarea {
min-width: min(100%, 8rem) !important;
}
.btn {
white-space: normal;
padding: 0.6rem 0.9rem;
}
.grid-cols-2 {
@@ -2289,11 +2305,6 @@ textarea {
.jump-link {
padding: 0.5rem 0.8rem;
}
.attack-bar {
top: 3.4rem;
max-height: 55dvh;
overflow-y: auto;
}
[data-jump] {
scroll-margin-top: 3.8rem;
}
@@ -2748,7 +2759,7 @@ button.slot-pip:disabled {
/* Everyone in the scene on one line that stays under the top bar. */
.turn-strip {
position: sticky;
top: 3.6rem;
top: var(--topbar-h);
z-index: 25;
display: flex;
gap: 0.4rem;
@@ -2813,20 +2824,17 @@ button.slot-pip:disabled {
.turn-chip.has-acted {
opacity: 0.55;
}
/* The attack bar sits under the strip, not behind it. */
.attack-bar {
top: 7.6rem;
}
/* On a wide screen the side column stays in view, scrolling on its own. */
@media (min-width: 960px) {
.encounter-side {
position: sticky;
top: 7.6rem;
max-height: calc(100vh - 8.4rem);
top: calc(var(--topbar-h) + 4rem);
max-height: calc(100vh - var(--topbar-h) - 4.8rem);
overflow-y: auto;
scrollbar-width: thin;
padding-right: 2px;
/* Room to scroll the last card up past the quick-stats button. */
padding-bottom: 5rem;
}
}
.spotlight {
@@ -2886,6 +2894,7 @@ button.slot-pip:disabled {
summary.card-title {
cursor: pointer;
list-style: none;
justify-content: flex-start;
}
summary.card-title::-webkit-details-marker {
display: none;
@@ -2902,15 +2911,6 @@ details:not([open]) > summary.card-title::after {
content: '▸';
}
@media (max-width: 900px) {
.turn-strip {
top: 2.9rem;
}
.attack-bar {
top: 6.6rem;
}
}
/* In one column the card on turn is already at the top of the list. */
@media (max-width: 959px) {
.spotlight,
+3 -1
View File
@@ -1589,6 +1589,8 @@ function BuildPreview({
for (const ability of all) if (!abilityList.some((a) => a.name === ability.name)) abilityList.push(ability);
}
const wisMod = abilityFinals.WIS !== undefined ? abilityModifier(abilityFinals.WIS) : null;
// Unarmored Defense, as the sheet will count it: a new character wears nothing yet.
const unarmoredDefense = Math.max(0, characterClass === 'BARBARIAN' ? (conMod ?? 0) : characterClass === 'MONK' ? (wisMod ?? 0) : 0);
return (
<div className="card">
@@ -1651,7 +1653,7 @@ function BuildPreview({
<span className="stat-label">
AC <InfoTip term="AC" />
</span>
<div className="stat-value">{dexMod !== null ? armorClass(dexMod, armorBonus) : '—'}</div>
<div className="stat-value">{dexMod !== null ? armorClass(dexMod, armorBonus) + unarmoredDefense : '—'}</div>
</div>
<div>
<span className="stat-label">
+21
View File
@@ -682,6 +682,27 @@ export function CharacterSheetPage() {
<p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p>
)}
{sheet.features.length > 0 && (
<div className="card mt-md">
<div className="card-title">From Class Features</div>
{sheet.features.map((feature) => (
<div key={`${feature.source}:${feature.name}:${feature.text}`} className="detail-row">
<span>
<strong>{feature.name}</strong>
<span className="combatant-sub" style={{ marginLeft: 6 }}>
{feature.source}
</span>
</span>
<span className="combatant-sub">{feature.text}</span>
</div>
))}
<p className="subtitle" style={{ margin: '0.4rem 0 0' }}>
Already counted in every number on this sheet: nothing to add by hand. What is worn decides some of them (armor and shields are
items marked as such); a speed set by the DM is used as it is.
</p>
</div>
)}
{(sheet.gear.effects.length > 0 || sheet.gear.notes.length > 0) && (
<div className="card mt-md">
<div className="card-title">From Equipped Gear</div>
+38 -15
View File
@@ -45,6 +45,7 @@ import { GearInForce } from '../components/CharacterPanel';
import { SheetNumbers } from '../components/SheetNumbers';
import { ConditionChip, ConditionPicker, ResourceTracker, StateBadges } from '../components/CharacterState';
import { useHotkeys } from '../hooks/useHotkeys';
import { useMediaQuery } from '../hooks/useMediaQuery';
import { Rollable } from '../components/Rollable';
import { GroupCheckModal } from '../components/GroupCheckModal';
import { withBonus } from '../lib/diceRoller';
@@ -496,6 +497,7 @@ function CombatantCard({
onTarget,
isPinned,
onPin,
detailsOpen,
onRemove,
onError,
}: {
@@ -519,17 +521,18 @@ function CombatantCard({
// Whether this combatant's stats are the ones kept in view, and the switch for it.
isPinned: boolean;
onPin: () => void;
// Whether the details start open (until the viewer opens or closes them).
detailsOpen: boolean;
onRemove: () => void;
onError: (err: unknown) => void;
}) {
const [hpDelta, setHpDelta] = useState('');
// How many rounds the next condition added lasts; empty for until removed.
const [rounds, setRounds] = useState('');
// Details start open for the DM (who runs the scene from this screen) and
// closed for players; null = "not toggled yet", so it follows isDM once the
// campaign role has loaded.
// null = "not toggled yet", so the details follow detailsOpen (which
// changes as the turn passes, and once the campaign role has loaded).
const [detailsToggled, setDetailsToggled] = useState<boolean | null>(null);
const showDetails = detailsToggled ?? isDM;
const showDetails = detailsToggled ?? detailsOpen;
const info = p.characterCombatInfo;
const monster = p.monster;
@@ -987,12 +990,14 @@ function ExperiencePanel({
difficulty,
xpAwarded,
partySize,
folded,
onError,
}: {
encounterId: string;
difficulty: EncounterDifficulty;
xpAwarded: number | null;
partySize: number;
folded: boolean; // starts closed, as it does while a battle is under way
onError: (err: unknown) => void;
}) {
const awardXp = useAwardXp(encounterId);
@@ -1001,11 +1006,11 @@ function ExperiencePanel({
const { thresholds, rating } = difficulty;
return (
<div className="card">
<div className="card-title">
<details className="card" open={!folded}>
<summary className="card-title">
<span>Experience</span>
{rating && <span className={`badge ${RATING_BADGES[rating]}`}>{RATING_LABELS[rating]}</span>}
</div>
</summary>
{difficulty.monsters === 0 ? (
<p className="combatant-sub" style={{ marginTop: 0 }}>
No monster here has a challenge rating, so there is nothing to judge the difficulty by. XP can still be awarded: type an amount.
@@ -1060,7 +1065,7 @@ function ExperiencePanel({
</button>
</div>
)}
</div>
</details>
);
}
@@ -1076,6 +1081,7 @@ function RewardsPanel({
characters,
weapons,
items,
folded,
onError,
}: {
encounter: Encounter;
@@ -1083,6 +1089,7 @@ function RewardsPanel({
characters: CharacterSummary[];
weapons: Weapon[];
items: Item[];
folded: boolean; // starts closed, as it does while a battle is under way
onError: (err: unknown) => void;
}) {
const addReward = useAddReward(encounter.id);
@@ -1130,8 +1137,8 @@ function RewardsPanel({
}
return (
<div className="card">
<div className="card-title">Rewards</div>
<details className="card" open={!folded}>
<summary className="card-title">Rewards{rewards.length > 0 && <span className="section-count">{rewards.length}</span>}</summary>
{isDM && rewards.length === 0 && (
<p className="combatant-sub" style={{ marginTop: 0 }}>
Line up coins, weapons and items here. Players only see a reward once you award it.
@@ -1284,7 +1291,7 @@ function RewardsPanel({
</div>
</div>
)}
</div>
</details>
);
}
@@ -1319,6 +1326,8 @@ export function EncounterPage() {
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
const navigate = useNavigate();
// Wide enough for the side column, and so for the spotlight in it.
const wide = useMediaQuery('(min-width: 960px)');
const [pickType, setPickType] = useState<'character' | 'monster' | 'npc'>('character');
const [pickId, setPickId] = useState('');
@@ -1454,6 +1463,17 @@ export function EncounterPage() {
);
const unrolled = participants.filter((p) => p.kind !== 'CHARACTER' && p.initiativeRoll == null).length;
// In a battle every card's details start closed, so the list stays short:
// the spotlight beside it shows whoever is on turn in full, and where there
// is no room for the spotlight (one column) their own card opens instead.
// Elsewhere the DM, who runs the scene from this screen, gets them open.
const detailsOpen = (p: EncounterParticipant) => (isBattle ? p.id === active?.id && !wide : isDM);
// A battle under way folds away what is for before and after it.
const inBattle = isBattle && encounter.status === 'ACTIVE';
// The scene is set before the fight and between rounds (while the first in
// the order is on turn), and stays out of the way during the round.
const showScene = !inBattle || active?.id === standing[0]?.id;
const card = (p: EncounterParticipant) => (
<CombatantCard
key={p.id}
@@ -1473,6 +1493,7 @@ export function EncounterPage() {
}
isPinned={p.id === pinnedId}
onPin={() => setPinnedId(p.id === pinnedId ? null : p.id)}
detailsOpen={detailsOpen(p)}
onRemove={() => removeParticipant.mutate(p.id, fail)}
onError={onError}
/>
@@ -1655,7 +1676,7 @@ export function EncounterPage() {
</div>
)}
{(isDM || encounter.description) && (
{(isDM || encounter.description) && showScene && (
<div className="card">
<div className="card-title">{isBattle ? 'The Scene' : isRoam ? 'Where the party is' : 'Quest'}</div>
{isDM ? (
@@ -1790,8 +1811,8 @@ export function EncounterPage() {
/>
)}
{isDM && (
<div className="card">
<div className="card-title">Add to the scene</div>
<details className="card" open={!inBattle}>
<summary className="card-title">Add to the scene</summary>
<div className="flex-row flex-wrap gap-sm">
<select
aria-label="What to add"
@@ -1855,7 +1876,7 @@ export function EncounterPage() {
Create {pickType === 'npc' ? 'NPCs' : 'monsters'} on the <Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link> first.
</p>
)}
</div>
</details>
)}
{isBattle && (encounter.recentActions ?? []).length > 0 && (
@@ -1895,6 +1916,7 @@ export function EncounterPage() {
difficulty={encounter.difficulty}
xpAwarded={encounter.xpAwarded ?? null}
partySize={participants.filter((p) => p.kind === 'CHARACTER' && p.character).length}
folded={inBattle}
onError={onError}
/>
)}
@@ -1905,6 +1927,7 @@ export function EncounterPage() {
characters={builtCharacters}
weapons={weapons ?? []}
items={items ?? []}
folded={inBattle}
onError={onError}
/>
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.29.0",
"version": "1.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.29.0",
"version": "1.32.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.29.0",
"version": "1.32.0",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.29.0",
"version": "1.32.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.29.0",
"version": "1.32.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [