Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bd301182b | ||
|
|
cbda10668b | ||
|
|
16b5ac05ca |
@@ -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,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';
|
||||
@@ -597,6 +597,10 @@ model Item {
|
||||
// item with an AC effect.
|
||||
effects Json @default("[]")
|
||||
effectNote String?
|
||||
// Set on armor and shields. Its AC is still an effect; this says what kind
|
||||
// it is, so the sheet knows when a character is unarmored (Unarmored
|
||||
// Defense, a monk's speed) or in heavy armor (a barbarian's Fast Movement).
|
||||
armorType ArmorType?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
|
||||
@@ -608,6 +612,13 @@ model Item {
|
||||
@@map("items")
|
||||
}
|
||||
|
||||
enum ArmorType {
|
||||
LIGHT
|
||||
MEDIUM
|
||||
HEAVY
|
||||
SHIELD
|
||||
}
|
||||
|
||||
// An Item from the campaign pool held by a character.
|
||||
model InventoryItem {
|
||||
id String @id @default(cuid())
|
||||
|
||||
@@ -187,8 +187,8 @@ const items = [
|
||||
{ name: 'Torches (bundle of 5)', description: 'Each burns for an hour: bright light for 20 feet, dim for 20 more.', isStartingOption: true },
|
||||
{ name: 'Healer’s Kit', description: 'Ten uses. Spend one to stabilise a creature at 0 hit points without a Medicine check.', isStartingOption: true },
|
||||
{ name: 'Thieves’ Tools', description: 'Picks, a small file and a mirror on a handle. Needed to pick a lock or disarm a trap.', isStartingOption: true },
|
||||
{ name: 'Shield', description: 'A wooden or steel shield carried on one arm.', isStartingOption: true, effects: [{ stat: 'AC', amount: 2 }], effectNote: 'Takes a hand: no two-handed weapon while it is up.' },
|
||||
{ name: 'Leather Armor', description: 'Boiled leather, light enough to sleep and sneak in.', isStartingOption: true, effects: [{ stat: 'AC', amount: 1 }] },
|
||||
{ name: 'Shield', armorType: 'SHIELD', description: 'A wooden or steel shield carried on one arm.', isStartingOption: true, effects: [{ stat: 'AC', amount: 2 }], effectNote: 'Takes a hand: no two-handed weapon while it is up.' },
|
||||
{ name: 'Leather Armor', armorType: 'LIGHT', description: 'Boiled leather, light enough to sleep and sneak in.', isStartingOption: true, effects: [{ stat: 'AC', amount: 1 }] },
|
||||
{ name: 'Spellcasting Focus', description: 'A wand, holy symbol, crystal or carved staff that your spells are cast through.', isStartingOption: true },
|
||||
{ name: 'Founding Fair Token', description: 'A stamped brass token from the Lantern Market. Good for one drink at any stall, or a small favour from whoever ran the game you won it at.', isStartingOption: true },
|
||||
|
||||
@@ -212,7 +212,7 @@ const items = [
|
||||
{ name: 'Seal-Stone Fragment', description: 'A shard of worked stone from the edge of the breach. Script runs along one face. It is warm, and holding it makes the bell script easier to look at.' },
|
||||
{ name: 'Seized House Documents', description: 'Ledgers and letters taken from the noble house after it was confronted. No value in coin; a great deal of value to anyone building a case.' },
|
||||
{ name: 'House Records', description: 'The fallen house’s private records: names, payments and meeting places for its contacts across Aldenfell.' },
|
||||
{ name: 'Masterwork Chain Mail', description: 'Close-linked mail from the Grey Dawn’s own armoury, fitted rather than issued.', minLevel: 5, effects: [{ stat: 'AC', amount: 7 }, { stat: 'SPEED', amount: -10 }], effectNote: 'Disadvantage on Stealth checks.' },
|
||||
{ name: 'Masterwork Chain Mail', armorType: 'HEAVY', description: 'Close-linked mail from the Grey Dawn’s own armoury, fitted rather than issued.', minLevel: 5, effects: [{ stat: 'AC', amount: 7 }, { stat: 'SPEED', amount: -10 }], effectNote: 'Disadvantage on Stealth checks.' },
|
||||
{ name: 'The Seal-Stone', description: 'The stone at the heart of the seal, whole again and quiet. It is heavier than it looks, and it is never quite cold.', minLevel: 7, effects: [{ stat: 'SAVE_WIS', amount: 2 }], effectNote: 'Grows warm near anything of the Hollow Below. Its other properties are the DM’s to decide: something tied to binding, warding or detecting.' },
|
||||
];
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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');
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// The class features that change a number on the sheet, worked out so nobody
|
||||
// has to add them by hand: Unarmored Defense, Draconic Resilience, Fast
|
||||
// Movement, Unarmored Movement, Aura of Protection, Diamond Soul, Slippery
|
||||
// Mind and Jack of All Trades. Everything else a class gives is a line of
|
||||
// text on the sheet (game/data/levelFeatures.js). Pure: no database.
|
||||
//
|
||||
// Each goes by the level in the class that gives it, so a multiclassed
|
||||
// character gets what each of their classes has earned (game/multiclass.js,
|
||||
// classLevels). Several depend on what is worn: armor and shields are items
|
||||
// marked with what kind they are (Item.armorType), and count while equipped.
|
||||
|
||||
const { SUBCLASS_LEVEL } = require('./data/subclasses');
|
||||
|
||||
// A monk's extra speed without armor or a shield, by monk level.
|
||||
function unarmoredMovement(monkLevel) {
|
||||
if (monkLevel < 2) return 0;
|
||||
if (monkLevel < 6) return 10;
|
||||
if (monkLevel < 10) return 15;
|
||||
if (monkLevel < 14) return 20;
|
||||
if (monkLevel < 18) return 25;
|
||||
return 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a character's class features do to the sheet's numbers.
|
||||
*
|
||||
* @param {Array<{characterClass: string, level: number, subclass: string|null}>} classes
|
||||
* @param {object} modifiers - ability modifiers by ability: { STR, DEX, CON, INT, WIS, CHA }
|
||||
* @param {number} profBonus
|
||||
* @param {{worn: 'LIGHT'|'MEDIUM'|'HEAVY'|null, shield: boolean}} armor - what is equipped
|
||||
* @returns {{
|
||||
* acBase: number|null, // what AC starts from in place of 10 + DEX, when that is better
|
||||
* speed: number, // added to walking speed
|
||||
* hp: number, // added to maximum HP
|
||||
* saveBonus: number, // added to every saving throw
|
||||
* saveProficiencies: string[],// saves the character is proficient in on top of their class's two
|
||||
* halfProficiency: number, // added to initiative and to skills they are not proficient in
|
||||
* inForce: Array<{name: string, source: string, text: string}>
|
||||
* }}
|
||||
*/
|
||||
function classFeatureEffects(classes, modifiers, profBonus, armor = { worn: null, shield: false }) {
|
||||
const levelIn = (characterClass) => {
|
||||
const entry = classes.find((c) => c.characterClass === characterClass);
|
||||
return entry ? entry.level : 0;
|
||||
};
|
||||
const unarmored = !armor.worn;
|
||||
const plain = 10 + modifiers.DEX;
|
||||
const inForce = [];
|
||||
const signed = (n) => (n >= 0 ? `+${n}` : `${n}`);
|
||||
|
||||
// Armor Class without armor: the best of the ways the character has of
|
||||
// working it out, and only when it beats 10 + DEX.
|
||||
const acWays = [];
|
||||
if (levelIn('BARBARIAN') >= 1 && unarmored) {
|
||||
acWays.push({ ac: plain + modifiers.CON, name: 'Unarmored Defense', source: 'Barbarian', text: `AC is 10 + Dexterity + Constitution (${signed(modifiers.CON)}) without armor; a shield still adds.` });
|
||||
}
|
||||
if (levelIn('MONK') >= 1 && unarmored && !armor.shield) {
|
||||
acWays.push({ ac: plain + modifiers.WIS, name: 'Unarmored Defense', source: 'Monk', text: `AC is 10 + Dexterity + Wisdom (${signed(modifiers.WIS)}) without armor or a shield.` });
|
||||
}
|
||||
const sorcerer = classes.find((c) => c.characterClass === 'SORCERER');
|
||||
const draconic = sorcerer && sorcerer.subclass === 'DRACONIC' && sorcerer.level >= SUBCLASS_LEVEL.SORCERER ? sorcerer : null;
|
||||
if (draconic && unarmored) {
|
||||
acWays.push({ ac: 13 + modifiers.DEX, name: 'Draconic Resilience', source: 'Draconic Bloodline', text: 'AC is 13 + Dexterity without armor.' });
|
||||
}
|
||||
const bestAc = acWays.sort((a, b) => b.ac - a.ac)[0];
|
||||
const acBase = bestAc && bestAc.ac > plain ? bestAc.ac : null;
|
||||
if (acBase !== null) inForce.push({ name: bestAc.name, source: bestAc.source, text: bestAc.text });
|
||||
|
||||
let hp = 0;
|
||||
if (draconic) {
|
||||
hp += draconic.level;
|
||||
inForce.push({ name: 'Draconic Resilience', source: 'Draconic Bloodline', text: `+1 maximum HP for each sorcerer level (+${draconic.level}).` });
|
||||
}
|
||||
|
||||
let speed = 0;
|
||||
if (levelIn('BARBARIAN') >= 5 && armor.worn !== 'HEAVY') {
|
||||
speed += 10;
|
||||
inForce.push({ name: 'Fast Movement', source: 'Barbarian', text: '+10 ft speed when not in heavy armor.' });
|
||||
}
|
||||
const monkSpeed = unarmored && !armor.shield ? unarmoredMovement(levelIn('MONK')) : 0;
|
||||
if (monkSpeed > 0) {
|
||||
speed += monkSpeed;
|
||||
inForce.push({ name: 'Unarmored Movement', source: 'Monk', text: `+${monkSpeed} ft speed without armor or a shield.` });
|
||||
}
|
||||
|
||||
let saveBonus = 0;
|
||||
if (levelIn('PALADIN') >= 6) {
|
||||
saveBonus = Math.max(1, modifiers.CHA);
|
||||
inForce.push({ name: 'Aura of Protection', source: 'Paladin', text: `+${saveBonus} (Charisma) to every saving throw while conscious. Allies in the aura add it by hand.` });
|
||||
}
|
||||
|
||||
const saveProficiencies = [];
|
||||
if (levelIn('MONK') >= 14) {
|
||||
saveProficiencies.push('STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA');
|
||||
inForce.push({ name: 'Diamond Soul', source: 'Monk', text: 'Proficient in every saving throw.' });
|
||||
}
|
||||
if (levelIn('ROGUE') >= 15) {
|
||||
saveProficiencies.push('WIS');
|
||||
inForce.push({ name: 'Slippery Mind', source: 'Rogue', text: 'Proficient in Wisdom saving throws.' });
|
||||
}
|
||||
|
||||
let halfProficiency = 0;
|
||||
if (levelIn('BARD') >= 2) {
|
||||
halfProficiency = Math.floor(profBonus / 2);
|
||||
inForce.push({ name: 'Jack of All Trades', source: 'Bard', text: `+${halfProficiency} (half proficiency) to initiative and to every skill you are not proficient in.` });
|
||||
}
|
||||
|
||||
return { acBase, speed, hp, saveBonus, saveProficiencies: [...new Set(saveProficiencies)], halfProficiency, inForce };
|
||||
}
|
||||
|
||||
module.exports = { classFeatureEffects, unarmoredMovement };
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
});
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -57,7 +57,7 @@ const LEVEL_FEATURES = {
|
||||
],
|
||||
3: [rage('Three times', 2)],
|
||||
4: [BOON],
|
||||
5: [EXTRA_ATTACK, { name: 'Fast Movement', text: '+10 ft speed when not in heavy armor (ask your DM to raise your speed).' }],
|
||||
5: [EXTRA_ATTACK, { name: 'Fast Movement', text: '+10 ft speed when not in heavy armor (counted on your sheet).' }],
|
||||
6: [rage('Four times', 2)],
|
||||
7: [{ name: 'Feral Instinct', text: 'Advantage on initiative; you can act normally in a surprise round if you rage first.' }],
|
||||
8: [BOON],
|
||||
@@ -77,7 +77,7 @@ const LEVEL_FEATURES = {
|
||||
},
|
||||
BARD: {
|
||||
2: [
|
||||
{ name: 'Jack of All Trades', text: 'Add half your proficiency bonus (round down) to any check you are not proficient in.' },
|
||||
{ name: 'Jack of All Trades', text: 'Add half your proficiency bonus (round down) to any check you are not proficient in (counted on your sheet for skills and initiative).' },
|
||||
{ name: 'Song of Rest', text: 'During a short rest, allies who spend Hit Dice heal an extra 1d6.' },
|
||||
],
|
||||
3: [
|
||||
@@ -169,7 +169,7 @@ const LEVEL_FEATURES = {
|
||||
{ name: 'Flurry of Blows', text: '1 ki: two unarmed strikes as a bonus action.' },
|
||||
{ name: 'Patient Defense', text: '1 ki: Dodge as a bonus action.' },
|
||||
{ name: 'Step of the Wind', text: '1 ki: Dash or Disengage as a bonus action, jump distance doubled.' },
|
||||
{ name: 'Unarmored Movement', text: '+10 ft speed without armor (ask your DM to raise your speed).' },
|
||||
{ name: 'Unarmored Movement', text: '+10 ft speed without armor (counted on your sheet).' },
|
||||
],
|
||||
3: [
|
||||
{ name: 'Deflect Missiles', text: 'Reaction: reduce ranged weapon damage by 1d10 + Dexterity modifier + your level.' },
|
||||
@@ -182,28 +182,28 @@ const LEVEL_FEATURES = {
|
||||
],
|
||||
6: [
|
||||
{ name: 'Ki-Empowered Strikes', text: 'Your unarmed strikes count as magical.' },
|
||||
{ name: 'Unarmored Movement', text: '+15 ft speed without armor (ask your DM to raise your speed).', upgrade: true },
|
||||
{ name: 'Unarmored Movement', text: '+15 ft speed without armor (counted on your sheet).', upgrade: true },
|
||||
],
|
||||
7: [EVASION, { name: 'Stillness of Mind', text: 'Action: end charm or fear on yourself.' }],
|
||||
8: [BOON],
|
||||
9: [{ name: 'Unarmored Movement', text: '+15 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true }],
|
||||
9: [{ name: 'Unarmored Movement', text: '+15 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true }],
|
||||
10: [
|
||||
{ name: 'Purity of Body', text: 'Immune to poison and disease.' },
|
||||
{ name: 'Unarmored Movement', text: '+20 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
|
||||
{ name: 'Unarmored Movement', text: '+20 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true },
|
||||
],
|
||||
11: [{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d8 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.', upgrade: true }],
|
||||
12: [BOON],
|
||||
13: [{ name: 'Tongue of the Sun and Moon', text: 'You understand every spoken language, and anyone who speaks one understands you.' }],
|
||||
14: [
|
||||
{ name: 'Diamond Soul', text: 'You are proficient in every saving throw (add the bonus by hand); 1 ki rerolls a failed save.' },
|
||||
{ name: 'Unarmored Movement', text: '+25 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
|
||||
{ name: 'Diamond Soul', text: 'You are proficient in every saving throw (counted on your sheet); 1 ki rerolls a failed save.' },
|
||||
{ name: 'Unarmored Movement', text: '+25 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true },
|
||||
],
|
||||
15: [{ name: 'Timeless Body', text: 'You need no food or water and suffer none of the frailty of old age.' }],
|
||||
16: [BOON],
|
||||
17: [{ name: 'Martial Arts', text: 'Your unarmed strikes deal 1d10 damage and can use Dexterity. After attacking, make one unarmed strike as a bonus action.', upgrade: true }],
|
||||
18: [
|
||||
{ name: 'Empty Body', text: '4 ki: invisible for one minute, and all damage but force against you is halved. 8 ki: cast Astral Projection on yourself.' },
|
||||
{ name: 'Unarmored Movement', text: '+30 ft speed without armor (ask your DM to raise your speed). You can run along walls and across water during your move.', upgrade: true },
|
||||
{ name: 'Unarmored Movement', text: '+30 ft speed without armor (counted on your sheet). You can run along walls and across water during your move.', upgrade: true },
|
||||
],
|
||||
19: [BOON],
|
||||
20: [{ name: 'Perfect Self', text: 'When you roll initiative with no ki left, regain 4 ki.' }],
|
||||
@@ -219,7 +219,7 @@ const LEVEL_FEATURES = {
|
||||
],
|
||||
4: [BOON],
|
||||
5: [EXTRA_ATTACK],
|
||||
6: [{ name: 'Aura of Protection', text: 'You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious.' }],
|
||||
6: [{ name: 'Aura of Protection', text: 'You and allies within 10 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet).' }],
|
||||
8: [BOON],
|
||||
10: [{ name: 'Aura of Courage', text: 'You and allies within 10 ft can’t be frightened.' }],
|
||||
11: [{ name: 'Improved Divine Smite', text: 'Every melee weapon hit deals an extra 1d8 radiant damage.' }],
|
||||
@@ -227,7 +227,7 @@ const LEVEL_FEATURES = {
|
||||
14: [{ name: 'Cleansing Touch', text: 'A few times per day (your Charisma modifier, at least once), action: end one spell on yourself or a willing creature you touch.' }],
|
||||
16: [BOON],
|
||||
18: [
|
||||
{ name: 'Aura of Protection', text: 'You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious.', upgrade: true },
|
||||
{ name: 'Aura of Protection', text: 'You and allies within 30 ft add your Charisma modifier to all saving throws while you are conscious (yours is counted on your sheet).', upgrade: true },
|
||||
{ name: 'Aura of Courage', text: 'You and allies within 30 ft can’t be frightened.', upgrade: true },
|
||||
],
|
||||
19: [BOON],
|
||||
@@ -275,7 +275,7 @@ const LEVEL_FEATURES = {
|
||||
12: [BOON],
|
||||
13: [sneakAttack('7d6')],
|
||||
14: [{ name: 'Blindsense', text: 'If you can hear, you know where any hidden or invisible creature within 10 ft is.' }],
|
||||
15: [sneakAttack('8d6'), { name: 'Slippery Mind', text: 'You are proficient in Wisdom saves (add the bonus by hand).' }],
|
||||
15: [sneakAttack('8d6'), { name: 'Slippery Mind', text: 'You are proficient in Wisdom saves (counted on your sheet).' }],
|
||||
16: [BOON],
|
||||
17: [sneakAttack('9d6')],
|
||||
18: [{ name: 'Elusive', text: 'No attack against you has advantage while you are not incapacitated.' }],
|
||||
|
||||
@@ -9,7 +9,7 @@ const SORCERER = [
|
||||
edition: '2014',
|
||||
summary: 'Dragon blood: tougher skin and one element at your call.',
|
||||
features: {
|
||||
3: [{ name: 'Dragon Ancestor', text: 'Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier (ask your DM about Extra AC).' }],
|
||||
3: [{ name: 'Dragon Ancestor', text: 'Draconic Bloodline. Choose a kind of dragon with your DM; its element is yours (red: fire, white: cold, blue: lightning, green: poison, black: acid). Thin scales make you hardier: without armor your AC is 13 + Dexterity, and you have 1 more maximum HP for each sorcerer level (both counted on your sheet).' }],
|
||||
6: [{ name: 'Elemental Affinity', text: 'Draconic Bloodline. Add your Charisma modifier to one damage roll of your dragon’s element; spend 1 sorcery point to resist that element for 1 hour.' }],
|
||||
14: [{ name: 'Dragon Wings', text: 'Bonus action: sprout dragon wings and fly at your speed until you dismiss them.' }],
|
||||
18: [{ name: 'Draconic Presence', text: 'Action, 5 sorcery points: for a minute, creatures within 60 ft make a Wisdom save or are charmed (awe) or frightened (fear) of you.' }],
|
||||
|
||||
@@ -2638,7 +2638,7 @@ describe('gear effects', () => {
|
||||
const quick = async (auth = playerAuth) => (await request(app).get(`${ch}/quick-stats`).set(auth)).body.quickStats;
|
||||
|
||||
const before = (await sheet()).sheet;
|
||||
expect(before.gear).toEqual({ effects: [], notes: [] });
|
||||
expect(before.gear).toEqual({ effects: [], notes: [], armor: { worn: null, 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -706,6 +706,8 @@ const itemSchema = z.object({
|
||||
damageScaling: z.number().int().min(1).max(MAX_DAMAGE_SCALING).optional(),
|
||||
minLevel: z.number().int().min(1).max(MAX_GEAR_LEVEL).optional(),
|
||||
isStartingOption: z.boolean().optional(),
|
||||
// Armor and shields: what kind it is. Null for anything else.
|
||||
armorType: z.enum(['LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD']).optional().nullable(),
|
||||
...gearEffects,
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ const POOLS = {
|
||||
},
|
||||
items: {
|
||||
library: ITEM_LIBRARY,
|
||||
row: z.strictObject({ name: text(120), description: optionalText(2000), damageDice: dice, ...gear }),
|
||||
row: z.strictObject({ name: text(120), description: optionalText(2000), damageDice: dice, armorType: z.enum(['LIGHT', 'MEDIUM', 'HEAVY', 'SHIELD']).nullish(), ...gear }),
|
||||
},
|
||||
spells: {
|
||||
library: SPELL_LIBRARY,
|
||||
|
||||
+74
-20
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.29.0",
|
||||
"version": "1.32.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user