Compare commits

...
2 Commits
Author SHA1 Message Date
joshuaandClaude Opus 5.5 0124e3bc53 V1.11.0: gear that applies itself, equipping that matters, a character panel
- Weapons and items can carry effects: bonuses and penalties to AC, speed,
  initiative, max HP, passive Perception, ability scores, saving throws
  and skills, plus a note. They apply on their own while equipped and stop
  when the thing comes off. Armor is an item with an AC effect. The DM
  sets them with "Effects" on a row in the weapon or item pool.
- The sheet's "armor bonus" is now "Extra AC", the DM's own adjustment on
  top of gear, and only the DM can set it.
- Only what is equipped counts: a weapon has to be equipped to be used in
  an encounter. A character can have two things equipped at once, one of
  them a weapon (campaign settings). Equipping at the limit is refused
  with a message, and can be changed at any time, mid-encounter included.
- A built-in item library: armor, shields, worn gear and supplies, with
  their effects set. The weapon library gains paired weapons (a pair of
  daggers is one weapon).
- A floating character button on every page opens a panel with a
  character's numbers and everything they hold, with equip ticks. Players
  get their own characters, the DM everyone's, and each character card in
  an encounter has an Inventory button. It replaces the quick stats drawer.
- A player's own characters stand out: marked and highlighted in
  encounters, with a button that jumps to their card; highlighted on the
  party screen; and listed once, not twice, on the campaign page.
- All packages at version 1.11.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:03:53 +02:00
joshuaandClaude Opus 5.5 2dcb41ae4a V1.10.1: a phone-friendly layout, easier navigation, paged encounters
- The main destinations are always on screen: Campaigns, Campaign and
  Party (for the campaign last visited, from any page) and Account. They
  are links along the top bar on a wide screen, and a bar along the
  bottom on a phone or small tablet (900px or less).
- Nothing floats over the page on a phone: the dice roller is the last
  button of the bottom bar, and quick stats sits in the top bar.
- The campaign page and the character sheet start with a row of links to
  their sections.
- On a small screen every table is a stack of labelled cards, rows of
  controls wrap, fields shrink to fit, and things to tap are bigger.
- The encounter list is paged, ten to a page, as is the archived list.
  Moving an encounter still works across pages.
- Text boxes with no type given, and native checkboxes and dropdowns, now
  match the dark theme. Long monster notes and item descriptions wrap
  inside their table cell.
- All packages at version 1.10.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:37:31 +02:00
39 changed files with 2444 additions and 339 deletions
+25 -4
View File
@@ -2,7 +2,7 @@
A web app for a D&D 5e Dungeon Master and their players to run a campaign
together: campaign management, a guided dice-roll character builder, live
character sheets, a mid-combat "quick stats" panel, weapons/inventory/spells,
character sheets, a character panel on every page, weapons/inventory/spells,
monsters, and a real-time encounter/combat tracker.
## How it works
@@ -12,9 +12,9 @@ monsters, and a real-time encounter/combat tracker.
| Document | Covers |
|---|---|
| `docs/CHARACTERS.md` | Building a character, every number on the sheet, levels, money, permissions |
| `docs/WEAPONS.md` | The weapon pool, weapon levels and how damage grows |
| `docs/WEAPONS.md` | The weapon pool and library, equipping, weapon levels and how damage grows |
| `docs/SPELLS.md` | The spell list, who can use which spells, the spell library |
| `docs/ITEMS.md` | The item pool, character inventory, items for sale |
| `docs/ITEMS.md` | The item pool and library, armor and other gear effects, what can be equipped, items for sale |
| `docs/ENCOUNTERS.md` | Battles and NPC quests, turns, rewards, trade, what players see |
| `docs/MONSTERS.md` | The monster list and built-in bestiary |
| `docs/NPCS.md` | Non-player characters, what they sell and the rates they change money at |
@@ -25,6 +25,27 @@ version was built from. The app has since moved on in places (gear levels,
the builder's story steps, picking a motivation); the documents above are the
current behaviour.
## Getting around
The same four destinations are always on screen: **Campaigns** (the list),
**Campaign** and **Party** (for the campaign you were last in, from any page,
including a character sheet or an encounter) and **Account**.
- On a wide screen they are links along the top bar, and the dice roller
floats at the bottom left.
- On a phone or small tablet (900px wide or less) they are a bar along the
bottom of the screen, with the **dice roller** as its last button. Nothing
floats over the page: the 🧙 character button sits in the top bar.
- The **🧙 character button** is on every page (floating at the bottom right
on a wide screen). It opens a panel with your character's numbers and
everything they hold; the DM can open it for any character.
- The campaign page and the character sheet are long, so each starts with a
row of **section links** (Characters, Encounters, NPCs, Monsters...) that
jump straight to that part of the page.
- On a small screen every table (weapons, items, spells, monsters, the party
overview) is shown as a stack of cards, one per row, with each figure
labelled, and rows of controls wrap rather than run off the side.
## Stack
- **Backend:** Node.js + Express, JavaScript, Prisma ORM, PostgreSQL, Socket.io, JWT auth (bcrypt + jsonwebtoken), zod validation.
@@ -85,7 +106,7 @@ and then restart the backend. It applies new migrations **and** regenerates
the Prisma client; without the second half the server keeps running with the
old data model and pages that use anything new fail to load (for example
"Could not load this encounter"). Change your password from the account page
(click your name in the top bar).
("Account" in the navigation).
## Tests
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "backend",
"version": "1.10.0",
"version": "1.11.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
@@ -0,0 +1,7 @@
-- AlterTable: a weapon or item can change the numbers on the sheet of whoever
-- has it equipped. `effects` is a list of { stat, amount }; `effectNote` is
-- for what it does that is not a number ("Disadvantage on Stealth").
ALTER TABLE "weapons" ADD COLUMN "effects" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "weapons" ADD COLUMN "effectNote" TEXT;
ALTER TABLE "items" ADD COLUMN "effects" JSONB NOT NULL DEFAULT '[]';
ALTER TABLE "items" ADD COLUMN "effectNote" TEXT;
@@ -0,0 +1,4 @@
-- AlterTable: how much a character can have equipped at once. Only equipped
-- gear can be used in an encounter or gives its effects.
ALTER TABLE "campaigns" ADD COLUMN "maxEquipped" INTEGER NOT NULL DEFAULT 2;
ALTER TABLE "campaigns" ADD COLUMN "maxEquippedWeapons" INTEGER NOT NULL DEFAULT 1;
+16 -1
View File
@@ -173,6 +173,12 @@ model Campaign {
// An attack on several targets is harder to land: each target's AC counts
// as this much higher for every target beyond the first. 0 turns it off.
areaAttackAcPenalty Int @default(2)
// How much a character can have equipped at once: weapons and items
// together, and weapons alone. Only equipped gear can be used in an
// encounter or gives its effects, so this is what a character can bring to
// bear at one time. A pair of daggers is one weapon, made as such.
maxEquipped Int @default(2)
maxEquippedWeapons Int @default(1)
ownerId String
// Set while the campaign is archived: kept in full, but moved off the main
// list and closed to new members until the DM restores it.
@@ -275,7 +281,7 @@ model Character {
deathSaveSuccesses Int @default(0)
deathSaveFailures Int @default(0)
stable Boolean @default(false) // at 0 HP but no longer rolling
armorBonus Int @default(0) // flat AC bonus from equipped armor/shield
armorBonus Int @default(0) // a flat AC adjustment the DM sets by hand, on top of what equipped gear gives
speedOverride Int?
// The next three are composed by the builder from the player's picks
@@ -368,6 +374,11 @@ model Weapon {
// Offered in the character builder's starting-equipment step. Off by default:
// the DM opts entries in, so the pool can hold gear that must be earned.
isStartingOption Boolean @default(false)
// What it does to the sheet of whoever has it equipped: a list of
// { stat, amount } (game/calculations.js, GEAR_STATS), and a note for
// anything that is not a number.
effects Json @default("[]")
effectNote String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
@@ -403,6 +414,10 @@ model Item {
damageScaling Int @default(1)
minLevel Int @default(1)
isStartingOption Boolean @default(false)
// As on a weapon: what it does to whoever has it equipped. Armor is an
// item with an AC effect.
effects Json @default("[]")
effectNote String?
createdAt DateTime @default(now())
campaign Campaign @relation(fields: [campaignId], references: [id], onDelete: Cascade)
+27 -3
View File
@@ -1,4 +1,4 @@
// Writes the full lists (every library spell, weapon and monster, and every
// Writes the full lists (every library spell, weapon, item and monster, and every
// option the character builder offers) into the docs, straight from the data
// files the app itself uses, so the docs cannot drift from the app.
//
@@ -10,7 +10,7 @@
// is written by hand.
const fs = require('fs');
const path = require('path');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../src/game/data/library');
const { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../src/game/data/library');
const options = require('../src/game/data/characterOptions');
const { SPECIES } = require('../src/game/data/species');
const { CLASSES } = require('../src/game/data/classes');
@@ -49,8 +49,31 @@ function weaponLibrary() {
ATTACK_WITH[w.attackAbility],
w.properties.join(', '),
w.rangeNormal ? `${w.rangeNormal}/${w.rangeLong} ft` : null,
[effectList(w.effects), w.effectNote].filter(Boolean).join('. ') || null,
]);
return `### ${category} (${weapons.length})\n\n${table(['Weapon', 'Base damage', 'Type', 'Attacks with', 'Properties', 'Range'], rows)}`;
return `### ${category} (${weapons.length})\n\n${table(['Weapon', 'Base damage', 'Type', 'Attacks with', 'Properties', 'Range', 'While equipped'], rows)}`;
})
.join('\n\n');
}
// "+6 AC, −10 Speed": what a library entry does while equipped.
const STAT_NAMES = { AC: 'AC', SPEED: 'Speed', INITIATIVE: 'Initiative', MAX_HP: 'Max HP', PASSIVE_PERCEPTION: 'Passive Perception', SAVES: 'all saves', SKILLS: 'all skill checks' };
const title = (key) => key.toLowerCase().split('_').map((w, i) => (i > 0 && w === 'of' ? w : w[0].toUpperCase() + w.slice(1))).join(' ');
function statName(stat) {
if (STAT_NAMES[stat]) return STAT_NAMES[stat];
if (stat.startsWith('SAVE_')) return `${stat.slice(5)} saves`;
if (stat.startsWith('SKILL_')) return title(stat.slice(6));
return stat;
}
const effectList = (effects = []) => effects.map((e) => `${signed(e.amount).replace('-', '−')} ${statName(e.stat)}`).join(', ');
function itemLibrary() {
const categories = [...new Set(ITEM_LIBRARY.map((i) => i.category))];
return categories
.map((category) => {
const items = ITEM_LIBRARY.filter((i) => i.category === category);
const rows = items.map((i) => [i.name, effectList(i.effects), i.effectNote, i.damageDice, i.description]);
return `### ${category} (${items.length})\n\n${table(['Item', 'While equipped', 'Note', 'Dice', 'Description'], rows)}`;
})
.join('\n\n');
}
@@ -94,6 +117,7 @@ function builderLists() {
const SECTIONS = {
'SPELLS.md': { 'spell-library': spellLibrary },
'WEAPONS.md': { 'weapon-library': weaponLibrary },
'ITEMS.md': { 'item-library': itemLibrary },
'MONSTERS.md': { 'monster-library': monsterLibrary },
'CHARACTERS.md': { 'builder-lists': builderLists },
};
+65 -8
View File
@@ -121,12 +121,60 @@ function getClassSpellcastingAbility(characterClass) {
return data ? data.spellcastingAbility : null;
}
// ---------------------------------------------------------------------------
// Gear effects (house rule). A weapon or item in the campaign pools can carry
// effects: signed amounts added to one of the numbers on the sheet. They count
// while a character has the thing equipped and is high enough level to use it;
// holding two of something does not double it.
// ---------------------------------------------------------------------------
// What an effect can change. An ability effect changes the score itself, so
// everything worked out from that ability moves with it.
const GEAR_STATS = [
'AC',
'SPEED',
'INITIATIVE',
'MAX_HP',
'PASSIVE_PERCEPTION',
...ABILITIES,
'SAVES', // every saving throw
...ABILITIES.map((ability) => `SAVE_${ability}`),
'SKILLS', // every skill check
...Object.keys(SKILLS).map((skill) => `SKILL_${skill}`),
];
// The effects in force for a character, each with the name of what gives it:
// [{ stat, amount, source }]. Takes a character with `inventoryItems` (each
// with its item) and `weapons` (each with its weapon) loaded; without them
// there are none. `notes` are the things gear does that are not a number.
function activeGear(character) {
const level = character.level || 1;
const held = [
...(character.inventoryItems || []).map((ii) => ({ ...ii, entry: ii.item })),
...(character.weapons || []).map((cw) => ({ ...cw, entry: cw.weapon })),
].filter((h) => h.entry && h.equipped && canUseGear(level, h.level));
const effects = [];
const notes = [];
for (const { entry } of held) {
for (const effect of Array.isArray(entry.effects) ? entry.effects : []) {
if (GEAR_STATS.includes(effect.stat) && Number.isInteger(effect.amount) && effect.amount !== 0) {
effects.push({ stat: effect.stat, amount: effect.amount, source: entry.name });
}
}
if (entry.effectNote) notes.push({ source: entry.name, note: entry.effectNote });
}
return { effects, notes };
}
/**
* Assembles the full derived character sheet from raw stored data. This is
* the single function the API and quick-stats view should call rather than
* re-deriving any of the above by hand.
*
* @param {object} character - rolled scores, species/class, level, armorBonus, etc.
* With `inventoryItems` and `weapons` loaded (each with its pool entry),
* the effects of equipped gear are applied; see activeGear.
* @param {Array<{skill: string, proficient: boolean, expertise: boolean}>} skillProficiencies
* @param {Array<{ability: string, proficient: boolean}>} saveProficiencies
*/
@@ -150,9 +198,14 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
const halfElfBonusAbilities = [halfElfBonusAbility1, halfElfBonusAbility2].filter(Boolean);
const rolled = { STR: strScore, DEX: dexScore, CON: conScore, INT: intScore, WIS: wisScore, CHA: chaScore };
const gear = activeGear(character);
// The total equipped gear adds to (or takes from) one number.
const fromGear = (...stats) => gear.effects.filter((e) => stats.includes(e.stat)).reduce((sum, e) => sum + e.amount, 0);
const abilities = {};
for (const ability of ABILITIES) {
const final = finalAbilityScore(rolled[ability], ability, species, halfElfBonusAbilities);
// A score never drops below 1, whatever is weighing the character down.
const final = Math.max(1, finalAbilityScore(rolled[ability], ability, species, halfElfBonusAbilities) + fromGear(ability));
abilities[ability] = {
rolled: rolled[ability],
final,
@@ -166,7 +219,7 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
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) };
saves[ability] = { proficient, bonus: saveBonus(abilities[ability].modifier, proficient, profBonus) + fromGear('SAVES', `SAVE_${ability}`) };
}
const skills = {};
@@ -179,7 +232,7 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
ability,
proficient,
expertise,
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise),
bonus: skillBonus(abilities[ability].modifier, proficient, profBonus, expertise) + fromGear('SKILLS', `SKILL_${skillKey}`),
};
}
@@ -189,14 +242,16 @@ function computeCharacterSheet(character, skillProficiencies = [], saveProficien
return {
abilities,
proficiencyBonus: profBonus,
armorClass: armorClass(abilities.DEX.modifier, armorBonus),
initiative: initiative(abilities.DEX.modifier),
speed: effectiveSpeed(species, speedOverride),
hp: { max: hpMax(level, hitDieSize, abilities.CON.modifier) },
armorClass: armorClass(abilities.DEX.modifier, armorBonus) + fromGear('AC'),
initiative: initiative(abilities.DEX.modifier) + fromGear('INITIATIVE'),
speed: Math.max(0, effectiveSpeed(species, speedOverride) + fromGear('SPEED')),
hp: { max: Math.max(1, hpMax(level, hitDieSize, abilities.CON.modifier) + fromGear('MAX_HP')) },
hitDice: { size: hitDieSize, count: level },
saves,
skills,
passivePerception: passivePerception(skills.PERCEPTION.bonus),
passivePerception: passivePerception(skills.PERCEPTION.bonus) + fromGear('PASSIVE_PERCEPTION'),
// What equipped gear is doing to the numbers above, and what else it does.
gear,
spellcasting: spellcastingAbility
? {
ability: spellcastingAbility,
@@ -350,6 +405,8 @@ module.exports = {
getClassSaveProficiencies,
getClassHitDieSize,
getClassSpellcastingAbility,
GEAR_STATS,
activeGear,
computeCharacterSheet,
computeWeaponStats,
MAX_GEAR_LEVEL,
+78
View File
@@ -0,0 +1,78 @@
// Default item library — armor, shields, a few worn pieces and common
// supplies, offered to any campaign so a DM isn't hand-typing them. Shape
// 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.
const STEALTH = 'Disadvantage on Stealth checks.';
const armor = (key, name, category, ac, description, note = null, more = []) => ({
key,
name,
category,
description,
damageDice: null,
effects: [{ stat: 'AC', amount: ac }, ...more],
effectNote: note,
});
const gear = (key, name, category, description, effects = [], effectNote = null, damageDice = null) => ({
key,
name,
category,
description,
damageDice,
effects,
effectNote,
});
const ITEM_LIBRARY = [
// Light armor
armor('padded-armor', 'Padded Armor', 'Light Armor', 1, 'Quilted layers of cloth and batting.', STEALTH),
armor('leather-armor', 'Leather Armor', 'Light Armor', 1, 'Boiled leather breastplate and shoulder guards over soft leather.'),
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.`),
// 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 }]),
// Shields
armor('shield', 'Shield', 'Shields', 2, 'Wood or metal, carried in one hand.'),
armor('tower-shield', 'Tower Shield', 'Shields', 3, 'A wall of oak and iron, as tall as its bearer.', null, [{ stat: 'SPEED', amount: -5 }]),
// Worn gear
gear('ring-of-protection', 'Ring of Protection', 'Worn Gear', 'A plain band that turns blows aside.', [{ stat: 'AC', amount: 1 }, { stat: 'SAVES', amount: 1 }]),
gear('cloak-of-protection', 'Cloak of Protection', 'Worn Gear', 'A heavy cloak that seems to catch what was meant for you.', [{ stat: 'AC', amount: 1 }, { stat: 'SAVES', amount: 1 }]),
gear('cloak-of-elvenkind', 'Cloak of Elvenkind', 'Worn Gear', 'With the hood up, its colour shifts to match what is behind it.', [{ stat: 'SKILL_STEALTH', amount: 5 }], 'With the hood up, others have disadvantage on Perception checks to see you.'),
gear('boots-of-striding', 'Boots of Striding', 'Worn Gear', 'Light, tireless boots.', [{ stat: 'SPEED', amount: 10 }]),
gear('gauntlets-of-might', 'Gauntlets of Might', 'Worn Gear', 'Iron-banded gauntlets that lend a giant’s grip.', [{ stat: 'STR', amount: 2 }]),
gear('gloves-of-thievery', 'Gloves of Thievery', 'Worn Gear', 'Thin grey gloves that all but vanish when worn.', [{ stat: 'SKILL_SLEIGHT_OF_HAND', amount: 5 }]),
gear('amulet-of-health', 'Amulet of Health', 'Worn Gear', 'A red stone on a golden chain, warm against the skin.', [{ stat: 'CON', amount: 2 }]),
gear('headband-of-intellect', 'Headband of Intellect', 'Worn Gear', 'A silver circlet set with a pale blue stone.', [{ stat: 'INT', amount: 2 }]),
gear('sentinel-charm', 'Sentinel Charm', 'Worn Gear', 'A brass eye on a cord that never quite closes.', [{ stat: 'INITIATIVE', amount: 2 }, { stat: 'PASSIVE_PERCEPTION', amount: 5 }]),
gear('stone-of-good-luck', 'Stone of Good Luck', 'Worn Gear', 'A polished agate that sits well in a pocket.', [{ stat: 'SAVES', amount: 1 }, { stat: 'SKILLS', amount: 1 }]),
// Supplies: nothing to equip, but every pack has them.
gear('healing-potion', 'Healing Potion', 'Supplies', 'A red liquid that glimmers when shaken. Drink it to regain hit points.', [], null, '2d4+2'),
gear('rope', 'Rope (50 ft)', 'Supplies', 'Hempen rope. Holds a climber.'),
gear('torch', 'Torch', 'Supplies', 'Burns for an hour, lighting 20 feet brightly.', [], 'Held in a hand to give light.'),
gear('lantern', 'Hooded Lantern', 'Supplies', 'Bright light for 30 feet; the hood dims it to a glow.', [], 'Held in a hand to give light.'),
gear('rations', 'Rations (1 day)', 'Supplies', 'Dried meat, hard biscuit, nuts.'),
gear('bedroll', 'Bedroll', 'Supplies', 'A blanket roll for sleeping rough.'),
gear('thieves-tools', 'Thieves’ Tools', 'Supplies', 'A file, picks, a small mirror, narrow scissors and pliers.'),
gear('healers-kit', 'Healer’s Kit', 'Supplies', 'Bandages, salves and splints. Steadies a dying creature without a check.'),
];
module.exports = { ITEM_LIBRARY };
+5 -3
View File
@@ -1,7 +1,9 @@
// Combined default-content library — see weaponsLibrary.js / spellsLibrary.js
// / monstersLibrary.js for the individual sets and their shape.
// Combined default-content library — see weaponsLibrary.js / itemsLibrary.js
// / spellsLibrary.js / monstersLibrary.js for the individual sets and their
// shape.
const { WEAPON_LIBRARY } = require('./weaponsLibrary');
const { ITEM_LIBRARY } = require('./itemsLibrary');
const { SPELL_LIBRARY } = require('./spellsLibrary');
const { MONSTER_LIBRARY } = require('./monstersLibrary');
module.exports = { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY };
module.exports = { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY };
+9
View File
@@ -3,6 +3,9 @@
// matches the `Weapon` Prisma model minus `id`/`campaignId`, plus a stable
// `key` for "add to campaign" lookups and a `category` for the library
// picker's filter. attackAbility follows the schema's STR|DEX|FINESSE.
//
// A character has one weapon equipped at a time (a campaign setting), so a
// weapon fought with in each hand is a single entry of its own: see "Paired".
const WEAPON_LIBRARY = [
// Simple Melee
@@ -49,6 +52,12 @@ const WEAPON_LIBRARY = [
{ key: 'heavy-crossbow', name: 'Heavy Crossbow', category: 'Martial Ranged', damageDice: '1d10', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'heavy', 'loading', 'two-handed'], rangeNormal: 100, rangeLong: 400 },
{ key: 'longbow', name: 'Longbow', category: 'Martial Ranged', damageDice: '1d8', damageType: 'piercing', attackAbility: 'DEX', properties: ['ammunition', 'heavy', 'two-handed'], rangeNormal: 150, rangeLong: 600 },
{ key: 'net', name: 'Net', category: 'Martial Ranged', damageDice: '-', damageType: 'special', attackAbility: 'DEX', properties: ['special', 'thrown'], rangeNormal: 5, rangeLong: 15 },
// Paired: one in each hand, as a single weapon. The damage is both blades.
{ key: 'paired-daggers', name: 'Paired Daggers', category: 'Paired', damageDice: '2d4', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'paired'], rangeNormal: null, rangeLong: null, effectNote: 'A dagger in each hand: takes both hands.' },
{ key: 'paired-shortswords', name: 'Paired Shortswords', category: 'Paired', damageDice: '2d6', damageType: 'piercing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'paired'], rangeNormal: null, rangeLong: null, effectNote: 'A shortsword in each hand: takes both hands.' },
{ key: 'paired-scimitars', name: 'Paired Scimitars', category: 'Paired', damageDice: '2d6', damageType: 'slashing', attackAbility: 'FINESSE', properties: ['finesse', 'light', 'paired'], rangeNormal: null, rangeLong: null, effectNote: 'A scimitar in each hand: takes both hands.' },
{ key: 'paired-handaxes', name: 'Paired Handaxes', category: 'Paired', damageDice: '2d6', damageType: 'slashing', attackAbility: 'STR', properties: ['light', 'paired'], rangeNormal: null, rangeLong: null, effectNote: 'A handaxe in each hand: takes both hands.' },
];
module.exports = { WEAPON_LIBRARY };
+182 -3
View File
@@ -419,7 +419,7 @@ describe('who can change what on a character', () => {
expect((await request(app).patch(`/characters/${characterId}`).set(dmAuth).send({ gold: 999 })).status).toBe(400);
// The player can still edit the rest of their own sheet.
expect((await request(app).patch(`/characters/${characterId}`).set(playerAuth).send({ armorBonus: 2 })).status).toBe(200);
expect((await request(app).patch(`/characters/${characterId}`).set(playerAuth).send({ tempHp: 2 })).status).toBe(200);
const dmRes = await request(app)
.patch(`/characters/${characterId}`)
@@ -795,11 +795,16 @@ describe('gear levels', () => {
expect(levelled.body.characterWeapon).toMatchObject({ level: 3, equipped: false });
expect(await held('Spear')).toMatchObject({ scaledDamageDice: '1d10', usable: false });
// Unusable weapons stay out of quick stats and the encounter view.
// The character panel lists everything held and marks what is out of reach;
// the encounter view leaves unusable weapons out.
const quick = async () => (await request(app).get(`${ch}/quick-stats`).set(playerAuth)).body.quickStats.weapons;
expect(await quick()).toEqual([]);
expect((await quick()).map((w) => [w.name, w.usable]).sort()).toEqual([
['Relic Blade', false],
['Spear', false],
]);
// At character level 3 both are usable, at their own level's dice.
await request(app).patch(c).set(dmAuth).send({ maxEquippedWeapons: 2 });
await request(app).patch(ch).set(dmAuth).send({ level: 3 });
expect(await held('Spear')).toMatchObject({ usable: true });
expect((await request(app).patch(`${ch}/weapons/${spear.id}`).set(playerAuth).send({ equipped: true })).status).toBe(200);
@@ -2530,3 +2535,177 @@ describe('trade', () => {
expect((await request(app).get(e).set(playerAuth)).body.encounter.wares).toEqual([]);
});
});
describe('gear effects', () => {
itIfDb('equipped gear changes the sheet on its own, and the character panel lists everything held', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('gear');
const c = `/campaigns/${campaign.id}`;
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
const ch = `/characters/${heroId}`;
const post = (path, body, auth = dmAuth) => request(app).post(path).set(auth).send(body);
const sheet = async (auth = playerAuth) => (await request(app).get(ch).set(auth)).body.character;
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: [] });
// Only the DM says what gear does, and only with numbers the sheet has.
const mail = {
name: 'Chain Mail',
effects: [{ stat: 'AC', amount: 6 }, { stat: 'SPEED', amount: -10 }, { stat: 'SKILL_STEALTH', amount: -2 }, { stat: 'CON', amount: 2 }, { stat: 'SAVES', amount: 1 }],
effectNote: 'Disadvantage on Stealth',
};
expect((await post(`${c}/items`, mail, playerAuth)).status).toBe(403);
expect((await post(`${c}/items`, { name: 'Bad', effects: [{ stat: 'LUCK', amount: 1 }] })).status).toBe(400);
expect((await post(`${c}/items`, { name: 'Bad', effects: [{ stat: 'AC', amount: 0 }] })).status).toBe(400);
const armor = (await post(`${c}/items`, mail)).body.item;
expect(armor).toMatchObject({ effects: mail.effects, effectNote: 'Disadvantage on Stealth' });
const heavy = (await post(`${c}/items`, { name: 'Dragon Plate', minLevel: 5, effects: [{ stat: 'AC', amount: 9 }] })).body.item;
const ring = (await post(`${c}/weapons`, { name: 'Warded Blade', damageDice: '1d8', damageType: 'slashing', attackAbility: 'STR', effects: [{ stat: 'AC', amount: 1 }] })).body.weapon;
// Carried but not worn, it does nothing.
await post(`${ch}/inventory`, { itemId: armor.id, quantity: 2 });
await post(`${ch}/inventory`, { itemId: heavy.id });
await post(`${ch}/weapons`, { weaponId: ring.id });
expect((await sheet()).sheet).toMatchObject({ armorClass: before.armorClass, speed: before.speed });
// Worn, every number moves, however many are carried; the player may put it on.
const wear = (path, id, equipped, auth = playerAuth) => request(app).patch(`${ch}/${path}/${id}`).set(auth).send({ equipped });
expect((await wear('inventory', armor.id, true)).status).toBe(200);
expect((await wear('weapons', ring.id, true)).status).toBe(200);
const worn = (await sheet()).sheet;
expect(worn.armorClass).toBe(before.armorClass + 6 + 1);
expect(worn.speed).toBe(before.speed - 10);
expect(worn.abilities.CON.final).toBe(before.abilities.CON.final + 2);
expect(worn.hp.max).toBe(before.hp.max + 1); // +2 CON is +1 to the modifier, at level 1
expect(worn.skills.STEALTH.bonus).toBe(before.skills.STEALTH.bonus - 2);
expect(worn.saves.DEX.bonus).toBe(before.saves.DEX.bonus + 1);
expect(worn.saves.CON.bonus).toBe(before.saves.CON.bonus + 2); // the save's own +1, and +1 from CON
expect(worn.gear.notes).toEqual([{ source: 'Chain Mail', note: 'Disadvantage on Stealth' }]);
expect(worn.gear.effects).toContainEqual({ stat: 'AC', amount: 1, source: 'Warded Blade' });
// The same numbers reach the party list and an encounter.
const listed = (await request(app).get(`${c}/characters`).set(dmAuth)).body.characters.find((x) => x.id === heroId);
expect(listed).toMatchObject({ armorClass: worn.armorClass, maxHp: worn.hp.max });
const encounterId = (await post(`${c}/encounters`, { name: 'Gear check' })).body.encounter.id;
const joined = (await post(`/encounters/${encounterId}/participants`, { characterId: heroId })).body.encounter.participants[0];
expect(joined.maxHp).toBe(worn.hp.max);
expect(joined.characterCombatInfo).toMatchObject({ armorClass: worn.armorClass, speed: worn.speed });
expect(joined.characterCombatInfo.gear.effects).toHaveLength(6);
// Gear above the character's level cannot be put on, but is still listed.
expect((await wear('inventory', heavy.id, true)).status).toBe(400);
const panel = await quick();
expect(panel).toMatchObject({ armorClass: worn.armorClass, speed: worn.speed, maxHp: worn.hp.max, restricted: false });
expect(panel.inventory.map((i) => [i.name, i.quantity, i.equipped, i.usable]).sort()).toEqual([
['Chain Mail', 2, true, true],
['Dragon Plate', 1, false, false],
]);
expect(panel.inventory.find((i) => i.name === 'Chain Mail')).toMatchObject({ effects: mail.effects, effectNote: 'Disadvantage on Stealth' });
expect(panel.weapons.find((w) => w.name === 'Warded Blade')).toMatchObject({ equipped: true, usable: true, effects: [{ stat: 'AC', amount: 1 }] });
expect(panel.purse).toEqual({ gold: 0, silver: 0, copper: 0, cash: 0 });
expect((await quick(dmAuth)).inventory).toHaveLength(2);
// Another player sees the numbers, not the pack or the purse.
const other = await request(app).post('/auth/register').send({ email: uniqueEmail('gear-other'), password: 'password123', displayName: 'Other' });
cleanupUserIds.push(other.body.user.id);
const otherAuth = { Authorization: `Bearer ${other.body.token}` };
await request(app).post(`/campaigns/join/${campaign.inviteCode}`).set(otherAuth);
expect(await quick(otherAuth)).toMatchObject({ armorClass: worn.armorClass, restricted: true, inventory: [], spells: [], purse: null });
// The DM's own adjustment sits on top, and is the DM's alone.
expect((await request(app).patch(ch).set(playerAuth).send({ armorBonus: 2 })).status).toBe(403);
expect((await request(app).patch(ch).set(dmAuth).send({ armorBonus: -1 })).body.character.sheet.armorClass).toBe(worn.armorClass - 1);
await request(app).patch(ch).set(dmAuth).send({ armorBonus: 0 });
// Changing what the item does reaches whoever wears it.
const patched = await request(app).patch(`${c}/items/${armor.id}`).set(dmAuth).send({ effects: [{ stat: 'AC', amount: 4 }], effectNote: null });
expect(patched.body.item).toMatchObject({ effects: [{ stat: 'AC', amount: 4 }], effectNote: null });
const lighter = (await sheet()).sheet;
expect(lighter.armorClass).toBe(before.armorClass + 4 + 1);
expect(lighter.speed).toBe(before.speed);
// The +1 max HP went with the CON bonus, and current HP came down with it.
expect(lighter.hp.max).toBe(before.hp.max);
expect((await sheet()).currentHp).toBe(before.hp.max);
// Taking it off, or losing it, takes its effects away.
await wear('inventory', armor.id, false);
expect((await sheet()).sheet.armorClass).toBe(before.armorClass + 1);
expect((await request(app).delete(`${c}/weapons/${ring.id}`).set(dmAuth)).status).toBe(204);
expect((await sheet()).sheet).toMatchObject({ armorClass: before.armorClass, gear: { effects: [], notes: [] } });
});
});
describe('equipping', () => {
itIfDb('only so much can be equipped at once, and only what is equipped is used in an encounter', async () => {
const { dmAuth, playerAuth, campaign } = await setupCampaign('equip');
const c = `/campaigns/${campaign.id}`;
expect(campaign).toMatchObject({ maxEquipped: 2, maxEquippedWeapons: 1 });
const heroId = await buildCharacter(campaign.id, playerAuth, 'Aria');
const ch = `/characters/${heroId}`;
const post = (path, body, auth = dmAuth) => request(app).post(path).set(auth).send(body);
// The library has armor, with its effects, and weapons made as a pair.
const library = (await request(app).get(`${c}/library`).set(dmAuth)).body;
expect(library.items.length).toBeGreaterThan(20);
expect(library.items.find((i) => i.key === 'chain-mail')).toMatchObject({ category: 'Heavy Armor', effects: [{ stat: 'AC', amount: 6 }, { stat: 'SPEED', amount: -10 }] });
expect(library.weapons.find((w) => w.key === 'paired-daggers')).toMatchObject({ category: 'Paired', damageDice: '2d4' });
expect((await post(`${c}/items/from-library`, { key: 'shield' }, playerAuth)).status).toBe(403);
expect((await post(`${c}/items/from-library`, { key: 'nope' })).status).toBe(404);
const shield = (await post(`${c}/items/from-library`, { key: 'shield' })).body.item;
expect(shield).toMatchObject({ name: 'Shield', effects: [{ stat: 'AC', amount: 2 }], isStartingOption: false });
const mail = (await post(`${c}/items/from-library`, { key: 'leather-armor' })).body.item;
const ring = (await post(`${c}/items/from-library`, { key: 'ring-of-protection' })).body.item;
const sword = (await post(`${c}/weapons/from-library`, { key: 'longsword' })).body.weapon;
const daggers = (await post(`${c}/weapons/from-library`, { key: 'paired-daggers' })).body.weapon;
expect(daggers.effectNote).toContain('both hands');
for (const item of [shield, mail, ring]) await post(`${ch}/inventory`, { itemId: item.id });
for (const weapon of [sword, daggers]) await post(`${ch}/weapons`, { weaponId: weapon.id });
const encounterId = (await post(`${c}/encounters`, { name: 'Drill' })).body.encounter.id;
await post(`/encounters/${encounterId}/participants`, { characterId: heroId });
const combat = async () => (await request(app).get(`/encounters/${encounterId}`).set(playerAuth)).body.encounter.participants[0].characterCombatInfo;
const wear = (path, id, equipped, auth = playerAuth) => request(app).patch(`${ch}/${path}/${id}`).set(auth).send({ equipped });
// Nothing in hand: nothing to fight with, though it is all in the pack.
expect(await combat()).toMatchObject({ weapons: [], items: [] });
const panel = async () => (await request(app).get(`${ch}/quick-stats`).set(playerAuth)).body.quickStats;
expect((await panel()).weapons.map((w) => w.name).sort()).toEqual(['Longsword', 'Paired Daggers']);
// One weapon at a time; the player changes it themselves, mid-encounter.
expect((await wear('weapons', sword.id, true)).status).toBe(200);
expect((await combat()).weapons.map((w) => w.name)).toEqual(['Longsword']);
const second = await wear('weapons', daggers.id, true);
expect(second.status).toBe(400);
expect(second.body.error).toContain('Longsword');
expect((await wear('weapons', sword.id, true)).status).toBe(200); // already on: no complaint
await wear('weapons', sword.id, false);
expect((await wear('weapons', daggers.id, true)).status).toBe(200);
expect((await combat()).weapons.map((w) => [w.name, w.damageDice])).toEqual([['Paired Daggers', '2d4']]);
// Two things in all: a weapon and an item, or two items.
expect((await wear('inventory', shield.id, true)).status).toBe(200);
expect((await combat()).items.map((i) => i.name)).toEqual(['Shield']);
const third = await wear('inventory', mail.id, true);
expect(third.status).toBe(400);
expect(third.body.error).toContain('Shield');
expect((await post(`${ch}/inventory`, { itemId: ring.id, equipped: true })).status).toBe(400); // nor by handing it over equipped
await wear('weapons', daggers.id, false);
expect((await wear('inventory', mail.id, true)).status).toBe(200);
expect((await wear('weapons', sword.id, true)).status).toBe(400); // both places are taken by items
expect((await combat()).items.map((i) => i.name).sort()).toEqual(['Leather Armor', 'Shield']);
// The DM sets the limits; raising them makes room, lowering them takes nothing off.
expect((await request(app).patch(c).set(playerAuth).send({ maxEquipped: 5 })).status).toBe(403);
expect((await request(app).patch(c).set(dmAuth).send({ maxEquipped: 0 })).status).toBe(400);
expect((await request(app).patch(c).set(dmAuth).send({ maxEquipped: 4, maxEquippedWeapons: 2 })).body.campaign).toMatchObject({ maxEquipped: 4, maxEquippedWeapons: 2 });
expect((await wear('weapons', sword.id, true)).status).toBe(200);
expect((await wear('weapons', daggers.id, true)).status).toBe(200);
expect((await wear('inventory', ring.id, true)).status).toBe(400); // four on
await request(app).patch(c).set(dmAuth).send({ maxEquipped: 2, maxEquippedWeapons: 1 });
expect((await combat()).weapons).toHaveLength(2);
await wear('weapons', sword.id, false);
expect((await wear('weapons', sword.id, true)).status).toBe(400);
});
});
+53 -4
View File
@@ -4,8 +4,9 @@ const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { requireCampaignMember, requireDM } = require('../middleware/campaignAccess');
const { generateInviteCode } = require('../utils/inviteCode');
const { computeCharacterSheet, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
const { WEAPON_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
const { computeCharacterSheet, GEAR_STATS, MAX_DAMAGE_SCALING, MAX_GEAR_LEVEL } = require('../game/calculations');
const { SHEET_INCLUDE, settleHp } = require('../utils/sheet');
const { WEAPON_LIBRARY, ITEM_LIBRARY, SPELL_LIBRARY, MONSTER_LIBRARY } = require('../game/data/library');
const { visitedLocationIds } = require('../utils/locations');
const { deathSaveInfo } = require('../utils/hp');
const { CURRENCIES } = require('../utils/grants');
@@ -27,6 +28,8 @@ function campaignSummary(campaign, role) {
skipMotivationRoll: campaign.skipMotivationRoll,
showMonsterHp: campaign.showMonsterHp,
areaAttackAcPenalty: campaign.areaAttackAcPenalty,
maxEquipped: campaign.maxEquipped,
maxEquippedWeapons: campaign.maxEquippedWeapons,
ownerId: campaign.ownerId,
archivedAt: campaign.archivedAt,
role,
@@ -57,6 +60,11 @@ const createCampaignSchema = z.object({
skipMotivationRoll: z.boolean().optional(),
showMonsterHp: z.boolean().optional(),
areaAttackAcPenalty: z.number().int().min(0).max(10).optional(),
// How many weapons and items a character can have equipped together, and
// how many of those can be weapons. Lowering them takes nothing off anyone:
// they are checked when something is put on.
maxEquipped: z.number().int().min(1).max(20).optional(),
maxEquippedWeapons: z.number().int().min(0).max(20).optional(),
});
// POST /campaigns — create, creator becomes DM/owner
@@ -214,7 +222,7 @@ router.get('/:id/characters', requireCampaignMember(), async (req, res, next) =>
try {
const characters = await prisma.character.findMany({
where: { campaignId: req.campaign.id },
include: { skillProficiencies: true, saveProficiencies: true, currentLocation: true },
include: { ...SHEET_INCLUDE, currentLocation: true },
orderBy: { createdAt: 'asc' },
});
const summaries = characters.map(characterSummary);
@@ -312,6 +320,25 @@ router.get('/:id/weapons', requireCampaignMember(), async (req, res, next) => {
}
});
// What a weapon or item does to the sheet of whoever has it equipped: signed
// amounts on the numbers in GEAR_STATS (armor is an item with an AC effect),
// and a note for what is not a number.
const gearEffects = {
effects: z
.array(z.object({ stat: z.enum(GEAR_STATS), amount: z.number().int().min(-100).max(100).refine((n) => n !== 0, 'An effect of 0 does nothing') }))
.max(12)
.optional(),
effectNote: z.string().trim().max(300).optional().nullable(),
};
// A change to what a pool entry does reaches everyone who has it equipped:
// their maximum HP may have dropped, and every open sheet should refetch.
async function gearChanged(req, heldModel, poolKey, poolId, holderIds) {
const holders = holderIds || (await prisma[heldModel].findMany({ where: { [poolKey]: poolId }, select: { characterId: true } })).map((h) => h.characterId);
await settleHp(prisma, holders);
req.app.get('io').to(`campaign:${req.campaign.id}`).emit('character:updated', {});
}
const weaponSchema = z.object({
name: z.string().min(1).max(80),
damageDice: z.string().min(1).max(20),
@@ -323,6 +350,7 @@ const weaponSchema = 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(),
...gearEffects,
});
// Raising a pool entry's starting level lifts every held copy that is now
@@ -367,6 +395,7 @@ router.patch('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), asy
const weapon = await updatePoolEntry('weapon', req.params.weaponId, req.campaign.id, parsed.data);
if (!weapon) return res.status(404).json({ error: 'Weapon not found in this campaign' });
await liftHeldCopies('characterWeapon', 'weaponId', weapon.id, parsed.data.minLevel);
await gearChanged(req, 'characterWeapon', 'weaponId', weapon.id);
res.json({ weapon });
} catch (err) {
next(err);
@@ -375,9 +404,11 @@ router.patch('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), asy
router.delete('/:id/weapons/:weaponId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const holders = (await prisma.characterWeapon.findMany({ where: { weaponId: req.params.weaponId }, select: { characterId: true } })).map((h) => h.characterId);
if (!(await deletePoolEntry('weapon', req.params.weaponId, req.campaign.id))) {
return res.status(404).json({ error: 'Weapon not found in this campaign' });
}
await gearChanged(req, 'characterWeapon', 'weaponId', req.params.weaponId, holders);
res.status(204).end();
} catch (err) {
next(err);
@@ -464,6 +495,7 @@ 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(),
...gearEffects,
});
router.post('/:id/items', requireCampaignMember(), requireDM(), async (req, res, next) => {
@@ -484,6 +516,7 @@ router.patch('/:id/items/:itemId', requireCampaignMember(), requireDM(), async (
const item = await updatePoolEntry('item', req.params.itemId, req.campaign.id, parsed.data);
if (!item) return res.status(404).json({ error: 'Item not found in this campaign' });
await liftHeldCopies('inventoryItem', 'itemId', item.id, parsed.data.minLevel);
await gearChanged(req, 'inventoryItem', 'itemId', item.id);
res.json({ item });
} catch (err) {
next(err);
@@ -492,9 +525,11 @@ router.patch('/:id/items/:itemId', requireCampaignMember(), requireDM(), async (
router.delete('/:id/items/:itemId', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const holders = (await prisma.inventoryItem.findMany({ where: { itemId: req.params.itemId }, select: { characterId: true } })).map((h) => h.characterId);
if (!(await deletePoolEntry('item', req.params.itemId, req.campaign.id))) {
return res.status(404).json({ error: 'Item not found in this campaign' });
}
await gearChanged(req, 'inventoryItem', 'itemId', req.params.itemId, holders);
res.status(204).end();
} catch (err) {
next(err);
@@ -891,7 +926,7 @@ router.delete('/:id/wares/:wareId', requireCampaignMember(), requireDM(), async
router.get('/:id/library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
res.json({ weapons: WEAPON_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY });
res.json({ weapons: WEAPON_LIBRARY, items: ITEM_LIBRARY, spells: SPELL_LIBRARY, monsters: MONSTER_LIBRARY });
} catch (err) {
next(err);
}
@@ -911,6 +946,20 @@ router.post('/:id/weapons/from-library', requireCampaignMember(), requireDM(), a
}
});
router.post('/:id/items/from-library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = z.object({ key: z.string() }).safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
const template = ITEM_LIBRARY.find((i) => i.key === parsed.data.key);
if (!template) return res.status(404).json({ error: 'Unknown library item.' });
const { key, category, ...data } = template;
const item = await prisma.item.create({ data: { ...data, campaignId: req.campaign.id } });
res.status(201).json({ item });
} catch (err) {
next(err);
}
});
router.post('/:id/spells/from-library', requireCampaignMember(), requireDM(), async (req, res, next) => {
try {
const parsed = z.object({ key: z.string() }).safeParse(req.body);
+96 -16
View File
@@ -31,6 +31,7 @@ const characterOptions = require('../game/data/characterOptions');
const { SKILLS } = require('../game/data/skills');
const { CURRENCIES, COINS, COIN_VALUE, COIN_COLUMN, InsufficientGoldError, adjustGold, spendCoins, worthInGold, giveGear } = require('../utils/grants');
const { recordVisits } = require('../utils/locations');
const { SHEET_INCLUDE, settleHp } = require('../utils/sheet');
const { absorbDamage, NO_DEATH_SAVES, lifeState, deathSaveInfo, settleDeathSaves, changeHp, rollDeathSave } = require('../utils/hp');
const router = express.Router();
@@ -150,7 +151,7 @@ const patchCharacterSchema = z.object({
name: z.string().min(1).max(80).optional(),
currentHp: z.number().int().optional(),
tempHp: z.number().int().min(0).max(9999).optional(),
armorBonus: z.number().int().min(0).max(30).optional(),
armorBonus: z.number().int().min(-30).max(30).optional(),
speedOverride: z.number().int().min(0).nullable().optional(),
specialAbilities: z.string().max(4000).nullable().optional(),
background: z.string().max(4000).nullable().optional(),
@@ -165,8 +166,9 @@ const patchCharacterSchema = z.object({
// builder composed (special abilities, background, appearance): a player
// picks those from lists and cannot type their own. The owning player can
// edit the rest of their own sheet. Gold is not patchable at all — it only
// moves through the ledger (POST /:id/gold).
const DM_ONLY_PATCH_FIELDS = ['level', 'xp', 'currentLocationId', 'speedOverride', 'specialAbilities', 'background', 'appearance'];
// moves through the ledger (POST /:id/gold). The armor bonus is the DM's
// adjustment on top of what equipped gear gives, so it is the DM's too.
const DM_ONLY_PATCH_FIELDS = ['level', 'xp', 'currentLocationId', 'speedOverride', 'armorBonus', 'specialAbilities', 'background', 'appearance'];
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
@@ -181,7 +183,7 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
if (!parsed.success) return res.status(400).json({ error: parsed.error.flatten() });
if (!req.isDM && DM_ONLY_PATCH_FIELDS.some((field) => parsed.data[field] !== undefined)) {
return res.status(403).json({ error: 'Only the DM can change level, XP, speed, location, special abilities, background or appearance.' });
return res.status(403).json({ error: 'Only the DM can change level, XP, speed, armor bonus, location, special abilities, background or appearance.' });
}
// Damage is soaked by temp HP before it reaches real HP — unless this
@@ -190,7 +192,8 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
const settingTempHp = parsed.data.tempHp !== undefined;
if (req.character.buildStage === 'COMPLETE' && (settingHp || parsed.data.level !== undefined)) {
// Current HP stays within 0..max, including when a level change moves max.
const maxHp = computeCharacterSheet({ ...req.character, ...parsed.data }).hp.max;
const geared = await prisma.character.findUnique({ where: { id: req.character.id }, include: SHEET_INCLUDE });
const maxHp = computeCharacterSheet({ ...geared, ...parsed.data }, geared.skillProficiencies, geared.saveProficiencies).hp.max;
if (!settingHp) {
parsed.data.currentHp = clamp(req.character.currentHp, 0, maxHp);
} else {
@@ -233,6 +236,8 @@ router.patch('/:id', requireCharacterAccess({ write: true }), async (req, res, n
where: { characterId: updated.id, spell: { level: { gt: updated.level } } },
data: { prepared: false },
});
// Gear that came off may have been holding the maximum HP up.
await settleHp(prisma, [updated.id]);
}
const full = await loadFullCharacter(updated.id);
@@ -893,22 +898,53 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
return res.status(400).json({ error: 'Character build is not finished yet.' });
}
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
// The pack, the spell list and the purse are the owner's and the DM's to
// see, as on the sheet; anyone else gets what is visible across the table.
const restricted = !req.isOwner && !req.isDM;
const effectsOf = (entry) => ({ effects: Array.isArray(entry.effects) ? entry.effects : [], effectNote: entry.effectNote || null });
const weapons = character.weapons
.filter((cw) => canUseGear(character.level, cw.level))
.map((cw) => ({
characterWeaponId: cw.id,
weaponId: cw.weaponId,
name: cw.weapon.name,
level: cw.level,
equipped: cw.equipped,
quantity: cw.quantity,
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true, cw.level),
}));
// Everything held is listed, usable or not: `usable` says whether the
// character is high enough level for it.
const weapons = character.weapons.map((cw) => ({
characterWeaponId: cw.id,
weaponId: cw.weaponId,
name: cw.weapon.name,
level: cw.level,
equipped: cw.equipped,
quantity: cw.quantity,
usable: canUseGear(character.level, cw.level),
...effectsOf(cw.weapon),
...computeWeaponStats(cw.weapon, sheet.abilities, sheet.proficiencyBonus, true, cw.level),
}));
const inventory = character.inventoryItems.map((ii) => ({
inventoryItemId: ii.id,
itemId: ii.itemId,
name: ii.item.name,
description: ii.item.description,
level: ii.level,
equipped: ii.equipped,
quantity: ii.quantity,
usable: canUseGear(character.level, ii.level),
damageDice: ii.item.damageDice ? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel) : null,
...effectsOf(ii.item),
}));
const spells = character.spells.map((cs) => ({
characterSpellId: cs.id,
name: cs.spell.name,
level: cs.spell.level,
damageDice: cs.spell.damageDice,
description: cs.spell.description,
prepared: cs.prepared,
usable: canUseGear(character.level, cs.spell.level),
}));
res.json({
quickStats: {
id: character.id,
name: character.name,
level: character.level,
species: character.species,
characterClass: character.characterClass,
currentHp: character.currentHp,
maxHp: sheet.hp.max,
tempHp: character.tempHp,
@@ -918,8 +954,16 @@ router.get('/:id/quick-stats', requireCharacterAccess(), async (req, res, next)
speed: sheet.speed,
proficiencyBonus: sheet.proficiencyBonus,
spellcasting: sheet.spellcasting,
passivePerception: sheet.passivePerception,
abilities: sheet.abilities,
specialAbilities: character.specialAbilities,
// What equipped gear is adding and taking away, and what else it does.
gear: sheet.gear,
weapons,
restricted,
inventory: restricted ? [] : inventory,
spells: restricted ? [] : spells,
purse: restricted ? null : { gold: character.gold, silver: character.silver, copper: character.copper, cash: character.cash },
},
});
} catch (err) {
@@ -1068,6 +1112,29 @@ const GEAR = [
const gearLevelSchema = z.number().int().min(1).max(MAX_GEAR_LEVEL);
// A character can only have so much equipped at once (campaign settings:
// weapons and items together, and weapons alone). Returns why `adding` — a
// weapon or an item, not yet counted — cannot go on as well, or null if it
// can. `exceptPoolId` is the pool entry being equipped, in case it already is.
async function equipBlocked(req, kind, exceptPoolId) {
const { maxEquipped, maxEquippedWeapons } = req.campaign;
const where = { characterId: req.character.id, equipped: true };
const [weapons, items] = await Promise.all([
prisma.characterWeapon.findMany({ where: { ...where, ...(kind === 'weapon' ? { weaponId: { not: exceptPoolId } } : {}) }, include: { weapon: true } }),
prisma.inventoryItem.findMany({ where: { ...where, ...(kind === 'item' ? { itemId: { not: exceptPoolId } } : {}) }, include: { item: true } }),
]);
const names = (rows, key) => rows.map((row) => row[key].name).join(', ');
if (kind === 'weapon' && weapons.length >= maxEquippedWeapons) {
if (maxEquippedWeapons === 0) return 'Nobody can equip a weapon in this campaign.';
return `${req.character.name} already has ${names(weapons, 'weapon')} equipped, and can hold ${maxEquippedWeapons === 1 ? 'one weapon' : `${maxEquippedWeapons} weapons`} at a time. Unequip it first.`;
}
if (weapons.length + items.length >= maxEquipped) {
const on = [names(weapons, 'weapon'), names(items, 'item')].filter(Boolean).join(', ');
return `${req.character.name} already has ${maxEquipped === 1 ? 'something' : `${maxEquipped} things`} equipped (${on}). Unequip one first.`;
}
return null;
}
for (const gear of GEAR) {
const uniqueKey = `characterId_${gear.key}`;
const whereHeld = (req) => ({ [uniqueKey]: { characterId: req.character.id, [gear.key]: req.params.poolId } });
@@ -1105,6 +1172,10 @@ for (const gear of GEAR) {
if (parsed.data[gear.flag] && !canUseGear(req.character.level, requiredLevel(parsed.data, entry))) {
return res.status(400).json({ error: tooHighMessage(requiredLevel(parsed.data, entry)) });
}
if (gear.ownLevel && parsed.data.equipped) {
const blocked = await equipBlocked(req, gear.pool, poolId);
if (blocked) return res.status(400).json({ error: blocked });
}
// Giving something the character already holds adds to the stack (the
// stack keeps the level it has).
@@ -1114,6 +1185,7 @@ for (const gear of GEAR) {
update: gear.ownLevel ? { quantity: { increment: parsed.data.quantity } } : { [gear.flag]: parsed.data[gear.flag] },
include,
});
if (gear.ownLevel) await settleHp(prisma, [req.character.id]);
emitCharacterUpdated(req);
res.status(201).json({ [gear.held]: row });
} catch (err) {
@@ -1145,8 +1217,13 @@ for (const gear of GEAR) {
if (data[gear.flag]) return res.status(400).json({ error: tooHighMessage(needed) });
data[gear.flag] = false; // levelled past its holder: it comes off
}
if (gear.ownLevel && data.equipped && !existing.equipped) {
const blocked = await equipBlocked(req, gear.pool, req.params.poolId);
if (blocked) return res.status(400).json({ error: blocked });
}
const row = await prisma[gear.held].update({ where: whereHeld(req), data, include });
if (gear.ownLevel) await settleHp(prisma, [req.character.id]);
emitCharacterUpdated(req);
res.json({ [gear.held]: row });
} catch (err) {
@@ -1161,6 +1238,7 @@ for (const gear of GEAR) {
where: { characterId: req.character.id, [gear.key]: req.params.poolId },
});
if (count === 0) return res.status(404).json({ error: `This character doesn't have that ${gear.label}.` });
if (gear.ownLevel) await settleHp(prisma, [req.character.id]);
emitCharacterUpdated(req);
res.status(204).end();
} catch (err) {
@@ -1247,6 +1325,8 @@ router.post('/:id/give', requireCharacterAccess({ write: true }), async (req, re
throw err;
}
// What was handed over may have been equipped, and holding the giver's maximum HP up.
await settleHp(prisma, [giver.id]);
const io = req.app.get('io');
for (const characterId of [giver.id, recipient.id]) {
io.to(`campaign:${giver.campaignId}`).emit('character:updated', { characterId });
+23 -10
View File
@@ -3,9 +3,10 @@ const { z } = require('zod');
const prisma = require('../db');
const { requireAuth } = require('../middleware/auth');
const { loadCampaignMembership } = require('../middleware/campaignAccess');
const { computeCharacterSheet, computeWeaponStats, canUseGear, abilityModifier, MAX_GEAR_LEVEL } = require('../game/calculations');
const { computeCharacterSheet, computeWeaponStats, scaledDamageDice, canUseGear, abilityModifier, MAX_GEAR_LEVEL } = require('../game/calculations');
const { CURRENCIES, InsufficientGoldError, adjustGold, spendCoins, giveGear } = require('../utils/grants');
const { moveCharacters, visitedLocationIds } = require('../utils/locations');
const { SHEET_INCLUDE } = require('../utils/sheet');
const { NO_DEATH_SAVES, lifeState, deathSaveInfo, changeHp } = require('../utils/hp');
const router = express.Router();
@@ -106,15 +107,26 @@ function revivalSpell(character) {
const initiativeRollSchema = z.number().int().min(1, 'An initiative roll is a d20: 1 to 20').max(20, 'An initiative roll is a d20: 1 to 20');
// What every campaign member sees about a character in an encounter: the
// sheet's headline numbers plus the weapons and spells it can actually use —
// so the party can plan around each other mid-fight, not only the DM. Uses the
// sheet's headline numbers plus what it has in hand — the weapons and items
// it has equipped — and the spells it can cast, so the party can plan around
// each other mid-fight, not only the DM. A weapon has to be equipped to be
// used; what is still in the pack is in the character panel, for the
// character's own player and the DM. Uses the
// same calculation-engine functions as the sheet, never re-deriving formulas.
function characterCombatInfo(character) {
const sheet = computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
// Gear and spells above the character's level are held but can't be used,
// so they stay out of the fight.
// so they stay out of the fight; so does anything not equipped.
const inHand = (held) => held.equipped && canUseGear(character.level, held.level);
const items = character.inventoryItems.filter(inHand).map((ii) => ({
inventoryItemId: ii.id,
name: ii.item.name,
level: ii.level,
quantity: ii.quantity,
damageDice: ii.item.damageDice ? scaledDamageDice(ii.item.damageDice, ii.level, ii.item.damageScaling, ii.item.minLevel) : null,
}));
const weapons = character.weapons
.filter((cw) => canUseGear(character.level, cw.level))
.filter(inHand)
.map((cw) => ({
characterWeaponId: cw.id,
name: cw.weapon.name,
@@ -149,7 +161,10 @@ function characterCombatInfo(character) {
abilities: sheet.abilities,
saves: sheet.saves,
specialAbilities: character.specialAbilities,
// What equipped gear is adding and taking away. The numbers above already include it.
gear: sheet.gear,
weapons,
items,
spells,
spellcasting: sheet.spellcasting,
};
@@ -180,9 +195,7 @@ async function serializeEncounter(encounterId, isDM, viewerId) {
include: {
character: {
include: {
skillProficiencies: true,
saveProficiencies: true,
weapons: { include: { weapon: true } },
...SHEET_INCLUDE,
spells: { include: { spell: true } },
},
},
@@ -365,7 +378,7 @@ router.post('/:id/participants', requireEncounterAccess, requireDM, async (req,
if (characterId) {
const character = await prisma.character.findUnique({
where: { id: characterId },
include: { skillProficiencies: true, saveProficiencies: true },
include: SHEET_INCLUDE,
});
if (!character || character.campaignId !== req.encounter.campaignId) {
return res.status(400).json({ error: 'That character is not part of this campaign.' });
@@ -436,7 +449,7 @@ router.patch('/:id/participants/:participantId', requireEncounterAccess, async (
const existing = await prisma.encounterParticipant.findUnique({
where: { id: req.params.participantId },
include: { character: { include: { skillProficiencies: true, saveProficiencies: true } }, monster: true },
include: { character: { include: SHEET_INCLUDE }, monster: true },
});
if (!existing || existing.encounterId !== req.encounter.id) {
return res.status(404).json({ error: 'Combatant not found in this encounter' });
+35
View File
@@ -0,0 +1,35 @@
// Loading a character so that its sheet comes out right. The sheet depends on
// what the character has equipped (game/calculations.js, activeGear), so every
// query that feeds computeCharacterSheet includes the same relations.
const { computeCharacterSheet } = require('../game/calculations');
const SHEET_INCLUDE = {
skillProficiencies: true,
saveProficiencies: true,
inventoryItems: { include: { item: true } },
weapons: { include: { weapon: true } },
};
function sheetOf(character) {
return computeCharacterSheet(character, character.skillProficiencies, character.saveProficiencies);
}
// Equipping, unequipping or changing gear can lower a character's maximum HP.
// Brings current HP back down to the new maximum where it is now above it,
// and returns the ids of the characters that changed. Takes a Prisma client or
// transaction client as `db`.
async function settleHp(db, characterIds) {
const changed = [];
const characters = await db.character.findMany({ where: { id: { in: [...new Set(characterIds)] }, buildStage: 'COMPLETE' }, include: SHEET_INCLUDE });
for (const character of characters) {
const max = sheetOf(character).hp.max;
if (character.currentHp > max) {
await db.character.update({ where: { id: character.id }, data: { currentHp: max } });
changed.push(character.id);
}
}
return changed;
}
module.exports = { SHEET_INCLUDE, sheetOf, settleHp };
+41 -19
View File
@@ -137,7 +137,7 @@ this order:
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 armor bonus.
Unarmored Defense does not change AC unless the DM sets the extra AC.
**Background** is five lines, one per question:
@@ -373,17 +373,27 @@ encounter.
| Stat | Formula |
|---|---|
| Armor Class | 10 + DEX modifier + armor bonus |
| Initiative | DEX modifier |
| Speed | Species speed, unless a speed override is set |
| Passive Perception | 10 + Perception skill bonus |
| 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 |
| Passive Perception | 10 + Perception skill bonus + equipped gear |
| Hit dice | One per level, of the class's die size (shown only; not spent or tracked) |
| Spell save DC | 8 + proficiency bonus + spellcasting ability modifier |
| Spell attack bonus | Proficiency bonus + spellcasting ability modifier |
**Armor bonus** is a single number typed in on the sheet to stand for armor and
shield. There are no armor items, and equipping an inventory item does not
change AC.
**Equipped gear.** Armor, shields and other gear are items (and weapons) with
**effects**: bonuses and penalties to AC, speed, initiative, max HP, passive
Perception, ability scores, saving throws and skills. They apply on their own
while the thing is equipped, and stop when it comes off. Every number on the
sheet already includes them, and the "From Equipped Gear" card says which
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.
**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
it was set to.)
**Speed override** exists in the data and API (DM only) but has no field on
the sheet.
@@ -427,7 +437,10 @@ character.
characters from the pool, changes how many a character has, levels them up,
and takes them away.
- **The owning player** can tick and untick Equipped (weapons, items) and
Prepared (spells) on gear their character can use, and can **give** what
Prepared (spells) on gear their character can use, at any time, including
mid-encounter. Only so much can be equipped at once: by default two things,
one of them a weapon (a campaign setting; [ITEMS.md](ITEMS.md), section 3).
A weapon has to be equipped to be used in an encounter, and can **give** what
their character holds to another character (section 12).
- Removing an entry from a pool removes it from every character holding it.
@@ -462,8 +475,9 @@ starting level, copies below it are lifted to it and unequipped.
| … | … |
Gear above the character's level can still be **held**, but it shows
"Requires level N", cannot be equipped, and is left out of quick stats and
encounters. If the DM levels a piece of gear past its holder, or lowers the
"Requires level N", cannot be equipped, gives none of its effects, and is
left out of the character's encounter card. The character panel still lists
it, marked "needs level N". If the DM levels a piece of gear past its holder, or lowers the
character's level, anything now out of reach is unequipped automatically.
**How damage grows.** The dice in the pool are the damage at the gear's
@@ -519,6 +533,10 @@ value, which is fixed.
## 11. Weapons and spells
A character holds weapons, each with a quantity, a level and an equipped flag.
Only an **equipped** weapon is in hand: it is the one shown on the character's
encounter card and offered in the DM's attack bar. One weapon can be equipped
at a time unless the campaign allows more, so a weapon for each hand is one
weapon made as a pair ([WEAPONS.md](WEAPONS.md), sections 3 and 4).
| Number | Formula |
|---|---|
@@ -549,7 +567,9 @@ A character knows spells, each with a prepared flag.
**Inventory** is the items a character holds from the campaign's item pool,
each with a quantity, a level and an equipped flag. Giving an item the
character already has adds to the stack. Items have no effect on any stat. An
character already has adds to the stack. An item can have **effects** that
change the sheet while it is equipped: this is how armor works
([ITEMS.md](ITEMS.md), section 5). An
item can carry optional dice (a potion's healing, a bomb's damage) that grow
with its level exactly like a weapon's (section 10).
@@ -642,11 +662,11 @@ character in the same campaign.
| Screen | What it shows |
|---|---|
| Campaign page | Character list: species, class, level, HP, AC, location. Players see their own first, then the rest of the party. |
| Party overview | One table of every character with an HP bar, AC, gold and location. |
| Campaign page | Character list: species, class, level, HP, AC, location. A player's own characters are under "Your Characters" and nowhere else; "Rest of the Party" is everyone else's. |
| Party overview | One table of every character with an HP bar, AC, gold and location. A player's own characters are highlighted and marked "★ Yours". |
| Character sheet | Everything in this document. |
| Character builder | A character that is still being built (section 2). |
| Quick stats drawer | A combat summary: HP, AC, initiative, speed, proficiency bonus, spellcasting numbers, special abilities, and each weapon's attack and damage. |
| Character panel (the floating 🧙 button, on every page: bottom right, or in the top bar on a phone) | One character at a glance: HP, AC, initiative, speed, proficiency bonus, passive Perception, ability scores, what equipped gear is doing, spellcasting numbers and special abilities; then **everything the character holds**: every weapon with its attack and damage, every item, every spell and the purse, with what is equipped and what each thing does. Gear can be equipped and unequipped from here. A player gets their own characters in the campaign they are in; the DM gets every character, with a menu to switch. Another player's panel shows the numbers and weapons only. It replaced the quick stats drawer. |
| Encounter page | Characters taking part in a battle or quest (next section). |
Changes are pushed live to everyone who has the campaign open, so sheets and
@@ -749,7 +769,9 @@ Another Campaign" card on its sheet.
| View the sheet and quick stats | Yes | Yes | Yes |
| View private notes | Yes | No | No |
| Run the builder | Yes | Yes | No |
| Change HP, temp HP, armor bonus | Yes | Yes | No |
| Change HP, temp HP | Yes | Yes | No |
| Set the extra AC | No | Yes | No |
| Open the character panel with the full inventory | Yes | Yes | No (numbers and weapons only) |
| Enter a death save, or stabilise | Yes | Yes | No |
| Set death-save tallies by hand, revive from dead | No | Yes | No |
| Edit special abilities, background, appearance (after the build) | No | Yes | No |
@@ -786,11 +808,11 @@ calculations for them.
subclasses and multiclassing
- Spell slots, spell preparation limits and concentration
- Ability score increases and feats on level-up
- Armor and shields as items (only the single armor bonus number)
- Armor rules beyond a flat bonus to AC (no "replaces 10 + DEX", no cap on DEX)
- Weapon proficiency (everyone is proficient with everything)
- Rolling attacks, damage, saves or checks for a character (the dice roller,
bottom left of every page, is a plain roller that knows nothing about the
sheet)
bottom left of every page, or the last button of the bottom bar on a phone,
is a plain roller that knows nothing about the sheet)
- Conditions on the sheet, and resting
- Automatic levelling from XP
- Alignment, languages, tool proficiencies and backgrounds-as-rules
+27 -5
View File
@@ -88,6 +88,10 @@ goes to the top. The "Sort by" menu re-sorts the list by name, status, type or
date instead; that choice is remembered per browser and changes nothing for
anyone else. ▲ and ▼ only show while sorting by campaign order.
The list shows **ten encounters a page**, with Prev and Next beneath it, and
the DM's archived list is paged the same way. The order runs on across pages:
moving the last encounter on a page down carries it to the top of the next.
**Archive.** "Archive" (on the list or the encounter page) puts an encounter
away without changing it. It moves to the DM's "Archived" section and
disappears from the players' list; a player whose character is in it can still
@@ -117,12 +121,29 @@ The DM adds people from the "Add to the scene" panel.
| Monster | The campaign's monster list ([MONSTERS.md](MONSTERS.md)) | Yes |
| NPC | The campaign's NPC list ([NPCS.md](NPCS.md)) | No |
**Finding your own character.** A player's own characters stand out: their
cards have a blue border and background and say "★ Your character", and a row
of buttons at the top of the page names each one with its HP ("★ Aria · 12/12
HP", plus "your turn" when it is) and scrolls to its card. The DM sees the
same for any character of their own.
**Inventory.** A character's card has an "🎒 Inventory" button for the DM and
for that character's own player. It opens the character panel on that
character: everything they hold, what is equipped and what each thing does
([CHARACTERS.md](CHARACTERS.md), section 14). The floating 🧙 button opens the
same panel from anywhere. A card's details also list what the character's
equipped gear is doing; the AC, speed and other numbers on the card already
include it.
Each has a card. The card's left edge is coloured by kind, and it shows:
- **Characters:** level, species and class; HP, AC, speed, initiative bonus,
passive perception, proficiency bonus and spell save DC. Under "details":
ability scores, saves, the weapons and spells the character can use (with
attack and damage numbers), and special abilities. The name links to the
ability scores, saves, the weapon and items the character has **equipped**
(with attack and damage numbers), the spells they can cast, and special
abilities. A weapon that is not equipped is not on the card and cannot be
used; "No weapon equipped" shows when there is none. Equipping is changed
from "🎒 Inventory", at any point in the scene. The name links to the
character sheet.
- **Monsters:** for the DM, AC, speed, attack and damage, and under "details"
ability scores, trait and notes.
@@ -276,8 +297,8 @@ healed) right now. One target is an ordinary attack; more than one is an
<whoever is on turn>".
- While anyone is marked the DM gets an **attack bar** above the list, which
stays in view while scrolling. It shows who is attacking whom, each target's
AC and HP, and the attacker's attacks: a character's equipped weapons (all
of them if none are equipped) and damaging spells, or a monster's or NPC's
AC and HP, and the attacker's attacks: a character's equipped weapons (none,
if nothing is equipped) and damaging spells, or a monster's or NPC's
attack and damage. The amount box is ready to type in; **Damage** (or
Enter) and **Heal** change HP exactly as the buttons on a card do.
- Targets stay marked after damage, for a second attack. They are cleared
@@ -404,6 +425,7 @@ when there is something to trade, and always for the DM in a quest.
| Scene description | Yes | Yes |
| DM notes | Yes | Never |
| Characters | Full card | Full card |
| A character's inventory, spells and purse | Yes, for everyone | Their own characters only |
| Monsters | Full stat block | Name, HP, conditions |
| NPCs | Everything | Name, role, attitude, description, HP bar |
| Monsters and NPCs at 0 HP | With "Show dead" ticked, marked hidden | Nothing |
@@ -442,7 +464,7 @@ People outside the campaign cannot open its encounters.
## 13. What is not modelled
- Rolling initiative, attacks, damage or saves (use the dice roller, bottom
left of every page)
left of every page, or in the bottom bar on a phone)
- Applying an attack's damage automatically
- Death saves for monsters and NPCs, and prompting for a save on a dying
character's turn
+173 -6
View File
@@ -14,10 +14,12 @@ level up the same way weapons do), [SPELLS.md](SPELLS.md),
3. [A character's inventory](#3-a-characters-inventory)
4. [Item levels](#4-item-levels)
5. [Dice on items](#5-dice-on-items)
- [Effects: armor and other gear that changes the sheet](#effects-armor-and-other-gear-that-changes-the-sheet)
6. [Starting items](#6-starting-items)
7. [Items as rewards and for sale](#7-items-as-rewards-and-for-sale)
8. [Permissions](#8-permissions)
9. [What is not modelled](#9-what-is-not-modelled)
10. [The complete item library](#10-the-complete-item-library)
---
@@ -27,7 +29,8 @@ level up the same way weapons do), [SPELLS.md](SPELLS.md),
anything that is not a weapon or a spell.
- A character's **inventory** is the items it holds from that pool. Players
cannot type in items of their own.
- The DM creates items and hands them out. Players can only tick Equipped.
- The DM creates items and hands them out. Players can only tick Equipped,
and only so much can be equipped at once (section 3).
- Items are **descriptive**. Holding or equipping one never changes a number
on the sheet.
@@ -43,8 +46,16 @@ The pool is the "Items Pool" card on the campaign page (DM only).
| Starts at level | 1 to 9. The lowest level this item can be (section 4) |
| Growth | Standard, Powerful or Legendary. Only matters if the item has dice |
| Starting option | Whether a brand-new character may pick it in the builder (section 6) |
| Effects | What it does to whoever has it equipped: bonuses and penalties to the sheet's numbers, and a note (section 5) |
- **Adding:** the "+ Add Item" form. There is no built-in library of items.
- **Adding:** by hand with the "+ Add Item" form, or with "📚 From Library",
which offers 32 ready-made items: light, medium and heavy armor, shields,
worn gear such as a Ring of Protection, and common supplies. Each is copied
into the pool with its effects already set (Chain Mail: +6 AC, −10 Speed,
and a note about Stealth), at starting level 1 and not ticked as a starting
option. The copy is yours to change. The full list is in section 10.
- **Effects:** the "Effects" button on an item's row. The row shows what the
item does under its name.
- **Changing:** starting level, growth (when the item has dice) and the
starting-option tick box can be changed in the table. Name, description and
dice cannot be changed on screen once created (the API accepts it); remove
@@ -67,8 +78,39 @@ On a character's sheet, the DM picks an item from the pool and clicks Give.
- Quantity cannot go below 1; to take the last one away the DM uses ✕.
- **Using up** an item (drinking a potion) is not automatic: the DM lowers the
quantity or removes it.
- **Equipped** is a label. It does not change Armor Class or anything else;
armor is represented by the single "armor bonus" number on the sheet.
- **Equipped** is what switches an item's effects on (section 5), and what
shows the item on the character's encounter card.
- **Only what is equipped counts.** A weapon has to be equipped to be used
in an encounter, and gear only gives its effects while equipped. What is
not equipped stays in the pack: still the character's, still listed on the
sheet and in the character panel, but not in hand.
- **There is a limit on how much can be equipped at once**, set per campaign
in Campaign Settings ("Equipped at once"):
| Setting | Starts at | Means |
|---|---|---|
| Things equipped at once | 2 | Weapons and items together |
| Of which weapons | 1 | So: one weapon, or one weapon and one item, or two items |
Armor and shields are items and count toward the limit. With the starting
numbers a character can have a weapon and a shield, or a weapon and armor,
but not all three; a DM who wants armor, shield and weapon together sets
the first number to 3.
- **Putting something on when the limit is reached is refused**, with a
message naming what is already equipped. Take something off first.
- **Equipping can be changed at any time, including in the middle of an
encounter**, by the owning player or the DM: on the sheet, or in the
character panel (the 🧙 button, or "🎒 Inventory" on the character's
encounter card).
- Lowering the limits takes nothing off anyone. A character with more on
than the new limit keeps it until they change what they have equipped.
- Everything arrives unequipped: gear from the DM, from a reward, bought, or
given by another character.
- The whole pack is always listed: on the sheet, and in the **character
panel** (the floating 🧙 button, on every page), which the owning player and
the DM can open from anywhere, including from the character's card in an
encounter. Things the character is too low level to use are shown and
marked "needs level N", not hidden.
**Passing items on.** A character's owner (or the DM) can give some or all of
a held stack to another character in the campaign from the "Give to Another
@@ -111,6 +153,48 @@ rungs per level depending on Growth. The full ladder is in
The dice are shown beside the item in the inventory. Nothing is rolled or
applied automatically; use the dice roller.
### Effects: armor and other gear that changes the sheet
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:
| It can change | Examples |
|---|---|
| AC | Chain mail +6, a shield +2, a cursed cloak −1 |
| Speed | Heavy armor −10, boots of speed +10. Speed never goes below 0 |
| Initiative | +2 |
| Max HP | +5. Max HP never goes below 1 |
| Passive Perception | +5 |
| An ability score (STR, DEX, CON, INT, WIS, CHA) | Gauntlets +2 STR. The score changes, so its modifier and everything built on it (saves, skills, attacks, HP from CON, AC from DEX) change with it. A score never goes below 1 |
| All saving throws, or one ability's saves | A ring: +1 to all saves |
| All skill checks, or one skill | Noisy armor: −2 Stealth |
An item can have up to 12 effects, one per number, each from −100 to +100,
plus a **note** for what is not a number ("Disadvantage on Stealth",
"Resistance to fire").
- **They apply on their own.** While a character has the item **equipped**
and is high enough level to use it, its effects are part of every number
the app shows for that character: the sheet, the character panel, the party
overview and their encounter card. Nothing has to be typed in.
- **Unequipping takes them away again**, as does giving the item away, the DM
removing it, or the character dropping below its level.
- **Effects add up.** Two different items with +1 AC give +2. Carrying two of
the same item does not double it: a stack counts once.
- **The sheet says where each change comes from.** A "From Equipped Gear"
card lists every item in force with what it is doing, and its note.
- **Max HP can move.** If taking something off lowers a character's maximum
HP below their current HP, current HP comes down to the new maximum.
- **Only the DM sets effects**, with the "Effects" button on the item's row in
the pool. Changing them changes the sheet of everyone wearing the item,
straight away.
- The DM also has an **Extra AC** number on each sheet, added on top of gear,
for anything an item does not cover. It can be negative.
Weapons can carry effects in exactly the same way
([WEAPONS.md](WEAPONS.md), section 2).
## 6. Starting items
In the builder's equipment step a new character can pick items that are:
@@ -151,11 +235,94 @@ and [ENCOUNTERS.md](ENCOUNTERS.md), section 10.
## 9. What is not modelled
- Any effect of an item on stats (AC, speed, abilities)
- Armor and shields as items
- 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)
- 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
- Advantage and disadvantage as rules (write them in the note)
- Using up items automatically, charges, and attunement
- Weight, carrying capacity and containers
- A price on the item itself: a price is set each time it is put up for sale
- Selling items back (record the gold change in the money log)
- Swaps as a single step (each side gives separately), and asking the
recipient to accept
- Hands and body slots: the limit is a plain count, so nothing stops two
suits of armor being equipped if the count allows it
- Swapping automatically: equipping at the limit is refused, not exchanged
## 10. The complete item library
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.
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.
<!-- BEGIN GENERATED: item-library -->
### Light Armor (3)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Padded Armor | +1 AC | Disadvantage on Stealth checks. | — | Quilted layers of cloth and batting. |
| Leather Armor | +1 AC | — | — | Boiled leather breastplate and shoulder guards over soft leather. |
| Studded Leather | +2 AC | — | — | Tough leather set with close rivets and spikes. |
### Medium Armor (5)
| 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. |
### 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. |
### Shields (2)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Shield | +2 AC | — | — | Wood or metal, carried in one hand. |
| Tower Shield | +3 AC, −5 Speed | — | — | A wall of oak and iron, as tall as its bearer. |
### Worn Gear (10)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Ring of Protection | +1 AC, +1 all saves | — | — | A plain band that turns blows aside. |
| Cloak of Protection | +1 AC, +1 all saves | — | — | A heavy cloak that seems to catch what was meant for you. |
| Cloak of Elvenkind | +5 Stealth | With the hood up, others have disadvantage on Perception checks to see you. | — | With the hood up, its colour shifts to match what is behind it. |
| Boots of Striding | +10 Speed | — | — | Light, tireless boots. |
| Gauntlets of Might | +2 STR | — | — | Iron-banded gauntlets that lend a giant’s grip. |
| Gloves of Thievery | +5 Sleight of Hand | — | — | Thin grey gloves that all but vanish when worn. |
| Amulet of Health | +2 CON | — | — | A red stone on a golden chain, warm against the skin. |
| Headband of Intellect | +2 INT | — | — | A silver circlet set with a pale blue stone. |
| Sentinel Charm | +2 Initiative, +5 Passive Perception | — | — | A brass eye on a cord that never quite closes. |
| Stone of Good Luck | +1 all saves, +1 all skill checks | — | — | A polished agate that sits well in a pocket. |
### Supplies (8)
| Item | While equipped | Note | Dice | Description |
|---|---|---|---|---|
| Healing Potion | — | — | 2d4+2 | A red liquid that glimmers when shaken. Drink it to regain hit points. |
| Rope (50 ft) | — | — | — | Hempen rope. Holds a climber. |
| Torch | — | Held in a hand to give light. | — | Burns for an hour, lighting 20 feet brightly. |
| Hooded Lantern | — | Held in a hand to give light. | — | Bright light for 30 feet; the hood dims it to a glow. |
| Rations (1 day) | — | — | — | Dried meat, hard biscuit, nuts. |
| Bedroll | — | — | — | A blanket roll for sleeping rough. |
| Thieves’ Tools | — | — | — | A file, picks, a small mirror, narrow scissors and pliers. |
| Healer’s Kit | — | — | — | Bandages, salves and splints. Steadies a dying creature without a check. |
<!-- END GENERATED: item-library -->
+100 -51
View File
@@ -49,8 +49,12 @@ The pool is the "Weapons Pool" card on the campaign page (DM only).
| Growth | Standard, Powerful or Legendary: how fast the dice climb per level (section 6) |
| Starting option | Whether a brand-new character may pick it in the builder (section 8) |
| Properties, range | Stored for library weapons (light, thrown, 20/60 ft…) but not shown or used anywhere |
| Effects | What it does to whoever has it equipped: bonuses and penalties to the sheet's numbers, and a note. Works exactly as for items ([ITEMS.md](ITEMS.md), section 5) |
- **Adding:** by hand with the "+ Add Weapon" form, or from the library.
- **Effects:** the "Effects" button on a weapon's row. A defending blade can
give +1 AC, a heavy maul −5 speed. They count only while the weapon is
equipped, and do not change its own attack or damage.
- **Changing:** starting level, growth and the starting-option tick box can be
changed in the table. Name, damage, type and ability cannot be changed on
screen once created (the API accepts it); remove and re-add instead.
@@ -61,7 +65,7 @@ The pool is the "Weapons Pool" card on the campaign page (DM only).
## 3. The built-in library
"📚 From Library" offers 37 standard weapons to copy into the pool:
"📚 From Library" offers 41 weapons to copy into the pool:
| Category | Count | Examples |
|---|---|---|
@@ -69,6 +73,13 @@ The pool is the "Weapons Pool" card on the campaign page (DM only).
| Simple ranged | 4 | Shortbow, Light Crossbow, Dart, Sling |
| Martial melee | 18 | Longsword 1d8, Rapier 1d8, Greataxe 1d12, Greatsword 2d6 |
| Martial ranged | 5 | Longbow, Heavy Crossbow, Hand Crossbow |
| Paired | 4 | Paired Daggers 2d4, Paired Shortswords 2d6, Paired Scimitars 2d6, Paired Handaxes 2d6 |
**Paired weapons.** A character has one weapon equipped at a time (section
4), so fighting with a weapon in each hand is not two weapons: it is **one
weapon made as a pair**, whose damage is both blades. The library has four,
and a DM makes others the same way with "+ Add Weapon" ("Twin Hammers",
2d4 bludgeoning).
A weapon added from the library arrives with starting level 1, Standard
growth, and **not** ticked as a starting option. Change those in the table
@@ -84,8 +95,33 @@ On a character's sheet, the DM picks a weapon from the pool and clicks Give.
keeps the level it has. A character holds one stack per weapon, so two
copies of the same weapon at different levels is not possible.
- The DM can take a weapon away with ✕ on the sheet.
- The owning player ticks **Equipped**. Equipped is a label only: unequipped
weapons still show their numbers, and nothing limits how many are equipped.
- The owning player, or the DM, ticks **Equipped**.
- **Only what is equipped counts.** A weapon has to be equipped to be used
in an encounter, and gear only gives its effects while equipped. What is
not equipped stays in the pack: still the character's, still listed on the
sheet and in the character panel, but not in hand.
- **There is a limit on how much can be equipped at once**, set per campaign
in Campaign Settings ("Equipped at once"):
| Setting | Starts at | Means |
|---|---|---|
| Things equipped at once | 2 | Weapons and items together |
| Of which weapons | 1 | So: one weapon, or one weapon and one item, or two items |
Armor and shields are items and count toward the limit. With the starting
numbers a character can have a weapon and a shield, or a weapon and armor,
but not all three; a DM who wants armor, shield and weapon together sets
the first number to 3.
- **Putting something on when the limit is reached is refused**, with a
message naming what is already equipped. Take something off first.
- **Equipping can be changed at any time, including in the middle of an
encounter**, by the owning player or the DM: on the sheet, or in the
character panel (the 🧙 button, or "🎒 Inventory" on the character's
encounter card).
- Lowering the limits takes nothing off anyone. A character with more on
than the new limit keeps it until they change what they have equipped.
- Everything arrives unequipped: gear from the DM, from a reward, bought, or
given by another character.
**Passing a weapon on.** A character's owner (or the DM) can give a held
weapon to another character in the campaign from the "Give to Another
@@ -115,7 +151,8 @@ This is a house rule of this app, not standard D&D.
| 9 and above | 1–9 |
A weapon above its holder's level is still held, but it shows "Requires level
N", cannot be equipped, and is left out of quick stats and encounters. If the
N", cannot be equipped, and so is never on its holder's encounter card. The
character panel lists it, marked "needs level N". If the
DM levels a weapon past its holder, or lowers the character's level, it is
unequipped automatically.
@@ -217,74 +254,86 @@ purse and the weapon joins what they hold, unequipped. See
## 11. What is not modelled
- Weapon proficiency by class (everyone is proficient with everything)
- Properties: two-handed, versatile, light, reach, loading, thrown
- Properties: two-handed, versatile, light, reach, loading, thrown (a
two-handed weapon counts as one thing equipped, like any other)
- Range and ammunition
- Critical hits, advantage, and rolling the attack itself (use the dice roller)
- Magic bonuses such as +1 weapons (use a higher level or growth instead)
- Magic bonuses to a weapon's own attack or damage, such as +1 weapons (use a
higher level or growth instead)
- A price on the weapon itself (a price is set each time it is put up for sale), and weight
## 12. The complete weapon library
Every weapon "📚 From Library" offers. Base damage is the damage at level 1.
Properties and range are carried into the campaign but not shown or used.
"While equipped" is the weapon's effects and note, which are carried in too.
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.
<!-- BEGIN GENERATED: weapon-library -->
### Simple Melee (10)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Club | 1d4 | bludgeoning | Strength | light | — |
| Dagger | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, light, thrown | 20/60 ft |
| Greatclub | 1d8 | bludgeoning | Strength | two-handed | — |
| Handaxe | 1d6 | slashing | Strength | light, thrown | 20/60 ft |
| Javelin | 1d6 | piercing | Strength | thrown | 30/120 ft |
| Light Hammer | 1d4 | bludgeoning | Strength | light, thrown | 20/60 ft |
| Mace | 1d6 | bludgeoning | Strength | — | — |
| Quarterstaff | 1d6 | bludgeoning | Strength | versatile (1d8) | — |
| Sickle | 1d4 | slashing | Strength | light | — |
| Spear | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft |
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Club | 1d4 | bludgeoning | Strength | light | — | — |
| Dagger | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, light, thrown | 20/60 ft | — |
| Greatclub | 1d8 | bludgeoning | Strength | two-handed | — | — |
| Handaxe | 1d6 | slashing | Strength | light, thrown | 20/60 ft | — |
| Javelin | 1d6 | piercing | Strength | thrown | 30/120 ft | — |
| Light Hammer | 1d4 | bludgeoning | Strength | light, thrown | 20/60 ft | — |
| Mace | 1d6 | bludgeoning | Strength | — | — | — |
| Quarterstaff | 1d6 | bludgeoning | Strength | versatile (1d8) | — | — |
| Sickle | 1d4 | slashing | Strength | light | — | — |
| Spear | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft | — |
### Simple Ranged (4)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Light Crossbow | 1d8 | piercing | Dexterity | ammunition, loading, two-handed | 80/320 ft |
| Dart | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, thrown | 20/60 ft |
| Shortbow | 1d6 | piercing | Dexterity | ammunition, two-handed | 80/320 ft |
| Sling | 1d4 | bludgeoning | Dexterity | ammunition | 30/120 ft |
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Light Crossbow | 1d8 | piercing | Dexterity | ammunition, loading, two-handed | 80/320 ft | — |
| Dart | 1d4 | piercing | Strength or Dexterity (finesse) | finesse, thrown | 20/60 ft | — |
| Shortbow | 1d6 | piercing | Dexterity | ammunition, two-handed | 80/320 ft | — |
| Sling | 1d4 | bludgeoning | Dexterity | ammunition | 30/120 ft | — |
### Martial Melee (18)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Battleaxe | 1d8 | slashing | Strength | versatile (1d10) | — |
| Flail | 1d8 | bludgeoning | Strength | — | — |
| Glaive | 1d10 | slashing | Strength | heavy, reach, two-handed | — |
| Greataxe | 1d12 | slashing | Strength | heavy, two-handed | — |
| Greatsword | 2d6 | slashing | Strength | heavy, two-handed | — |
| Halberd | 1d10 | slashing | Strength | heavy, reach, two-handed | — |
| Lance | 1d12 | piercing | Strength | reach, special | — |
| Longsword | 1d8 | slashing | Strength | versatile (1d10) | — |
| Maul | 2d6 | bludgeoning | Strength | heavy, two-handed | — |
| Morningstar | 1d8 | piercing | Strength | — | — |
| Pike | 1d10 | piercing | Strength | heavy, reach, two-handed | — |
| Rapier | 1d8 | piercing | Strength or Dexterity (finesse) | finesse | — |
| Scimitar | 1d6 | slashing | Strength or Dexterity (finesse) | finesse, light | — |
| Shortsword | 1d6 | piercing | Strength or Dexterity (finesse) | finesse, light | — |
| Trident | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft |
| War Pick | 1d8 | piercing | Strength | — | — |
| Warhammer | 1d8 | bludgeoning | Strength | versatile (1d10) | — |
| Whip | 1d4 | slashing | Strength or Dexterity (finesse) | finesse, reach | — |
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Battleaxe | 1d8 | slashing | Strength | versatile (1d10) | — | — |
| Flail | 1d8 | bludgeoning | Strength | — | — | — |
| Glaive | 1d10 | slashing | Strength | heavy, reach, two-handed | — | — |
| Greataxe | 1d12 | slashing | Strength | heavy, two-handed | — | — |
| Greatsword | 2d6 | slashing | Strength | heavy, two-handed | — | — |
| Halberd | 1d10 | slashing | Strength | heavy, reach, two-handed | — | — |
| Lance | 1d12 | piercing | Strength | reach, special | — | — |
| Longsword | 1d8 | slashing | Strength | versatile (1d10) | — | — |
| Maul | 2d6 | bludgeoning | Strength | heavy, two-handed | — | — |
| Morningstar | 1d8 | piercing | Strength | — | — | — |
| Pike | 1d10 | piercing | Strength | heavy, reach, two-handed | — | — |
| Rapier | 1d8 | piercing | Strength or Dexterity (finesse) | finesse | — | — |
| Scimitar | 1d6 | slashing | Strength or Dexterity (finesse) | finesse, light | — | — |
| Shortsword | 1d6 | piercing | Strength or Dexterity (finesse) | finesse, light | — | — |
| Trident | 1d6 | piercing | Strength | thrown, versatile (1d8) | 20/60 ft | — |
| War Pick | 1d8 | piercing | Strength | — | — | — |
| Warhammer | 1d8 | bludgeoning | Strength | versatile (1d10) | — | — |
| Whip | 1d4 | slashing | Strength or Dexterity (finesse) | finesse, reach | — | — |
### Martial Ranged (5)
| Weapon | Base damage | Type | Attacks with | Properties | Range |
|---|---|---|---|---|---|
| Blowgun | 1 | piercing | Dexterity | ammunition, loading | 25/100 ft |
| Hand Crossbow | 1d6 | piercing | Dexterity | ammunition, light, loading | 30/120 ft |
| Heavy Crossbow | 1d10 | piercing | Dexterity | ammunition, heavy, loading, two-handed | 100/400 ft |
| Longbow | 1d8 | piercing | Dexterity | ammunition, heavy, two-handed | 150/600 ft |
| Net | - | special | Dexterity | special, thrown | 5/15 ft |
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Blowgun | 1 | piercing | Dexterity | ammunition, loading | 25/100 ft | — |
| Hand Crossbow | 1d6 | piercing | Dexterity | ammunition, light, loading | 30/120 ft | — |
| Heavy Crossbow | 1d10 | piercing | Dexterity | ammunition, heavy, loading, two-handed | 100/400 ft | — |
| Longbow | 1d8 | piercing | Dexterity | ammunition, heavy, two-handed | 150/600 ft | — |
| Net | - | special | Dexterity | special, thrown | 5/15 ft | — |
### Paired (4)
| Weapon | Base damage | Type | Attacks with | Properties | Range | While equipped |
|---|---|---|---|---|---|---|
| Paired Daggers | 2d4 | piercing | Strength or Dexterity (finesse) | finesse, light, paired | — | A dagger in each hand: takes both hands. |
| Paired Shortswords | 2d6 | piercing | Strength or Dexterity (finesse) | finesse, light, paired | — | A shortsword in each hand: takes both hands. |
| Paired Scimitars | 2d6 | slashing | Strength or Dexterity (finesse) | finesse, light, paired | — | A scimitar in each hand: takes both hands. |
| Paired Handaxes | 2d6 | slashing | Strength | light, paired | — | A handaxe in each hand: takes both hands. |
<!-- END GENERATED: weapon-library -->
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.10.0",
"version": "1.11.0",
"type": "module",
"scripts": {
"dev": "vite",
+17 -1
View File
@@ -12,6 +12,7 @@ import type {
Encounter,
EncounterMode,
EncounterParticipant,
GearEffect,
GoldTransaction,
Item,
Location,
@@ -470,9 +471,16 @@ export function usePatchPoolEntry(campaignId: string, pool: Pool) {
description?: string | null;
exchangeGold?: number | null;
exchangeCash?: number | null;
effects?: GearEffect[];
effectNote?: string | null;
}) =>
(await apiClient.patch(`/campaigns/${campaignId}/${pool}/${id}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, pool] }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['campaign', campaignId, pool] });
// What an entry does while equipped changes the sheet of whoever holds it.
qc.invalidateQueries({ queryKey: ['character'] });
qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'characters'] });
},
});
}
@@ -585,6 +593,14 @@ export function useAddWeaponFromLibrary(campaignId: string) {
});
}
export function useAddItemFromLibrary(campaignId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (key: string) => (await apiClient.post<{ item: Item }>(`/campaigns/${campaignId}/items/from-library`, { key })).data.item,
onSuccess: () => qc.invalidateQueries({ queryKey: ['campaign', campaignId, 'items'] }),
});
}
export function useAddSpellFromLibrary(campaignId: string) {
const qc = useQueryClient();
return useMutation({
+91 -2
View File
@@ -95,6 +95,10 @@ export interface Campaign {
showMonsterHp: boolean; // off: players get a health bar for monsters and NPCs, no numbers
// Added to each target's AC for every target beyond the first in an area attack; 0 is off.
areaAttackAcPenalty: number;
// How many weapons and items a character can have equipped together, and
// how many of those can be weapons. Only equipped gear is used in a fight.
maxEquipped: number;
maxEquippedWeapons: number;
ownerId: string;
archivedAt: string | null; // set while the campaign is archived
role: 'DM' | 'PLAYER';
@@ -162,6 +166,21 @@ export interface AbilityDetail {
modifier: number;
}
// One thing a weapon or item does to whoever has it equipped: a signed amount
// on one of the sheet's numbers ("AC", "SPEED", "STR", "SAVE_DEX",
// "SKILL_STEALTH"...). The full list is GEAR_STATS on the server.
export interface GearEffect {
stat: string;
amount: number;
}
// What a character's equipped gear is doing right now. The sheet's numbers
// already include it; this says where each change comes from.
export interface ActiveGear {
effects: (GearEffect & { source: string })[];
notes: { source: string; note: string }[];
}
export interface CharacterSheet {
abilities: Record<Ability, AbilityDetail>;
proficiencyBonus: number;
@@ -174,6 +193,7 @@ export interface CharacterSheet {
skills: Record<string, { ability: Ability; proficient: boolean; expertise: boolean; bonus: number }>;
passivePerception: number;
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
gear: ActiveGear;
}
export interface Weapon {
@@ -192,6 +212,8 @@ export interface Weapon {
damageScaling: number;
minLevel: number;
isStartingOption: boolean;
effects: GearEffect[]; // what it does to whoever has it equipped
effectNote: string | null; // and what it does that is not a number
}
export interface CharacterWeapon {
@@ -240,6 +262,8 @@ export interface Item {
damageScaling: number;
minLevel: number;
isStartingOption: boolean;
effects: GearEffect[]; // armor is an item with an AC effect
effectNote: string | null;
}
export interface InventoryItem {
@@ -413,6 +437,15 @@ export interface CombatWeapon {
damageType: string;
}
// An item a character has equipped, as everyone in an encounter sees it.
export interface CombatItem {
inventoryItemId: string;
name: string;
level: number;
quantity: number;
damageDice: string | null;
}
export interface CombatSpell {
characterSpellId: string;
name: string;
@@ -437,7 +470,9 @@ export interface CharacterCombatInfo {
abilities: Record<Ability, AbilityDetail>;
saves: Record<Ability, { proficient: boolean; bonus: number }>;
specialAbilities: string | null;
weapons: CombatWeapon[];
gear: ActiveGear;
weapons: CombatWeapon[]; // the equipped ones: a weapon has to be in hand to be used
items: CombatItem[]; // likewise
spells: CombatSpell[];
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
}
@@ -520,6 +555,18 @@ export interface LibraryWeapon {
properties: string[];
rangeNormal: number | null;
rangeLong: number | null;
effects?: GearEffect[];
effectNote?: string | null;
}
export interface LibraryItem {
key: string;
name: string;
category: string;
description: string | null;
damageDice: string | null;
effects: GearEffect[];
effectNote: string | null;
}
export interface LibrarySpell {
@@ -557,6 +604,7 @@ export interface LibraryMonster {
export interface CampaignLibrary {
weapons: LibraryWeapon[];
items: LibraryItem[];
spells: LibrarySpell[];
monsters: LibraryMonster[];
}
@@ -568,14 +616,48 @@ export interface QuickStatsWeapon {
level: number;
equipped: boolean;
quantity: number;
usable: boolean; // false while the character is below this copy's level
effects: GearEffect[];
effectNote: string | null;
attackBonus: number;
damageBonus: number;
damageDice: string;
damageType: string;
}
export interface QuickStats {
export interface QuickStatsItem {
inventoryItemId: string;
itemId: string;
name: string;
description: string | null;
level: number;
equipped: boolean;
quantity: number;
usable: boolean;
damageDice: string | null; // at this copy's level
effects: GearEffect[];
effectNote: string | null;
}
export interface QuickStatsSpell {
characterSpellId: string;
name: string;
level: number;
damageDice: string | null;
description: string | null;
prepared: boolean;
usable: boolean;
}
// A character at a glance, with everything it holds. The pack, the spell
// list and the purse are only sent to the owning player and the DM
// (`restricted` is true for anyone else).
export interface QuickStats {
id: string;
name: string;
level: number;
species: Species | null;
characterClass: CharacterClass | null;
currentHp: number;
maxHp: number;
tempHp: number;
@@ -585,6 +667,13 @@ export interface QuickStats {
speed: number;
proficiencyBonus: number;
spellcasting: { ability: Ability; saveDC: number; attackBonus: number } | null;
passivePerception: number;
abilities: Record<Ability, AbilityDetail>;
specialAbilities: string | null;
gear: ActiveGear;
weapons: QuickStatsWeapon[];
restricted: boolean;
inventory: QuickStatsItem[];
spells: QuickStatsSpell[];
purse: { gold: number; silver: number; copper: number; cash: number } | null;
}
+298
View File
@@ -0,0 +1,298 @@
import { Link, useMatch } from 'react-router-dom';
import { useCampaign, useCampaignCharacters, usePatchCharacterWeapon, usePatchInventoryItem, useQuickStats } from '../api/queries';
import { useAuth } from '../auth/AuthContext';
import { useLastCampaignId } from '../lib/lastCampaign';
import { closeCharacterPanel, openCharacterPanel, useCharacterPanelId } from '../lib/characterPanel';
import { CLASS_LABELS, SPECIES_LABELS, fmtMod, formatPurse, spellLevelLabel } from '../lib/format';
import { effectLabel, effectsSummary } from '../lib/gear';
import { speciesIcon } from '../lib/speciesIcons';
import { Spinner, ErrorBanner } from './Spinner';
import { HpBar } from './HpBar';
import { DeathSaves } from './DeathSaves';
import { useState } from 'react';
import { extractErrorMessage } from '../lib/format';
import type { ActiveGear } from '../api/types';
// What equipped gear is doing, one line per thing: "Chain Mail: +6 AC · −10 Speed".
export function GearInForce({ gear }: { gear: ActiveGear }) {
const sources = [...new Set([...gear.effects.map((e) => e.source), ...gear.notes.map((n) => n.source)])];
if (sources.length === 0) return null;
return (
<>
{sources.map((source) => (
<div key={source} className="detail-row">
<strong>{source}</strong>
<span>
{[...gear.effects.filter((e) => e.source === source).map(effectLabel), ...gear.notes.filter((n) => n.source === source).map((n) => n.note)].join(' · ')}
</span>
</div>
))}
</>
);
}
function Flags({ equipped, usable, level }: { equipped: boolean; usable: boolean; level: number }) {
return (
<>
{equipped && <span className="badge badge-live" style={{ marginLeft: 6 }}>equipped</span>}
{!usable && <span className="badge badge-hostile" style={{ marginLeft: 6 }}>needs level {level}</span>}
</>
);
}
// One character at a glance: the numbers for a fight, and everything they
// hold. The pack, spells and purse are there for the character's own player
// and the DM, who can also put gear on and take it off from here.
function PanelBody({ characterId }: { characterId: string }) {
const { data, isLoading, error } = useQuickStats(characterId, true);
const patchWeapon = usePatchCharacterWeapon(characterId);
const patchItem = usePatchInventoryItem(characterId);
// Why something could not be equipped: too much on already.
const [equipError, setEquipError] = useState<string | null>(null);
const equip = { onError: (err: unknown) => setEquipError(extractErrorMessage(err)), onSuccess: () => setEquipError(null) };
if (isLoading) return <Spinner label="Reading the sheet…" />;
if (error || !data) return <ErrorBanner message="Could not load this character." />;
const canEquip = !data.restricted;
const stats: [string, string | number][] = [
['AC', data.armorClass],
['Initiative', fmtMod(data.initiative)],
['Speed', data.speed],
['Prof.', fmtMod(data.proficiencyBonus)],
['Passive Perc.', data.passivePerception],
];
return (
<div className="mt-md">
<div className="flex-between flex-wrap">
<div>
<h3 style={{ margin: 0 }}>
<span aria-hidden="true">{speciesIcon(data.species)}</span> {data.name}
</h3>
<div className="combatant-sub">
{data.species && SPECIES_LABELS[data.species]} {data.characterClass && CLASS_LABELS[data.characterClass]} · Level {data.level}
</div>
</div>
<Link to={`/characters/${data.id}`} className="btn" onClick={closeCharacterPanel}>
Full sheet
</Link>
</div>
<div className="stat-chips mt-sm">
<span className="stat-chip">
<span>HP</span>
<b>
{data.currentHp}/{data.maxHp}
{data.tempHp > 0 && ` +${data.tempHp}`}
</b>
</span>
{stats.map(([label, value]) => (
<span key={label} className="stat-chip">
<span>{label}</span>
<b>{value}</b>
</span>
))}
</div>
<div className="mt-sm">
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
</div>
{/* Read-only here; the saves are rolled on the sheet or in the encounter. */}
<DeathSaves characterId={characterId} name={data.name} deathSaves={data.deathSaves} canAct={false} isDM={false} />
<div className="stat-chips mt-sm">
{(Object.keys(data.abilities) as (keyof typeof data.abilities)[]).map((a) => (
<span key={a} className="stat-chip">
<span>{a}</span>
<b>
{data.abilities[a].final} ({fmtMod(data.abilities[a].modifier)})
</b>
</span>
))}
</div>
{(data.gear.effects.length > 0 || data.gear.notes.length > 0) && (
<div className="card mt-md">
<div className="card-title">From equipped gear</div>
<GearInForce gear={data.gear} />
<p className="combatant-sub" style={{ margin: '0.4rem 0 0' }}>Already counted in the numbers above.</p>
</div>
)}
{data.spellcasting && (
<div className="card mt-md">
<div className="card-title">Spellcasting</div>
<p style={{ margin: 0 }}>
Save DC {data.spellcasting.saveDC} · Attack {fmtMod(data.spellcasting.attackBonus)}
</p>
</div>
)}
{canEquip && (
<p className="combatant-sub mt-md" style={{ marginBottom: 0 }}>
Tick what is equipped. Only equipped weapons can be used in an encounter, and only equipped gear gives its effects.
</p>
)}
{equipError && <ErrorBanner message={equipError} />}
<div className="card mt-md">
<div className="card-title">Weapons · {data.weapons.length}</div>
{data.weapons.length === 0 && <p className="combatant-sub" style={{ margin: 0 }}>No weapons.</p>}
{data.weapons.map((w) => (
<div key={w.characterWeaponId} className="reward-row">
<div className="flex-between">
<div>
<strong>{w.name}</strong>
{w.quantity > 1 && <span className="combatant-sub"> ×{w.quantity}</span>}
<Flags equipped={w.equipped} usable={w.usable} level={w.level} />
<div className="combatant-sub">
Lv {w.level} · attack {fmtMod(w.attackBonus)} · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
</div>
{effectsSummary(w.effects, w.effectNote) && <div className="combatant-sub">✦ {effectsSummary(w.effects, w.effectNote)}</div>}
</div>
{canEquip && (
<input
type="checkbox"
aria-label={`${w.name} is equipped`}
title="Equipped"
checked={w.equipped}
disabled={!w.usable || patchWeapon.isPending}
onChange={(e) => patchWeapon.mutate({ weaponId: w.weaponId, equipped: e.target.checked }, equip)}
/>
)}
</div>
</div>
))}
</div>
{data.restricted ? (
<p className="combatant-sub mt-md">Their pack, spells and purse are their own business.</p>
) : (
<>
<div className="card mt-md">
<div className="card-title">Inventory · {data.inventory.length}</div>
{data.inventory.length === 0 && <p className="combatant-sub" style={{ margin: 0 }}>Nothing carried.</p>}
{data.inventory.map((item) => (
<div key={item.inventoryItemId} className="reward-row">
<div className="flex-between">
<div>
<strong>{item.name}</strong>
<span className="combatant-sub"> ×{item.quantity}</span>
<Flags equipped={item.equipped} usable={item.usable} level={item.level} />
<div className="combatant-sub">
Lv {item.level}
{item.damageDice && ` · ${item.damageDice}`}
</div>
{effectsSummary(item.effects, item.effectNote) && <div className="combatant-sub">✦ {effectsSummary(item.effects, item.effectNote)}</div>}
{item.description && <div className="combatant-sub">{item.description}</div>}
</div>
<input
type="checkbox"
aria-label={`${item.name} is equipped`}
title="Equipped"
checked={item.equipped}
disabled={!item.usable || patchItem.isPending}
onChange={(e) => patchItem.mutate({ itemId: item.itemId, equipped: e.target.checked }, equip)}
/>
</div>
</div>
))}
</div>
<div className="card mt-md">
<div className="card-title">
<span>Money</span>
{data.purse && <span className="mono">{formatPurse(data.purse)}</span>}
</div>
</div>
{data.spells.length > 0 && (
<div className="card mt-md">
<div className="card-title">Spells · {data.spells.length}</div>
{data.spells.map((s) => (
<div key={s.characterSpellId} className="reward-row">
<strong>{s.name}</strong>
<span className="combatant-sub">
{' '}
{spellLevelLabel(s.level)}
{s.damageDice && ` · ${s.damageDice}`}
</span>
{s.prepared && <span className="badge badge-live" style={{ marginLeft: 6 }}>prepared</span>}
{!s.usable && <span className="badge badge-hostile" style={{ marginLeft: 6 }}>needs level {s.level}</span>}
{s.description && <div className="combatant-sub">{s.description}</div>}
</div>
))}
</div>
)}
</>
)}
{data.specialAbilities && (
<div className="card mt-md">
<div className="card-title">Special Abilities</div>
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{data.specialAbilities}</p>
</div>
)}
</div>
);
}
// The floating character button, on every page, and the panel it opens. A
// player gets their own characters in the campaign they are in; the DM gets
// everyone's. Other pages can open the panel for a particular character with
// openCharacterPanel (lib/characterPanel.ts).
export function CharacterPanel() {
const { user } = useAuth();
const campaignId = useLastCampaignId() ?? undefined;
const { data: campaignData } = useCampaign(campaignId);
const { data: characters } = useCampaignCharacters(campaignId);
const onSheet = useMatch('/characters/:characterId');
const openId = useCharacterPanelId();
const isDM = campaignData?.campaign.role === 'DM';
const choices = (characters ?? []).filter((c) => c.buildStage === 'COMPLETE' && (isDM || c.userId === user?.id));
if (choices.length === 0 && !openId) return null;
// On a character's own sheet the button opens that character; otherwise the first of yours.
const fallback = choices.find((c) => c.id === onSheet?.params.characterId) ?? choices.find((c) => c.userId === user?.id) ?? choices[0];
const shown = openId ?? undefined;
return (
<>
{fallback && (
<button
className="fab-quickstats"
onClick={() => openCharacterPanel(fallback.id)}
title={isDM ? 'Characters: stats and inventory' : 'Your character: stats and inventory'}
aria-label="Open the character panel"
>
🧙
</button>
)}
{shown && (
<>
<div className="drawer-backdrop" onClick={closeCharacterPanel} />
<div className="drawer" role="dialog" aria-label="Character panel">
<div className="flex-between">
{choices.length > 1 && choices.some((c) => c.id === shown) ? (
<select aria-label="Which character" value={shown} onChange={(e) => openCharacterPanel(e.target.value)} style={{ flex: 1, minWidth: 0 }}>
{choices.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{isDM && c.userId === user?.id ? ' (yours)' : ''}
</option>
))}
</select>
) : (
<h2 style={{ margin: 0 }}>Character</h2>
)}
<button className="btn btn-ghost" onClick={closeCharacterPanel} aria-label="Close the character panel">
✕
</button>
</div>
<PanelBody key={shown} characterId={shown} />
</div>
</>
)}
</>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useState } from 'react';
import { NumberInput } from './NumberInput';
import { GEAR_STAT_GROUPS, effectLabel, gearStatLabel } from '../lib/gear';
import type { GearEffect } from '../api/types';
// The DM's editor for what a weapon or item does to whoever has it equipped:
// a list of signed amounts on the sheet's numbers (armor is "+6 AC"), and a
// note for anything that is not a number. It holds nothing itself: the list
// and the note live with whoever renders it.
export function EffectsEditor({
effects,
note,
onChange,
}: {
effects: GearEffect[];
note: string;
onChange: (effects: GearEffect[], note: string) => void;
}) {
const [stat, setStat] = useState('AC');
const [amount, setAmount] = useState('');
function handleAdd() {
const value = Math.trunc(Number(amount));
if (!value) return;
// One line per number: adding to one already listed replaces it.
onChange([...effects.filter((e) => e.stat !== stat), { stat, amount: value }], note);
setAmount('');
}
return (
<div>
<div className="section-label">While equipped</div>
{effects.length === 0 && (
<p className="combatant-sub" style={{ marginTop: 0 }}>
No effects. Add a bonus (armor: +AC) or a penalty (heavy armor: −Speed).
</p>
)}
{effects.length > 0 && (
<div className="stat-chips" style={{ marginBottom: '0.5rem' }}>
{effects.map((effect) => (
<span key={effect.stat} className={`condition-chip ${effect.amount > 0 ? 'is-bonus' : ''}`}>
{effectLabel(effect)}
<button type="button" aria-label={`Remove the ${gearStatLabel(effect.stat)} effect`} onClick={() => onChange(effects.filter((e) => e !== effect), note)}>
✕
</button>
</span>
))}
</div>
)}
<div className="flex-row flex-wrap gap-sm">
<select aria-label="What it changes" value={stat} onChange={(e) => setStat(e.target.value)} style={{ flex: 1, minWidth: 130 }}>
{GEAR_STAT_GROUPS.map((group) => (
<optgroup key={group.label} label={group.label}>
{group.stats.map((s) => (
<option key={s} value={s}>
{gearStatLabel(s)}
</option>
))}
</optgroup>
))}
</select>
<NumberInput
min={-100}
max={100}
placeholder="+ or −"
aria-label="By how much (negative for a penalty)"
title="By how much. A negative number is a penalty."
value={amount}
onChange={(e) => setAmount(e.target.value)}
style={{ width: 90 }}
/>
<button type="button" className="btn" onClick={handleAdd} disabled={!Math.trunc(Number(amount))}>
Add effect
</button>
</div>
<input
className="mt-sm"
style={{ width: '100%' }}
placeholder="Anything else it does (e.g. Disadvantage on Stealth)"
aria-label="What else it does, in words"
maxLength={300}
value={note}
onChange={(e) => onChange(effects, e.target.value)}
/>
</div>
);
}
+1 -1
View File
@@ -16,7 +16,7 @@ export 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: 'Armor Bonus — a flat bonus to AC from equipped armor or a shield, added on top of 10 + Dex modifier.',
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.',
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.',
+36
View File
@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
// A row of links to the sections of a long page. It finds them itself: any
// element with a `data-jump="Label"` attribute that is on the page right now
// gets a link, so sections only some people see need no special handling.
export function JumpNav() {
const [labels, setLabels] = useState<string[]>([]);
// Sections come and go as data loads, so the page is watched for them.
useEffect(() => {
const look = () => {
const found = [...document.querySelectorAll<HTMLElement>('[data-jump]')].map((el) => el.dataset.jump ?? '');
setLabels((current) => (current.join('|') === found.join('|') ? current : found));
};
const observer = new MutationObserver(look);
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-jump'] });
look();
return () => observer.disconnect();
}, []);
if (labels.length < 3) return null;
return (
<nav className="jump-nav" aria-label="Sections of this page">
{labels.map((label) => (
<button
key={label}
type="button"
className="jump-link"
onClick={() => document.querySelector(`[data-jump="${label}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' })}
>
{label}
</button>
))}
</nav>
);
}
+43 -8
View File
@@ -1,10 +1,40 @@
import { Link, Outlet } from 'react-router-dom';
import { Link, NavLink, Outlet, useMatch } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
import { useLastCampaignId, useRememberCampaign } from '../lib/lastCampaign';
import { DiceRoller } from './DiceRoller';
import { CharacterPanel } from './CharacterPanel';
export function Layout() {
const { user, logout } = useAuth();
// The campaign pages say which campaign they are in their address; sheets
// and encounters report theirs themselves (useRememberCampaign).
const onCampaign = useMatch('/campaigns/:campaignId/*');
useRememberCampaign(onCampaign?.params.campaignId);
const campaignId = useLastCampaignId();
// 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 = [
{ to: '/campaigns', end: true, icon: '🗺', label: 'Campaigns' },
...(campaignId
? [
{ to: `/campaigns/${campaignId}`, end: true, icon: '🏰', label: 'Campaign' },
{ to: `/campaigns/${campaignId}/party`, end: true, icon: '👥', label: 'Party' },
]
: []),
{ to: '/account', end: true, icon: '👤', label: 'Account' },
];
const links = (className: string) =>
destinations.map((d) => (
<NavLink key={d.to} to={d.to} end={d.end} className={({ isActive }) => `${className}${isActive ? ' is-active' : ''}`}>
<span className="nav-icon" aria-hidden>
{d.icon}
</span>
<span>{d.label}</span>
</NavLink>
));
return (
<div className="app-shell">
<header className="topbar">
@@ -14,23 +44,28 @@ export function Layout() {
</span>
The Sunken Reach
</Link>
<nav className="topbar-nav">
{user && (
{user && (
<nav className="topbar-nav" aria-label="Main">
<div className="topbar-links">{links('topbar-link')}</div>
<div className="topbar-user">
<Link to="/account" title="Account settings">
{user.displayName}
</Link>
<span className="topbar-name">{user.displayName}</span>
<button className="btn btn-ghost" onClick={logout}>
Log out
</button>
</div>
)}
</nav>
</nav>
)}
</header>
<main className="page">
<Outlet />
</main>
{user && (
<nav className="bottom-nav" aria-label="Main">
{links('bottom-nav-link')}
</nav>
)}
<DiceRoller />
{user && <CharacterPanel />}
</div>
);
}
@@ -1,98 +0,0 @@
import { useState } from 'react';
import { useQuickStats } from '../api/queries';
import { Spinner, ErrorBanner } from './Spinner';
import { fmtMod } from '../lib/format';
import { HpBar } from './HpBar';
import { DeathSaves } from './DeathSaves';
export function QuickStatsFab({ characterId }: { characterId: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button className="fab-quickstats" onClick={() => setOpen(true)} title="Quick Stats" aria-label="Open quick stats">
⚡
</button>
{open && <QuickStatsDrawer characterId={characterId} onClose={() => setOpen(false)} />}
</>
);
}
export function QuickStatsDrawer({ characterId, onClose }: { characterId: string; onClose: () => void }) {
const { data, isLoading, error } = useQuickStats(characterId, true);
return (
<>
<div className="drawer-backdrop" onClick={onClose} />
<div className="drawer">
<div className="flex-between">
<h2 style={{ margin: 0 }}>Quick Stats</h2>
<button className="btn btn-ghost" onClick={onClose}>
✕
</button>
</div>
{isLoading && <Spinner label="Reading the sheet…" />}
{error && <ErrorBanner message="Could not load quick stats." />}
{data && (
<div className="mt-md">
<h3 style={{ margin: 0 }}>{data.name}</h3>
<div className="grid grid-cols-3 mt-sm">
<div className="stat-block">
<span className="stat-label">HP</span>
<span className="stat-value">
{data.currentHp}/{data.maxHp}
</span>
{data.tempHp > 0 && <span className="stat-sub">+{data.tempHp} temp</span>}
</div>
<div className="stat-block">
<span className="stat-label">AC</span>
<span className="stat-value">{data.armorClass}</span>
</div>
<div className="stat-block">
<span className="stat-label">Initiative</span>
<span className="stat-value">{fmtMod(data.initiative)}</span>
</div>
</div>
<HpBar current={data.currentHp} max={data.maxHp} temp={data.tempHp} />
{/* Read-only here; the saves are rolled on the sheet or in the encounter. */}
<DeathSaves characterId={characterId} name={data.name} deathSaves={data.deathSaves} canAct={false} isDM={false} />
{data.spellcasting && (
<div className="card mt-md">
<div className="card-title">Spellcasting</div>
<p style={{ margin: 0 }}>
Save DC {data.spellcasting.saveDC} · Attack {fmtMod(data.spellcasting.attackBonus)}
</p>
</div>
)}
<div className="card mt-md">
<div className="card-title">Weapons</div>
{data.weapons.length === 0 && <p style={{ color: 'var(--text-muted)', margin: 0 }}>No weapons equipped.</p>}
{data.weapons.map((w) => (
<div key={w.characterWeaponId} className="flex-between mt-sm">
<div>
<strong>{w.name}</strong>
{w.equipped && <span className="badge" style={{ marginLeft: 6 }}>equipped</span>}
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Lv {w.level} · {w.damageDice} {w.damageType} {fmtMod(w.damageBonus)}
</p>
</div>
<span className="stat-value" style={{ fontSize: '1.1rem' }}>
{fmtMod(w.attackBonus)}
</span>
</div>
))}
</div>
{data.specialAbilities && (
<div className="card mt-md">
<div className="card-title">Special Abilities</div>
<p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{data.specialAbilities}</p>
</div>
)}
</div>
)}
</div>
</>
);
}
+467 -4
View File
@@ -1,4 +1,6 @@
:root {
/* Dark throughout, so the browser draws its own controls dark too. */
color-scheme: dark;
--bg: #131009;
--bg-elevated: #1b160e;
--bg-card: #221b12;
@@ -347,10 +349,8 @@ textarea {
color: var(--text-muted);
}
input[type='text'],
input[type='email'],
input[type='password'],
input[type='number'],
/* Every text-like field, including an <input> with no type given. */
input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='file']),
select,
textarea {
background: var(--bg-inset);
@@ -1290,3 +1290,466 @@ button.death-pip {
margin: 0;
white-space: pre-wrap;
}
/* ---------------------------------------------------------------------- */
/* Navigation: links along the top bar on a wide screen, a bar along the */
/* bottom on a phone; and a row of links to the sections of a long page */
/* ---------------------------------------------------------------------- */
.topbar-links {
display: flex;
align-items: center;
gap: 0.25rem;
}
.topbar-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border-radius: 6px;
border: 1px solid transparent;
color: var(--text-muted);
font-family: var(--font-display);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.topbar-link:hover {
text-decoration: none;
color: var(--accent-strong);
border-color: var(--border);
}
.topbar-link.is-active {
color: var(--accent-strong);
border-color: var(--accent);
background: var(--accent-soft);
}
.topbar-name {
max-width: 14ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bottom-nav {
display: none;
}
.jump-nav {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 1.25rem;
}
.jump-link {
font-family: var(--font-display);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.4rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-inset);
color: var(--text-muted);
cursor: pointer;
}
.jump-link:hover {
border-color: var(--accent);
color: var(--accent-strong);
}
/* A section jumped to stops below the sticky top bar, not under it. */
[data-jump] {
scroll-margin-top: 4.5rem;
}
/* A table cell that holds a sentence rather than a figure may wrap. */
table.data-table td.cell-title,
table.data-table td.cell-wide {
white-space: normal;
min-width: 12rem;
}
/* ---------------------------------------------------------------------- */
/* Small screens. Nothing may be wider than the screen: rows of controls */
/* wrap, fields shrink to fit, tables become stacked cards, and the main */
/* destinations move to a bar along the bottom. */
/* ---------------------------------------------------------------------- */
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body,
#root,
.app-shell {
min-height: 100dvh;
}
/* A field never forces its row wider than the space there is. */
input,
select,
textarea {
max-width: 100%;
min-width: 0;
}
@media (max-width: 900px) {
:root {
--bottom-nav-height: calc(3.6rem + env(safe-area-inset-bottom, 0px));
--dice-slot: 4.2rem;
}
body {
overflow-wrap: break-word;
}
.page {
padding: 1rem 0.85rem calc(var(--bottom-nav-height) + 1.5rem);
}
.topbar {
padding: 0.5rem 0.85rem;
gap: 0.5rem;
}
.topbar-brand {
font-size: 1rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar-links,
.topbar-name {
display: none;
}
.topbar-nav {
flex-wrap: nowrap;
}
.bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 70;
display: flex;
height: var(--bottom-nav-height);
padding-bottom: env(safe-area-inset-bottom, 0px);
background: linear-gradient(180deg, #1e1810 0%, #120e08 100%);
border-top: 1px solid var(--border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.45);
}
.bottom-nav-link {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
color: var(--text-muted);
font-family: var(--font-display);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border-top: 2px solid transparent;
}
.bottom-nav-link:hover {
text-decoration: none;
}
.bottom-nav-link .nav-icon {
font-size: 1.25rem;
line-height: 1.2;
}
.bottom-nav-link.is-active {
color: var(--accent-strong);
border-top-color: var(--accent-strong);
background: var(--accent-soft);
}
/* Nothing floats over the page on a phone. The dice roller takes the last
place in the bottom bar, and quick stats a place in the top bar. */
.bottom-nav {
padding-right: var(--dice-slot);
}
.fab-dice {
left: auto;
right: 0;
bottom: 0;
z-index: 71;
width: var(--dice-slot);
height: var(--bottom-nav-height);
padding: 0 0 env(safe-area-inset-bottom, 0px);
flex-direction: column;
gap: 0.1rem;
border: none;
border-top: 2px solid transparent;
border-radius: 0;
background: transparent;
box-shadow: none;
font-size: 1.25rem;
line-height: 1.2;
color: var(--text-muted);
}
.fab-dice::after {
content: 'Dice';
font-family: var(--font-display);
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.fab-dice:hover {
transform: none;
}
.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);
}
.fab-quickstats {
top: 0.3rem;
bottom: auto;
right: 6.4rem;
z-index: 31;
width: 40px;
height: 40px;
font-size: 1.15rem;
box-shadow: none;
}
/* The brand makes room for it. */
.app-shell:has(.fab-quickstats) .topbar-brand {
max-width: calc(100vw - 11rem);
}
.drawer {
width: 100vw;
padding: 1rem 0.85rem calc(var(--bottom-nav-height) + 1rem);
z-index: 95;
}
.drawer-backdrop {
z-index: 94;
}
h1 {
font-size: 1.5rem;
}
.card {
padding: 0.9rem 0.85rem;
}
.card-title {
flex-wrap: wrap;
}
.page-header {
margin-bottom: 1rem;
}
/* Rows of controls wrap instead of running off the side, and what is in
them may shrink: an inline min-width never beats the screen. */
.flex-row,
.flex-between,
.detail-row {
flex-wrap: wrap;
}
.flex-row > *,
.flex-between > * {
max-width: 100%;
}
.flex-row > input,
.flex-row > select,
.flex-row > textarea,
.flex-row > a,
.flex-between > a {
min-width: min(100%, 8rem) !important;
}
.btn {
white-space: normal;
padding: 0.6rem 0.9rem;
}
.grid-cols-2 {
grid-template-columns: minmax(0, 1fr);
}
.grid-cols-3 {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 130px), 1fr));
}
.choice-grid {
grid-template-columns: 1fr;
}
.btn-roll {
font-size: 1.1rem;
padding: 1.1rem 1.25rem;
max-width: 100%;
}
.step-footer > * {
flex: 1 1 auto;
}
/* Things to tap are big enough to hit with a thumb. */
input[type='checkbox'],
input[type='radio'] {
width: 22px;
height: 22px;
flex: none;
}
.death-pip {
width: 1.5rem;
height: 1.5rem;
}
.btn-target {
padding: 0.4rem 0.75rem;
}
.condition-chip {
padding: 0.3rem 0.6rem;
}
.condition-chip button {
padding: 0.15rem 0.35rem;
font-size: 1rem;
}
.info-tip-trigger {
font-size: 1.05em;
padding: 0.2rem;
}
.pick-chip {
padding: 0.5rem 0.9rem;
}
.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;
}
/* Tables: every row becomes a card. The first cell is its heading; the
rest carry their column's name from data-label. */
.table-wrap {
overflow: visible;
border: none;
}
table.data-table,
table.data-table tbody {
display: block;
}
table.data-table thead {
display: none;
}
table.data-table tbody tr {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.9rem;
padding: 0.7rem 0.75rem;
margin-bottom: 0.6rem;
background: var(--bg-inset);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
}
table.data-table tbody tr:last-child {
border-bottom: 1px solid var(--border-soft);
margin-bottom: 0;
}
table.data-table tbody tr:hover {
background: var(--bg-inset);
}
table.data-table td {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0;
white-space: normal;
min-width: 0;
}
table.data-table td:empty {
display: none;
}
table.data-table td.cell-title,
table.data-table td.cell-wide {
display: block;
flex: 1 1 100%;
min-width: 0;
}
table.data-table td.cell-title {
font-size: 1.05rem;
}
table.data-table td[data-label]::before {
content: attr(data-label);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-dim);
flex: none;
}
table.data-table td.cell-actions {
margin-left: auto;
}
}
/* The narrowest phones: five places in the bottom bar still fit. */
@media (max-width: 380px) {
:root {
--dice-slot: 3.4rem;
}
.bottom-nav-link,
.fab-dice::after {
font-size: 0.58rem;
letter-spacing: 0;
}
}
/* Between a phone and a full desktop the top bar drops the name to stay on one line. */
@media (max-width: 1080px) {
.topbar-name {
display: none;
}
.topbar-nav {
flex-wrap: nowrap;
}
}
/* ---------------------------------------------------------------------- */
/* "This one is yours": a player's own character, wherever it is listed */
/* ---------------------------------------------------------------------- */
.badge-mine {
border-color: var(--arcane-strong);
color: var(--arcane-strong);
background: rgba(92, 127, 176, 0.18);
}
.mine-marker {
color: var(--arcane-strong);
font-family: var(--font-display);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.combatant.is-mine {
border-color: var(--arcane-strong);
background: linear-gradient(160deg, #1d2431 0%, var(--bg-elevated) 100%);
box-shadow: 0 0 0 1px var(--arcane-strong), 0 0 20px rgba(127, 163, 214, 0.22), var(--shadow-deep);
}
/* Whose turn it is and who is targeted still win the border. */
.combatant.is-mine.is-active {
border-color: var(--accent-strong);
box-shadow: 0 0 0 1px var(--accent-strong), 0 0 22px rgba(201, 162, 39, 0.25), var(--shadow-deep);
}
.combatant.is-mine.is-targeted {
border-color: var(--danger-strong);
box-shadow: 0 0 0 1px var(--danger-strong), 0 0 22px rgba(216, 84, 84, 0.3), var(--shadow-deep);
}
.jump-link.is-mine {
border-color: var(--arcane-strong);
color: var(--arcane-strong);
background: rgba(92, 127, 176, 0.18);
}
table.data-table tbody tr.is-mine,
table.data-table tbody tr.is-mine:hover {
background: rgba(92, 127, 176, 0.16);
box-shadow: inset 4px 0 0 var(--arcane-strong);
}
/* A bonus from gear reads green; a penalty keeps the red of a condition. */
.condition-chip.is-bonus {
border-color: var(--success);
color: var(--success-strong);
background: rgba(95, 138, 79, 0.18);
}
+29
View File
@@ -0,0 +1,29 @@
import { useSyncExternalStore } from 'react';
// Which character the floating character panel is showing, if it is open.
// Kept outside React so that any page — an encounter card, say — can open the
// panel for a particular character without being wired to the layout.
let openId: string | null = null;
const listeners = new Set<() => void>();
function set(id: string | null) {
openId = id;
listeners.forEach((listener) => listener());
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function openCharacterPanel(characterId: string) {
set(characterId);
}
export function closeCharacterPanel() {
set(null);
}
export function useCharacterPanelId(): string | null {
return useSyncExternalStore(subscribe, () => openId);
}
+43
View File
@@ -0,0 +1,43 @@
import { ABILITY_LABELS, SKILL_LABELS, fmtMod } from './format';
import type { GearEffect } from '../api/types';
// What a weapon or item can change on the sheet of whoever has it equipped.
// Mirrors GEAR_STATS in backend/src/game/calculations.js; the server is what
// applies them, this is only for naming them and offering them in a form.
const ABILITIES = Object.keys(ABILITY_LABELS);
const SKILLS = Object.keys(SKILL_LABELS);
export const GEAR_STAT_GROUPS: { label: string; stats: string[] }[] = [
{ label: 'Combat', stats: ['AC', 'SPEED', 'INITIATIVE', 'MAX_HP', 'PASSIVE_PERCEPTION'] },
{ label: 'Ability scores', stats: ABILITIES },
{ label: 'Saving throws', stats: ['SAVES', ...ABILITIES.map((a) => `SAVE_${a}`)] },
{ label: 'Skills', stats: ['SKILLS', ...SKILLS.map((s) => `SKILL_${s}`)] },
];
const FIXED_LABELS: Record<string, string> = {
AC: 'AC',
SPEED: 'Speed',
INITIATIVE: 'Initiative',
MAX_HP: 'Max HP',
PASSIVE_PERCEPTION: 'Passive Perception',
SAVES: 'All saving throws',
SKILLS: 'All skill checks',
};
export function gearStatLabel(stat: string): string {
if (FIXED_LABELS[stat]) return FIXED_LABELS[stat];
if (stat.startsWith('SAVE_')) return `${ABILITY_LABELS[stat.slice(5)] ?? stat} saves`;
if (stat.startsWith('SKILL_')) return SKILL_LABELS[stat.slice(6)] ?? stat;
return ABILITY_LABELS[stat] ?? stat;
}
// "+6 AC", "−10 Speed".
export function effectLabel(effect: GearEffect): string {
return `${fmtMod(effect.amount).replace('-', '−')} ${gearStatLabel(effect.stat)}`;
}
// "+6 AC · −10 Speed · Disadvantage on Stealth", or null when it does nothing.
export function effectsSummary(effects: GearEffect[] | undefined, note?: string | null): string | null {
const parts = [...(effects ?? []).map(effectLabel), ...(note ? [note] : [])];
return parts.length ? parts.join(' · ') : null;
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useSyncExternalStore } from 'react';
// The campaign the user was last in, so the navigation can always offer a way
// back to it — from a character sheet or an encounter, whose addresses don't
// say which campaign they belong to. Kept per browser.
const KEY = 'dnd_last_campaign';
const listeners = new Set<() => void>();
function read(): string | null {
return localStorage.getItem(KEY);
}
function subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function forgetCampaign() {
localStorage.removeItem(KEY);
listeners.forEach((listener) => listener());
}
export function useLastCampaignId(): string | null {
return useSyncExternalStore(subscribe, read);
}
// Call from any page that knows which campaign it is showing.
export function useRememberCampaign(campaignId: string | null | undefined) {
useEffect(() => {
if (!campaignId || read() === campaignId) return;
localStorage.setItem(KEY, campaignId);
listeners.forEach((listener) => listener());
}, [campaignId]);
}
+205 -46
View File
@@ -35,6 +35,7 @@ import {
useDeleteLocation,
useCampaignLibrary,
useAddWeaponFromLibrary,
useAddItemFromLibrary,
useAddSpellFromLibrary,
useAddMonsterFromLibrary,
} from '../api/queries';
@@ -42,13 +43,16 @@ import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { JumpNav } from '../components/JumpNav';
import { LifeBadge } from '../components/DeathSaves';
import { usePagination, Pagination } from '../components/Paginated';
import { WaresEditor } from '../components/Wares';
import { EffectsEditor } from '../components/EffectsEditor';
import { effectsSummary } from '../lib/gear';
import { SPELL_DAMAGE_GROUPS, SPELL_DAMAGE_SPECIALS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel, spellRequirementLabel } from '../lib/format';
import { speciesIcon } from '../lib/speciesIcons';
import { ABILITY_ORDER, DAMAGE_SCALING_LABELS, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type { CharacterSummary, Encounter, EncounterMode, Item, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Weapon } from '../api/types';
import type { CharacterSummary, Encounter, EncounterMode, GearEffect, Item, LibraryItem, LibraryMonster, LibrarySpell, LibraryWeapon, Monster, Npc, NpcDisposition, Weapon } from '../api/types';
// How the encounter list can be ordered. "custom" is the campaign's own order,
// which the DM arranges; the rest only change what this browser shows.
@@ -127,14 +131,17 @@ function PoolRowControls({
entry,
onToggleStarting,
onDelete,
onEffects,
}: {
entry: { name: string; isStartingOption: boolean };
onToggleStarting: (isStartingOption: boolean) => void;
onDelete: () => void;
// For weapons and items: opens what it does while equipped.
onEffects?: () => void;
}) {
return (
<>
<td>
<td data-label="Starting option">
<input
type="checkbox"
aria-label={`${entry.name} is a starting option`}
@@ -142,7 +149,12 @@ function PoolRowControls({
onChange={(e) => onToggleStarting(e.target.checked)}
/>
</td>
<td>
<td className="cell-actions">
{onEffects && (
<button className="btn btn-ghost" aria-label={`What ${entry.name} does while equipped`} onClick={onEffects}>
Effects
</button>
)}
<button
className="btn btn-ghost"
aria-label={`Remove ${entry.name}`}
@@ -238,6 +250,48 @@ function SpellDamageSelect({
);
}
// The DM's editor for what one weapon or item does while equipped. Saving
// changes the sheet of every character who has it on.
function GearEffectsPanel({
entry,
saving,
onSave,
onClose,
}: {
entry: Weapon | Item;
saving: boolean;
onSave: (effects: GearEffect[], effectNote: string | null) => void;
onClose: () => void;
}) {
const [effects, setEffects] = useState<GearEffect[]>(entry.effects ?? []);
const [note, setNote] = useState(entry.effectNote ?? '');
return (
<div className="card mt-sm" style={{ background: 'var(--bg-inset)' }}>
<div className="section-label">What {entry.name} does</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
+AC; a penalty is a negative number.
</p>
<EffectsEditor
effects={effects}
note={note}
onChange={(nextEffects, nextNote) => {
setEffects(nextEffects);
setNote(nextNote);
}}
/>
<div className="flex-row mt-sm">
<button className="btn btn-primary" disabled={saving} onClick={() => onSave(effects, note.trim() || null)}>
Save Effects
</button>
<button className="btn btn-ghost" onClick={onClose}>
Cancel
</button>
</div>
</div>
);
}
type MonsterInput = Omit<Monster, 'id' | 'campaignId'>;
type AbilityScores = Pick<Monster, 'strScore' | 'dexScore' | 'conScore' | 'intScore' | 'wisScore' | 'chaScore'>;
const scoreField = (ability: string) => `${ability.toLowerCase()}Score` as keyof AbilityScores;
@@ -535,6 +589,7 @@ export function CampaignHomePage() {
const createLocation = useCreateLocation(campaignId);
const deleteLocation = useDeleteLocation(campaignId);
const addWeaponFromLibrary = useAddWeaponFromLibrary(campaignId);
const addItemFromLibrary = useAddItemFromLibrary(campaignId);
const addSpellFromLibrary = useAddSpellFromLibrary(campaignId);
const addMonsterFromLibrary = useAddMonsterFromLibrary(campaignId);
@@ -554,10 +609,13 @@ export function CampaignHomePage() {
const [deleteConfirmName, setDeleteConfirmName] = useState('');
const [showMonsterForm, setShowMonsterForm] = useState(false);
const [editingMonsterId, setEditingMonsterId] = useState<string | null>(null);
// The weapon or item whose effects are being edited, as "weapons:<id>" or "items:<id>".
const [effectsFor, setEffectsFor] = useState<string | null>(null);
const [showWeaponForm, setShowWeaponForm] = useState(false);
const [showSpellForm, setShowSpellForm] = useState(false);
const [showItemForm, setShowItemForm] = useState(false);
const [showWeaponLibrary, setShowWeaponLibrary] = useState(false);
const [showItemLibrary, setShowItemLibrary] = useState(false);
const [showSpellLibrary, setShowSpellLibrary] = useState(false);
const [showMonsterLibrary, setShowMonsterLibrary] = useState(false);
@@ -570,6 +628,8 @@ export function CampaignHomePage() {
const liveEncounters = encounters?.filter((e) => !e.archivedAt) || [];
const archivedEncounters = encounters?.filter((e) => e.archivedAt) || [];
const shownEncounters = sortEncounters(liveEncounters, encounterSort);
const encountersPage = usePagination(shownEncounters, 10);
const archivedEncountersPage = usePagination(archivedEncounters, 10);
const sharedNotes = notes?.filter((n) => n.visibility === 'SHARED');
const dmNotes = notes?.filter((n) => n.visibility === 'DM_ONLY');
const sharedNotesPage = usePagination(sharedNotes, 6);
@@ -581,7 +641,7 @@ export function CampaignHomePage() {
const { campaign, members } = data;
const isDM = campaign.role === 'DM';
const myCharacters = characters?.filter((c) => c.userId === user?.id) || [];
const partyCharacters = characters?.filter((c) => !isDM || c.userId !== user?.id) || [];
const partyCharacters = characters?.filter((c) => c.userId !== user?.id) || [];
async function handleCreateCharacter(e: FormEvent) {
e.preventDefault();
@@ -676,8 +736,10 @@ export function CampaignHomePage() {
</div>
</div>
<JumpNav />
{isDM && (
<div className="card">
<div data-jump="Settings" className="card">
<div className="card-title">
<span>Campaign Settings</span>
<button className="btn btn-ghost" onClick={() => setShowSettings((s) => !s)}>
@@ -704,6 +766,8 @@ export function CampaignHomePage() {
skipMotivationRoll: fd.get('skipMotivationRoll') === 'on',
showMonsterHp: fd.get('showMonsterHp') === 'on',
areaAttackAcPenalty: Number(fd.get('areaAttackAcPenalty')),
maxEquipped: Number(fd.get('maxEquipped')),
maxEquippedWeapons: Number(fd.get('maxEquippedWeapons')),
});
}}
>
@@ -742,6 +806,32 @@ export function CampaignHomePage() {
/>
<span className="combatant-sub">added to each target's AC for every target beyond the first (0 for none)</span>
</label>
<label className="flex-row flex-wrap gap-sm mt-sm">
Equipped at once
<NumberInput
name="maxEquipped"
min={1}
max={20}
keepValue
defaultValue={campaign.maxEquipped}
style={{ width: 70 }}
title="How many weapons and items a character can have equipped together. Only equipped gear can be used in an encounter or gives its effects."
/>
things, of which
<NumberInput
name="maxEquippedWeapons"
min={0}
max={20}
keepValue
defaultValue={campaign.maxEquippedWeapons}
style={{ width: 70 }}
title="How many of those can be weapons. A weapon for each hand, like a pair of daggers, is made as one weapon."
/>
<span className="combatant-sub">
can be weapons. Armor and shields are items and count. Lowering these takes nothing off anyone; they are checked when
something is put on.
</span>
</label>
<button type="submit" className="btn btn-primary mt-md" disabled={updateCampaign.isPending}>
Save Settings
</button>
@@ -810,7 +900,7 @@ export function CampaignHomePage() {
{formError && <div className="mt-md"><ErrorBanner message={formError} /></div>}
{!isDM && myCharacters.length > 0 && (
<div className="mt-lg">
<div data-jump="My characters" className="mt-lg">
<h2 style={{ fontSize: '1.1rem' }}>Your Character{myCharacters.length > 1 ? 's' : ''}</h2>
{myCharacters.map((c) => (
<CharacterRow key={c.id} character={c} isDM={false} onDelete={() => {}} />
@@ -818,7 +908,7 @@ export function CampaignHomePage() {
</div>
)}
<div className="mt-lg">
<div data-jump={isDM ? 'Characters' : 'Party'} className="mt-lg">
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>{isDM ? 'Characters' : 'Rest of the Party'}</h2>
</div>
@@ -834,7 +924,7 @@ export function CampaignHomePage() {
</button>
</form>
{(isDM ? characters : partyCharacters)?.length === 0 && (
<div className="empty-state">No characters yet. Roll one up above.</div>
<div className="empty-state">{isDM || myCharacters.length === 0 ? 'No characters yet. Roll one up above.' : 'Nobody else has a character yet.'}</div>
)}
{(isDM ? characters : partyCharacters)?.map((c) => (
<CharacterRow
@@ -846,7 +936,7 @@ export function CampaignHomePage() {
))}
</div>
<div className="mt-lg">
<div data-jump="Encounters" className="mt-lg">
<div className="page-header" style={{ marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.1rem', margin: 0 }}>Encounters</h2>
{liveEncounters.length > 1 && (
@@ -888,13 +978,17 @@ export function CampaignHomePage() {
)}
{isDM && encounterSort === 'custom' && liveEncounters.length > 1 && (
<p className="subtitle" style={{ marginTop: 0 }}>
This is the campaign's own order, which everyone sees by default. Use ▲ and ▼ to rearrange it.
This is the campaign's own order, which everyone sees by default. Use ▲ and ▼ to rearrange it; moving past the
top or bottom of a page carries an encounter onto the next one.
</p>
)}
{encounters && liveEncounters.length === 0 && (
<div className="empty-state">{archivedEncounters.length > 0 ? 'Every encounter is archived.' : 'No encounters yet.'}</div>
)}
{shownEncounters.map((enc, index) => (
{encountersPage.pageItems.map((enc) => {
// Its place in the whole list, not on this page: that is what moving it changes.
const index = shownEncounters.indexOf(enc);
return (
<div key={enc.id} className="card flex-between flex-wrap">
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
@@ -939,7 +1033,9 @@ export function CampaignHomePage() {
)}
</span>
</div>
))}
);
})}
<Pagination page={encountersPage.page} pageCount={encountersPage.pageCount} onPrev={encountersPage.prevPage} onNext={encountersPage.nextPage} />
{isDM && archivedEncounters.length > 0 && (
<div className="mt-md">
@@ -952,7 +1048,7 @@ export function CampaignHomePage() {
{showArchivedEncounters && (
<>
<p className="subtitle" style={{ marginTop: 0 }}>Players don't see these in their list.</p>
{archivedEncounters.map((enc) => (
{archivedEncountersPage.pageItems.map((enc) => (
<div key={enc.id} className="card flex-between flex-wrap">
<Link to={`/encounters/${enc.id}`} style={{ flex: 1, minWidth: 200 }}>
<span className="badge" style={{ marginRight: 8 }}>{MODE_LABELS[enc.mode]}</span>
@@ -979,6 +1075,12 @@ export function CampaignHomePage() {
</span>
</div>
))}
<Pagination
page={archivedEncountersPage.page}
pageCount={archivedEncountersPage.pageCount}
onPrev={archivedEncountersPage.prevPage}
onNext={archivedEncountersPage.nextPage}
/>
</>
)}
</div>
@@ -986,7 +1088,7 @@ export function CampaignHomePage() {
</div>
{!isDM && locations && locations.length > 0 && (
<div className="mt-lg card">
<div data-jump="Places" className="mt-lg card">
<div className="card-title">Places You've Been</div>
{locations.map((loc) => (
<div key={loc.id} style={{ padding: '0.5rem 0', borderBottom: '1px solid var(--border-soft)' }}>
@@ -998,7 +1100,7 @@ export function CampaignHomePage() {
)}
{isDM && (
<div className="mt-lg card">
<div data-jump="NPCs" className="mt-lg card">
<div className="card-title">
<span>NPCs</span>
<button className="btn btn-ghost" onClick={() => setShowNpcForm((s) => !s)}>
@@ -1100,7 +1202,7 @@ export function CampaignHomePage() {
)}
{isDM && (
<div className="mt-lg card">
<div data-jump="Locations" className="mt-lg card">
<div className="card-title">
<span>Locations</span>
</div>
@@ -1144,7 +1246,7 @@ export function CampaignHomePage() {
must be at least a weapon's or item's level to use it, and at least a spell's level to cast it.
</p>
</div>
<div className="mt-sm card">
<div data-jump="Weapons" className="mt-sm card">
<div className="card-title">
<span>Weapons Pool</span>
<div className="flex-row gap-sm">
@@ -1163,6 +1265,7 @@ export function CampaignHomePage() {
onAdd={(key) => addWeaponFromLibrary.mutate(key)}
placeholder="Search the weapon library…"
renderMeta={(w) => `${w.category} · ${w.damageDice} ${w.damageType} · starts at level 1`}
renderDetail={(w) => effectsSummary(w.effects, w.effectNote)}
/>
)}
{showWeaponForm && (
@@ -1181,6 +1284,8 @@ export function CampaignHomePage() {
damageScaling: Number(fd.get('damageScaling')),
minLevel: Number(fd.get('minLevel')),
isStartingOption: fd.get('isStartingOption') === 'on',
effects: [],
effectNote: null,
});
(e.target as HTMLFormElement).reset();
}}
@@ -1203,6 +1308,20 @@ export function CampaignHomePage() {
<button type="submit" className="btn btn-primary">Add</button>
</form>
)}
{weapons
?.filter((w) => effectsFor === `weapons:${w.id}`)
.map((w) => (
<GearEffectsPanel
key={w.id}
entry={w}
saving={patchWeapon.isPending}
onClose={() => setEffectsFor(null)}
onSave={(effects, effectNote) => patchWeapon.mutate({ id: w.id, effects, effectNote }, { onSuccess: () => setEffectsFor(null), onError: (err) => setFormError(extractErrorMessage(err)) })}
/>
))}
<p className="subtitle" style={{ margin: '0.5rem 0 0' }}>
"Effects" on a row sets what a weapon does to whoever has it equipped: bonuses and penalties to AC, speed, saves and more.
</p>
<div className="table-wrap mt-sm">
<table className="data-table">
<thead>
@@ -1218,18 +1337,22 @@ export function CampaignHomePage() {
<tbody>
{weaponsPage.pageItems.map((w) => (
<tr key={w.id}>
<td>{w.name}</td>
<td>{w.damageDice} {w.damageType}</td>
<td>
<td className="cell-title">
{w.name}
{effectsSummary(w.effects, w.effectNote) && <div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>✦ {effectsSummary(w.effects, w.effectNote)}</div>}
</td>
<td data-label="Base damage">{w.damageDice} {w.damageType}</td>
<td data-label="Level">
<StartLevelSelect value={w.minLevel} onChange={(minLevel) => patchWeapon.mutate({ id: w.id, minLevel })} />
</td>
<td>
<td data-label="Growth">
<ScalingSelect value={w.damageScaling} onChange={(damageScaling) => patchWeapon.mutate({ id: w.id, damageScaling })} />
</td>
<PoolRowControls
entry={w}
onToggleStarting={(isStartingOption) => patchWeapon.mutate({ id: w.id, isStartingOption })}
onDelete={() => deleteWeapon.mutate(w.id)}
onEffects={() => setEffectsFor(effectsFor === `weapons:${w.id}` ? null : `weapons:${w.id}`)}
/>
</tr>
))}
@@ -1239,13 +1362,28 @@ export function CampaignHomePage() {
<Pagination page={weaponsPage.page} pageCount={weaponsPage.pageCount} onPrev={weaponsPage.prevPage} onNext={weaponsPage.nextPage} />
</div>
<div className="mt-md card">
<div data-jump="Items" className="mt-md card">
<div className="card-title">
<span>Items Pool</span>
<button className="btn btn-ghost" onClick={() => setShowItemForm((s) => !s)}>
{showItemForm ? 'Close' : '+ Add Item'}
</button>
<div className="flex-row gap-sm">
<button className="btn btn-ghost" onClick={() => setShowItemLibrary((s) => !s)}>
{showItemLibrary ? 'Close Library' : '📚 From Library'}
</button>
<button className="btn btn-ghost" onClick={() => setShowItemForm((s) => !s)}>
{showItemForm ? 'Close' : '+ Add Item'}
</button>
</div>
</div>
{showItemLibrary && library && (
<LibraryPicker<LibraryItem>
items={library.items}
adding={addItemFromLibrary.isPending}
onAdd={(key) => addItemFromLibrary.mutate(key)}
placeholder="Search the item library: armor, shields, worn gear, supplies…"
renderMeta={(i) => [i.category, effectsSummary(i.effects, i.effectNote), i.damageDice].filter(Boolean).join(' · ')}
renderDetail={(i) => i.description}
/>
)}
{showItemForm && (
<form
onSubmit={async (e) => {
@@ -1259,6 +1397,8 @@ export function CampaignHomePage() {
damageScaling: Number(fd.get('damageScaling')),
minLevel: Number(fd.get('minLevel')),
isStartingOption: fd.get('isStartingOption') === 'on',
effects: [],
effectNote: null,
});
form.reset();
}}
@@ -1276,7 +1416,22 @@ export function CampaignHomePage() {
<button type="submit" className="btn btn-primary">Add</button>
</form>
)}
{items?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No items yet. Add rope, potions, quest items…</p>}
{items?.length === 0 && <p style={{ color: 'var(--text-muted)' }}>No items yet. Add rope, potions, armor, quest items…</p>}
{items
?.filter((item) => effectsFor === `items:${item.id}`)
.map((item) => (
<GearEffectsPanel
key={item.id}
entry={item}
saving={patchItem.isPending}
onClose={() => setEffectsFor(null)}
onSave={(effects, effectNote) => patchItem.mutate({ id: item.id, effects, effectNote }, { onSuccess: () => setEffectsFor(null), onError: (err) => setFormError(extractErrorMessage(err)) })}
/>
))}
<p className="subtitle" style={{ margin: '0.5rem 0 0' }}>
Armor, shields, rings and the like are items with effects: add the item, then press "Effects" on its row (chain mail: +6 AC,
−10 Speed). They apply on their own while a character has the item equipped.
</p>
{items && items.length > 0 && (
<div className="table-wrap mt-sm">
<table className="data-table">
@@ -1294,13 +1449,16 @@ export function CampaignHomePage() {
<tbody>
{itemsPage.pageItems.map((item) => (
<tr key={item.id}>
<td>{item.name}</td>
<td>{item.description}</td>
<td>{item.damageDice || '—'}</td>
<td>
<td className="cell-title">
{item.name}
{effectsSummary(item.effects, item.effectNote) && <div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>✦ {effectsSummary(item.effects, item.effectNote)}</div>}
</td>
<td className="cell-wide">{item.description}</td>
<td data-label="Base dice">{item.damageDice || '—'}</td>
<td data-label="Level">
<StartLevelSelect value={item.minLevel} onChange={(minLevel) => patchItem.mutate({ id: item.id, minLevel })} />
</td>
<td>
<td data-label="Growth">
{item.damageDice ? (
<ScalingSelect value={item.damageScaling} onChange={(damageScaling) => patchItem.mutate({ id: item.id, damageScaling })} />
) : (
@@ -1311,6 +1469,7 @@ export function CampaignHomePage() {
entry={item}
onToggleStarting={(isStartingOption) => patchItem.mutate({ id: item.id, isStartingOption })}
onDelete={() => deleteItem.mutate(item.id)}
onEffects={() => setEffectsFor(effectsFor === `items:${item.id}` ? null : `items:${item.id}`)}
/>
</tr>
))}
@@ -1321,7 +1480,7 @@ export function CampaignHomePage() {
<Pagination page={itemsPage.page} pageCount={itemsPage.pageCount} onPrev={itemsPage.prevPage} onNext={itemsPage.nextPage} />
</div>
<div className="mt-md card">
<div data-jump="Spells" className="mt-md card">
<div className="card-title">
<span>Spell List</span>
<div className="flex-row gap-sm">
@@ -1405,15 +1564,15 @@ export function CampaignHomePage() {
<tbody>
{spellsPage.pageItems.map((s) => (
<tr key={s.id}>
<td>
<td className="cell-title">
<strong>{s.name}</strong>
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>{s.description || 'No description.'}</div>
</td>
<td style={{ whiteSpace: 'nowrap' }}>{spellLevelLabel(s.level)}</td>
<td>
<td data-label="Level" style={{ whiteSpace: 'nowrap' }}>{spellLevelLabel(s.level)}</td>
<td data-label="Damage">
<SpellDamageSelect spellName={s.name} value={s.damageDice} onChange={(damageDice) => patchSpell.mutate({ id: s.id, damageDice })} />
</td>
<td>
<td data-label="Revives">
<input
type="checkbox"
aria-label={`${s.name} revives the dead`}
@@ -1434,7 +1593,7 @@ export function CampaignHomePage() {
<Pagination page={spellsPage.page} pageCount={spellsPage.pageCount} onPrev={spellsPage.prevPage} onNext={spellsPage.nextPage} />
</div>
<div className="mt-md card">
<div data-jump="Monsters" className="mt-md card">
<div className="card-title">
<span>Monsters</span>
<div className="flex-row gap-sm">
@@ -1507,7 +1666,7 @@ export function CampaignHomePage() {
<tbody>
{monstersPage.pageItems.map((m) => (
<tr key={m.id}>
<td>
<td className="cell-title">
<strong>{m.name}</strong>
<div className="mono" style={{ color: 'var(--text-muted)', fontSize: '0.8rem' }}>{abilityLine(m)}</div>
{m.specialTrait && (
@@ -1517,13 +1676,13 @@ export function CampaignHomePage() {
)}
{m.notes && <div style={{ color: 'var(--text-muted)', fontSize: '0.9rem', whiteSpace: 'pre-wrap' }}>{m.notes}</div>}
</td>
<td>{m.challengeRating || '—'}</td>
<td>{m.ac}</td>
<td>{m.maxHp}</td>
<td>{m.speed}</td>
<td>{fmtMod(m.attackBonus)}</td>
<td>{m.damageDice || '—'}</td>
<td>
<td data-label="CR">{m.challengeRating || '—'}</td>
<td data-label="AC">{m.ac}</td>
<td data-label="HP">{m.maxHp}</td>
<td data-label="Speed">{m.speed}</td>
<td data-label="Attack">{fmtMod(m.attackBonus)}</td>
<td data-label="Damage">{m.damageDice || '—'}</td>
<td className="cell-actions">
<button
className="btn btn-ghost"
aria-label={`Edit ${m.name}`}
@@ -1556,7 +1715,7 @@ export function CampaignHomePage() {
</>
)}
<div className="mt-lg card">
<div data-jump="Notes" className="mt-lg card">
<div className="card-title">Shared Notes</div>
<form onSubmit={handleCreateNote} className="flex-row flex-wrap">
<textarea
@@ -1,5 +1,6 @@
import { useState, type ReactNode } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useRememberCampaign } from '../lib/lastCampaign';
import { useBuildBack, useBuildChoose, useBuildFinalize, useBuildOptions, useBuildRoll, useCampaign, useCampaignItems, useCampaignSpells, useCampaignWeapons, useCharacter, usePatchDiceMode } from '../api/queries';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
@@ -95,6 +96,7 @@ export function CharacterBuilderPage() {
const { characterId = '' } = useParams();
const navigate = useNavigate();
const { data: character, isLoading, error } = useCharacter(characterId);
useRememberCampaign(character?.campaignId);
const roll = useBuildRoll(characterId);
const choose = useBuildChoose(characterId);
const finalize = useBuildFinalize(characterId);
+58 -24
View File
@@ -32,9 +32,12 @@ import { useAuth } from '../auth/AuthContext';
import { useCampaignSocket } from '../hooks/useCampaignSocket';
import { Spinner, ErrorBanner } from '../components/Spinner';
import { NumberInput } from '../components/NumberInput';
import { JumpNav } from '../components/JumpNav';
import { useRememberCampaign } from '../lib/lastCampaign';
import { HpBar } from '../components/HpBar';
import { DeathSaves } from '../components/DeathSaves';
import { QuickStatsFab } from '../components/QuickStatsDrawer';
import { GearInForce } from '../components/CharacterPanel';
import { effectsSummary } from '../lib/gear';
import { InfoTip } from '../components/InfoTip';
import { usePagination, Pagination } from '../components/Paginated';
import {
@@ -235,6 +238,7 @@ export function CharacterSheetPage() {
const { characterId = '' } = useParams();
const { user } = useAuth();
const { data: character, isLoading, error } = useCharacter(characterId);
useRememberCampaign(character?.campaignId);
const patchCharacter = usePatchCharacter(characterId);
useCampaignSocket(character?.campaignId);
@@ -330,6 +334,16 @@ export function CharacterSheetPage() {
}
}
// Equipping can be refused (too much on already). The reason goes in the
// banner at the top, so the page goes there too.
const equipResult = {
onSuccess: () => setFormError(null),
onError: (err: unknown) => {
setFormError(extractErrorMessage(err));
window.scrollTo({ top: 0, behavior: 'smooth' });
},
};
async function handleGold(sign: 1 | -1) {
const amount = Math.floor(Number(goldAmount));
if (!(amount > 0)) return;
@@ -500,18 +514,21 @@ export function CharacterSheetPage() {
onBlur={(e) => patchCharacter.mutate({ tempHp: Number(e.target.value) })}
/>
</label>
<label className="flex-row gap-sm">
Armor Bonus <InfoTip term="ARMOR_BONUS" />
<NumberInput
key={`armor:${character.armorBonus}`}
min={0}
max={30}
keepValue
defaultValue={character.armorBonus}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ armorBonus: Number(e.target.value) })}
/>
</label>
{/* Armor is an equipped item now; this is the DM's own adjustment on top. */}
{isDM && (
<label className="flex-row gap-sm" title="Added to AC on top of what equipped gear gives. Can be negative.">
Extra AC (DM) <InfoTip term="ARMOR_BONUS" />
<NumberInput
key={`armor:${character.armorBonus}`}
min={-30}
max={30}
keepValue
defaultValue={character.armorBonus}
style={{ width: 70 }}
onBlur={(e) => patchCharacter.mutate({ armorBonus: Number(e.target.value) })}
/>
</label>
)}
</div>
)}
@@ -552,7 +569,19 @@ export function CharacterSheetPage() {
<p className="subtitle mt-sm">📍 Currently at: <strong>{character.currentLocation.name}</strong></p>
)}
<div className="card mt-md">
{(sheet.gear.effects.length > 0 || sheet.gear.notes.length > 0) && (
<div className="card mt-md">
<div className="card-title">From Equipped Gear</div>
<GearInForce gear={sheet.gear} />
<p className="subtitle" style={{ margin: '0.4rem 0 0' }}>
Already counted in every number on this sheet. Take something off and its line goes.
</p>
</div>
)}
<JumpNav />
<div data-jump="Abilities" className="card mt-md">
<div className="card-title">Ability Scores</div>
<div className="grid grid-cols-3">
{(Object.keys(sheet.abilities) as Ability[]).map((ability) => (
@@ -568,7 +597,7 @@ export function CharacterSheetPage() {
</div>
<div className="grid grid-cols-2 mt-md">
<div className="card">
<div data-jump="Saves" className="card">
<div className="card-title">
Saving Throws <InfoTip term="SAVING_THROW" />
</div>
@@ -581,7 +610,7 @@ export function CharacterSheetPage() {
</div>
))}
</div>
<div className="card">
<div data-jump="Skills" className="card">
<div className="card-title">
Skills <InfoTip term="SKILL_BONUS" />
</div>
@@ -615,7 +644,7 @@ export function CharacterSheetPage() {
</div>
)}
<div className="card mt-md">
<div data-jump="Weapons" className="card mt-md">
<div className="card-title">
Weapons <InfoTip term="ATTACK_BONUS" />
</div>
@@ -627,6 +656,9 @@ export function CharacterSheetPage() {
Lv {cw.level} · {cw.scaledDamageDice} {cw.weapon.damageType} · x{cw.quantity}
</span>
{!cw.usable && <RequiresLevel level={cw.level} />}
{effectsSummary(cw.weapon.effects, cw.weapon.effectNote) && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>✦ While equipped: {effectsSummary(cw.weapon.effects, cw.weapon.effectNote)}</div>
)}
</div>
{canEdit && (
<div className="flex-row gap-sm">
@@ -636,7 +668,7 @@ export function CharacterSheetPage() {
type="checkbox"
checked={cw.equipped}
disabled={!cw.usable}
onChange={(e) => patchWeapon.mutate({ weaponId: cw.weaponId, equipped: e.target.checked })}
onChange={(e) => patchWeapon.mutate({ weaponId: cw.weaponId, equipped: e.target.checked }, equipResult)}
/>
</label>
{isDM && (
@@ -678,7 +710,7 @@ export function CharacterSheetPage() {
</div>
{campaignSpells && campaignSpells.length > 0 && (
<div className="card mt-md">
<div data-jump="Spells" className="card mt-md">
<div className="card-title">Spells Known</div>
{spellsPage.pageItems.map((cs) => (
<div key={cs.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
@@ -742,7 +774,7 @@ export function CharacterSheetPage() {
{!character.restricted && (
<>
<div className="card mt-md">
<div data-jump="Inventory" className="card mt-md">
<div className="card-title">Inventory</div>
{itemsPage.pageItems.map((held) => (
<div key={held.id} className="flex-between" style={{ padding: '0.4rem 0', borderBottom: '1px solid var(--border-soft)' }}>
@@ -753,6 +785,9 @@ export function CharacterSheetPage() {
{held.scaledDamageDice && ` · ${held.scaledDamageDice}`} · x{held.quantity}
</span>
{!held.usable && <RequiresLevel level={held.level} />}
{effectsSummary(held.item.effects, held.item.effectNote) && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>✦ While equipped: {effectsSummary(held.item.effects, held.item.effectNote)}</div>
)}
{held.item.description && (
<div style={{ color: 'var(--text-muted)', fontSize: '0.85rem' }}>{held.item.description}</div>
)}
@@ -765,7 +800,7 @@ export function CharacterSheetPage() {
type="checkbox"
checked={held.equipped}
disabled={!held.usable}
onChange={(e) => patchItem.mutate({ itemId: held.itemId, equipped: e.target.checked })}
onChange={(e) => patchItem.mutate({ itemId: held.itemId, equipped: e.target.checked }, equipResult)}
/>
</label>
{isDM && (
@@ -822,7 +857,7 @@ export function CharacterSheetPage() {
)}
</div>
<div className="card mt-md">
<div data-jump="Money" className="card mt-md">
<div className="card-title">
<span>Money</span>
<span className="mono">{formatPurse(character)}</span>
@@ -999,7 +1034,7 @@ export function CharacterSheetPage() {
</div>
)}
<div className="card mt-md">
<div data-jump="Notes" className="card mt-md">
<div className="card-title">Notes</div>
<form onSubmit={handleAddNote} className="flex-row flex-wrap">
<textarea
@@ -1026,7 +1061,6 @@ export function CharacterSheetPage() {
<Pagination page={notesPage.page} pageCount={notesPage.pageCount} onPrev={notesPage.prevPage} onNext={notesPage.nextPage} />
</div>
<QuickStatsFab characterId={characterId} />
</div>
);
}
+50 -5
View File
@@ -31,6 +31,9 @@ import { NumberInput } from '../components/NumberInput';
import { HpBar } from '../components/HpBar';
import { DeathSaves } from '../components/DeathSaves';
import { TradePanel } from '../components/TradePanel';
import { useRememberCampaign } from '../lib/lastCampaign';
import { openCharacterPanel } from '../lib/characterPanel';
import { GearInForce } from '../components/CharacterPanel';
import { ABILITY_LABELS, CLASS_LABELS, COIN_LABELS, CURRENCIES, SPECIES_LABELS, extractErrorMessage, fmtMod, formatMoney, spellLevelLabel } from '../lib/format';
import { abilityModifier, scaledDamageDice, MAX_GEAR_LEVEL } from '../lib/previewCalc';
import type {
@@ -104,6 +107,7 @@ function subline(p: EncounterParticipant): string {
function CombatantCard({
participant: p,
isDM,
isMine,
isActive,
onPatch,
canRollInitiative,
@@ -115,6 +119,8 @@ function CombatantCard({
}: {
participant: EncounterParticipant;
isDM: boolean;
// The viewer's own character: its card is marked so it is easy to find.
isMine: boolean;
isActive: boolean;
onPatch: (data: Partial<EncounterParticipant>) => void;
// The DM for anyone; a player for their own character. The same goes for
@@ -169,9 +175,13 @@ function CombatantCard({
}
return (
<div className={`card combatant combatant-${p.kind.toLowerCase()}${isActive ? ' is-active' : ''}${isDown && !isDying ? ' is-down' : ''}${isTargeted ? ' is-targeted' : ''}`}>
<div
id={`combatant-${p.id}`}
className={`card combatant combatant-${p.kind.toLowerCase()}${isMine ? ' is-mine' : ''}${isActive ? ' is-active' : ''}${isDown && !isDying ? ' is-down' : ''}${isTargeted ? ' is-targeted' : ''}`}
>
<div className="flex-between flex-wrap">
<div>
{isMine && <div className="mine-marker">★ Your character</div>}
{isActive && <div className="turn-marker">▶ On turn</div>}
{isTargeted && (
<div className="target-marker">
@@ -189,6 +199,15 @@ function CombatantCard({
<span className="combatant-name">{p.displayName}</span>
)}
{npc && <span className={`badge badge-${npc.disposition.toLowerCase()}`}>{npc.disposition}</span>}
{p.character && (isDM || isMine) && (
<button
className="btn btn-target"
title={`Everything ${p.displayName} holds: weapons, items, spells and money`}
onClick={() => p.character && openCharacterPanel(p.character.id)}
>
🎒 Inventory
</button>
)}
{isDown && !info && <span className="badge badge-hostile">Down</span>}
{p.hidden && (
<span className="badge" title="Out of the fight. Players no longer see this card, and it has no turn.">
@@ -366,11 +385,19 @@ function CombatantCard({
{ABILITIES.map((a) => `${a} ${fmtMod(info.saves[a].bonus)}${info.saves[a].proficient ? '*' : ''}`).join(' · ')}{' '}
<span title="Proficient">(* proficient)</span>
</p>
{info.weapons.length === 0 && info.spells.length === 0 && <p className="combatant-sub">No usable weapons or spells.</p>}
<GearInForce gear={info.gear} />
{info.weapons.length === 0 && (
<p className="combatant-sub">No weapon equipped. A weapon has to be equipped to be used: open 🎒 Inventory to put one on.</p>
)}
{info.items.length > 0 && (
<p className="combatant-sub">
Equipped: {info.items.map((i) => `${i.name}${i.damageDice ? ` (${i.damageDice})` : ''}`).join(' · ')}
</p>
)}
{info.weapons.map((w) => (
<div key={w.characterWeaponId} className="detail-row">
<span>
{w.name} {w.equipped && <span className="badge">equipped</span>}
{w.name}
<span className="combatant-sub" style={{ marginLeft: 6 }}>
Lv {w.level} · {w.damageDice} {fmtMod(w.damageBonus)} {w.damageType}
</span>
@@ -442,8 +469,7 @@ function CombatantCard({
function attackLines(p: EncounterParticipant): string[] {
const info = p.characterCombatInfo;
if (info) {
const equipped = info.weapons.filter((w) => w.equipped);
const weapons = (equipped.length ? equipped : info.weapons).map(
const weapons = info.weapons.map(
(w) => `${w.name}: ${fmtMod(w.attackBonus)} to hit · ${w.damageDice} ${fmtMod(w.damageBonus)} ${w.damageType}`
);
const spells = info.spells
@@ -835,6 +861,7 @@ export function EncounterPage() {
const { user } = useAuth();
const { data: encounter, isLoading, error } = useEncounter(encounterId);
useCampaignSocket(encounter?.campaignId);
useRememberCampaign(encounter?.campaignId);
const { data: campaignData } = useCampaign(encounter?.campaignId);
const isDM = campaignData?.campaign.role === 'DM';
@@ -875,6 +902,8 @@ export function EncounterPage() {
const outOfTheFight = participants.filter((p) => p.hidden);
const fallen = encounter.fallen ?? [];
const active = isBattle && encounter.status === 'ACTIVE' ? participants.find((p) => p.id === encounter.activeParticipantId) : undefined;
// The viewer's own characters here, to mark and to jump to.
const mine = participants.filter((p) => p.character != null && p.character.userId === user?.id);
const targetIds = encounter.targetParticipantIds ?? [];
const targets = targetIds.map((id) => participants.find((p) => p.id === id)).filter((p): p is EncounterParticipant => p !== undefined);
// The DM marks targets whenever they like; a player while their own character is on turn.
@@ -912,6 +941,7 @@ export function EncounterPage() {
key={p.id}
participant={p}
isDM={isDM}
isMine={p.character != null && p.character.userId === user?.id}
isActive={p.id === active?.id}
onPatch={(data) => patchParticipant.mutate({ participantId: p.id, ...data }, fail)}
canRollInitiative={isDM || (p.character != null && p.character.userId === user?.id)}
@@ -1042,6 +1072,21 @@ export function EncounterPage() {
{formError && <ErrorBanner message={formError} />}
{mine.length > 0 && (
<nav className="jump-nav" aria-label="Your characters in this scene">
{mine.map((p) => (
<button
key={p.id}
type="button"
className="jump-link is-mine"
onClick={() => document.getElementById(`combatant-${p.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })}
>
★ {p.displayName} · {p.currentHp}/{p.maxHp} HP{p.id === active?.id ? ' · your turn' : ''}
</button>
))}
</nav>
)}
{(isDM || encounter.description) && (
<div className="card">
<div className="card-title">{isBattle ? 'The Scene' : 'Quest'}</div>
+10 -10
View File
@@ -66,17 +66,17 @@ export function PartyOverviewPage() {
const inProgress = c.buildStage !== 'COMPLETE';
const isMine = c.userId === user?.id;
return (
<tr key={c.id}>
<td>
<tr key={c.id} className={isMine ? 'is-mine' : undefined}>
<td className="cell-title">
<Link to={inProgress ? `/characters/${c.id}/build` : `/characters/${c.id}`}>
<span aria-hidden="true">{speciesIcon(c.species)}</span> {c.name}
</Link>
{isMine && <span className="badge" style={{ marginLeft: 6 }}>you</span>}
{isMine && <span className="badge badge-mine" style={{ marginLeft: 6 }}>★ Yours</span>}
</td>
<td>{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
<td>{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
<td>{c.level}</td>
<td>
<td data-label="Species">{c.species ? SPECIES_LABELS[c.species] : '—'}</td>
<td data-label="Class">{c.characterClass ? CLASS_LABELS[c.characterClass] : inProgress ? 'Building…' : '—'}</td>
<td data-label="Level">{c.level}</td>
<td data-label="HP">
{c.maxHp != null ? (
<div style={{ minWidth: 90 }}>
<span className="mono">
@@ -90,9 +90,9 @@ export function PartyOverviewPage() {
'—'
)}
</td>
<td>{c.armorClass ?? '—'}</td>
<td className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
<td>{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
<td data-label="AC">{c.armorClass ?? '—'}</td>
<td data-label="Coins" className="mono">{c.gold == null ? '—' : formatPurse(c)}</td>
<td data-label="Location">{c.currentLocationName ? `📍 ${c.currentLocationName}` : '—'}</td>
</tr>
);
})}
+4 -4
View File
@@ -1,19 +1,19 @@
{
"name": "dnd-campaign-manager",
"version": "1.10.0",
"version": "1.11.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dnd-campaign-manager",
"version": "1.10.0",
"version": "1.11.0",
"workspaces": [
"backend",
"frontend"
]
},
"backend": {
"version": "1.10.0",
"version": "1.11.0",
"dependencies": {
"@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1",
@@ -31,7 +31,7 @@
}
},
"frontend": {
"version": "1.10.0",
"version": "1.11.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.10.0",
"version": "1.11.0",
"private": true,
"description": "D&D 5e campaign manager — monorepo root",
"workspaces": [