Compare commits

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:24:28 +02:00
20 changed files with 254 additions and 97 deletions
+8
View File
@@ -104,6 +104,14 @@ including a character sheet or an encounter) and **Account**.
Aura of Protection, Diamond Soul, Slippery Mind and Jack of All Trades are
counted on the sheet, by what is equipped (armor and shields are marked
items) and by level in the class that gives them.
- **Armor holds Dexterity back** as in the rules: all of it counts toward AC
in light armor, at most +2 in medium, none in heavy.
- **Battles that keep to the fight.** While a battle is under way the scene
description shows only between rounds, cards' details start closed (the
spotlight, or on a phone the card on turn, has them), and the DM's setup,
Experience and Rewards panels fold to their titles. Button labels wrap
inside their buttons, and on a phone quick stats no longer sits on Log out
nor incoming rolls on the bottom bar.
- **Stricter battles.** A spell with no slot of its level or higher left
cannot be cast (cantrips never need one), and a player changes the weapon
in hand only as the battle begins or on their own turn after attacking.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.30.0",
"version": "1.32.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
File diff suppressed because one or more lines are too long
+22 -2
View File
@@ -73,6 +73,15 @@ function armorClass(dexMod, armorBonus = 0) {
return 10 + dexMod + armorBonus;
}
// How much of a Dexterity modifier counts toward AC in the armor worn: all
// of it in light armor or none, at most +2 in medium armor, and none of it
// (a penalty included) in heavy armor.
function armorDexterity(dexMod, worn = null) {
if (worn === 'HEAVY') return 0;
if (worn === 'MEDIUM') return Math.min(dexMod, 2);
return dexMod;
}
function initiative(dexMod) {
return dexMod;
}
@@ -290,7 +299,9 @@ function activeGear(character) {
// The armor on their back (the heaviest, should there be two) and whether
// a shield is on their arm: class features turn on what is worn.
const kinds = held.map((h) => h.entry.armorType).filter(Boolean);
const armor = { worn: ['HEAVY', 'MEDIUM', 'LIGHT'].find((kind) => kinds.includes(kind)) || null, shield: kinds.includes('SHIELD') };
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) {
@@ -358,6 +369,14 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
// 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) {
@@ -398,7 +417,7 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
abilities,
proficiencyBonus: profBonus,
// Unarmored Defense and the like replace the 10 + DEX that AC starts from.
armorClass: (features.acBase !== null ? features.acBase + armorBonus : armorClass(abilities.DEX.modifier, armorBonus)) + fromGear('AC'),
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.
@@ -565,6 +584,7 @@ module.exports = {
passivePerception,
passiveScore,
armorClass,
armorDexterity,
initiative,
hpMax,
spellSaveDC,
+34
View File
@@ -132,3 +132,37 @@ describe('multiclassed', () => {
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);
});
});
+15 -14
View File
@@ -3,11 +3,12 @@
// 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' };
@@ -39,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.'),
+4 -3
View File
@@ -2638,7 +2638,7 @@ describe('gear effects', () => {
const quick = async (auth = playerAuth) => (await request(app).get(`${ch}/quick-stats`).set(auth)).body.quickStats;
const before = (await sheet()).sheet;
expect(before.gear).toEqual({ effects: [], notes: [], armor: { worn: null, shield: false } });
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 = {
@@ -4234,11 +4234,12 @@ describe('class features on the sheet', () => {
expect(s).toMatchObject({ armorClass: 14, speed: 40 });
expect(s.features.map((f) => f.name)).toEqual(['Fast Movement']);
// The DM says what kind of thing an item is. Heavy: the extra speed goes. Not armor: all of it is back, on top of the +2.
// 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: 14, speed: 30 });
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 });
+18 -4
View File
@@ -816,7 +816,7 @@ themselves.
| Stat | Formula |
|---|---|
| Armor Class | 10 + DEX modifier (or what a class feature gives without armor, below) + equipped gear + the DM's extra AC |
| 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 |
@@ -834,6 +834,21 @@ 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
@@ -1381,9 +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: medium armor does not cap Dexterity
and heavy armor does not ignore it. (Being unarmored is modelled: see
"Class features that are counted" in section 8)
- 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
+16 -1
View File
@@ -155,6 +155,16 @@ characters, and characters keep their current HP.
Both save when the DM clicks out of the box.
In a battle under way the Scene box is only there between rounds: while the
first in the turn order is on turn. For the rest of the round it is out of
the way, for the DM and the players alike. Before the battle begins and once
it is over it is always there.
**Folded panels.** While a battle is in progress, "Add to the scene",
Experience and Rewards on the DM's side column start folded to their title
(click the title to open one), so the fight is what fills the screen. They
open again when the battle ends, or before it begins.
## 5. Who is in the scene
The DM adds people from the "Add to the scene" panel.
@@ -240,7 +250,12 @@ Each has a card. The card's left edge is coloured by kind, and it shows:
- **NPCs:** role, attitude and description; for the DM also AC, attack,
damage and DM notes.
Details start open for the DM and closed for players. Weapons and spells
In a battle every card's details start closed, so the list stays short: on a
wide screen the spotlight shows whoever is on turn in full, and on a narrower
one the card of whoever is on turn opens its details by itself (and closes
them again when the turn passes). In a quest or free roam the details start
open for the DM and closed for players. "▸ Show details" / "▾ Hide details"
on a card overrides this for that card. Weapons and spells
above a character's level are left out, because the character cannot use
them.
+5
View File
@@ -66,6 +66,11 @@ 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,
+20 -17
View File
@@ -170,8 +170,11 @@ 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); the mark says what kind of thing it is, because some
class features depend on what is worn: a barbarian's and a monk's Unarmored
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
@@ -254,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
@@ -275,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.
@@ -295,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)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.30.0",
"version": "1.32.0",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -16,7 +16,7 @@ const STAT_HELP: Record<string, string> = {
SKILL_BONUS: 'Skill Bonus — added when you roll to use a skill. The governing ability modifier, plus your proficiency bonus if you are proficient (doubled if you have expertise).',
EXPERTISE: 'Expertise — doubles your proficiency bonus for this skill, on top of being proficient.',
ABILITY_MODIFIER: 'Ability Modifier — the number added to rolls that use this ability. floor((score − 10) / 2).',
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item. Unarmored Defense (barbarian, monk) and a draconic sorcerer’s scales are worked out for you: do not add them here.',
ARMOR_BONUS: 'Extra AC — a number the DM sets, added to AC on top of 10 + Dex modifier and whatever equipped gear gives. Armor itself is an equipped item, and medium or heavy armor holds back how much Dexterity counts. Unarmored Defense (barbarian, monk) and a draconic sorcerer’s scales are worked out for you: do not add them here.',
SPEED: 'Speed — how far you can move on your turn, in feet. Set by your species, unless overridden.',
SPELL_SAVE_DC: 'Spell Save DC — the difficulty a target must beat to resist your spell. 8 + proficiency bonus + your spellcasting ability modifier.',
SPELL_ATTACK_BONUS: 'Spell Attack Bonus — added when you make a spell attack roll. Proficiency bonus + your spellcasting ability modifier.',
+15 -2
View File
@@ -1,4 +1,4 @@
import { Suspense } from 'react';
import { Suspense, useEffect, useRef } from 'react';
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
import { useAuth } from '../auth/useAuth';
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
@@ -15,6 +15,19 @@ export function Layout() {
useRememberCampaign(onCampaign?.params.campaignId);
const campaignId = useLastCampaignId();
// How tall the top bar really is, as --topbar-h, so that what sticks under
// it (the turn order in a battle) sits right below it on any screen.
const topbar = useRef<HTMLElement>(null);
useEffect(() => {
const bar = topbar.current;
if (!bar) return;
const measure = () => document.documentElement.style.setProperty('--topbar-h', `${bar.offsetHeight}px`);
measure();
const observer = new ResizeObserver(measure);
observer.observe(bar);
return () => observer.disconnect();
}, []);
// The same destinations twice: along the top bar on a wide screen, and as a
// bar along the bottom, in reach of a thumb, on a phone.
const destinations = [
@@ -39,7 +52,7 @@ export function Layout() {
return (
<div className="app-shell">
<header className="topbar">
<header className="topbar" ref={topbar}>
<Link to="/campaigns" className="topbar-brand">
<span className="die" aria-hidden>
⚄
@@ -104,7 +104,9 @@ function GearDetail({ campaignId, pool, entry, added, onClose }: { added: ReactN
)}
</Field>
<p className="combatant-sub mt-sm">
Its AC is still set below, as +AC. This says what kind it is, for the class features that depend on what is worn: a barbarian’s and a
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>
+15
View File
@@ -0,0 +1,15 @@
import { useEffect, useState } from 'react';
// Whether a CSS media query matches right now, kept up to date as the window
// is resized: useMediaQuery('(min-width: 960px)').
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const list = window.matchMedia(query);
const onChange = () => setMatches(list.matches);
onChange();
list.addEventListener('change', onChange);
return () => list.removeEventListener('change', onChange);
}, [query]);
return matches;
}
+28 -28
View File
@@ -25,6 +25,8 @@
--radius: 8px;
--font-display: 'Cinzel', Georgia, serif;
--font-body: 'Crimson Pro', Georgia, 'Times New Roman', serif;
/* The top bar's height, measured by Layout.tsx; this is only until then. */
--topbar-h: 3.6rem;
}
@media (prefers-color-scheme: light) {
@@ -262,7 +264,12 @@ textarea {
color: var(--text);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
/* A label longer than the room there is wraps inside the button rather
than running out of it (or under the button beside it). */
max-width: 100%;
line-height: 1.25;
text-align: center;
overflow-wrap: break-word;
}
.btn:hover:not(:disabled) {
border-color: var(--accent);
@@ -883,7 +890,7 @@ input[type='radio'] {
/* Who acts when, as one line of names: this round, a flag, the next round. */
.initiative-track {
position: sticky;
top: 3.6rem;
top: var(--topbar-h);
z-index: 25;
display: flex;
align-items: center;
@@ -2176,16 +2183,26 @@ textarea {
.fab-dice:hover {
transform: none;
}
.dice-incoming {
left: 0.85rem;
bottom: calc(var(--bottom-nav-height) + 0.6rem);
width: calc(100vw - 1.7rem);
}
.dice-panel {
left: 0.85rem;
bottom: calc(var(--bottom-nav-height) + 0.6rem);
width: calc(100vw - 1.7rem);
max-height: calc(100dvh - var(--bottom-nav-height) - 5rem);
}
/* Log out has a set width on a phone, so quick stats can sit just left of it. */
.topbar-user .btn {
width: 5.2rem;
padding: 0.45rem 0.3rem;
}
.fab-quickstats {
top: 0.3rem;
top: calc((var(--topbar-h) - 40px) / 2);
bottom: auto;
right: 6.4rem;
right: calc(0.85rem + 5.2rem + 0.5rem);
z-index: 31;
width: 40px;
height: 40px;
@@ -2194,7 +2211,7 @@ textarea {
}
/* The brand makes room for it. */
.app-shell:has(.fab-quickstats) .topbar-brand {
max-width: calc(100vw - 11rem);
max-width: calc(100vw - 0.85rem - 6.55rem - 40px - 1.25rem);
}
.drawer {
width: 100vw;
@@ -2237,7 +2254,6 @@ textarea {
min-width: min(100%, 8rem) !important;
}
.btn {
white-space: normal;
padding: 0.6rem 0.9rem;
}
.grid-cols-2 {
@@ -2289,11 +2305,6 @@ textarea {
.jump-link {
padding: 0.5rem 0.8rem;
}
.attack-bar {
top: 3.4rem;
max-height: 55dvh;
overflow-y: auto;
}
[data-jump] {
scroll-margin-top: 3.8rem;
}
@@ -2748,7 +2759,7 @@ button.slot-pip:disabled {
/* Everyone in the scene on one line that stays under the top bar. */
.turn-strip {
position: sticky;
top: 3.6rem;
top: var(--topbar-h);
z-index: 25;
display: flex;
gap: 0.4rem;
@@ -2813,20 +2824,17 @@ button.slot-pip:disabled {
.turn-chip.has-acted {
opacity: 0.55;
}
/* The attack bar sits under the strip, not behind it. */
.attack-bar {
top: 7.6rem;
}
/* On a wide screen the side column stays in view, scrolling on its own. */
@media (min-width: 960px) {
.encounter-side {
position: sticky;
top: 7.6rem;
max-height: calc(100vh - 8.4rem);
top: calc(var(--topbar-h) + 4rem);
max-height: calc(100vh - var(--topbar-h) - 4.8rem);
overflow-y: auto;
scrollbar-width: thin;
padding-right: 2px;
/* Room to scroll the last card up past the quick-stats button. */
padding-bottom: 5rem;
}
}
.spotlight {
@@ -2886,6 +2894,7 @@ button.slot-pip:disabled {
summary.card-title {
cursor: pointer;
list-style: none;
justify-content: flex-start;
}
summary.card-title::-webkit-details-marker {
display: none;
@@ -2902,15 +2911,6 @@ details:not([open]) > summary.card-title::after {
content: '▸';
}
@media (max-width: 900px) {
.turn-strip {
top: 2.9rem;
}
.attack-bar {
top: 6.6rem;
}
}
/* In one column the card on turn is already at the top of the list. */
@media (max-width: 959px) {
.spotlight,
+38 -15
View File
@@ -45,6 +45,7 @@ import { GearInForce } from '../components/CharacterPanel';
import { SheetNumbers } from '../components/SheetNumbers';
import { ConditionChip, ConditionPicker, ResourceTracker, StateBadges } from '../components/CharacterState';
import { useHotkeys } from '../hooks/useHotkeys';
import { useMediaQuery } from '../hooks/useMediaQuery';
import { Rollable } from '../components/Rollable';
import { GroupCheckModal } from '../components/GroupCheckModal';
import { withBonus } from '../lib/diceRoller';
@@ -496,6 +497,7 @@ function CombatantCard({
onTarget,
isPinned,
onPin,
detailsOpen,
onRemove,
onError,
}: {
@@ -519,17 +521,18 @@ function CombatantCard({
// Whether this combatant's stats are the ones kept in view, and the switch for it.
isPinned: boolean;
onPin: () => void;
// Whether the details start open (until the viewer opens or closes them).
detailsOpen: boolean;
onRemove: () => void;
onError: (err: unknown) => void;
}) {
const [hpDelta, setHpDelta] = useState('');
// How many rounds the next condition added lasts; empty for until removed.
const [rounds, setRounds] = useState('');
// Details start open for the DM (who runs the scene from this screen) and
// closed for players; null = "not toggled yet", so it follows isDM once the
// campaign role has loaded.
// null = "not toggled yet", so the details follow detailsOpen (which
// changes as the turn passes, and once the campaign role has loaded).
const [detailsToggled, setDetailsToggled] = useState<boolean | null>(null);
const showDetails = detailsToggled ?? isDM;
const showDetails = detailsToggled ?? detailsOpen;
const info = p.characterCombatInfo;
const monster = p.monster;
@@ -987,12 +990,14 @@ function ExperiencePanel({
difficulty,
xpAwarded,
partySize,
folded,
onError,
}: {
encounterId: string;
difficulty: EncounterDifficulty;
xpAwarded: number | null;
partySize: number;
folded: boolean; // starts closed, as it does while a battle is under way
onError: (err: unknown) => void;
}) {
const awardXp = useAwardXp(encounterId);
@@ -1001,11 +1006,11 @@ function ExperiencePanel({
const { thresholds, rating } = difficulty;
return (
<div className="card">
<div className="card-title">
<details className="card" open={!folded}>
<summary className="card-title">
<span>Experience</span>
{rating && <span className={`badge ${RATING_BADGES[rating]}`}>{RATING_LABELS[rating]}</span>}
</div>
</summary>
{difficulty.monsters === 0 ? (
<p className="combatant-sub" style={{ marginTop: 0 }}>
No monster here has a challenge rating, so there is nothing to judge the difficulty by. XP can still be awarded: type an amount.
@@ -1060,7 +1065,7 @@ function ExperiencePanel({
</button>
</div>
)}
</div>
</details>
);
}
@@ -1076,6 +1081,7 @@ function RewardsPanel({
characters,
weapons,
items,
folded,
onError,
}: {
encounter: Encounter;
@@ -1083,6 +1089,7 @@ function RewardsPanel({
characters: CharacterSummary[];
weapons: Weapon[];
items: Item[];
folded: boolean; // starts closed, as it does while a battle is under way
onError: (err: unknown) => void;
}) {
const addReward = useAddReward(encounter.id);
@@ -1130,8 +1137,8 @@ function RewardsPanel({
}
return (
<div className="card">
<div className="card-title">Rewards</div>
<details className="card" open={!folded}>
<summary className="card-title">Rewards{rewards.length > 0 && <span className="section-count">{rewards.length}</span>}</summary>
{isDM && rewards.length === 0 && (
<p className="combatant-sub" style={{ marginTop: 0 }}>
Line up coins, weapons and items here. Players only see a reward once you award it.
@@ -1284,7 +1291,7 @@ function RewardsPanel({
</div>
</div>
)}
</div>
</details>
);
}
@@ -1319,6 +1326,8 @@ export function EncounterPage() {
const setArchived = useSetEncounterArchived(encounter?.campaignId ?? '');
const deleteEncounter = useDeleteEncounter(encounter?.campaignId ?? '');
const navigate = useNavigate();
// Wide enough for the side column, and so for the spotlight in it.
const wide = useMediaQuery('(min-width: 960px)');
const [pickType, setPickType] = useState<'character' | 'monster' | 'npc'>('character');
const [pickId, setPickId] = useState('');
@@ -1454,6 +1463,17 @@ export function EncounterPage() {
);
const unrolled = participants.filter((p) => p.kind !== 'CHARACTER' && p.initiativeRoll == null).length;
// In a battle every card's details start closed, so the list stays short:
// the spotlight beside it shows whoever is on turn in full, and where there
// is no room for the spotlight (one column) their own card opens instead.
// Elsewhere the DM, who runs the scene from this screen, gets them open.
const detailsOpen = (p: EncounterParticipant) => (isBattle ? p.id === active?.id && !wide : isDM);
// A battle under way folds away what is for before and after it.
const inBattle = isBattle && encounter.status === 'ACTIVE';
// The scene is set before the fight and between rounds (while the first in
// the order is on turn), and stays out of the way during the round.
const showScene = !inBattle || active?.id === standing[0]?.id;
const card = (p: EncounterParticipant) => (
<CombatantCard
key={p.id}
@@ -1473,6 +1493,7 @@ export function EncounterPage() {
}
isPinned={p.id === pinnedId}
onPin={() => setPinnedId(p.id === pinnedId ? null : p.id)}
detailsOpen={detailsOpen(p)}
onRemove={() => removeParticipant.mutate(p.id, fail)}
onError={onError}
/>
@@ -1655,7 +1676,7 @@ export function EncounterPage() {
</div>
)}
{(isDM || encounter.description) && (
{(isDM || encounter.description) && showScene && (
<div className="card">
<div className="card-title">{isBattle ? 'The Scene' : isRoam ? 'Where the party is' : 'Quest'}</div>
{isDM ? (
@@ -1790,8 +1811,8 @@ export function EncounterPage() {
/>
)}
{isDM && (
<div className="card">
<div className="card-title">Add to the scene</div>
<details className="card" open={!inBattle}>
<summary className="card-title">Add to the scene</summary>
<div className="flex-row flex-wrap gap-sm">
<select
aria-label="What to add"
@@ -1855,7 +1876,7 @@ export function EncounterPage() {
Create {pickType === 'npc' ? 'NPCs' : 'monsters'} on the <Link to={`/campaigns/${encounter.campaignId}`}>campaign page</Link> first.
</p>
)}
</div>
</details>
)}
{isBattle && (encounter.recentActions ?? []).length > 0 && (
@@ -1895,6 +1916,7 @@ export function EncounterPage() {
difficulty={encounter.difficulty}
xpAwarded={encounter.xpAwarded ?? null}
partySize={participants.filter((p) => p.kind === 'CHARACTER' && p.character).length}
folded={inBattle}
onError={onError}
/>
)}
@@ -1905,6 +1927,7 @@ export function EncounterPage() {
characters={builtCharacters}
weapons={weapons ?? []}
items={items ?? []}
folded={inBattle}
onError={onError}
/>
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.30.0",
"version": "1.32.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.30.0",
"version": "1.32.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.30.0",
"version": "1.32.0",
"dependencies": {
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
@@ -33,7 +33,7 @@
}
},
"frontend": {
"version": "1.30.0",
"version": "1.32.0",
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"axios": "^1.20.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dnd-campaign-manager",
"version": "1.30.0",
"version": "1.32.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [